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