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