feat(comments): add block-level comment endpoints with TDD

RED/GREEN for CommentService:
- getCommentsForBlock(blockId): returns root comments filtered by blockId
- postBlockComment(documentId, blockId, content, mentions, author): creates
  comment with block_id set

RED/GREEN for CommentController:
- GET /api/documents/{docId}/transcription-blocks/{blockId}/comments
- POST /api/documents/{docId}/transcription-blocks/{blockId}/comments
- POST .../comments/{commentId}/replies (reuses existing replyToComment)

4 new tests: 2 service unit tests + 2 controller integration tests
All 25 CommentServiceTest + 24 CommentControllerTest green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-05 21:01:02 +02:00
parent 3b2d905041
commit da43cadb0a
5 changed files with 117 additions and 0 deletions

View File

@@ -85,6 +85,37 @@ public class CommentController {
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Block (transcription) comments ────────────────────────────────────────
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
public List<DocumentComment> getBlockComments(@PathVariable UUID blockId) {
return commentService.getCommentsForBlock(blockId);
}
@PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment postBlockComment(
@PathVariable UUID documentId,
@PathVariable UUID blockId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.postBlockComment(documentId, blockId, dto.getContent(), dto.getMentionedUserIds(), author);
}
@PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments/{commentId}/replies")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
public DocumentComment replyToBlockComment(
@PathVariable UUID documentId,
@PathVariable UUID commentId,
@RequestBody CreateCommentDTO dto,
Authentication authentication) {
AppUser author = resolveUser(authentication);
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
}
// ─── Edit and delete (shared) ─────────────────────────────────────────────
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")

View File

@@ -13,4 +13,6 @@ public interface CommentRepository extends JpaRepository<DocumentComment, UUID>
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
List<DocumentComment> findByParentId(UUID parentId);
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
}

View File

@@ -34,6 +34,28 @@ public class CommentService {
return withRepliesAndMentions(roots);
}
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
return withRepliesAndMentions(roots);
}
@Transactional
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
List<UUID> mentionedUserIds, AppUser author) {
DocumentComment comment = DocumentComment.builder()
.documentId(documentId)
.blockId(blockId)
.content(content)
.authorId(author.getId())
.authorName(resolveAuthorName(author))
.build();
saveMentions(comment, mentionedUserIds);
DocumentComment saved = commentRepository.save(comment);
withMentionDTOs(saved);
notificationService.notifyMentions(mentionedUserIds, saved);
return saved;
}
@Transactional
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
List<UUID> mentionedUserIds, AppUser author) {

View File

@@ -279,4 +279,30 @@ class CommentControllerTest {
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated());
}
// ─── Block comment endpoints ─────────────────────────────────────────────
@Test
@WithMockUser
void getBlockComments_returns200() throws Exception {
UUID blockId = UUID.randomUUID();
when(commentService.getCommentsForBlock(blockId)).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(authorities = "WRITE_ALL")
void postBlockComment_returns201() throws Exception {
UUID blockId = UUID.randomUUID();
DocumentComment saved = DocumentComment.builder()
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
}
}

View File

@@ -488,4 +488,40 @@ class CommentServiceTest {
.build()))
.build();
}
// ─── Block-level comments ────────────────────────────────────────────────
@Test
void getCommentsForBlock_returnsRootCommentsFilteredByBlockId() {
UUID blockId = UUID.randomUUID();
DocumentComment root = DocumentComment.builder()
.id(UUID.randomUUID()).blockId(blockId).content("Nice work").authorName("Felix")
.createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()).build();
when(commentRepository.findByBlockIdAndParentIdIsNull(blockId)).thenReturn(List.of(root));
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of());
List<DocumentComment> result = commentService.getCommentsForBlock(blockId);
assertThat(result).hasSize(1);
assertThat(result.getFirst().getContent()).isEqualTo("Nice work");
}
@Test
void postBlockComment_setsBlockIdOnComment() {
UUID documentId = UUID.randomUUID();
UUID blockId = UUID.randomUUID();
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("felix").firstName("Felix").lastName("Brandt").build();
when(commentRepository.save(any())).thenAnswer(inv -> {
DocumentComment c = inv.getArgument(0);
c.setId(UUID.randomUUID());
return c;
});
DocumentComment result = commentService.postBlockComment(
documentId, blockId, "Looks like Breslau", List.of(), author);
assertThat(result.getBlockId()).isEqualTo(blockId);
assertThat(result.getDocumentId()).isEqualTo(documentId);
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
}
}