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