From b45ec744b292c6ff042b6e5af3f0e0a33c3e64c6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 23 Mar 2026 23:27:21 +0100 Subject: [PATCH] feat: add PDF annotation feature (#40) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add ANNOTATE_ALL permission - Add ANNOTATION_NOT_FOUND and ANNOTATION_OVERLAP error codes - V10 migration: document_annotations table with page/rect/color/owner - DocumentAnnotation entity, AnnotationRepository, CreateAnnotationDTO - AnnotationService: overlap detection (rectangle intersection), ownership enforcement on delete - AnnotationController: GET (authenticated), POST/DELETE (ANNOTATE_ALL) - 15 new tests (AnnotationServiceTest, AnnotationControllerTest) — TDD red/green Frontend: - AnnotationLayer.svelte: pointer-event drawing, colored rect overlays, delete buttons - PdfViewer.svelte: annotate toggle, color picker, loads/saves/deletes annotations via API - Disabled annotate button with tooltip for users without ANNOTATE_ALL - canAnnotate exposed from layout server, passed to PdfViewer - errors.ts + de/en/es translations for new error codes - 3 new unit tests for AnnotationLayer — TDD red/green Co-Authored-By: Claude Sonnet 4.6 --- .../controller/AnnotationController.java | 67 +++++++ .../dto/CreateAnnotationDTO.java | 17 ++ .../familienarchiv/exception/ErrorCode.java | 6 + .../model/DocumentAnnotation.java | 59 +++++++ .../repository/AnnotationRepository.java | 17 ++ .../familienarchiv/security/Permission.java | 1 + .../service/AnnotationService.java | 74 ++++++++ .../V10__add_document_annotations.sql | 14 ++ .../controller/AnnotationControllerTest.java | 130 ++++++++++++++ .../service/AnnotationServiceTest.java | 131 ++++++++++++++ frontend/messages/de.json | 2 + frontend/messages/en.json | 2 + frontend/messages/es.json | 2 + .../src/lib/components/AnnotationLayer.svelte | 165 ++++++++++++++++++ .../components/AnnotationLayer.svelte.spec.ts | 74 ++++++++ frontend/src/lib/components/PdfViewer.svelte | 119 ++++++++++++- frontend/src/lib/errors.ts | 6 + frontend/src/routes/+layout.server.ts | 7 +- frontend/src/routes/admin/page.svelte.spec.ts | 1 + .../admin/users/[id]/page.svelte.spec.ts | 8 +- .../admin/users/new/page.svelte.spec.ts | 2 +- .../routes/conversations/page.svelte.spec.ts | 1 + .../src/routes/documents/[id]/+page.svelte | 2 +- .../routes/documents/new/page.svelte.spec.ts | 1 + frontend/src/routes/layout.svelte.spec.ts | 1 + frontend/src/routes/page.svelte.spec.ts | 1 + .../src/routes/persons/page.svelte.spec.ts | 2 +- 27 files changed, 903 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/AnnotationRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java create mode 100644 backend/src/main/resources/db/migration/V10__add_document_annotations.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java create mode 100644 frontend/src/lib/components/AnnotationLayer.svelte create mode 100644 frontend/src/lib/components/AnnotationLayer.svelte.spec.ts diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java new file mode 100644 index 00000000..04ca3c77 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java @@ -0,0 +1,67 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.service.AnnotationService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/documents/{documentId}/annotations") +@RequiredArgsConstructor +@Slf4j +public class AnnotationController { + + private final AnnotationService annotationService; + private final UserService userService; + + @GetMapping + public List listAnnotations(@PathVariable UUID documentId) { + return annotationService.listAnnotations(documentId); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.ANNOTATE_ALL) + public DocumentAnnotation createAnnotation( + @PathVariable UUID documentId, + @RequestBody CreateAnnotationDTO dto, + Authentication authentication) { + UUID userId = resolveUserId(authentication); + return annotationService.createAnnotation(documentId, dto, userId); + } + + @DeleteMapping("/{annotationId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @RequirePermission(Permission.ANNOTATE_ALL) + public void deleteAnnotation( + @PathVariable UUID documentId, + @PathVariable UUID annotationId, + Authentication authentication) { + UUID userId = resolveUserId(authentication); + annotationService.deleteAnnotation(documentId, annotationId, userId); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private UUID resolveUserId(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) return null; + try { + AppUser user = userService.findByUsername(authentication.getName()); + return user != null ? user.getId() : null; + } catch (Exception e) { + log.warn("Could not resolve user for annotation: {}", e.getMessage()); + return null; + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java new file mode 100644 index 00000000..db81687f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateAnnotationDTO.java @@ -0,0 +1,17 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateAnnotationDTO { + private int pageNumber; + private double x; + private double y; + private double width; + private double height; + private String color; +} 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 c7968d27..04894717 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -38,6 +38,12 @@ public enum ErrorCode { /** The password-reset token is missing, expired, or already used. 400 */ INVALID_RESET_TOKEN, + // --- Annotations --- + /** The annotation with the given ID does not exist. 404 */ + ANNOTATION_NOT_FOUND, + /** The new annotation overlaps an existing one on the same page. 409 */ + ANNOTATION_OVERLAP, + // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java new file mode 100644 index 00000000..cdf6a079 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentAnnotation.java @@ -0,0 +1,59 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "document_annotations") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DocumentAnnotation { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(name = "document_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID documentId; + + @Column(name = "page_number", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int pageNumber; + + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private double x; + + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private double y; + + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private double width; + + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private double height; + + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String color; + + @Column(name = "created_by") + private UUID createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/AnnotationRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/AnnotationRepository.java new file mode 100644 index 00000000..66eb61e4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/AnnotationRepository.java @@ -0,0 +1,17 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface AnnotationRepository extends JpaRepository { + + List findByDocumentId(UUID documentId); + + List findByDocumentIdAndPageNumber(UUID documentId, int pageNumber); + + Optional findByIdAndDocumentId(UUID id, UUID documentId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/Permission.java b/backend/src/main/java/org/raddatz/familienarchiv/security/Permission.java index 25120a2b..f26e6c85 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/Permission.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/Permission.java @@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.security; public enum Permission { READ_ALL, WRITE_ALL, + ANNOTATE_ALL, ADMIN, ADMIN_USER, ADMIN_TAG, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java new file mode 100644 index 00000000..6f1f91d1 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/AnnotationService.java @@ -0,0 +1,74 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +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.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AnnotationService { + + private final AnnotationRepository annotationRepository; + + public List listAnnotations(UUID documentId) { + return annotationRepository.findByDocumentId(documentId); + } + + @Transactional + public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) { + List existing = + annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber()); + + boolean overlaps = existing.stream().anyMatch(a -> overlaps(a, dto)); + if (overlaps) { + throw DomainException.conflict( + ErrorCode.ANNOTATION_OVERLAP, "Annotation overlaps an existing one on this page"); + } + + DocumentAnnotation annotation = DocumentAnnotation.builder() + .documentId(documentId) + .pageNumber(dto.getPageNumber()) + .x(dto.getX()) + .y(dto.getY()) + .width(dto.getWidth()) + .height(dto.getHeight()) + .color(dto.getColor()) + .createdBy(userId) + .build(); + + return annotationRepository.save(annotation); + } + + @Transactional + public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) { + DocumentAnnotation annotation = annotationRepository + .findByIdAndDocumentId(annotationId, documentId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId)); + + if (userId == null || !userId.equals(annotation.getCreatedBy())) { + throw DomainException.forbidden("Only the annotation author can delete it"); + } + + annotationRepository.delete(annotation); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) { + double ex2 = existing.getX() + existing.getWidth(); + double ey2 = existing.getY() + existing.getHeight(); + double dx2 = dto.getX() + dto.getWidth(); + double dy2 = dto.getY() + dto.getHeight(); + return existing.getX() < dx2 && ex2 > dto.getX() + && existing.getY() < dy2 && ey2 > dto.getY(); + } +} diff --git a/backend/src/main/resources/db/migration/V10__add_document_annotations.sql b/backend/src/main/resources/db/migration/V10__add_document_annotations.sql new file mode 100644 index 00000000..f933a738 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__add_document_annotations.sql @@ -0,0 +1,14 @@ +CREATE TABLE document_annotations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + page_number INTEGER NOT NULL, + x DOUBLE PRECISION NOT NULL, + y DOUBLE PRECISION NOT NULL, + width DOUBLE PRECISION NOT NULL, + height DOUBLE PRECISION NOT NULL, + color VARCHAR(20) NOT NULL, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX ON document_annotations (document_id, page_number); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java new file mode 100644 index 00000000..a2a4d3ec --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -0,0 +1,130 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.AnnotationService; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AnnotationController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class AnnotationControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean AnnotationService annotationService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final String ANNOTATION_JSON = + "{\"pageNumber\":1,\"x\":0.1,\"y\":0.1,\"width\":0.2,\"height\":0.2,\"color\":\"#ff0000\"}"; + + // ─── GET /api/documents/{documentId}/annotations ────────────────────────── + + @Test + void listAnnotations_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void listAnnotations_returns200_whenAuthenticated() throws Exception { + when(annotationService.listAnnotations(any())).thenReturn(List.of()); + + mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations")) + .andExpect(status().isOk()); + } + + // ─── POST /api/documents/{documentId}/annotations ───────────────────────── + + @Test + void createAnnotation_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + .contentType(MediaType.APPLICATION_JSON) + .content(ANNOTATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + .contentType(MediaType.APPLICATION_JSON) + .content(ANNOTATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception { + UUID docId = UUID.randomUUID(); + DocumentAnnotation saved = DocumentAnnotation.builder() + .id(UUID.randomUUID()).documentId(docId).pageNumber(1) + .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build(); + when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + docId + "/annotations") + .contentType(MediaType.APPLICATION_JSON) + .content(ANNOTATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.pageNumber").value(1)); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void createAnnotation_returns409_whenOverlap() throws Exception { + when(annotationService.createAnnotation(any(), any(), any())) + .thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap")); + + mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations") + .contentType(MediaType.APPLICATION_JSON) + .content(ANNOTATION_JSON)) + .andExpect(status().isConflict()); + } + + // ─── DELETE /api/documents/{documentId}/annotations/{annotationId} ───────── + + @Test + void deleteAnnotation_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception { + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception { + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + .andExpect(status().isNoContent()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java new file mode 100644 index 00000000..319c4b8b --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/AnnotationServiceTest.java @@ -0,0 +1,131 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.CreateAnnotationDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.DocumentAnnotation; +import org.raddatz.familienarchiv.repository.AnnotationRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +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.CONFLICT; +import static org.springframework.http.HttpStatus.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@ExtendWith(MockitoExtension.class) +class AnnotationServiceTest { + + @Mock AnnotationRepository annotationRepository; + @InjectMocks AnnotationService annotationService; + + // ─── createAnnotation ───────────────────────────────────────────────────── + + @Test + void createAnnotation_throwsConflict_whenAnnotationOverlapsExisting() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#ff0000"); + + DocumentAnnotation existing = DocumentAnnotation.builder() + .id(UUID.randomUUID()).documentId(docId).pageNumber(1) + .x(0.2).y(0.2).width(0.3).height(0.3).color("#00ff00").build(); + when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)) + .thenReturn(List.of(existing)); + + assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT)); + + verify(annotationRepository, never()).save(any()); + } + + @Test + void createAnnotation_savesAndReturns_whenNoOverlap() { + UUID docId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000"); + + when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of()); + DocumentAnnotation saved = DocumentAnnotation.builder() + .id(UUID.randomUUID()).documentId(docId).pageNumber(1) + .x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build(); + when(annotationRepository.save(any())).thenReturn(saved); + + DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId); + + assertThat(result).isEqualTo(saved); + verify(annotationRepository).save(any()); + } + + // ─── deleteAnnotation ───────────────────────────────────────────────────── + + @Test + void deleteAnnotation_throwsNotFound_whenMissing() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, UUID.randomUUID())) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + } + + @Test + void deleteAnnotation_throwsForbidden_whenNotOwner() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + UUID otherId = UUID.randomUUID(); + + DocumentAnnotation annotation = DocumentAnnotation.builder() + .id(annotId).documentId(docId).createdBy(ownerId).build(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)) + .thenReturn(Optional.of(annotation)); + + assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, otherId)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN)); + + verify(annotationRepository, never()).delete(any()); + } + + @Test + void deleteAnnotation_succeeds_whenOwner() { + UUID docId = UUID.randomUUID(); + UUID annotId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + + DocumentAnnotation annotation = DocumentAnnotation.builder() + .id(annotId).documentId(docId).createdBy(ownerId).build(); + when(annotationRepository.findByIdAndDocumentId(annotId, docId)) + .thenReturn(Optional.of(annotation)); + + annotationService.deleteAnnotation(docId, annotId, ownerId); + + verify(annotationRepository).delete(annotation); + } + + // ─── listAnnotations ────────────────────────────────────────────────────── + + @Test + void listAnnotations_returnsAllForDocument() { + UUID docId = UUID.randomUUID(); + DocumentAnnotation a = DocumentAnnotation.builder() + .id(UUID.randomUUID()).documentId(docId).build(); + when(annotationRepository.findByDocumentId(docId)).thenReturn(List.of(a)); + + assertThat(annotationService.listAnnotations(docId)).containsExactly(a); + } +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 017d4a29..78d24422 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1,5 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "error_annotation_not_found": "Die Annotation wurde nicht gefunden.", + "error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.", "error_document_not_found": "Das Dokument wurde nicht gefunden.", "error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.", "error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 23f677d1..cb9f5abf 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,5 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "error_annotation_not_found": "Annotation not found.", + "error_annotation_overlap": "The annotation overlaps an existing one.", "error_document_not_found": "Document not found.", "error_document_no_file": "No file is associated with this document.", "error_file_not_found": "The file could not be found in storage.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 595a0dba..1285aacc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1,5 +1,7 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", + "error_annotation_not_found": "Anotación no encontrada.", + "error_annotation_overlap": "La anotación se superpone con una existente.", "error_document_not_found": "Documento no encontrado.", "error_document_no_file": "No hay ningún archivo asociado a este documento.", "error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.", diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte new file mode 100644 index 00000000..51be80ba --- /dev/null +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -0,0 +1,165 @@ + + +
+ {#each annotations as annotation (annotation.id)} +
+ {#if canAnnotate} + + {/if} +
+ {/each} + + {#if drawRect && drawRect.width > 0} +
+ {/if} +
diff --git a/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts new file mode 100644 index 00000000..e25f95c4 --- /dev/null +++ b/frontend/src/lib/components/AnnotationLayer.svelte.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import AnnotationLayer from './AnnotationLayer.svelte'; + +afterEach(cleanup); + +type Annotation = { + id: string; + documentId: string; + pageNumber: number; + x: number; + y: number; + width: number; + height: number; + color: string; + createdAt: string; +}; + +function makeAnnotation(id = 'ann-1'): Annotation { + return { + id, + documentId: 'doc-1', + pageNumber: 1, + x: 0.1, + y: 0.1, + width: 0.3, + height: 0.2, + color: '#ff0000', + createdAt: new Date().toISOString() + }; +} + +describe('AnnotationLayer', () => { + it('renders a colored element for each annotation', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')], + canAnnotate: false, + color: '#ff0000', + onDraw: () => {}, + onDelete: () => {} + }); + + await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument(); + await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument(); + }); + + it('shows a delete button for each annotation when canAnnotate is true', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1')], + canAnnotate: true, + color: '#ff0000', + onDraw: () => {}, + onDelete: () => {} + }); + + await expect + .element(page.getByRole('button', { name: /annotation löschen/i })) + .toBeInTheDocument(); + }); + + it('does not show delete buttons when canAnnotate is false', async () => { + render(AnnotationLayer, { + annotations: [makeAnnotation('ann-1')], + canAnnotate: false, + color: '#ff0000', + onDraw: () => {}, + onDelete: () => {} + }); + + expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 6514b707..7863143c 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -1,8 +1,17 @@