diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java index 43b715d7..01471222 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.controller; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; @@ -11,6 +12,7 @@ import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.AnnotationService; import org.raddatz.familienarchiv.service.DocumentService; import org.raddatz.familienarchiv.service.UserService; +import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -45,6 +47,15 @@ public class AnnotationController { return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash()); } + @PatchMapping("/{annotationId}") + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) + public DocumentAnnotation updateAnnotation( + @PathVariable UUID documentId, + @PathVariable UUID annotationId, + @Valid @RequestBody UpdateAnnotationDTO dto) { + return annotationService.updateAnnotation(documentId, annotationId, dto); + } + @DeleteMapping("/{annotationId}") @ResponseStatus(HttpStatus.NO_CONTENT) @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateAnnotationDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateAnnotationDTO.java new file mode 100644 index 00000000..19fa3c7d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/UpdateAnnotationDTO.java @@ -0,0 +1,29 @@ +package org.raddatz.familienarchiv.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +/** + * Partial update payload for annotation position and size. + * All fields are optional — only non-null values are applied. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateAnnotationDTO { + + @DecimalMin("0.0") @DecimalMax("1.0") + private Double x; + + @DecimalMin("0.0") @DecimalMax("1.0") + private Double y; + + @DecimalMin("0.01") @DecimalMax("1.0") + private Double width; + + @DecimalMin("0.01") @DecimalMax("1.0") + private Double height; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 60930824..26aab838 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -49,6 +49,8 @@ public enum ErrorCode { // --- Annotations --- /** The annotation with the given ID does not exist. 404 */ ANNOTATION_NOT_FOUND, + /** The annotation position/size could not be saved (bounds constraint violated). 400 */ + ANNOTATION_UPDATE_FAILED, // --- Transcription Blocks --- /** The transcription block with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java index 7b3e1252..7616a9a0 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java @@ -1,18 +1,22 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.repository.AnnotationRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor public class AnnotationService { @@ -61,6 +65,26 @@ public class AnnotationService { return annotationRepository.save(annotation); } + @Transactional + public DocumentAnnotation updateAnnotation(UUID documentId, UUID annotationId, UpdateAnnotationDTO dto) { + DocumentAnnotation annotation = annotationRepository + .findByIdAndDocumentId(annotationId, documentId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId)); + + if (dto.getX() != null) annotation.setX(dto.getX()); + if (dto.getY() != null) annotation.setY(dto.getY()); + if (dto.getWidth() != null) annotation.setWidth(dto.getWidth()); + if (dto.getHeight() != null) annotation.setHeight(dto.getHeight()); + + try { + return annotationRepository.save(annotation); + } catch (DataIntegrityViolationException e) { + log.warn("Annotation bounds constraint violated for {}: {}", annotationId, e.getMessage()); + throw DomainException.badRequest(ErrorCode.ANNOTATION_UPDATE_FAILED, "Bounds out of range"); + } + } + @Transactional public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) { DocumentAnnotation annotation = annotationRepository diff --git a/backend/src/main/resources/db/migration/V33__add_annotation_bounds_constraint.sql b/backend/src/main/resources/db/migration/V33__add_annotation_bounds_constraint.sql new file mode 100644 index 00000000..608ebe99 --- /dev/null +++ b/backend/src/main/resources/db/migration/V33__add_annotation_bounds_constraint.sql @@ -0,0 +1,12 @@ +-- Enforce valid normalized coordinate ranges for annotation bounding boxes. +-- x and y must be within [0, 1]; width and height must be at least 1% of the +-- document dimension and at most 100%. +-- Consistent with the application-layer minimum draw threshold (0.01). +ALTER TABLE document_annotations + ADD CONSTRAINT chk_annotation_bounds + CHECK ( + x >= 0 AND x <= 1 AND + y >= 0 AND y <= 1 AND + width >= 0.01 AND width <= 1 AND + height >= 0.01 AND height <= 1 + ); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index 4b95d877..317cb4fa 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -160,6 +161,109 @@ class AnnotationControllerTest { .andExpect(status().isNoContent()); } + // ─── PATCH /api/documents/{documentId}/annotations/{annotationId} ───────── + + private static final String PATCH_JSON = "{\"x\":0.2,\"y\":0.3}"; + + @Test + void patchAnnotation_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void patchAnnotation_returns403_withoutPermission() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns200_withWriteAllPermission() throws Exception { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation updated = DocumentAnnotation.builder() + .id(annotId).documentId(docId).pageNumber(1) + .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); + when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.x").value(0.2)) + .andExpect(jsonPath("$.y").value(0.3)); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void patchAnnotation_returns200_withAnnotateAllPermission() throws Exception { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation updated = DocumentAnnotation.builder() + .id(annotId).documentId(docId).pageNumber(1) + .x(0.2).y(0.3).width(0.2).height(0.2).color("#ff0000").build(); + when(annotationService.updateAnnotation(any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + docId + "/annotations/" + annotId) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns404_whenAnnotationBelongsToDifferentDocument() throws Exception { + when(annotationService.updateAnnotation(any(), any(), any())) + .thenThrow(DomainException.notFound(ErrorCode.ANNOTATION_NOT_FOUND, "not found")); + + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content(PATCH_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withOutOfBoundsCoordinates() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"x\":-0.1,\"y\":0.3}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withWidthBelowMinimum() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"width\":0.005}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withHeightBelowMinimum() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"height\":0.005}")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void patchAnnotation_returns400_withXAboveMaximum() throws Exception { + mockMvc.perform(patch("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"x\":1.1}")) + .andExpect(status().isBadRequest()); + } + // ─── resolveUserId — unauthenticated / null user / exception branches ───── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java index 7fb3c406..db5b98a3 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/repository/MigrationIntegrationTest.java @@ -121,6 +121,53 @@ class MigrationIntegrationTest { assertThat(rows2).isEqualTo(1); } + // ─── V33: chk_annotation_bounds CHECK constraint ───────────────────────── + + @Test + void v33_boundsCheckConstraint_rejectsXAboveOne() { + UUID docId = createDocument(); + + assertThatThrownBy(() -> + jdbc.update( + """ + INSERT INTO document_annotations + (id, document_id, page_number, x, y, width, height, color) + VALUES (gen_random_uuid(), ?, 1, 1.5, 0.1, 0.3, 0.1, '#ff0000') + """, + docId) + ).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void v33_boundsCheckConstraint_rejectsHeightBelowMinimum() { + UUID docId = createDocument(); + + assertThatThrownBy(() -> + jdbc.update( + """ + INSERT INTO document_annotations + (id, document_id, page_number, x, y, width, height, color) + VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.3, 0.005, '#ff0000') + """, + docId) + ).isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void v33_boundsCheckConstraint_acceptsValidAnnotation() { + UUID docId = createDocument(); + + int rows = jdbc.update( + """ + INSERT INTO document_annotations + (id, document_id, page_number, x, y, width, height, color) + VALUES (gen_random_uuid(), ?, 1, 0.1, 0.1, 0.3, 0.1, '#ff0000') + """, + docId); + + assertThat(rows).isEqualTo(1); + } + // ─── helpers ───────────────────────────────────────────────────────────── private UUID createDocument() { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java index cd736005..bd3064a9 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -6,10 +6,12 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.repository.AnnotationRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; +import org.springframework.dao.DataIntegrityViolationException; import java.util.List; import java.util.Optional; @@ -21,6 +23,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -203,6 +206,62 @@ class AnnotationServiceTest { .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN)); } + // ─── updateAnnotation ───────────────────────────────────────────────────── + + @Test + void updateAnnotation_throwsNotFound_whenAnnotationNotInDocument() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> annotationService.updateAnnotation(docId, annotId, new UpdateAnnotationDTO())) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + } + + @Test + void updateAnnotation_updatesOnlyPresentFields() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation annotation = DocumentAnnotation.builder() + .id(annotId).documentId(docId) + .x(0.1).y(0.2).width(0.3).height(0.4).build(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)) + .thenReturn(Optional.of(annotation)); + when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateAnnotationDTO dto = new UpdateAnnotationDTO(); + dto.setX(0.5); + dto.setY(0.6); + + DocumentAnnotation result = annotationService.updateAnnotation(docId, annotId, dto); + + assertThat(result.getX()).isEqualTo(0.5); + assertThat(result.getY()).isEqualTo(0.6); + assertThat(result.getWidth()).isEqualTo(0.3); // unchanged + assertThat(result.getHeight()).isEqualTo(0.4); // unchanged + verify(annotationRepository).save(annotation); + } + + @Test + void updateAnnotation_throwsAnnotationUpdateFailed_whenDbConstraintViolated() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + DocumentAnnotation annotation = DocumentAnnotation.builder() + .id(annotId).documentId(docId) + .x(0.1).y(0.2).width(0.3).height(0.4).build(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)) + .thenReturn(Optional.of(annotation)); + when(annotationRepository.save(any())).thenThrow(new DataIntegrityViolationException("constraint")); + + UpdateAnnotationDTO dto = new UpdateAnnotationDTO(); + dto.setX(1.5); + + assertThatThrownBy(() -> annotationService.updateAnnotation(docId, annotId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(BAD_REQUEST)); + } + // ─── listAnnotations ────────────────────────────────────────────────────── @Test diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 48135368..aea547f0 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1,6 +1,10 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Die Annotation wurde nicht gefunden.", + "error_annotation_update_failed": "Annotation konnte nicht gespeichert werden.", + "annotation_edit_mode_active": "Annotation ausgewählt — Ziehpunkte sichtbar.", + "annotation_resize_area": "Annotationsgröße und -position ändern", + "annotation_resize_handle": "Ziehpunkt {direction}", "annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.", "error_document_not_found": "Das Dokument wurde nicht gefunden.", "error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8f55b111..f4b958d0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,6 +1,10 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Annotation not found.", + "error_annotation_update_failed": "Could not save annotation position.", + "annotation_edit_mode_active": "Annotation selected — resize handles visible.", + "annotation_resize_area": "Resize and reposition annotation", + "annotation_resize_handle": "Resize handle: {direction}", "annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.", "error_document_not_found": "Document not found.", "error_document_no_file": "No file is associated with this document.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index b4b0ba65..2a784daa 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1,6 +1,10 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", "error_annotation_not_found": "Anotación no encontrada.", + "error_annotation_update_failed": "No se pudo guardar la posición de la anotación.", + "annotation_edit_mode_active": "Anotación seleccionada — tiradores visibles.", + "annotation_resize_area": "Cambiar tamaño y posición de la anotación", + "annotation_resize_handle": "Control de redimensión: {direction}", "annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.", "error_document_not_found": "Documento no encontrado.", "error_document_no_file": "No hay ningún archivo asociado a este documento.", diff --git a/frontend/src/lib/components/AnnotationEditOverlay.svelte b/frontend/src/lib/components/AnnotationEditOverlay.svelte new file mode 100644 index 00000000..d4da250e --- /dev/null +++ b/frontend/src/lib/components/AnnotationEditOverlay.svelte @@ -0,0 +1,349 @@ + + +