View Javadoc
1   /*
2    * Copyright (C) 2025 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.drawing;
7   
8   import static org.tailormap.api.persistence.helper.AdminAdditionalPropertyHelper.KEY_DRAWINGS_ADMIN;
9   import static org.tailormap.api.persistence.helper.AdminAdditionalPropertyHelper.KEY_DRAWINGS_READ_ALL;
10  
11  import java.lang.invoke.MethodHandles;
12  import java.sql.ResultSet;
13  import java.sql.SQLException;
14  import java.time.OffsetDateTime;
15  import java.time.ZoneId;
16  import java.util.Comparator;
17  import java.util.LinkedHashSet;
18  import java.util.List;
19  import java.util.Map;
20  import java.util.Objects;
21  import java.util.Optional;
22  import java.util.Set;
23  import java.util.UUID;
24  import java.util.stream.Collectors;
25  import org.jspecify.annotations.NonNull;
26  import org.postgresql.util.PGobject;
27  import org.slf4j.Logger;
28  import org.slf4j.LoggerFactory;
29  import org.springframework.core.convert.converter.Converter;
30  import org.springframework.core.convert.support.DefaultConversionService;
31  import org.springframework.core.convert.support.GenericConversionService;
32  import org.springframework.http.HttpStatus;
33  import org.springframework.jdbc.core.RowMapper;
34  import org.springframework.jdbc.core.SimplePropertyRowMapper;
35  import org.springframework.jdbc.core.simple.JdbcClient;
36  import org.springframework.security.authentication.AnonymousAuthenticationToken;
37  import org.springframework.security.core.Authentication;
38  import org.springframework.stereotype.Service;
39  import org.springframework.transaction.annotation.Transactional;
40  import org.springframework.web.server.ResponseStatusException;
41  import org.tailormap.api.security.TailormapUserDetails;
42  import org.tailormap.api.viewer.model.Drawing;
43  import tools.jackson.core.JacksonException;
44  import tools.jackson.databind.JsonNode;
45  import tools.jackson.databind.json.JsonMapper;
46  import tools.jackson.databind.node.ArrayNode;
47  import tools.jackson.databind.node.ObjectNode;
48  
49  /**
50   * Service for managing drawings.
51   *
52   * <p>This service provides methods for creating, updating, reading, and deleting drawings and persisting these
53   * operations in the data schema of the tailormap database. Any call can throw a {@link ResponseStatusException} if the
54   * user is not allowed to perform the operation.
55   */
56  @Service
57  public class DrawingService {
58    private static final Logger logger =
59        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
60  
61    private final JdbcClient jdbcClient;
62    private final RowMapper<Drawing> drawingRowMapper;
63    private final JsonMapper mapper;
64  
65    public DrawingService(JdbcClient jdbcClient, JsonMapper jsonMapper) {
66      this.jdbcClient = jdbcClient;
67      this.mapper = jsonMapper;
68  
69      final GenericConversionService conversionService = new GenericConversionService();
70      DefaultConversionService.addDefaultConverters(conversionService);
71  
72      conversionService.addConverter(new Converter<String, Drawing.AccessEnum>() {
73        @Override
74        public Drawing.AccessEnum convert(@NonNull String source) {
75          return Drawing.AccessEnum.fromValue(source);
76        }
77      });
78  
79      conversionService.addConverter(new Converter<PGobject, Map<String, Object>>() {
80        @Override
81        @SuppressWarnings("unchecked")
82        public Map<String, Object> convert(@NonNull PGobject source) {
83          try {
84            return jsonMapper.readValue(source.getValue(), Map.class);
85          } catch (JacksonException e) {
86            throw new IllegalArgumentException("Failed to convert PGobject to Map", e);
87          }
88        }
89      });
90  
91      drawingRowMapper = new SimplePropertyRowMapper<>(Drawing.class, conversionService) {
92        @Override
93        @NonNull public Drawing mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
94          return super.mapRow(rs, rowNum);
95        }
96      };
97    }
98  
99    /**
100    * Create a new drawing.
101    *
102    * @param drawing the drawing to create
103    * @param authentication the current user
104    * @return the created drawing
105    */
106   @Transactional
107   public Drawing createDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
108       throws JacksonException {
109 
110     canCreateDrawing(authentication);
111 
112     logger.trace(
113         "creating new drawing: {}, domainData {}, createdAt {}",
114         drawing,
115         mapper.writeValueAsString(drawing.getDomainData()),
116         OffsetDateTime.now(ZoneId.systemDefault()));
117 
118     Drawing storedDrawing = jdbcClient
119         .sql("""
120 INSERT INTO data.drawing (name, description, domain_data, access, created_at, created_by,srid)
121 VALUES (?, ?, ?::jsonb, ?, ?, ?, ?) RETURNING *
122 """)
123         .param(drawing.getName())
124         .param(drawing.getDescription())
125         .param(mapper.writeValueAsString(drawing.getDomainData()))
126         .param(drawing.getAccess().getValue())
127         .param(OffsetDateTime.now(ZoneId.systemDefault()))
128         .param(authentication.getName())
129         .param(drawing.getSrid())
130         .query(drawingRowMapper)
131         .single();
132 
133     if (drawing.getFeatureCollection() != null) {
134       ObjectNode featureCollection = insertGeoJsonFeatureCollection(
135           storedDrawing.getId(),
136           drawing.getSrid(),
137           mapper.writeValueAsString(drawing.getFeatureCollection()));
138       storedDrawing.setFeatureCollection(featureCollection);
139     }
140 
141     logger.trace("stored new drawing: {}", storedDrawing);
142     return storedDrawing;
143   }
144 
145   private ObjectNode insertGeoJsonFeatureCollection(UUID drawingId, int srid, String featureCollectionToStore)
146       throws JacksonException {
147     List<JsonNode> storedFeatures = jdbcClient
148         .sql("""
149 WITH jsonData AS (SELECT :featureCollectionToStore::json AS featureCollection)
150 INSERT INTO data.drawing_feature (drawing_id, geometry, properties)
151 SELECT :drawingId::uuid AS drawing_id,
152 ST_SetSRID(ST_GeomFromGeoJSON(feature ->> 'geometry'), :srid) AS geometry,
153 feature -> 'properties' AS properties
154 FROM (SELECT json_array_elements(featureCollection -> 'features') AS feature
155 FROM jsonData)
156 AS f
157 RETURNING
158 -- since we cannot use aggregate functions in a returning clause, we will return a list of geojson
159 -- features and aggregate them into a featureCollection in the next step
160 ST_AsGeoJSON(data.drawing_feature.*, geom_column =>'geometry', id_column => 'id')::json;
161 """)
162         .param("featureCollectionToStore", featureCollectionToStore)
163         .param("drawingId", drawingId)
164         .param("srid", srid)
165         .query(new RowMapper<JsonNode>() {
166           @Override
167           public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
168             try {
169               JsonNode jsonNode = mapper.readTree(rs.getString(1));
170               // merge/un-nest properties with nested properties, because we have a jsonb
171               // column
172               // called "properties" and we are using the `ST_AsGeoJSON(::record,...)`
173               // function
174               final ObjectNode properties = (ObjectNode) jsonNode.get("properties");
175               JsonNode nestedProperties = properties.get("properties");
176               if (nestedProperties != null) {
177                 nestedProperties.properties().stream()
178                     .iterator()
179                     .forEachRemaining(
180                         entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
181               }
182               properties.remove("properties");
183               return jsonNode;
184             } catch (JacksonException e) {
185               throw new RuntimeException(e);
186             }
187           }
188         })
189         .list();
190 
191     return mapper.createObjectNode()
192         .put("type", "FeatureCollection")
193         .set("features", mapper.createArrayNode().addAll(storedFeatures));
194   }
195 
196   /**
197    * Update an existing drawing.
198    *
199    * @param drawing the drawing to create
200    * @param authentication the current user
201    * @return the created drawing
202    */
203   @Transactional
204   public Drawing updateDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
205       throws JacksonException {
206 
207     canSaveOrDeleteDrawing(drawing, authentication);
208 
209     logger.trace(
210         "updating drawing: {}, domainData {}, updatedAt {}",
211         drawing,
212         mapper.writeValueAsString(drawing.getDomainData()),
213         OffsetDateTime.now(ZoneId.systemDefault()));
214 
215     final Drawing oldDrawing = getDrawing(drawing.getId(), authentication)
216         .orElseThrow(() ->
217             new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing has been deleted by another user"));
218 
219     if (drawing.getVersion() < oldDrawing.getVersion()) {
220       throw new ResponseStatusException(HttpStatus.CONFLICT, "Drawing has been updated by another user");
221     }
222     drawing.setVersion(drawing.getVersion() + 1);
223 
224     Drawing updatedDrawing = jdbcClient
225         .sql("""
226 UPDATE data.drawing SET
227 id=:id,
228 name=:name,
229 description=:description,
230 domain_data=:domainData::jsonb,
231 access=:access,
232 created_by=:createdBy,
233 created_at=:createdAt,
234 updated_by=:updatedBy,
235 updated_at=:updatedAt,
236 srid=:srid,
237 version=:version
238 WHERE id = :id RETURNING *""")
239         .param("id", drawing.getId())
240         .param("name", drawing.getName())
241         .param("description", drawing.getDescription())
242         .param("domainData", mapper.writeValueAsString(drawing.getDomainData()))
243         .param("access", drawing.getAccess().getValue())
244         .param("createdBy", drawing.getCreatedBy())
245         .param("createdAt", drawing.getCreatedAt())
246         .param("updatedBy", authentication.getName())
247         .param("updatedAt", OffsetDateTime.now(ZoneId.systemDefault()))
248         .param("srid", drawing.getSrid())
249         .param("version", drawing.getVersion())
250         .query(drawingRowMapper)
251         .single();
252 
253     // delete even if drawing.getFeatureCollection()==null, because all features could have been
254     // removed, (re)insert the featureCollection afterward
255     jdbcClient
256         .sql("DELETE FROM data.drawing_feature WHERE drawing_id = ?")
257         .param(drawing.getId())
258         .update();
259 
260     if (drawing.getFeatureCollection() != null) {
261       ObjectNode featureCollection = insertGeoJsonFeatureCollection(
262           drawing.getId(), drawing.getSrid(), mapper.writeValueAsString(drawing.getFeatureCollection()));
263       updatedDrawing.setFeatureCollection(featureCollection);
264     }
265 
266     logger.trace("stored updated drawing: {}", updatedDrawing);
267     return updatedDrawing;
268   }
269 
270   /**
271    * Get all drawings for the current user.
272    *
273    * @param authentication the current user
274    * @return the drawings, a possibly empty set
275    */
276   public Set<Drawing> getDrawingsForUser(Authentication authentication) throws ResponseStatusException {
277     if (authentication == null || authentication instanceof AnonymousAuthenticationToken) {
278       return Set.of();
279     }
280     return jdbcClient.sql("SELECT * FROM data.drawing").query(drawingRowMapper).set().stream()
281         .filter(d -> {
282           try {
283             canReadDrawing(d, authentication);
284             return true;
285           } catch (ResponseStatusException e) {
286             return false;
287           }
288         })
289         .sorted(Comparator.comparing(Drawing::getCreatedAt))
290         .collect(Collectors.toCollection(LinkedHashSet::new));
291   }
292 
293   /**
294    * Get a drawing only — no geometry data — by its ID.
295    *
296    * @param drawingId the ID of the drawing
297    * @param authentication the current user
298    * @return the — thinly populated — drawing
299    */
300   @SuppressWarnings("SpringTransactionalMethodCallsInspection")
301   public Optional<Drawing> getDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
302     return this.getDrawing(drawingId, authentication, false, 0);
303   }
304 
305   /**
306    * Get a complete drawing by its ID with GeoJSON geometries in the requested srid.
307    *
308    * @param drawingId the ID of the drawing
309    * @param authentication the current user
310    * @param withGeometries whether to fetch the geometries for the drawing
311    * @param requestedSrid the SRID to return the geometries in
312    * @return the complete drawing
313    */
314   @Transactional
315   public Optional<Drawing> getDrawing(
316       @NonNull UUID drawingId,
317       @NonNull Authentication authentication,
318       boolean withGeometries,
319       int requestedSrid) {
320     Optional<Drawing> drawing =
321         jdbcClient
322             .sql("SELECT * FROM data.drawing WHERE id = ?")
323             .param(1, drawingId)
324             .query(drawingRowMapper)
325             .stream()
326             .findFirst();
327 
328     drawing.ifPresent(d -> {
329       // check if the user is allowed to read the drawing
330       canReadDrawing(d, authentication);
331 
332       d.setSrid(requestedSrid);
333       if (withGeometries) {
334         d.setFeatureCollection(getFeatureCollection(drawingId, requestedSrid));
335       }
336     });
337 
338     return drawing;
339   }
340 
341   /**
342    * Retrieve the feature collection as GeoJSON for a drawing.
343    *
344    * @param drawingId the ID of the drawing
345    * @param srid the SRID to return the geometries in
346    * @return the feature collection as GeoJSON
347    */
348   private JsonNode getFeatureCollection(UUID drawingId, int srid) {
349     return jdbcClient
350         .sql("""
351 SELECT row_to_json(featureCollection) from (
352 SELECT
353 'FeatureCollection' AS type,
354 array_to_json(array_agg(feature)) AS features FROM (
355 SELECT
356 'Feature' AS type,
357 id as id,
358 ST_ASGeoJSON(ST_Transform(geomTable.geometry, :srid))::json AS geometry,
359 row_to_json((SELECT l from (SELECT id, drawing_id, properties) AS l)) AS properties
360 FROM data.drawing_feature AS geomTable WHERE drawing_id = :drawingId::uuid) AS feature) AS featureCollection
361 """)
362         .param("drawingId", drawingId)
363         .param("srid", srid)
364         .query(new RowMapper<JsonNode>() {
365           @Override
366           public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
367             try {
368               JsonNode jsonNode = mapper.readTree(rs.getString(1));
369               // merge/un-nest properties with nested properties, because we have a jsonb column
370               // called "properties" and we are using the `ST_AsGeoJSON(::record,...)` function
371               ArrayNode features = (ArrayNode) jsonNode.get("features");
372               features.elements().forEach(feature -> {
373                 ObjectNode properties = (ObjectNode) feature.get("properties");
374                 JsonNode nestedProperties = properties.get("properties");
375                 if (nestedProperties != null) {
376                   nestedProperties
377                       .properties()
378                       .iterator()
379                       .forEachRemaining(
380                           entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
381                 }
382                 properties.remove("properties");
383               });
384               return jsonNode;
385             } catch (JacksonException e) {
386               throw new RuntimeException(e);
387             }
388           }
389         })
390         .single();
391   }
392 
393   /**
394    * Delete a drawing by its ID.
395    *
396    * @param drawingId the ID of the drawing
397    * @param authentication the current user
398    */
399   public void deleteDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
400     canSaveOrDeleteDrawing(
401         getDrawing(drawingId, authentication)
402             .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing not found")),
403         authentication);
404 
405     jdbcClient.sql("DELETE FROM data.drawing WHERE id = ?").param(drawingId).update();
406   }
407 
408   /**
409    * Check if the current user can create a drawing. If not, throw an unauthorized exception.
410    *
411    * @param authentication the current user
412    * @throws ResponseStatusException if the user is not allowed to create a drawing
413    */
414   private void canCreateDrawing(@NonNull Authentication authentication) throws ResponseStatusException {
415     if ((authentication instanceof AnonymousAuthenticationToken)) {
416       throw new ResponseStatusException(
417           HttpStatus.UNAUTHORIZED, "Insufficient permissions to create new drawing");
418     }
419     // TODO check if this user is allowed to create/add drawings using additional properties, currently none are
420     // defined
421   }
422 
423   /**
424    * Check if the current user can read the drawing. If not, throw an unauthorized exception.
425    *
426    * @param drawing the drawing to check
427    * @param authentication the current user
428    * @throws ResponseStatusException if the user is not allowed to read the drawing
429    */
430   private void canReadDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
431       throws ResponseStatusException {
432     boolean isAuthenticated = !(authentication instanceof AnonymousAuthenticationToken);
433     boolean canRead =
434         switch (drawing.getAccess()) {
435           case PRIVATE -> {
436             if (isAuthenticated) {
437               if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
438                 // is a drawing owner
439                 yield true;
440               }
441               if (authentication.getPrincipal() instanceof TailormapUserDetails userProperties) {
442                 yield userProperties.hasTruePropertyForKey(KEY_DRAWINGS_ADMIN)
443                     || userProperties.hasTruePropertyForKey(KEY_DRAWINGS_READ_ALL);
444               }
445             }
446             yield false;
447           }
448           case SHARED -> isAuthenticated;
449           case PUBLIC -> true;
450         };
451 
452     if (!canRead) {
453       throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to access drawing");
454     }
455   }
456 
457   /**
458    * Check if the current user can save the drawing. If not, throw an unauthorized exception.
459    *
460    * @param drawing the drawing to check
461    * @param authentication the current user
462    * @throws ResponseStatusException if the user is not allowed to save/delete the drawing
463    */
464   private void canSaveOrDeleteDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
465       throws ResponseStatusException {
466     if (authentication instanceof AnonymousAuthenticationToken) {
467       // Only authenticated users can save drawings, irrelevant of drawing access level
468       throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
469     }
470 
471     boolean canSave =
472         switch (drawing.getAccess()) {
473           case PRIVATE -> {
474             if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
475               // is a drawing owner
476               yield true;
477             }
478             if (authentication.getPrincipal() instanceof TailormapUserDetails userDetails) {
479               yield userDetails.hasTruePropertyForKey(KEY_DRAWINGS_ADMIN);
480             }
481             yield false;
482           }
483           case SHARED, PUBLIC -> true;
484         };
485 
486     if (!canSave) {
487       throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
488     }
489   }
490 }