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
174               // column
175               // called "properties" and we are using the `ST_AsGeoJSON(::record,...)`
176               // function
177               final ObjectNode properties = (ObjectNode) jsonNode.get("properties");
178               JsonNode nestedProperties = properties.get("properties");
179               if (nestedProperties != null) {
180                 nestedProperties.properties().stream()
181                     .iterator()
182                     .forEachRemaining(
183                         entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
184               }
185               properties.remove("properties");
186               return jsonNode;
187             } catch (JsonProcessingException e) {
188               throw new RuntimeException(e);
189             }
190           }
191         })
192         .list();
193 
194     return objectMapper
195         .createObjectNode()
196         .put("type", "FeatureCollection")
197         .set("features", objectMapper.createArrayNode().addAll(storedFeatures));
198   }
199 
200   /**
201    * Update an existing drawing.
202    *
203    * @param drawing the drawing to create
204    * @param authentication the current user
205    * @return the created drawing
206    */
207   @Transactional
208   public Drawing updateDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
209       throws JsonProcessingException {
210 
211     canSaveOrDeleteDrawing(drawing, authentication);
212 
213     logger.trace(
214         "updating drawing: {}, domainData {}, updatedAt {}",
215         drawing,
216         objectMapper.writeValueAsString(drawing.getDomainData()),
217         OffsetDateTime.now(ZoneId.systemDefault()));
218 
219     final Drawing oldDrawing = getDrawing(drawing.getId(), authentication)
220         .orElseThrow(() ->
221             new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing has been deleted by another user"));
222 
223     if (drawing.getVersion() < oldDrawing.getVersion()) {
224       throw new ResponseStatusException(HttpStatus.CONFLICT, "Drawing has been updated by another user");
225     }
226     drawing.setVersion(drawing.getVersion() + 1);
227 
228     Drawing updatedDrawing = jdbcClient
229         .sql(
230             """
231 UPDATE data.drawing SET
232 id=:id,
233 name=:name,
234 description=:description,
235 domain_data=:domainData::jsonb,
236 access=:access,
237 created_by=:createdBy,
238 created_at=:createdAt,
239 updated_by=:updatedBy,
240 updated_at=:updatedAt,
241 srid=:srid,
242 version=:version
243 WHERE id = :id RETURNING *""")
244         .param("id", drawing.getId())
245         .param("name", drawing.getName())
246         .param("description", drawing.getDescription())
247         .param("domainData", objectMapper.writeValueAsString(drawing.getDomainData()))
248         .param("access", drawing.getAccess().getValue())
249         .param("createdBy", drawing.getCreatedBy())
250         .param("createdAt", drawing.getCreatedAt())
251         .param("updatedBy", authentication.getName())
252         .param("updatedAt", OffsetDateTime.now(ZoneId.systemDefault()))
253         .param("srid", drawing.getSrid())
254         .param("version", drawing.getVersion())
255         .query(drawingRowMapper)
256         .single();
257 
258     // delete even if drawing.getFeatureCollection()==null, because all features could have been
259     // removed, (re)insert the featureCollection afterward
260     jdbcClient
261         .sql("DELETE FROM data.drawing_feature WHERE drawing_id = ?")
262         .param(drawing.getId())
263         .update();
264 
265     if (drawing.getFeatureCollection() != null) {
266       ObjectNode featureCollection = insertGeoJsonFeatureCollection(
267           drawing.getId(),
268           drawing.getSrid(),
269           objectMapper.writeValueAsString(drawing.getFeatureCollection()));
270       updatedDrawing.setFeatureCollection(featureCollection);
271     }
272 
273     logger.trace("stored updated drawing: {}", updatedDrawing);
274     return updatedDrawing;
275   }
276 
277   /**
278    * Get all drawings for the current user.
279    *
280    * @param authentication the current user
281    * @return the drawings, a possibly empty set
282    */
283   public Set<Drawing> getDrawingsForUser(Authentication authentication) throws ResponseStatusException {
284     if (authentication == null || authentication instanceof AnonymousAuthenticationToken) {
285       return Set.of();
286     }
287     return jdbcClient.sql("SELECT * FROM data.drawing").query(drawingRowMapper).set().stream()
288         .filter(d -> {
289           try {
290             canReadDrawing(d, authentication);
291             return true;
292           } catch (ResponseStatusException e) {
293             return false;
294           }
295         })
296         .sorted(Comparator.comparing(Drawing::getCreatedAt))
297         .collect(Collectors.toCollection(LinkedHashSet::new));
298   }
299 
300   /**
301    * Get a drawing only — no geometry data — by its ID.
302    *
303    * @param drawingId the ID of the drawing
304    * @param authentication the current user
305    * @return the — thinly populated — drawing
306    */
307   @SuppressWarnings("SpringTransactionalMethodCallsInspection")
308   public Optional<Drawing> getDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
309     return this.getDrawing(drawingId, authentication, false, 0);
310   }
311 
312   /**
313    * Get a complete drawing by its ID with GeoJSON geometries in the requested srid.
314    *
315    * @param drawingId the ID of the drawing
316    * @param authentication the current user
317    * @param withGeometries whether to fetch the geometries for the drawing
318    * @param requestedSrid the SRID to return the geometries in
319    * @return the complete drawing
320    */
321   @Transactional
322   public Optional<Drawing> getDrawing(
323       @NonNull UUID drawingId,
324       @NonNull Authentication authentication,
325       boolean withGeometries,
326       int requestedSrid) {
327     Optional<Drawing> drawing =
328         jdbcClient
329             .sql("SELECT * FROM data.drawing WHERE id = ?")
330             .param(1, drawingId)
331             .query(drawingRowMapper)
332             .stream()
333             .findFirst();
334 
335     drawing.ifPresent(d -> {
336       // check if the user is allowed to read the drawing
337       canReadDrawing(d, authentication);
338 
339       d.setSrid(requestedSrid);
340       if (withGeometries) {
341         d.setFeatureCollection(getFeatureCollection(drawingId, requestedSrid));
342       }
343     });
344 
345     return drawing;
346   }
347 
348   /**
349    * Retrieve the feature collection as GeoJSON for a drawing.
350    *
351    * @param drawingId the ID of the drawing
352    * @param srid the SRID to return the geometries in
353    * @return the feature collection as GeoJSON
354    */
355   private JsonNode getFeatureCollection(UUID drawingId, int srid) {
356     return jdbcClient
357         .sql(
358             """
359 SELECT row_to_json(featureCollection) from (
360 SELECT
361 'FeatureCollection' AS type,
362 array_to_json(array_agg(feature)) AS features FROM (
363 SELECT
364 'Feature' AS type,
365 id as id,
366 ST_ASGeoJSON(ST_Transform(geomTable.geometry, :srid))::json AS geometry,
367 row_to_json((SELECT l from (SELECT id, drawing_id, properties) AS l)) AS properties
368 FROM data.drawing_feature AS geomTable WHERE drawing_id = :drawingId::uuid) AS feature) AS featureCollection
369 """)
370         .param("drawingId", drawingId)
371         .param("srid", srid)
372         .query(new RowMapper<JsonNode>() {
373           @Override
374           public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
375             try {
376               JsonNode jsonNode = objectMapper.readTree(rs.getString(1));
377               // merge/un-nest properties with nested properties, because we have a jsonb column
378               // called "properties" and we are using the `ST_AsGeoJSON(::record,...)` function
379               ArrayNode features = (ArrayNode) jsonNode.get("features");
380               features.elements().forEachRemaining(feature -> {
381                 ObjectNode properties = (ObjectNode) feature.get("properties");
382                 JsonNode nestedProperties = properties.get("properties");
383                 if (nestedProperties != null) {
384                   nestedProperties
385                       .properties()
386                       .iterator()
387                       .forEachRemaining(
388                           entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
389                 }
390                 properties.remove("properties");
391               });
392               return jsonNode;
393             } catch (JsonProcessingException e) {
394               throw new RuntimeException(e);
395             }
396           }
397         })
398         .single();
399   }
400 
401   /**
402    * Delete a drawing by its ID.
403    *
404    * @param drawingId the ID of the drawing
405    * @param authentication the current user
406    */
407   public void deleteDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
408     canSaveOrDeleteDrawing(
409         getDrawing(drawingId, authentication)
410             .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing not found")),
411         authentication);
412 
413     jdbcClient.sql("DELETE FROM data.drawing WHERE id = ?").param(drawingId).update();
414   }
415 
416   /**
417    * Check if the current user can create a drawing. If not, throw an unauthorized exception.
418    *
419    * @param authentication the current user
420    * @throws ResponseStatusException if the user is not allowed to create a drawing
421    */
422   private void canCreateDrawing(@NonNull Authentication authentication) throws ResponseStatusException {
423     if ((authentication instanceof AnonymousAuthenticationToken)) {
424       throw new ResponseStatusException(
425           HttpStatus.UNAUTHORIZED, "Insufficient permissions to create new drawing");
426     }
427     // TODO check if this user is allowed to create/add drawings using additional properties, currently none are
428     // defined
429   }
430 
431   /**
432    * Check if the current user can read the drawing. If not, throw an unauthorized exception.
433    *
434    * @param drawing the drawing to check
435    * @param authentication the current user
436    * @throws ResponseStatusException if the user is not allowed to read the drawing
437    */
438   private void canReadDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
439       throws ResponseStatusException {
440     boolean isAuthenticated = !(authentication instanceof AnonymousAuthenticationToken);
441     boolean canRead =
442         switch (drawing.getAccess()) {
443           case PRIVATE -> {
444             if (isAuthenticated) {
445               if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
446                 // is drawing owner
447                 yield true;
448               }
449               if (authentication.getPrincipal() instanceof TailormapUserDetails userDetails) {
450                 // check if the user has either the `drawings-admin` or `drawings-read-all` property
451                 // set
452                 for (AdminAdditionalProperty ap : userDetails.getAdditionalProperties()) {
453                   if (ap.getKey().equals(KEY_DRAWINGS_ADMIN)
454                       || ap.getKey().equals(KEY_DRAWINGS_READ_ALL)) {
455                     if ("true".equals(ap.getValue().toString())) {
456                       yield true;
457                     }
458                   }
459                 }
460               }
461             }
462             yield false;
463           }
464           case SHARED -> isAuthenticated;
465           case PUBLIC -> true;
466         };
467 
468     if (!canRead) {
469       throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to access drawing");
470     }
471   }
472 
473   /**
474    * Check if the current user can save the drawing. If not, throw an unauthorized exception.
475    *
476    * @param drawing the drawing to check
477    * @param authentication the current user
478    * @throws ResponseStatusException if the user is not allowed to save/delete the drawing
479    */
480   private void canSaveOrDeleteDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
481       throws ResponseStatusException {
482     if (authentication instanceof AnonymousAuthenticationToken) {
483       // Only authenticated users can save drawings, irrelevant of drawing access level
484       throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
485     }
486 
487     boolean canSave =
488         switch (drawing.getAccess()) {
489           case PRIVATE -> {
490             if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
491               // is drawing owner
492               yield true;
493             }
494             if (authentication.getPrincipal() instanceof TailormapUserDetails userDetails) {
495               // check if the user has the drawings-admin property set
496               for (AdminAdditionalProperty ap : userDetails.getAdditionalProperties()) {
497                 if (ap.getKey().equals(KEY_DRAWINGS_ADMIN)
498                     && "true".equals(ap.getValue().toString())) {
499                   yield true;
500                 }
501               }
502             }
503             yield false;
504           }
505           case SHARED, PUBLIC -> true;
506         };
507 
508     if (!canSave) {
509       throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
510     }
511   }
512 }