DrawingService.java
/*
* Copyright (C) 2025 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/
package org.tailormap.api.drawing;
import static org.tailormap.api.persistence.helper.AdminAdditionalPropertyHelper.KEY_DRAWINGS_ADMIN;
import static org.tailormap.api.persistence.helper.AdminAdditionalPropertyHelper.KEY_DRAWINGS_READ_ALL;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.lang.invoke.MethodHandles;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.postgresql.util.PGobject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.core.convert.support.GenericConversionService;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.SimplePropertyRowMapper;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.tailormap.api.security.TailormapUserDetails;
import org.tailormap.api.viewer.model.Drawing;
/**
* Service for managing drawings.
*
* <p>This service provides methods for creating, updating, reading, and deleting drawings and persisting these
* operations in the data schema of the tailormap database. Any call can throw a {@link ResponseStatusException} if the
* user is not allowed to perform the operation.
*/
@Service
public class DrawingService {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
private final JdbcClient jdbcClient;
private final RowMapper<Drawing> drawingRowMapper;
private final ObjectMapper objectMapper;
public DrawingService(JdbcClient jdbcClient, ObjectMapper objectMapper) {
this.jdbcClient = jdbcClient;
this.objectMapper = objectMapper;
final GenericConversionService conversionService = new GenericConversionService();
DefaultConversionService.addDefaultConverters(conversionService);
conversionService.addConverter(new Converter<String, Drawing.AccessEnum>() {
@Override
public Drawing.AccessEnum convert(@NonNull String source) {
return Drawing.AccessEnum.fromValue(source);
}
});
conversionService.addConverter(new Converter<PGobject, Map<String, Object>>() {
@Override
@SuppressWarnings("unchecked")
public Map<String, Object> convert(@NonNull PGobject source) {
try {
return objectMapper.readValue(source.getValue(), Map.class);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to convert PGobject to Map", e);
}
}
});
drawingRowMapper = new SimplePropertyRowMapper<>(Drawing.class, conversionService) {
@Override
@NonNull public Drawing mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
return super.mapRow(rs, rowNum);
}
};
}
/**
* Create a new drawing.
*
* @param drawing the drawing to create
* @param authentication the current user
* @return the created drawing
*/
@Transactional
public Drawing createDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
throws JsonProcessingException {
canCreateDrawing(authentication);
logger.trace(
"creating new drawing: {}, domainData {}, createdAt {}",
drawing,
objectMapper.writeValueAsString(drawing.getDomainData()),
OffsetDateTime.now(ZoneId.systemDefault()));
Drawing storedDrawing = jdbcClient
.sql(
"""
INSERT INTO data.drawing (name, description, domain_data, access, created_at, created_by,srid)
VALUES (?, ?, ?::jsonb, ?, ?, ?, ?) RETURNING *
""")
.param(drawing.getName())
.param(drawing.getDescription())
.param(objectMapper.writeValueAsString(drawing.getDomainData()))
.param(drawing.getAccess().getValue())
.param(OffsetDateTime.now(ZoneId.systemDefault()))
.param(authentication.getName())
.param(drawing.getSrid())
.query(drawingRowMapper)
.single();
if (drawing.getFeatureCollection() != null) {
ObjectNode featureCollection = insertGeoJsonFeatureCollection(
storedDrawing.getId(),
drawing.getSrid(),
objectMapper.writeValueAsString(drawing.getFeatureCollection()));
storedDrawing.setFeatureCollection(featureCollection);
}
logger.trace("stored new drawing: {}", storedDrawing);
return storedDrawing;
}
private ObjectNode insertGeoJsonFeatureCollection(UUID drawingId, int srid, String featureCollectionToStore)
throws JsonProcessingException {
List<JsonNode> storedFeatures = jdbcClient
.sql(
"""
WITH jsonData AS (SELECT :featureCollectionToStore::json AS featureCollection)
INSERT INTO data.drawing_feature (drawing_id, geometry, properties)
SELECT :drawingId::uuid AS drawing_id,
ST_SetSRID(ST_GeomFromGeoJSON(feature ->> 'geometry'), :srid) AS geometry,
feature -> 'properties' AS properties
FROM (SELECT json_array_elements(featureCollection -> 'features') AS feature
FROM jsonData)
AS f
RETURNING
-- since we cannot use aggregate functions in a returning clause, we will return a list of geojson
-- features and aggregate them into a featureCollection in the next step
ST_AsGeoJSON(data.drawing_feature.*, geom_column =>'geometry', id_column => 'id')::json;
""")
.param("featureCollectionToStore", featureCollectionToStore)
.param("drawingId", drawingId)
.param("srid", srid)
.query(new RowMapper<JsonNode>() {
@Override
public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
try {
JsonNode jsonNode = objectMapper.readTree(rs.getString(1));
// merge/un-nest properties with nested properties, because we have a jsonb
// column
// called "properties" and we are using the `ST_AsGeoJSON(::record,...)`
// function
final ObjectNode properties = (ObjectNode) jsonNode.get("properties");
JsonNode nestedProperties = properties.get("properties");
if (nestedProperties != null) {
nestedProperties.properties().stream()
.iterator()
.forEachRemaining(
entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
}
properties.remove("properties");
return jsonNode;
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
})
.list();
return objectMapper
.createObjectNode()
.put("type", "FeatureCollection")
.set("features", objectMapper.createArrayNode().addAll(storedFeatures));
}
/**
* Update an existing drawing.
*
* @param drawing the drawing to create
* @param authentication the current user
* @return the created drawing
*/
@Transactional
public Drawing updateDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
throws JsonProcessingException {
canSaveOrDeleteDrawing(drawing, authentication);
logger.trace(
"updating drawing: {}, domainData {}, updatedAt {}",
drawing,
objectMapper.writeValueAsString(drawing.getDomainData()),
OffsetDateTime.now(ZoneId.systemDefault()));
final Drawing oldDrawing = getDrawing(drawing.getId(), authentication)
.orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing has been deleted by another user"));
if (drawing.getVersion() < oldDrawing.getVersion()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Drawing has been updated by another user");
}
drawing.setVersion(drawing.getVersion() + 1);
Drawing updatedDrawing = jdbcClient
.sql(
"""
UPDATE data.drawing SET
id=:id,
name=:name,
description=:description,
domain_data=:domainData::jsonb,
access=:access,
created_by=:createdBy,
created_at=:createdAt,
updated_by=:updatedBy,
updated_at=:updatedAt,
srid=:srid,
version=:version
WHERE id = :id RETURNING *""")
.param("id", drawing.getId())
.param("name", drawing.getName())
.param("description", drawing.getDescription())
.param("domainData", objectMapper.writeValueAsString(drawing.getDomainData()))
.param("access", drawing.getAccess().getValue())
.param("createdBy", drawing.getCreatedBy())
.param("createdAt", drawing.getCreatedAt())
.param("updatedBy", authentication.getName())
.param("updatedAt", OffsetDateTime.now(ZoneId.systemDefault()))
.param("srid", drawing.getSrid())
.param("version", drawing.getVersion())
.query(drawingRowMapper)
.single();
// delete even if drawing.getFeatureCollection()==null, because all features could have been
// removed, (re)insert the featureCollection afterward
jdbcClient
.sql("DELETE FROM data.drawing_feature WHERE drawing_id = ?")
.param(drawing.getId())
.update();
if (drawing.getFeatureCollection() != null) {
ObjectNode featureCollection = insertGeoJsonFeatureCollection(
drawing.getId(),
drawing.getSrid(),
objectMapper.writeValueAsString(drawing.getFeatureCollection()));
updatedDrawing.setFeatureCollection(featureCollection);
}
logger.trace("stored updated drawing: {}", updatedDrawing);
return updatedDrawing;
}
/**
* Get all drawings for the current user.
*
* @param authentication the current user
* @return the drawings, a possibly empty set
*/
public Set<Drawing> getDrawingsForUser(Authentication authentication) throws ResponseStatusException {
if (authentication == null || authentication instanceof AnonymousAuthenticationToken) {
return Set.of();
}
return jdbcClient.sql("SELECT * FROM data.drawing").query(drawingRowMapper).set().stream()
.filter(d -> {
try {
canReadDrawing(d, authentication);
return true;
} catch (ResponseStatusException e) {
return false;
}
})
.sorted(Comparator.comparing(Drawing::getCreatedAt))
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* Get a drawing only — no geometry data — by its ID.
*
* @param drawingId the ID of the drawing
* @param authentication the current user
* @return the — thinly populated — drawing
*/
@SuppressWarnings("SpringTransactionalMethodCallsInspection")
public Optional<Drawing> getDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
return this.getDrawing(drawingId, authentication, false, 0);
}
/**
* Get a complete drawing by its ID with GeoJSON geometries in the requested srid.
*
* @param drawingId the ID of the drawing
* @param authentication the current user
* @param withGeometries whether to fetch the geometries for the drawing
* @param requestedSrid the SRID to return the geometries in
* @return the complete drawing
*/
@Transactional
public Optional<Drawing> getDrawing(
@NonNull UUID drawingId,
@NonNull Authentication authentication,
boolean withGeometries,
int requestedSrid) {
Optional<Drawing> drawing =
jdbcClient
.sql("SELECT * FROM data.drawing WHERE id = ?")
.param(1, drawingId)
.query(drawingRowMapper)
.stream()
.findFirst();
drawing.ifPresent(d -> {
// check if the user is allowed to read the drawing
canReadDrawing(d, authentication);
d.setSrid(requestedSrid);
if (withGeometries) {
d.setFeatureCollection(getFeatureCollection(drawingId, requestedSrid));
}
});
return drawing;
}
/**
* Retrieve the feature collection as GeoJSON for a drawing.
*
* @param drawingId the ID of the drawing
* @param srid the SRID to return the geometries in
* @return the feature collection as GeoJSON
*/
private JsonNode getFeatureCollection(UUID drawingId, int srid) {
return jdbcClient
.sql(
"""
SELECT row_to_json(featureCollection) from (
SELECT
'FeatureCollection' AS type,
array_to_json(array_agg(feature)) AS features FROM (
SELECT
'Feature' AS type,
id as id,
ST_ASGeoJSON(ST_Transform(geomTable.geometry, :srid))::json AS geometry,
row_to_json((SELECT l from (SELECT id, drawing_id, properties) AS l)) AS properties
FROM data.drawing_feature AS geomTable WHERE drawing_id = :drawingId::uuid) AS feature) AS featureCollection
""")
.param("drawingId", drawingId)
.param("srid", srid)
.query(new RowMapper<JsonNode>() {
@Override
public JsonNode mapRow(@NonNull ResultSet rs, int rowNum) throws SQLException {
try {
JsonNode jsonNode = objectMapper.readTree(rs.getString(1));
// merge/un-nest properties with nested properties, because we have a jsonb column
// called "properties" and we are using the `ST_AsGeoJSON(::record,...)` function
ArrayNode features = (ArrayNode) jsonNode.get("features");
features.elements().forEachRemaining(feature -> {
ObjectNode properties = (ObjectNode) feature.get("properties");
JsonNode nestedProperties = properties.get("properties");
if (nestedProperties != null) {
nestedProperties
.properties()
.iterator()
.forEachRemaining(
entry -> properties.putIfAbsent(entry.getKey(), entry.getValue()));
}
properties.remove("properties");
});
return jsonNode;
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
})
.single();
}
/**
* Delete a drawing by its ID.
*
* @param drawingId the ID of the drawing
* @param authentication the current user
*/
public void deleteDrawing(@NonNull UUID drawingId, @NonNull Authentication authentication) {
canSaveOrDeleteDrawing(
getDrawing(drawingId, authentication)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Drawing not found")),
authentication);
jdbcClient.sql("DELETE FROM data.drawing WHERE id = ?").param(drawingId).update();
}
/**
* Check if the current user can create a drawing. If not, throw an unauthorized exception.
*
* @param authentication the current user
* @throws ResponseStatusException if the user is not allowed to create a drawing
*/
private void canCreateDrawing(@NonNull Authentication authentication) throws ResponseStatusException {
if ((authentication instanceof AnonymousAuthenticationToken)) {
throw new ResponseStatusException(
HttpStatus.UNAUTHORIZED, "Insufficient permissions to create new drawing");
}
// TODO check if this user is allowed to create/add drawings using additional properties, currently none are
// defined
}
/**
* Check if the current user can read the drawing. If not, throw an unauthorized exception.
*
* @param drawing the drawing to check
* @param authentication the current user
* @throws ResponseStatusException if the user is not allowed to read the drawing
*/
private void canReadDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
throws ResponseStatusException {
boolean isAuthenticated = !(authentication instanceof AnonymousAuthenticationToken);
boolean canRead =
switch (drawing.getAccess()) {
case PRIVATE -> {
if (isAuthenticated) {
if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
// is a drawing owner
yield true;
}
if (authentication.getPrincipal() instanceof TailormapUserDetails userProperties) {
yield userProperties.hasTruePropertyForKey(KEY_DRAWINGS_ADMIN)
|| userProperties.hasTruePropertyForKey(KEY_DRAWINGS_READ_ALL);
}
}
yield false;
}
case SHARED -> isAuthenticated;
case PUBLIC -> true;
};
if (!canRead) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to access drawing");
}
}
/**
* Check if the current user can save the drawing. If not, throw an unauthorized exception.
*
* @param drawing the drawing to check
* @param authentication the current user
* @throws ResponseStatusException if the user is not allowed to save/delete the drawing
*/
private void canSaveOrDeleteDrawing(@NonNull Drawing drawing, @NonNull Authentication authentication)
throws ResponseStatusException {
if (authentication instanceof AnonymousAuthenticationToken) {
// Only authenticated users can save drawings, irrelevant of drawing access level
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
}
boolean canSave =
switch (drawing.getAccess()) {
case PRIVATE -> {
if (Objects.equals(authentication.getName(), drawing.getCreatedBy())) {
// is a drawing owner
yield true;
}
if (authentication.getPrincipal() instanceof TailormapUserDetails userDetails) {
yield userDetails.hasTruePropertyForKey(KEY_DRAWINGS_ADMIN);
}
yield false;
}
case SHARED, PUBLIC -> true;
};
if (!canSave) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Insufficient permissions to save drawing");
}
}
}