diff --git a/backend/pom.xml b/backend/pom.xml index ad068e07..a5eddab8 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -148,7 +148,7 @@ true - dev + dev,e2e diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java b/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java index 25f69894..f101f5a1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/DataInitializer.java @@ -49,7 +49,7 @@ public class DataInitializer { // 1. Admin Gruppe erstellen UserGroup adminGroup = UserGroup.builder() .name("Administrators") - .permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION")) + .permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION")) .build(); groupRepository.save(adminGroup); @@ -84,8 +84,24 @@ public class DataInitializer { TagRepository tagRepo, PasswordEncoder passwordEncoder) { return args -> { + // Always ensure the read-only test user exists, even when seed data was already loaded. + if (userRepository.findByUsername("reader").isEmpty()) { + log.info("E2E seed: Erstelle 'reader'-Testbenutzer..."); + UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() -> + groupRepository.save(UserGroup.builder() + .name("Leser") + .permissions(Set.of("READ_ALL")) + .build())); + userRepository.save(AppUser.builder() + .username("reader") + .password(passwordEncoder.encode("reader123")) + .groups(Set.of(leserGroup)) + .build()); + log.info("E2E seed: 'reader'-Testbenutzer erstellt."); + } + if (personRepo.count() > 0) { - log.info("E2E seed: Daten bereits vorhanden, überspringe."); + log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed."); return; } @@ -166,19 +182,6 @@ public class DataInitializer { .receivers(Set.of(otto)) .build()); - // ── Read-only user (for permissions E2E tests) ─────────────────── - // Username: reader / Password: reader123 - // Has only READ_ALL — used to assert write controls are absent. - UserGroup leserGroup = groupRepository.save(UserGroup.builder() - .name("Leser") - .permissions(Set.of("READ_ALL")) - .build()); - userRepository.save(AppUser.builder() - .username("reader") - .password(passwordEncoder.encode("reader123")) - .groups(Set.of(leserGroup)) - .build()); - log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.", personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count()); }; 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/main/resources/db/migration/V11__add_annotate_all_permission.sql b/backend/src/main/resources/db/migration/V11__add_annotate_all_permission.sql new file mode 100644 index 00000000..4c797f6c --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__add_annotate_all_permission.sql @@ -0,0 +1,7 @@ +-- Grant ANNOTATE_ALL to every group that already has ADMIN. +-- New installs get it via DataInitializer; this covers existing deployments. +INSERT INTO group_permissions (group_id, permission) +SELECT g.id, 'ANNOTATE_ALL' +FROM user_groups g +WHERE g.id IN (SELECT group_id FROM group_permissions WHERE permission = 'ADMIN') + AND g.id NOT IN (SELECT group_id FROM group_permissions WHERE permission = 'ANNOTATE_ALL'); 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/docker-compose.yml b/docker-compose.yml index 16fef739..7ceabc66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,7 @@ services: S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD} S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS} S3_REGION: us-east-1 + SPRING_PROFILES_ACTIVE: dev,e2e APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000} # Defaults to the local Mailpit catcher — override in .env for production SMTP MAIL_HOST: ${MAIL_HOST:-mailpit} diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json index 1398d3b8..de4f774a 100644 --- a/frontend/e2e/.auth/user.json +++ b/frontend/e2e/.auth/user.json @@ -5,7 +5,7 @@ "value": "de", "domain": "localhost", "path": "/", - "expires": 1808565334.192108, + "expires": 1808896929.897686, "httpOnly": false, "secure": false, "sameSite": "Lax" @@ -15,7 +15,7 @@ "value": "Basic%20YWRtaW46YWRtaW4xMjM%3D", "domain": "localhost", "path": "/", - "expires": 1774091734.449243, + "expires": 1774423330.233039, "httpOnly": true, "secure": false, "sameSite": "Strict" diff --git a/frontend/e2e/documents.spec.ts b/frontend/e2e/documents.spec.ts index 5743ab02..6c17518e 100644 --- a/frontend/e2e/documents.spec.ts +++ b/frontend/e2e/documents.spec.ts @@ -1,4 +1,9 @@ import { test, expect } from '@playwright/test'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** * Document management E2E tests. @@ -142,3 +147,231 @@ test.describe('Document edit', () => { await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' }); }); }); + +// ─── PDF Viewer ─────────────────────────────────────────────────────────────── + +const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf'); + +test.describe('PDF viewer', () => { + let pdfDocHref: string; + let noFileDocHref: string; + + test.beforeAll(async ({ request }) => { + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + + // Create a document with a PDF file. + const createRes = await request.post('/api/documents', { + multipart: { title: 'E2E PDF Viewer Test' } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + + const uploadRes = await request.put(`/api/documents/${doc.id}`, { + multipart: { + title: doc.title, + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); + pdfDocHref = `${baseURL}/documents/${doc.id}`; + + // Create a document WITHOUT a file — used to verify no canvas is rendered. + const noFileRes = await request.post('/api/documents', { + multipart: { title: 'E2E No-File Test' } + }); + if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`); + const noFileDoc = await noFileRes.json(); + noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`; + }); + + test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({ + page + }) => { + await page.goto(pdfDocHref); + await page.waitForSelector('[data-hydrated]'); + + // There must be NO iframe — we replaced it with PDF.js canvas rendering. + await expect(page.locator('iframe')).not.toBeAttached(); + + // At least one canvas element must be visible (one per rendered page). + await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 }); + + await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' }); + }); + + test('page navigation controls are visible', async ({ page }) => { + await page.goto(pdfDocHref); + await page.waitForSelector('[data-hydrated]'); + await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 }); + + await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible(); + await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible(); + + await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' }); + }); + + test('document without a file has no canvas', async ({ page }) => { + // A document with no file attached must not render a PDF canvas. + await page.goto(noFileDocHref); + await page.waitForSelector('[data-hydrated]'); + + // No canvas — this document has no file + await expect(page.locator('canvas')).not.toBeAttached(); + + await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' }); + }); +}); + +// ─── PDF Annotations (admin) ────────────────────────────────────────────────── + +// Shared with the read-only user describe block below +let sharedAnnotationDocId: string; + +test.describe('PDF annotations — admin', () => { + let annotationDocHref: string; + + test.beforeAll(async ({ request }) => { + // Create a document with a PDF via API — much faster than UI automation. + const createRes = await request.post('/api/documents', { + multipart: { title: 'E2E Annotations Test' } + }); + if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`); + const doc = await createRes.json(); + + const uploadRes = await request.put(`/api/documents/${doc.id}`, { + multipart: { + title: doc.title, + file: { + name: 'minimal.pdf', + mimeType: 'application/pdf', + buffer: fs.readFileSync(PDF_FIXTURE) + } + } + }); + if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`); + + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + annotationDocHref = `${baseURL}/documents/${doc.id}`; + sharedAnnotationDocId = doc.id; + }); + + test('admin user sees an active Annotieren button on a PDF', async ({ page }) => { + test.setTimeout(60_000); + await page.goto(annotationDocHref); + await page.waitForSelector('[data-hydrated]'); + await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); + + // Admin has ANNOTATE_ALL — button must be enabled + const annotateBtn = page.getByRole('button', { name: /^annotieren$/i }); + await expect(annotateBtn).toBeVisible(); + await expect(annotateBtn).not.toBeDisabled(); + + await page.screenshot({ path: 'test-results/e2e/annotations-button-admin.png' }); + }); + + test('admin can draw an annotation and it appears on the page', async ({ page }) => { + test.setTimeout(60_000); + await page.goto(annotationDocHref); + await page.waitForSelector('[data-hydrated]'); + await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); + + // Enable annotate mode + await page.getByRole('button', { name: /^annotieren$/i }).click(); + + // Color picker must appear + await expect(page.getByLabel(/farbe/i)).toBeVisible(); + + // Draw on the annotation layer overlay + const annotationLayer = page.locator('[role="presentation"]').last(); + const box = await annotationLayer.boundingBox(); + if (!box) throw new Error('Annotation layer not found'); + + const startX = box.x + box.width * 0.3; + const startY = box.y + box.height * 0.3; + const endX = box.x + box.width * 0.55; + const endY = box.y + box.height * 0.55; + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(endX, endY); + await page.mouse.up(); + + await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({ + timeout: 8000 + }); + + await page.screenshot({ path: 'test-results/e2e/annotation-drawn.png' }); + }); + + test('annotation persists after page reload', async ({ page }) => { + test.setTimeout(60_000); + await page.goto(annotationDocHref); + await page.waitForSelector('[data-hydrated]'); + await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); + + // Annotation from the previous test must be loaded from the API + await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({ + timeout: 8000 + }); + + await page.screenshot({ path: 'test-results/e2e/annotation-persisted.png' }); + }); + + test('admin can delete an annotation', async ({ page }) => { + test.setTimeout(60_000); + await page.goto(annotationDocHref); + await page.waitForSelector('[data-hydrated]'); + await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 }); + + // Ensure annotation is visible before enabling annotate mode + await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({ + timeout: 8000 + }); + + // Enable annotate mode to show delete buttons + await page.getByRole('button', { name: /^annotieren$/i }).click(); + + const deleteBtn = page.getByRole('button', { name: /annotation löschen/i }).first(); + await expect(deleteBtn).toBeVisible({ timeout: 8000 }); + await deleteBtn.click(); + + await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { + timeout: 8000 + }); + + await page.screenshot({ path: 'test-results/e2e/annotation-deleted.png' }); + }); +}); + +// ─── PDF Annotations (read-only user) ───────────────────────────────────────── + +test.describe('PDF annotations — read-only user', () => { + // Isolated session — does not share the admin storage state + test.use({ storageState: { cookies: [], origins: [] } }); + + test('read-only user sees a disabled Annotieren button', async ({ page }) => { + test.setTimeout(60_000); + await page.goto('/login'); + await page.getByLabel('Benutzername').fill('reader'); + await page.getByLabel('Passwort').fill('reader123'); + await page.getByRole('button', { name: 'Anmelden' }).click(); + await page.waitForURL('/'); + + // Navigate directly to the PDF document created by the admin beforeAll. + const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000'; + await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`); + await page.waitForSelector('[data-hydrated]'); + // Wait for the PDF canvas — once rendered, the controls bar (with disabled button) is shown. + await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 30000 }); + + const disabledBtn = page.getByRole('button', { name: /annotieren/i }); + await expect(disabledBtn).toBeVisible({ timeout: 5000 }); + await expect(disabledBtn).toBeDisabled(); + + await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' }); + }); +}); diff --git a/frontend/e2e/fixtures/minimal.pdf b/frontend/e2e/fixtures/minimal.pdf new file mode 100644 index 00000000..c127ad96 --- /dev/null +++ b/frontend/e2e/fixtures/minimal.pdf @@ -0,0 +1,21 @@ +%PDF-1.4 +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<> +endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000054 00000 n +0000000105 00000 n +trailer +<> +startxref +170 +%%EOF 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/package-lock.json b/frontend/package-lock.json index 6795fc10..493c551e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.1", "dependencies": { "diff": "^8.0.3", - "openapi-fetch": "^0.13.5" + "openapi-fetch": "^0.13.5", + "pdfjs-dist": "^5.5.207" }, "devDependencies": { "@eslint/compat": "^1.4.0", @@ -885,6 +886,256 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -3954,6 +4205,13 @@ "dev": true, "license": "MIT" }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -4129,6 +4387,19 @@ "dev": true, "license": "MIT" }, + "node_modules/pdfjs-dist": { + "version": "5.5.207", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", + "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0 || >=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.95", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7e8ded46..4d622d4d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,8 @@ }, "dependencies": { "diff": "^8.0.3", - "openapi-fetch": "^0.13.5" + "openapi-fetch": "^0.13.5", + "pdfjs-dist": "^5.5.207" }, "devDependencies": { "@eslint/compat": "^1.4.0", diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte new file mode 100644 index 00000000..3a8cfae6 --- /dev/null +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -0,0 +1,170 @@ + + +
+ {#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 new file mode 100644 index 00000000..7863143c --- /dev/null +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -0,0 +1,422 @@ + + +{#if !url} +
+

Keine Datei vorhanden

+
+{:else if error} +
+

Fehler beim Laden der PDF

+ + Direkt öffnen + +
+{:else} +
+ +
+ +
+ + + {#if totalPages > 0} + + {currentPage} / {totalPages} + + {/if} + + +
+ + +
+ + +
+ + + {#if canAnnotate} +
+ + {#if annotateMode} + + {/if} +
+ {:else} + + {/if} +
+ + +
+ {#if loading} +
+
+
+ {:else} +
+
+ +
+ a.pageNumber === currentPage)} + canAnnotate={annotateMode} + color={annotateColor} + onDraw={handleAnnotationDraw} + onDelete={handleAnnotationDelete} + /> +
+
+ {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/PdfViewer.svelte.spec.ts b/frontend/src/lib/components/PdfViewer.svelte.spec.ts new file mode 100644 index 00000000..c8408d71 --- /dev/null +++ b/frontend/src/lib/components/PdfViewer.svelte.spec.ts @@ -0,0 +1,53 @@ +import { vi, describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need +// a real browser PDF engine. The interesting behaviour under test here is the +// component's own UI logic (controls, page counter), not pdfjs internals. +vi.mock('pdfjs-dist', () => { + function TextLayerMock() {} + TextLayerMock.prototype.render = () => Promise.resolve(); + TextLayerMock.prototype.cancel = () => {}; + + return { + GlobalWorkerOptions: { workerSrc: '' }, + getDocument: vi.fn().mockReturnValue({ + promise: Promise.resolve({ + numPages: 2, + getPage: vi.fn().mockResolvedValue({ + getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }), + render: vi.fn().mockReturnValue({ promise: Promise.resolve() }), + streamTextContent: vi.fn().mockReturnValue(new ReadableStream()) + }) + }) + }), + TextLayer: TextLayerMock + }; +}); + +vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' })); + +import PdfViewer from './PdfViewer.svelte'; + +afterEach(cleanup); + +describe('PdfViewer', () => { + it('shows previous and next page navigation buttons', async () => { + render(PdfViewer, { url: '/api/documents/test-id/file' }); + await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument(); + }); + + it('shows zoom controls', async () => { + render(PdfViewer, { url: '/api/documents/test-id/file' }); + await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument(); + }); + + it('displays the page counter once the PDF has loaded', async () => { + render(PdfViewer, { url: '/api/documents/test-id/file' }); + // Mock resolves synchronously, so "1 / 2" should appear quickly + await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 227d20a1..5df07506 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -14,6 +14,8 @@ export type ErrorCode = | 'WRONG_CURRENT_PASSWORD' | 'IMPORT_ALREADY_RUNNING' | 'INVALID_RESET_TOKEN' + | 'ANNOTATION_NOT_FOUND' + | 'ANNOTATION_OVERLAP' | 'UNAUTHORIZED' | 'FORBIDDEN' | 'VALIDATION_ERROR' @@ -61,6 +63,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_import_already_running(); case 'INVALID_RESET_TOKEN': return m.error_invalid_reset_token(); + case 'ANNOTATION_NOT_FOUND': + return m.error_annotation_not_found(); + case 'ANNOTATION_OVERLAP': + return m.error_annotation_overlap(); case 'UNAUTHORIZED': return m.error_unauthorized(); case 'FORBIDDEN': diff --git a/frontend/src/routes/+layout.server.ts b/frontend/src/routes/+layout.server.ts index e703623b..58a50a87 100644 --- a/frontend/src/routes/+layout.server.ts +++ b/frontend/src/routes/+layout.server.ts @@ -1,11 +1,10 @@ import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = async ({ locals }) => { + const groups: { permissions: string[] }[] = locals.user?.groups ?? []; return { user: locals.user, - canWrite: - locals.user?.groups?.some((g: { permissions: string[] }) => - g.permissions.includes('WRITE_ALL') - ) ?? false + canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')), + canAnnotate: groups.some((g) => g.permissions.includes('ANNOTATE_ALL')) }; }; diff --git a/frontend/src/routes/admin/page.svelte.spec.ts b/frontend/src/routes/admin/page.svelte.spec.ts index 848fb1b4..fcbfd824 100644 --- a/frontend/src/routes/admin/page.svelte.spec.ts +++ b/frontend/src/routes/admin/page.svelte.spec.ts @@ -29,6 +29,7 @@ const makeUser = (overrides = {}) => ({ const baseData = { user: undefined, canWrite: true, + canAnnotate: false, users: [makeUser()], groups: [makeGroup()], tags: [] diff --git a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts index 2ff1b450..7a7822a5 100644 --- a/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts +++ b/frontend/src/routes/admin/users/[id]/page.svelte.spec.ts @@ -24,7 +24,13 @@ const makeUser = (overrides = {}) => ({ ...overrides }); -const baseData = { user: undefined, canWrite: true, editUser: makeUser(), groups }; +const baseData = { + user: undefined, + canWrite: true, + canAnnotate: false, + editUser: makeUser(), + groups +}; afterEach(cleanup); diff --git a/frontend/src/routes/admin/users/new/page.svelte.spec.ts b/frontend/src/routes/admin/users/new/page.svelte.spec.ts index 372cf779..d80f5c6f 100644 --- a/frontend/src/routes/admin/users/new/page.svelte.spec.ts +++ b/frontend/src/routes/admin/users/new/page.svelte.spec.ts @@ -10,7 +10,7 @@ const groups = [ { id: 'g2', name: 'Admins', permissions: ['ADMIN'] } ]; -const baseData = { user: undefined, canWrite: true, groups }; +const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups }; afterEach(cleanup); diff --git a/frontend/src/routes/conversations/page.svelte.spec.ts b/frontend/src/routes/conversations/page.svelte.spec.ts index 5bdc9f57..85f18e63 100644 --- a/frontend/src/routes/conversations/page.svelte.spec.ts +++ b/frontend/src/routes/conversations/page.svelte.spec.ts @@ -12,6 +12,7 @@ afterEach(cleanup); const baseData = { user: undefined, canWrite: true, + canAnnotate: false, documents: [], initialValues: { senderName: '', receiverName: '' }, filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const } diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 75a03373..7716b311 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -3,6 +3,7 @@ import { m } from '$lib/paraglide/messages.js'; import { formatDate } from '$lib/utils/date'; import { diffWords } from 'diff'; import ExpandableText from '$lib/components/ExpandableText.svelte'; +import PdfViewer from '$lib/components/PdfViewer.svelte'; let { data } = $props(); @@ -873,16 +874,12 @@ function versionLabel(v: VersionSummary, index: number): string {

{m.doc_no_scan()}

- {:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')} - + {:else if fileUrl && doc.contentType?.startsWith('application/pdf')} + {:else if fileUrl}
{m.doc_image_alt()} diff --git a/frontend/src/routes/documents/new/page.svelte.spec.ts b/frontend/src/routes/documents/new/page.svelte.spec.ts index 0a269279..dcc3b387 100644 --- a/frontend/src/routes/documents/new/page.svelte.spec.ts +++ b/frontend/src/routes/documents/new/page.svelte.spec.ts @@ -10,6 +10,7 @@ afterEach(cleanup); const baseData = { user: undefined, canWrite: true, + canAnnotate: false, persons: [], initialSenderId: '', initialSenderName: '', diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts index add63655..6103089c 100644 --- a/frontend/src/routes/layout.svelte.spec.ts +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -22,6 +22,7 @@ const makeData = (overrides = {}) => ({ createdAt: '' }, canWrite: true, + canAnnotate: false, ...overrides }); diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts index df6eddd4..d50ce18b 100644 --- a/frontend/src/routes/page.svelte.spec.ts +++ b/frontend/src/routes/page.svelte.spec.ts @@ -20,6 +20,7 @@ afterEach(cleanup); const emptyData = { user: undefined, canWrite: true, + canAnnotate: false, filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] }, documents: [], initialValues: { senderName: '', receiverName: '' }, diff --git a/frontend/src/routes/persons/page.svelte.spec.ts b/frontend/src/routes/persons/page.svelte.spec.ts index a2c1f8d5..9dc5fc75 100644 --- a/frontend/src/routes/persons/page.svelte.spec.ts +++ b/frontend/src/routes/persons/page.svelte.spec.ts @@ -14,7 +14,7 @@ const makePerson = (overrides = {}) => ({ ...overrides }); -const emptyData = { user: undefined, canWrite: true, q: '', persons: [] }; +const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] }; const dataWithPersons = { ...emptyData, persons: [makePerson()] }; afterEach(cleanup); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 76be0fe1..c5f71a26 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,9 @@ import { playwright } from '@vitest/browser-playwright'; import { sveltekit } from '@sveltejs/kit/vite'; export default defineConfig({ + optimizeDeps: { + include: ['pdfjs-dist'] + }, server: { host: '0.0.0.0', // Erlaubt Zugriff von außen port: 5173, // Standard SvelteKit Port diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 386ec56f..c75d7088 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -207,6 +207,28 @@ resolved "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz" integrity sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ== +"@napi-rs/canvas-linux-x64-gnu@0.1.97": + version "0.1.97" + resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz" + integrity sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg== + +"@napi-rs/canvas@^0.1.95": + version "0.1.97" + resolved "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz" + integrity sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ== + optionalDependencies: + "@napi-rs/canvas-android-arm64" "0.1.97" + "@napi-rs/canvas-darwin-arm64" "0.1.97" + "@napi-rs/canvas-darwin-x64" "0.1.97" + "@napi-rs/canvas-linux-arm-gnueabihf" "0.1.97" + "@napi-rs/canvas-linux-arm64-gnu" "0.1.97" + "@napi-rs/canvas-linux-arm64-musl" "0.1.97" + "@napi-rs/canvas-linux-riscv64-gnu" "0.1.97" + "@napi-rs/canvas-linux-x64-gnu" "0.1.97" + "@napi-rs/canvas-linux-x64-musl" "0.1.97" + "@napi-rs/canvas-win32-arm64-msvc" "0.1.97" + "@napi-rs/canvas-win32-x64-msvc" "0.1.97" + "@playwright/test@^1.58.2": version "1.58.2" resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz" @@ -1462,6 +1484,11 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +node-readable-to-web-readable-stream@^0.4.2: + version "0.4.2" + resolved "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz" + integrity sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ== + obug@^2.1.0, obug@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz" @@ -1553,6 +1580,14 @@ pathe@^2.0.3: resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz" integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== +pdfjs-dist@^5.5.207: + version "5.5.207" + resolved "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz" + integrity sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw== + optionalDependencies: + "@napi-rs/canvas" "^0.1.95" + node-readable-to-web-readable-stream "^0.4.2" + picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" diff --git a/scripts/rebuild-frontend.sh b/scripts/rebuild-frontend.sh new file mode 100755 index 00000000..7e50b474 --- /dev/null +++ b/scripts/rebuild-frontend.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Rebuilds the frontend Docker container and refreshes the node_modules volume. +# Run this after adding or updating npm dependencies. +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "Stopping frontend container..." +docker compose stop frontend + +echo "Removing frontend container..." +docker compose rm -f frontend + +echo "Removing stale node_modules volume..." +docker volume rm familienarchiv_frontend_node_modules 2>/dev/null || true + +echo "Rebuilding image and starting container..." +docker compose up -d --build frontend + +echo "Done. Tailing logs (Ctrl+C to exit)..." +docker compose logs -f frontend