feat(annotations): add polygon JSONB support for quadrilateral shapes

- V23 migration adds polygon JSONB column with 4-point CHECK constraint
- PolygonConverter: AttributeConverter for List<List<Double>> <-> JSONB
- @UniquePoints custom validator rejects duplicate coordinates
- CreateAnnotationDTO: validated optional polygon field
- DocumentAnnotation entity: polygon field with converter

Refs #227

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-12 15:10:35 +02:00
parent ec32d225b5
commit 878a90a86d
8 changed files with 291 additions and 0 deletions

View File

@@ -1,9 +1,15 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@@ -14,4 +20,19 @@ public class CreateAnnotationDTO {
private double width;
private double height;
private String color;
@Size(min = 4, max = 4, message = "polygon must have exactly 4 points")
@UniquePoints
@Valid
private List<@Size(min = 2, max = 2, message = "each point must have exactly 2 coordinates")
List<@DecimalMin("0.0") @DecimalMax("1.0") Double>> polygon;
public CreateAnnotationDTO(int pageNumber, double x, double y, double width, double height, String color) {
this.pageNumber = pageNumber;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this.color = color;
}
}

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = UniquePointsValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface UniquePoints {
String message() default "polygon must contain 4 unique points";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@@ -0,0 +1,16 @@
package org.raddatz.familienarchiv.dto;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.HashSet;
import java.util.List;
public class UniquePointsValidator implements ConstraintValidator<UniquePoints, List<List<Double>>> {
@Override
public boolean isValid(List<List<Double>> polygon, ConstraintValidatorContext context) {
if (polygon == null) return true;
return new HashSet<>(polygon).size() == polygon.size();
}
}

View File

@@ -6,6 +6,7 @@ import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@Entity
@@ -52,6 +53,10 @@ public class DocumentAnnotation {
@Column(name = "file_hash", length = 64)
private String fileHash;
@Column(columnDefinition = "jsonb")
@Convert(converter = PolygonConverter.class)
private List<List<Double>> polygon;
@Column(name = "created_by")
private UUID createdBy;

View File

@@ -0,0 +1,36 @@
package org.raddatz.familienarchiv.model;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.List;
@Converter
public class PolygonConverter implements AttributeConverter<List<List<Double>>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final TypeReference<List<List<Double>>> TYPE_REF = new TypeReference<>() {};
@Override
public String convertToDatabaseColumn(List<List<Double>> polygon) {
if (polygon == null) return null;
try {
return MAPPER.writeValueAsString(polygon);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to serialize polygon", e);
}
}
@Override
public List<List<Double>> convertToEntityAttribute(String json) {
if (json == null || json.isEmpty()) return null;
try {
return MAPPER.readValue(json, TYPE_REF);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to deserialize polygon", e);
}
}
}

View File

@@ -0,0 +1,8 @@
-- Add optional polygon field for quadrilateral annotation shapes (Kraken OCR output).
-- See ADR-002 for the design decision.
ALTER TABLE document_annotations ADD COLUMN polygon JSONB;
ALTER TABLE document_annotations
ADD CONSTRAINT chk_annotation_polygon_quad
CHECK (polygon IS NULL OR jsonb_array_length(polygon) = 4);