diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java index db81687f..846d9321 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java @@ -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; + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePoints.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePoints.java new file mode 100644 index 00000000..6e954094 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePoints.java @@ -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[] payload() default {}; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePointsValidator.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePointsValidator.java new file mode 100644 index 00000000..eac16820 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UniquePointsValidator.java @@ -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>> { + + @Override + public boolean isValid(List> polygon, ConstraintValidatorContext context) { + if (polygon == null) return true; + return new HashSet<>(polygon).size() == polygon.size(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java index 281f88a2..d4e02258 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java @@ -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> polygon; + @Column(name = "created_by") private UUID createdBy; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PolygonConverter.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PolygonConverter.java new file mode 100644 index 00000000..28362e8f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PolygonConverter.java @@ -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>, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final TypeReference>> TYPE_REF = new TypeReference<>() {}; + + @Override + public String convertToDatabaseColumn(List> 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> 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); + } + } +} diff --git a/backend/src/main/resources/db/migration/V23__add_polygon_to_annotations.sql b/backend/src/main/resources/db/migration/V23__add_polygon_to_annotations.sql new file mode 100644 index 00000000..74a4d246 --- /dev/null +++ b/backend/src/main/resources/db/migration/V23__add_polygon_to_annotations.sql @@ -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); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/dto/UniquePointsValidatorTest.java b/backend/src/test/java/org/raddatz/familienarchiv/dto/UniquePointsValidatorTest.java new file mode 100644 index 00000000..be2690c4 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/dto/UniquePointsValidatorTest.java @@ -0,0 +1,124 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class UniquePointsValidatorTest { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void shouldAcceptNull() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(null); + + Set> violations = validator.validate(dto); + + assertThat(violations).noneMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldAcceptFourUniquePoints() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).noneMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldRejectDuplicatePoints() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1, 0.1), + List.of(0.1, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldRejectPolygonWithThreePoints() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldRejectPolygonWithFivePoints() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1, 0.1), + List.of(0.5, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().equals("polygon")); + } + + @Test + void shouldRejectCoordinateOutOfRange() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(1.5, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon")); + } + + @Test + void shouldRejectNegativeCoordinate() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(-0.1, 0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon")); + } + + @Test + void shouldRejectPointWithOneCoordinate() { + var dto = new CreateAnnotationDTO(); + dto.setPolygon(List.of( + List.of(0.1), + List.of(0.9, 0.1), + List.of(0.9, 0.9), + List.of(0.1, 0.9))); + + Set> violations = validator.validate(dto); + + assertThat(violations).anyMatch(v -> v.getPropertyPath().toString().contains("polygon")); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/PolygonConverterTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/PolygonConverterTest.java new file mode 100644 index 00000000..916cfa2f --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/PolygonConverterTest.java @@ -0,0 +1,65 @@ +package org.raddatz.familienarchiv.model; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PolygonConverterTest { + + private final PolygonConverter converter = new PolygonConverter(); + + @Test + void convertToDatabaseColumn_returnsNull_whenPolygonIsNull() { + assertThat(converter.convertToDatabaseColumn(null)).isNull(); + } + + @Test + void convertToDatabaseColumn_returnsJsonArray_whenPolygonIsValid() { + List> polygon = List.of( + List.of(0.1, 0.2), + List.of(0.9, 0.2), + List.of(0.9, 0.8), + List.of(0.1, 0.8)); + + String json = converter.convertToDatabaseColumn(polygon); + + assertThat(json).isEqualTo("[[0.1,0.2],[0.9,0.2],[0.9,0.8],[0.1,0.8]]"); + } + + @Test + void convertToEntityAttribute_returnsNull_whenJsonIsNull() { + assertThat(converter.convertToEntityAttribute(null)).isNull(); + } + + @Test + void convertToEntityAttribute_returnsNull_whenJsonIsEmpty() { + assertThat(converter.convertToEntityAttribute("")).isNull(); + } + + @Test + void convertToEntityAttribute_returnsPolygon_whenJsonIsValid() { + String json = "[[0.1,0.2],[0.9,0.2],[0.9,0.8],[0.1,0.8]]"; + + List> polygon = converter.convertToEntityAttribute(json); + + assertThat(polygon).hasSize(4); + assertThat(polygon.get(0)).containsExactly(0.1, 0.2); + assertThat(polygon.get(3)).containsExactly(0.1, 0.8); + } + + @Test + void roundTrip_preservesValues() { + List> original = List.of( + List.of(0.12, 0.08), + List.of(0.88, 0.09), + List.of(0.87, 0.14), + List.of(0.11, 0.13)); + + String json = converter.convertToDatabaseColumn(original); + List> restored = converter.convertToEntityAttribute(json); + + assertThat(restored).isEqualTo(original); + } +}