From 83e5a1fde5ddf2e32a5b9991c3b445643db546e4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 24 Mar 2026 10:32:11 +0100 Subject: [PATCH 01/10] test(comments): add failing CommentServiceTest and V12 migration (red) Co-Authored-By: Claude Sonnet 4.6 --- .../migration/V12__add_document_comments.sql | 15 ++ .../service/CommentServiceTest.java | 249 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V12__add_document_comments.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java diff --git a/backend/src/main/resources/db/migration/V12__add_document_comments.sql b/backend/src/main/resources/db/migration/V12__add_document_comments.sql new file mode 100644 index 00000000..12e3b843 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__add_document_comments.sql @@ -0,0 +1,15 @@ +CREATE TABLE document_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE, + parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE, + author_id UUID REFERENCES users(id) ON DELETE SET NULL, + author_name VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_dc_document ON document_comments(document_id); +CREATE INDEX idx_dc_annotation ON document_comments(annotation_id); +CREATE INDEX idx_dc_parent ON document_comments(parent_id); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java new file mode 100644 index 00000000..13d1906c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -0,0 +1,249 @@ +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.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.DocumentComment; +import org.raddatz.familienarchiv.model.UserGroup; +import org.raddatz.familienarchiv.repository.CommentRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; +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.FORBIDDEN; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + + @Mock CommentRepository commentRepository; + @InjectMocks CommentService commentService; + + // ─── postComment ────────────────────────────────────────────────────────── + + @Test + void postComment_capturesAuthorNameAtWriteTime() { + UUID docId = UUID.randomUUID(); + AppUser author = AppUser.builder() + .id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("Müller").build(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build(); + when(commentRepository.save(any())).thenReturn(saved); + + DocumentComment result = commentService.postComment(docId, null, "Test", author); + + assertThat(result.getAuthorName()).isEqualTo("Hans Müller"); + } + + @Test + void postComment_fallsBackToUsername_whenNamesAreBlank() { + UUID docId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans42").build(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build(); + when(commentRepository.save(any())).thenReturn(saved); + + DocumentComment result = commentService.postComment(docId, null, "Test", author); + + assertThat(result.getAuthorName()).isEqualTo("hans42"); + } + + // ─── replyToComment ─────────────────────────────────────────────────────── + + @Test + void replyToComment_throwsNotFound_whenTargetCommentMissing() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); + + verify(commentRepository, never()).save(any()); + } + + @Test + void replyToComment_resolvesToRootParent_whenReplyingToAReply() { + UUID docId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + UUID replyId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); + + DocumentComment root = DocumentComment.builder() + .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); + DocumentComment existingReply = DocumentComment.builder() + .id(replyId).documentId(docId).parentId(rootId).content("Reply1").authorName("Anna").build(); + + when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply)); + when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build(); + when(commentRepository.save(any())).thenReturn(saved); + + DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author); + + assertThat(result.getParentId()).isEqualTo(rootId); + } + + @Test + void replyToComment_usesDirectComment_whenReplyingToTopLevel() { + UUID docId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); + + DocumentComment root = DocumentComment.builder() + .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); + + when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build(); + when(commentRepository.save(any())).thenReturn(saved); + + DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author); + + assertThat(result.getParentId()).isEqualTo(rootId); + } + + // ─── editComment ────────────────────────────────────────────────────────── + + @Test + void editComment_throwsForbidden_whenNotAuthor() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build(); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> commentService.editComment(docId, commentId, "Changed", other)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN)); + + verify(commentRepository, never()).save(any()); + } + + @Test + void editComment_updatesContent_whenAuthor() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(authorId).username("hans").build(); + LocalDateTime created = LocalDateTime.now().minusMinutes(5); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(authorId) + .content("Original").authorName("Hans").createdAt(created).build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + DocumentComment result = commentService.editComment(docId, commentId, "Updated", author); + + assertThat(result.getContent()).isEqualTo("Updated"); + assertThat(result.getCreatedAt()).isEqualTo(created); + } + + // ─── deleteComment ──────────────────────────────────────────────────────── + + @Test + void deleteComment_throwsForbidden_whenNotAuthorAndNotAdmin() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build(); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> commentService.deleteComment(docId, commentId, other)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN)); + + verify(commentRepository, never()).delete(any()); + } + + @Test + void deleteComment_succeeds_whenAuthor() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(authorId).username("hans").build(); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + commentService.deleteComment(docId, commentId, author); + + verify(commentRepository).delete(comment); + } + + @Test + void deleteComment_succeeds_whenAdmin() { + UUID docId = UUID.randomUUID(); + UUID commentId = UUID.randomUUID(); + UUID ownerId = UUID.randomUUID(); + AppUser admin = buildAdmin(); + + DocumentComment comment = DocumentComment.builder() + .id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build(); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + + commentService.deleteComment(docId, commentId, admin); + + verify(commentRepository).delete(comment); + } + + // ─── getCommentsForDocument ─────────────────────────────────────────────── + + @Test + void getCommentsForDocument_returnsRootsWithRepliesAttached() { + UUID docId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + + DocumentComment root = DocumentComment.builder() + .id(rootId).documentId(docId).authorName("Hans").content("Root").build(); + DocumentComment reply = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build(); + + when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId)) + .thenReturn(List.of(root)); + when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply)); + + List result = commentService.getCommentsForDocument(docId); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getReplies()).containsExactly(reply); + } + + // ─── helpers ────────────────────────────────────────────────────────────── + + private AppUser buildAdmin() { + return AppUser.builder() + .id(UUID.randomUUID()) + .username("admin") + .groups(Set.of(UserGroup.builder() + .id(UUID.randomUUID()) + .name("admins") + .permissions(Set.of("ADMIN")) + .build())) + .build(); + } +} -- 2.49.1 From 48040dc7e4076651163ab8153ef888b3f96e24a9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 24 Mar 2026 10:33:39 +0100 Subject: [PATCH 02/10] feat(comments): add DocumentComment entity, CommentRepository, and CommentService (green) Co-Authored-By: Claude Sonnet 4.6 --- .../familienarchiv/exception/ErrorCode.java | 4 + .../familienarchiv/model/DocumentComment.java | 63 ++++++++++ .../repository/CommentRepository.java | 16 +++ .../service/CommentService.java | 109 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java 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 04894717..a26e5e5a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -44,6 +44,10 @@ public enum ErrorCode { /** The new annotation overlaps an existing one on the same page. 409 */ ANNOTATION_OVERLAP, + // --- Comments --- + /** The comment with the given ID does not exist. 404 */ + COMMENT_NOT_FOUND, + // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java new file mode 100644 index 00000000..b93b4244 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java @@ -0,0 +1,63 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "document_comments") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DocumentComment { + + @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 = "annotation_id") + private UUID annotationId; + + @Column(name = "parent_id") + private UUID parentId; + + @Column(name = "author_id") + private UUID authorId; + + @Column(name = "author_name", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String authorName; + + @Column(nullable = false, columnDefinition = "TEXT") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Column(name = "created_at", nullable = false, updatable = false) + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + @UpdateTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updatedAt; + + // Populated by the service — not stored in the database + @Transient + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List replies = new ArrayList<>(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java new file mode 100644 index 00000000..80269305 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/CommentRepository.java @@ -0,0 +1,16 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.DocumentComment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface CommentRepository extends JpaRepository { + + List findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId); + + List findByAnnotationIdAndParentIdIsNull(UUID annotationId); + + List findByParentId(UUID parentId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java new file mode 100644 index 00000000..84bf9f0b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -0,0 +1,109 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.DocumentComment; +import org.raddatz.familienarchiv.repository.CommentRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + + public List getCommentsForDocument(UUID documentId) { + List roots = + commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId); + return withReplies(roots); + } + + public List getCommentsForAnnotation(UUID annotationId) { + List roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId); + return withReplies(roots); + } + + @Transactional + public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) { + DocumentComment comment = DocumentComment.builder() + .documentId(documentId) + .annotationId(annotationId) + .content(content) + .authorId(author.getId()) + .authorName(resolveAuthorName(author)) + .build(); + return commentRepository.save(comment); + } + + @Transactional + public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) { + DocumentComment target = commentRepository.findById(commentId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId)); + + UUID rootId = target.getParentId() != null ? target.getParentId() : target.getId(); + DocumentComment root = commentRepository.findById(rootId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + rootId)); + + DocumentComment reply = DocumentComment.builder() + .documentId(documentId) + .annotationId(root.getAnnotationId()) + .parentId(root.getId()) + .content(content) + .authorId(author.getId()) + .authorName(resolveAuthorName(author)) + .build(); + return commentRepository.save(reply); + } + + @Transactional + public DocumentComment editComment(UUID documentId, UUID commentId, String content, AppUser currentUser) { + DocumentComment comment = findComment(documentId, commentId); + if (!currentUser.getId().equals(comment.getAuthorId())) { + throw DomainException.forbidden("Only the comment author can edit it"); + } + comment.setContent(content); + return commentRepository.save(comment); + } + + @Transactional + public void deleteComment(UUID documentId, UUID commentId, AppUser currentUser) { + DocumentComment comment = findComment(documentId, commentId); + boolean isAuthor = currentUser.getId().equals(comment.getAuthorId()); + boolean isAdmin = currentUser.hasPermission("ADMIN"); + if (!isAuthor && !isAdmin) { + throw DomainException.forbidden("Only the comment author or an admin can delete it"); + } + commentRepository.delete(comment); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private List withReplies(List roots) { + roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId()))); + return roots; + } + + private DocumentComment findComment(UUID documentId, UUID commentId) { + return commentRepository.findById(commentId) + .filter(c -> documentId.equals(c.getDocumentId())) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId)); + } + + private String resolveAuthorName(AppUser author) { + String first = author.getFirstName(); + String last = author.getLastName(); + if ((first == null || first.isBlank()) && (last == null || last.isBlank())) { + return author.getUsername(); + } + return ((first != null ? first : "") + " " + (last != null ? last : "")).strip(); + } +} -- 2.49.1 From ee49bac2efd3215a476a058c0610a2f993b4b1c8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 24 Mar 2026 10:34:47 +0100 Subject: [PATCH 03/10] test(comments): add failing CommentControllerTest (red) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/CommentControllerTest.java | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java new file mode 100644 index 00000000..9b3bfbe8 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java @@ -0,0 +1,203 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.model.DocumentComment; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CommentService; +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.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(CommentController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class CommentControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean CommentService commentService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}"; + private static final UUID DOC_ID = UUID.randomUUID(); + private static final UUID ANN_ID = UUID.randomUUID(); + private static final UUID COMMENT_ID = UUID.randomUUID(); + + // ─── GET /api/documents/{documentId}/comments ───────────────────────────── + + @Test + void getDocumentComments_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getDocumentComments_returns200_whenAuthenticated() throws Exception { + when(commentService.getCommentsForDocument(any())).thenReturn(List.of()); + mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments")) + .andExpect(status().isOk()); + } + + // ─── POST /api/documents/{documentId}/comments ──────────────────────────── + + @Test + void postDocumentComment_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void postDocumentComment_returns403_whenMissingPermission() throws Exception { + mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void postDocumentComment_returns201_whenHasPermission() throws Exception { + DocumentComment saved = DocumentComment.builder() + .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); + when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.content").value("Test comment")); + } + + // ─── POST /api/documents/{documentId}/comments/{commentId}/replies ──────── + + @Test + void replyToComment_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void replyToComment_returns201_whenHasPermission() throws Exception { + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID) + .authorName("Anna").content("Test comment").build(); + when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()); + } + + // ─── PATCH /api/documents/{documentId}/comments/{commentId} ────────────── + + @Test + void editComment_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void editComment_returns200_whenHasPermission() throws Exception { + DocumentComment updated = DocumentComment.builder() + .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); + when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isOk()); + } + + // ─── DELETE /api/documents/{documentId}/comments/{commentId} ───────────── + + @Test + void deleteComment_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void deleteComment_returns204_whenAuthenticated() throws Exception { + mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)) + .andExpect(status().isNoContent()); + } + + // ─── GET /api/documents/{documentId}/annotations/{annId}/comments ───────── + + @Test + void getAnnotationComments_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void getAnnotationComments_returns200_whenAuthenticated() throws Exception { + when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of()); + mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")) + .andExpect(status().isOk()); + } + + // ─── POST /api/documents/{documentId}/annotations/{annId}/comments ──────── + + @Test + @WithMockUser + void postAnnotationComment_returns403_whenMissingPermission() throws Exception { + mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void postAnnotationComment_returns201_whenHasPermission() throws Exception { + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) + .authorName("Hans").content("Test comment").build(); + when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()); + } + + // ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─ + + @Test + @WithMockUser(authorities = "ANNOTATE_ALL") + void replyToAnnotationComment_returns201_whenHasPermission() throws Exception { + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) + .parentId(COMMENT_ID).authorName("Anna").content("Test comment").build(); + when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()); + } +} -- 2.49.1 From 3e5d296b0929b5da87f7135e830b81dd1bce6c24 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 24 Mar 2026 10:36:33 +0100 Subject: [PATCH 04/10] feat(comments): add CommentController and CreateCommentDTO (green) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/CommentController.java | 122 ++++++++++++++++++ .../familienarchiv/dto/CreateCommentDTO.java | 8 ++ 2 files changed, 130 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/CreateCommentDTO.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java new file mode 100644 index 00000000..1373f71f --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java @@ -0,0 +1,122 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.CreateCommentDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.DocumentComment; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.service.CommentService; +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 +@RequiredArgsConstructor +@Slf4j +public class CommentController { + + private final CommentService commentService; + private final UserService userService; + + // ─── General document comments ──────────────────────────────────────────── + + @GetMapping("/api/documents/{documentId}/comments") + public List getDocumentComments(@PathVariable UUID documentId) { + return commentService.getCommentsForDocument(documentId); + } + + @PostMapping("/api/documents/{documentId}/comments") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.ANNOTATE_ALL) + public DocumentComment postDocumentComment( + @PathVariable UUID documentId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser author = resolveUser(authentication); + return commentService.postComment(documentId, null, dto.getContent(), author); + } + + @PostMapping("/api/documents/{documentId}/comments/{commentId}/replies") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.ANNOTATE_ALL) + public DocumentComment replyToDocumentComment( + @PathVariable UUID documentId, + @PathVariable UUID commentId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser author = resolveUser(authentication); + return commentService.replyToComment(documentId, commentId, dto.getContent(), author); + } + + // ─── Annotation comments ────────────────────────────────────────────────── + + @GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments") + public List getAnnotationComments(@PathVariable UUID annotationId) { + return commentService.getCommentsForAnnotation(annotationId); + } + + @PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.ANNOTATE_ALL) + public DocumentComment postAnnotationComment( + @PathVariable UUID documentId, + @PathVariable UUID annotationId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser author = resolveUser(authentication); + return commentService.postComment(documentId, annotationId, dto.getContent(), author); + } + + @PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies") + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission(Permission.ANNOTATE_ALL) + public DocumentComment replyToAnnotationComment( + @PathVariable UUID documentId, + @PathVariable UUID commentId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser author = resolveUser(authentication); + return commentService.replyToComment(documentId, commentId, dto.getContent(), author); + } + + // ─── Edit and delete (shared) ───────────────────────────────────────────── + + @PatchMapping("/api/documents/{documentId}/comments/{commentId}") + @RequirePermission(Permission.ANNOTATE_ALL) + public DocumentComment editComment( + @PathVariable UUID documentId, + @PathVariable UUID commentId, + @RequestBody CreateCommentDTO dto, + Authentication authentication) { + AppUser currentUser = resolveUser(authentication); + return commentService.editComment(documentId, commentId, dto.getContent(), currentUser); + } + + @DeleteMapping("/api/documents/{documentId}/comments/{commentId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteComment( + @PathVariable UUID documentId, + @PathVariable UUID commentId, + Authentication authentication) { + AppUser currentUser = resolveUser(authentication); + commentService.deleteComment(documentId, commentId, currentUser); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private AppUser resolveUser(Authentication authentication) { + if (authentication == null || !authentication.isAuthenticated()) return null; + try { + return userService.findByUsername(authentication.getName()); + } catch (Exception e) { + log.warn("Could not resolve user for comment: {}", e.getMessage()); + return null; + } + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateCommentDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateCommentDTO.java new file mode 100644 index 00000000..9caa4b1a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateCommentDTO.java @@ -0,0 +1,8 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +@Data +public class CreateCommentDTO { + private String content; +} -- 2.49.1 From 1070e6e9ec2915f7aa463692b97ebd1cef42384a Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 24 Mar 2026 11:02:38 +0100 Subject: [PATCH 05/10] feat(comments): add CommentThread, annotation panel, Diskussion section, and i18n keys Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 10 +- frontend/messages/en.json | 10 +- frontend/messages/es.json | 10 +- .../components/AnnotationCommentPanel.svelte | 90 ++++ .../src/lib/components/AnnotationLayer.svelte | 36 +- .../src/lib/components/CommentThread.svelte | 394 ++++++++++++++++++ frontend/src/lib/components/PdfViewer.svelte | 50 ++- frontend/src/lib/errors.ts | 3 + .../src/routes/documents/[id]/+page.server.ts | 19 +- .../src/routes/documents/[id]/+page.svelte | 35 +- 10 files changed, 643 insertions(+), 14 deletions(-) create mode 100644 frontend/src/lib/components/AnnotationCommentPanel.svelte create mode 100644 frontend/src/lib/components/CommentThread.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 78d24422..8d8a86c9 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -242,5 +242,13 @@ "admin_system_backfill_btn": "Jetzt auffüllen", "admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.", "comp_expandable_show_more": "Mehr anzeigen", - "comp_expandable_show_less": "Weniger anzeigen" + "comp_expandable_show_less": "Weniger anzeigen", + "error_comment_not_found": "Der Kommentar wurde nicht gefunden.", + "comment_section_title": "Diskussion", + "comment_placeholder": "Kommentar schreiben…", + "comment_btn_post": "Senden", + "comment_btn_reply": "Antworten", + "comment_edited_label": "· bearbeitet", + "comment_panel_title": "Kommentare", + "comment_panel_close": "Schließen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cb9f5abf..8b9fbdf5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -242,5 +242,13 @@ "admin_system_backfill_btn": "Backfill now", "admin_system_backfill_success": "{count} documents were backfilled.", "comp_expandable_show_more": "Show more", - "comp_expandable_show_less": "Show less" + "comp_expandable_show_less": "Show less", + "error_comment_not_found": "The comment could not be found.", + "comment_section_title": "Discussion", + "comment_placeholder": "Write a comment…", + "comment_btn_post": "Send", + "comment_btn_reply": "Reply", + "comment_edited_label": "· edited", + "comment_panel_title": "Comments", + "comment_panel_close": "Close" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1285aacc..01d37915 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -242,5 +242,13 @@ "admin_system_backfill_btn": "Completar ahora", "admin_system_backfill_success": "{count} documentos fueron completados.", "comp_expandable_show_more": "Mostrar más", - "comp_expandable_show_less": "Mostrar menos" + "comp_expandable_show_less": "Mostrar menos", + "error_comment_not_found": "El comentario no pudo encontrarse.", + "comment_section_title": "Discusión", + "comment_placeholder": "Escribe un comentario…", + "comment_btn_post": "Enviar", + "comment_btn_reply": "Responder", + "comment_edited_label": "· editado", + "comment_panel_title": "Comentarios", + "comment_panel_close": "Cerrar" } diff --git a/frontend/src/lib/components/AnnotationCommentPanel.svelte b/frontend/src/lib/components/AnnotationCommentPanel.svelte new file mode 100644 index 00000000..b1bb59fe --- /dev/null +++ b/frontend/src/lib/components/AnnotationCommentPanel.svelte @@ -0,0 +1,90 @@ + + + + + + +
+ + + + +
+
+

+ {m.comment_panel_title()} +

+ +
+
+ +
+
+
diff --git a/frontend/src/lib/components/AnnotationLayer.svelte b/frontend/src/lib/components/AnnotationLayer.svelte index 3a8cfae6..0321514d 100644 --- a/frontend/src/lib/components/AnnotationLayer.svelte +++ b/frontend/src/lib/components/AnnotationLayer.svelte @@ -23,13 +23,17 @@ let { canAnnotate, color, onDraw, - onDelete + onDelete, + commentCounts, + onAnnotationClick }: { annotations: Annotation[]; canAnnotate: boolean; color: string; onDraw: (rect: { x: number; y: number; width: number; height: number }) => void; onDelete: (id: string) => void; + commentCounts?: Record; + onAnnotationClick?: (id: string) => void; } = $props(); let drawStart = $state<{ x: number; y: number } | null>(null); @@ -112,6 +116,10 @@ const containerStyle = $derived(
onAnnotationClick?.(annotation.id)} + onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }} style=" position: absolute; left: {annotation.x * 100}%; @@ -119,7 +127,8 @@ const containerStyle = $derived( width: {annotation.width * 100}%; height: {annotation.height * 100}%; background-color: {hexToRgba(annotation.color, 0.3)}; - pointer-events: {canAnnotate ? 'auto' : 'none'}; + pointer-events: auto; + {onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''} " > {#if canAnnotate} @@ -150,6 +159,29 @@ const containerStyle = $derived( ">× {/if} + {#if (commentCounts?.[annotation.id] ?? 0) > 0} +
+ {commentCounts?.[annotation.id]} +
+ {/if}
{/each} diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte new file mode 100644 index 00000000..0e3afd7a --- /dev/null +++ b/frontend/src/lib/components/CommentThread.svelte @@ -0,0 +1,394 @@ + + +
+ {#each comments as thread, ti (thread.id)} +
0 ? 'border-t border-brand-sand pt-4' : ''}> + +
+ {#if editingId === thread.id} +
+ +
+ + +
+
+ {:else} +
+
+
+ {thread.authorName} + {timeAgo(thread.createdAt)} + {#if wasEdited(thread)} + + {m.comment_edited_label()} + {timeAgo(thread.updatedAt)} + + {/if} +
+

{thread.content}

+
+ {#if canModify(thread)} +
+ + +
+ {/if} +
+ + {#if thread.replies.length === 0 && canComment} +
+ +
+ {/if} + {/if} +
+ + + {#each thread.replies as reply, ri (reply.id)} +
+ {#if editingId === reply.id} +
+ +
+ + +
+
+ {:else} +
+
+
+ {reply.authorName} + {timeAgo(reply.createdAt)} + {#if wasEdited(reply)} + + {m.comment_edited_label()} + {timeAgo(reply.updatedAt)} + + {/if} +
+

{reply.content}

+
+ {#if canModify(reply)} +
+ + +
+ {/if} +
+ + {#if ri === thread.replies.length - 1 && canComment} +
+ +
+ {/if} + {/if} +
+ {/each} + + + {#if replyingTo === thread.id} +
+ +
+ + +
+
+ {/if} +
+ {/each} + + + {#if canComment} +
0 ? 'border-t border-brand-sand pt-4' : ''}> +
+ +
+ +
+
+
+ {/if} +
diff --git a/frontend/src/lib/components/PdfViewer.svelte b/frontend/src/lib/components/PdfViewer.svelte index 7863143c..fc27684d 100644 --- a/frontend/src/lib/components/PdfViewer.svelte +++ b/frontend/src/lib/components/PdfViewer.svelte @@ -1,16 +1,24 @@