feat: add PDF annotation feature (#40)

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-23 23:27:21 +01:00
parent ca5726e7c3
commit b45ec744b2
27 changed files with 903 additions and 9 deletions

View File

@@ -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<DocumentAnnotation> 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;
}
}
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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<DocumentAnnotation, UUID> {
List<DocumentAnnotation> findByDocumentId(UUID documentId);
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
}

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.security;
public enum Permission {
READ_ALL,
WRITE_ALL,
ANNOTATE_ALL,
ADMIN,
ADMIN_USER,
ADMIN_TAG,

View File

@@ -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<DocumentAnnotation> listAnnotations(UUID documentId) {
return annotationRepository.findByDocumentId(documentId);
}
@Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) {
List<DocumentAnnotation> 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();
}
}

View File

@@ -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);

View File

@@ -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());
}
}

View File

@@ -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);
}
}