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