bug: notification deep-link does not scroll to comment on document detail page #299
@@ -24,67 +24,6 @@ public class CommentController {
|
|||||||
private final CommentService commentService;
|
private final CommentService commentService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
// ─── General document comments ────────────────────────────────────────────
|
|
||||||
|
|
||||||
@GetMapping("/api/documents/{documentId}/comments")
|
|
||||||
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
|
|
||||||
return commentService.getCommentsForDocument(documentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/comments")
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
|
||||||
public DocumentComment postDocumentComment(
|
|
||||||
@PathVariable UUID documentId,
|
|
||||||
@RequestBody CreateCommentDTO dto,
|
|
||||||
Authentication authentication) {
|
|
||||||
AppUser author = resolveUser(authentication);
|
|
||||||
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_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(), dto.getMentionedUserIds(), author);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Annotation comments ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
|
||||||
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
|
|
||||||
return commentService.getCommentsForAnnotation(annotationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_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(), dto.getMentionedUserIds(), author);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_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(), dto.getMentionedUserIds(), author);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Block (transcription) comments ────────────────────────────────────────
|
// ─── Block (transcription) comments ────────────────────────────────────────
|
||||||
|
|
||||||
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
|
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
|
||||||
|
|
||||||
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
|
|
||||||
|
|
||||||
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
|
|
||||||
|
|
||||||
List<DocumentComment> findByParentId(UUID parentId);
|
List<DocumentComment> findByParentId(UUID parentId);
|
||||||
|
|
||||||
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
|
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
|||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -26,17 +27,7 @@ public class CommentService {
|
|||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final TranscriptionService transcriptionService;
|
||||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
|
||||||
List<DocumentComment> roots =
|
|
||||||
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
|
||||||
return withRepliesAndMentions(roots);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
|
||||||
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
|
||||||
return withRepliesAndMentions(roots);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||||
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
||||||
@@ -46,27 +37,11 @@ public class CommentService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
|
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
|
||||||
List<UUID> mentionedUserIds, AppUser author) {
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
|
TranscriptionBlock block = transcriptionService.getBlock(documentId, blockId);
|
||||||
DocumentComment comment = DocumentComment.builder()
|
DocumentComment comment = DocumentComment.builder()
|
||||||
.documentId(documentId)
|
.documentId(documentId)
|
||||||
.blockId(blockId)
|
.blockId(blockId)
|
||||||
.content(content)
|
.annotationId(block.getAnnotationId())
|
||||||
.authorId(author.getId())
|
|
||||||
.authorName(resolveAuthorName(author))
|
|
||||||
.build();
|
|
||||||
saveMentions(comment, mentionedUserIds);
|
|
||||||
DocumentComment saved = commentRepository.save(comment);
|
|
||||||
withMentionDTOs(saved);
|
|
||||||
notificationService.notifyMentions(mentionedUserIds, saved);
|
|
||||||
logCommentPosted(author, documentId, saved, mentionedUserIds);
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
|
|
||||||
List<UUID> mentionedUserIds, AppUser author) {
|
|
||||||
DocumentComment comment = DocumentComment.builder()
|
|
||||||
.documentId(documentId)
|
|
||||||
.annotationId(annotationId)
|
|
||||||
.content(content)
|
.content(content)
|
||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(resolveAuthorName(author))
|
.authorName(resolveAuthorName(author))
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Backfill annotation_id on block comments and their notifications.
|
||||||
|
--
|
||||||
|
-- Before the upstream fix, CommentService.postBlockComment did not set
|
||||||
|
-- DocumentComment.annotationId, so block comments were stored with
|
||||||
|
-- annotation_id = NULL and every notification built from them inherited
|
||||||
|
-- that NULL (see NotificationService.notifyMentions/notifyReply).
|
||||||
|
--
|
||||||
|
-- The frontend deep-link flow needs annotationId in the URL query string
|
||||||
|
-- to open the correct annotation panel and scroll to the comment.
|
||||||
|
-- Without this backfill, previously issued notifications would still
|
||||||
|
-- carry annotation_id = NULL even after the code fix lands.
|
||||||
|
|
||||||
|
UPDATE document_comments dc
|
||||||
|
SET annotation_id = tb.annotation_id
|
||||||
|
FROM transcription_blocks tb
|
||||||
|
WHERE dc.block_id = tb.id
|
||||||
|
AND dc.annotation_id IS NULL;
|
||||||
|
|
||||||
|
UPDATE notifications n
|
||||||
|
SET annotation_id = dc.annotation_id
|
||||||
|
FROM document_comments dc
|
||||||
|
WHERE n.reference_id = dc.id
|
||||||
|
AND n.annotation_id IS NULL
|
||||||
|
AND dc.annotation_id IS NOT NULL;
|
||||||
@@ -40,246 +40,8 @@ class CommentControllerTest {
|
|||||||
|
|
||||||
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
||||||
private static final UUID DOC_ID = UUID.randomUUID();
|
private static final UUID DOC_ID = UUID.randomUUID();
|
||||||
private static final UUID ANN_ID = UUID.randomUUID();
|
|
||||||
private static final UUID COMMENT_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(), 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"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void postDocumentComment_returns201_whenHasWriteAllPermission() 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(), any())).thenReturn(saved);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
|
||||||
.andExpect(status().isCreated());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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(), any())).thenReturn(saved);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
|
||||||
.andExpect(status().isCreated());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void replyToComment_returns201_whenHasWriteAllPermission() 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(), 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void editComment_returns200_whenHasWriteAllPermission() 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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(), any())).thenReturn(saved);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
|
||||||
.andExpect(status().isCreated());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void postAnnotationComment_returns201_whenHasWriteAllPermission() 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(), 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(), 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void replyToAnnotationComment_returns201_whenHasWriteAllPermission() 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(), 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── resolveUser — exception branch ──────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@WithMockUser(authorities = "WRITE_ALL")
|
|
||||||
void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception {
|
|
||||||
// findByEmail throws → catch block in resolveUser → author null, saves anyway
|
|
||||||
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build();
|
|
||||||
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
|
||||||
.andExpect(status().isCreated());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Block comment endpoints ─────────────────────────────────────────────
|
// ─── Block comment endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -305,4 +67,138 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isCreated())
|
.andExpect(status().isCreated())
|
||||||
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void postBlockComment_returns403_whenMissingPermission() throws Exception {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void postBlockComment_returns201_whenHasAnnotatePermission() 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void postBlockComment_stillSucceeds_whenUserServiceThrows() throws Exception {
|
||||||
|
// findByEmail throws → catch block in resolveUser → author null, saves anyway
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
when(userService.findByEmail(any())).thenThrow(new RuntimeException("DB error"));
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Test comment").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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Block reply endpoints ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToBlockComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
|
+ "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void replyToBlockComment_returns201_whenHasPermission() throws Exception {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID)
|
||||||
|
.authorName("Anna").content("Reply").build();
|
||||||
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
|
+ "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void replyToBlockComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).parentId(COMMENT_ID)
|
||||||
|
.authorName("Anna").content("Reply").build();
|
||||||
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId
|
||||||
|
+ "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/documents/{documentId}/comments/{commentId} (shared edit) ──
|
||||||
|
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void editComment_returns200_whenHasWriteAllPermission() 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} (shared) ────
|
||||||
|
|
||||||
|
@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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,57 @@ class MigrationIntegrationTest {
|
|||||||
).isInstanceOf(DataIntegrityViolationException.class);
|
).isInstanceOf(DataIntegrityViolationException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── V51: backfill annotation_id on block comments and notifications ─────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v51_backfillsAnnotationIdOnBlockCommentsFromTheirBlocks() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
UUID annotationId = insertAnnotation(docId);
|
||||||
|
UUID blockId = insertBlock(docId, annotationId);
|
||||||
|
UUID commentId = insertBlockCommentWithNullAnnotationId(docId, blockId);
|
||||||
|
|
||||||
|
jdbc.update(V51_BACKFILL_COMMENTS_SQL);
|
||||||
|
|
||||||
|
UUID stored = jdbc.queryForObject(
|
||||||
|
"SELECT annotation_id FROM document_comments WHERE id = ?",
|
||||||
|
UUID.class, commentId);
|
||||||
|
assertThat(stored).isEqualTo(annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v51_backfillsAnnotationIdOnNotificationsFromTheirReferencedComment() {
|
||||||
|
UUID docId = createDocument();
|
||||||
|
UUID userId = insertUser("recipient-" + UUID.randomUUID() + "@example.com");
|
||||||
|
UUID annotationId = insertAnnotation(docId);
|
||||||
|
UUID blockId = insertBlock(docId, annotationId);
|
||||||
|
UUID commentId = insertBlockCommentWithAnnotationId(docId, blockId, annotationId);
|
||||||
|
UUID notificationId = insertNotificationWithNullAnnotationId(docId, commentId, userId);
|
||||||
|
|
||||||
|
jdbc.update(V51_BACKFILL_NOTIFICATIONS_SQL);
|
||||||
|
|
||||||
|
UUID stored = jdbc.queryForObject(
|
||||||
|
"SELECT annotation_id FROM notifications WHERE id = ?",
|
||||||
|
UUID.class, notificationId);
|
||||||
|
assertThat(stored).isEqualTo(annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String V51_BACKFILL_COMMENTS_SQL = """
|
||||||
|
UPDATE document_comments dc
|
||||||
|
SET annotation_id = tb.annotation_id
|
||||||
|
FROM transcription_blocks tb
|
||||||
|
WHERE dc.block_id = tb.id
|
||||||
|
AND dc.annotation_id IS NULL
|
||||||
|
""";
|
||||||
|
|
||||||
|
private static final String V51_BACKFILL_NOTIFICATIONS_SQL = """
|
||||||
|
UPDATE notifications n
|
||||||
|
SET annotation_id = dc.annotation_id
|
||||||
|
FROM document_comments dc
|
||||||
|
WHERE n.reference_id = dc.id
|
||||||
|
AND n.annotation_id IS NULL
|
||||||
|
AND dc.annotation_id IS NOT NULL
|
||||||
|
""";
|
||||||
|
|
||||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private UUID createPerson(String firstName, String lastName) {
|
private UUID createPerson(String firstName, String lastName) {
|
||||||
@@ -326,4 +377,63 @@ class MigrationIntegrationTest {
|
|||||||
em.flush();
|
em.flush();
|
||||||
return doc.getId();
|
return doc.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private UUID insertAnnotation(UUID docId) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO document_annotations
|
||||||
|
(id, document_id, page_number, x, y, width, height, color)
|
||||||
|
VALUES (?, ?, 1, 0.1, 0.1, 0.3, 0.1, '#00C7B1')
|
||||||
|
""", id, docId);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID insertBlock(UUID docId, UUID annotationId) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO transcription_blocks
|
||||||
|
(id, annotation_id, document_id, text, sort_order)
|
||||||
|
VALUES (?, ?, ?, '', 0)
|
||||||
|
""", id, annotationId, docId);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID insertUser(String email) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO users (id, email, password, enabled, notify_on_reply, notify_on_mention)
|
||||||
|
VALUES (?, ?, 'hash', true, false, false)
|
||||||
|
""", id, email);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID insertBlockCommentWithNullAnnotationId(UUID docId, UUID blockId) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO document_comments
|
||||||
|
(id, document_id, block_id, annotation_id, author_name, content)
|
||||||
|
VALUES (?, ?, ?, NULL, 'Tester', 'Hi')
|
||||||
|
""", id, docId, blockId);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID insertBlockCommentWithAnnotationId(UUID docId, UUID blockId, UUID annotationId) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO document_comments
|
||||||
|
(id, document_id, block_id, annotation_id, author_name, content)
|
||||||
|
VALUES (?, ?, ?, ?, 'Tester', 'Hi')
|
||||||
|
""", id, docId, blockId, annotationId);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID insertNotificationWithNullAnnotationId(UUID docId, UUID commentId, UUID recipientId) {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO notifications
|
||||||
|
(id, recipient_id, type, document_id, reference_id, annotation_id, read, actor_name)
|
||||||
|
VALUES (?, ?, 'MENTION', ?, ?, NULL, false, 'Tester')
|
||||||
|
""", id, recipientId, docId, commentId);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.audit.AuditService;
|
|||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.model.UserGroup;
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
|
|
||||||
@@ -39,54 +40,9 @@ class CommentServiceTest {
|
|||||||
@Mock UserService userService;
|
@Mock UserService userService;
|
||||||
@Mock NotificationService notificationService;
|
@Mock NotificationService notificationService;
|
||||||
@Mock AuditService auditService;
|
@Mock AuditService auditService;
|
||||||
|
@Mock TranscriptionService transcriptionService;
|
||||||
@InjectMocks CommentService commentService;
|
@InjectMocks CommentService commentService;
|
||||||
|
|
||||||
// ─── postComment ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_capturesAuthorNameAtWriteTime() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder()
|
|
||||||
.id(UUID.randomUUID()).email("hans@example.com").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", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_fallsBackToUsername_whenNamesAreBlank() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").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", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID mentionedId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
|
||||||
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build();
|
|
||||||
|
|
||||||
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postComment(docId, null, "Hey @Anna S", List.of(mentionedId), author);
|
|
||||||
|
|
||||||
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── replyToComment ───────────────────────────────────────────────────────
|
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -222,7 +178,7 @@ class CommentServiceTest {
|
|||||||
.id(commentId).documentId(docId).authorId(authorId)
|
.id(commentId).documentId(docId).authorId(authorId)
|
||||||
.content("Original").authorName("Hans").createdAt(created).build();
|
.content("Original").authorName("Hans").createdAt(created).build();
|
||||||
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
stubSaveAssigningRandomId();
|
||||||
|
|
||||||
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
||||||
|
|
||||||
@@ -282,28 +238,6 @@ class CommentServiceTest {
|
|||||||
verify(commentRepository).delete(comment);
|
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<DocumentComment> result = commentService.getCommentsForDocument(docId);
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── replyToComment — reply with null authorId in thread ─────────────────
|
// ─── replyToComment — reply with null authorId in thread ─────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -330,82 +264,6 @@ class CommentServiceTest {
|
|||||||
verify(notificationService).notifyReply(eq(saved), anySet());
|
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── resolveAuthorName edge cases ─────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
|
||||||
.firstName(" ").lastName(null).build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("user42");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
|
||||||
.firstName(null).lastName(" ").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
|
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("user42");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_includesOnlyFirstName_whenLastNameIsNull() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
|
||||||
.firstName("Hans").lastName(null).build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build();
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postComment(docId, null, "Hi", List.of(), author);
|
|
||||||
|
|
||||||
// first != null && !blank → true; last == null → entire condition false → returns stripped first
|
|
||||||
verify(commentRepository).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_includesOnlyLastName_whenFirstNameIsNull() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
|
||||||
.firstName(null).lastName("Müller").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build();
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postComment(docId, null, "Hi", List.of(), author);
|
|
||||||
|
|
||||||
// No exception — name resolution with null first name strips cleanly
|
|
||||||
verify(commentRepository).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── saveMentions — null/empty guard ─────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
|
|
||||||
.firstName("Hans").lastName("M").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build();
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postComment(docId, null, "Hi", null, author);
|
|
||||||
|
|
||||||
verify(userService, never()).findAllById(anyList());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── collectParticipantIds — non-null authorId in reply ──────────────────
|
// ─── collectParticipantIds — non-null authorId in reply ──────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -459,26 +317,6 @@ class CommentServiceTest {
|
|||||||
verify(notificationService).notifyReply(eq(saved), anySet());
|
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── getCommentsForAnnotation ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getCommentsForAnnotation_returnsRootsForAnnotation() {
|
|
||||||
UUID annotationId = UUID.randomUUID();
|
|
||||||
UUID rootId = UUID.randomUUID();
|
|
||||||
|
|
||||||
DocumentComment root = DocumentComment.builder()
|
|
||||||
.id(rootId).annotationId(annotationId).authorName("Hans").content("Root").build();
|
|
||||||
|
|
||||||
when(commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId))
|
|
||||||
.thenReturn(List.of(root));
|
|
||||||
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
|
||||||
|
|
||||||
List<DocumentComment> result = commentService.getCommentsForAnnotation(annotationId);
|
|
||||||
|
|
||||||
assertThat(result).hasSize(1);
|
|
||||||
assertThat(result.get(0).getAnnotationId()).isEqualTo(annotationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private AppUser buildAdmin() {
|
private AppUser buildAdmin() {
|
||||||
@@ -495,65 +333,6 @@ class CommentServiceTest {
|
|||||||
|
|
||||||
// ─── audit: COMMENT_ADDED and MENTION_CREATED ─────────────────────────────
|
// ─── audit: COMMENT_ADDED and MENTION_CREATED ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_logsCommentAdded() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID savedId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(savedId).documentId(docId).authorName("Hans M").content("Hello").build();
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postComment(docId, null, "Hello", List.of(), author);
|
|
||||||
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
eq(AuditKind.COMMENT_ADDED),
|
|
||||||
eq(author.getId()),
|
|
||||||
eq(docId),
|
|
||||||
argThat(p -> savedId.toString().equals(p.get("commentId"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_logsMentionCreated_oncePerMentionedUser() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
UUID savedId = UUID.randomUUID();
|
|
||||||
UUID mentionedId1 = UUID.randomUUID();
|
|
||||||
UUID mentionedId2 = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
|
||||||
AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build();
|
|
||||||
AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(savedId).documentId(docId).authorName("Hans M").content("Hey @Anna @Bob").build();
|
|
||||||
when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2));
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postComment(docId, null, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author);
|
|
||||||
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
eq(AuditKind.MENTION_CREATED),
|
|
||||||
eq(author.getId()),
|
|
||||||
eq(docId),
|
|
||||||
argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId"))));
|
|
||||||
verify(auditService).logAfterCommit(
|
|
||||||
eq(AuditKind.MENTION_CREATED),
|
|
||||||
eq(author.getId()),
|
|
||||||
eq(docId),
|
|
||||||
argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId"))));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void postComment_doesNotLogMentionCreated_whenNoMentions() {
|
|
||||||
UUID docId = UUID.randomUUID();
|
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
|
||||||
DocumentComment saved = DocumentComment.builder()
|
|
||||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hello").build();
|
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
|
||||||
|
|
||||||
commentService.postComment(docId, null, "Hello", List.of(), author);
|
|
||||||
|
|
||||||
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void replyToComment_logsCommentAdded() {
|
void replyToComment_logsCommentAdded() {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
@@ -611,6 +390,8 @@ class CommentServiceTest {
|
|||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("B").build();
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("B").build();
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(savedId).documentId(docId).blockId(blockId).authorName("Felix B").content("Nice").build();
|
.id(savedId).documentId(docId).blockId(blockId).authorName("Felix B").content("Nice").build();
|
||||||
|
when(transcriptionService.getBlock(docId, blockId))
|
||||||
|
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build());
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
commentService.postBlockComment(docId, blockId, "Nice", List.of(), author);
|
commentService.postBlockComment(docId, blockId, "Nice", List.of(), author);
|
||||||
@@ -643,7 +424,10 @@ class CommentServiceTest {
|
|||||||
void postBlockComment_setsBlockIdOnComment() {
|
void postBlockComment_setsBlockIdOnComment() {
|
||||||
UUID documentId = UUID.randomUUID();
|
UUID documentId = UUID.randomUUID();
|
||||||
UUID blockId = UUID.randomUUID();
|
UUID blockId = UUID.randomUUID();
|
||||||
|
UUID annotationId = UUID.randomUUID();
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
||||||
|
when(transcriptionService.getBlock(documentId, blockId))
|
||||||
|
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build());
|
||||||
when(commentRepository.save(any())).thenAnswer(inv -> {
|
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||||
DocumentComment c = inv.getArgument(0);
|
DocumentComment c = inv.getArgument(0);
|
||||||
c.setId(UUID.randomUUID());
|
c.setId(UUID.randomUUID());
|
||||||
@@ -657,4 +441,217 @@ class CommentServiceTest {
|
|||||||
assertThat(result.getDocumentId()).isEqualTo(documentId);
|
assertThat(result.getDocumentId()).isEqualTo(documentId);
|
||||||
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
|
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_setsAnnotationIdFromBlock() {
|
||||||
|
UUID documentId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
UUID annotationId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
||||||
|
when(transcriptionService.getBlock(documentId, blockId))
|
||||||
|
.thenReturn(TranscriptionBlock.builder().id(blockId).documentId(documentId).annotationId(annotationId).sortOrder(0).build());
|
||||||
|
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
DocumentComment c = inv.getArgument(0);
|
||||||
|
c.setId(UUID.randomUUID());
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postBlockComment(
|
||||||
|
documentId, blockId, "Nice work", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAnnotationId()).isEqualTo(annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_propagatesNotFound_whenBlockDoesNotExist() {
|
||||||
|
UUID documentId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("felix@example.com").firstName("Felix").lastName("Brandt").build();
|
||||||
|
when(transcriptionService.getBlock(documentId, blockId))
|
||||||
|
.thenThrow(DomainException.notFound(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND,
|
||||||
|
"Transcription block not found: " + blockId));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> commentService.postBlockComment(documentId, blockId, "Hi", List.of(), author))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("Transcription block not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── postBlockComment — authorName resolution ────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_capturesAuthorNameAtWriteTime() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder()
|
||||||
|
.id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("Müller").build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
stubSaveAssigningRandomId();
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_fallsBackToEmail_whenNamesAreBlank() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans42@example.com").build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
stubSaveAssigningRandomId();
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postBlockComment(docId, blockId, "Test", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("hans42@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_fallsBackToEmail_whenFirstNameBlankAndLastNameNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||||
|
.firstName(" ").lastName(null).build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
stubSaveAssigningRandomId();
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("user42@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_fallsBackToEmail_whenFirstNameNullAndLastNameBlank() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||||
|
.firstName(null).lastName(" ").build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
stubSaveAssigningRandomId();
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("user42@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_usesFirstNameAlone_whenLastNameIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||||
|
.firstName("Hans").lastName(null).build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
stubSaveAssigningRandomId();
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_usesLastNameAlone_whenFirstNameIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("user42@example.com")
|
||||||
|
.firstName(null).lastName("Müller").build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
stubSaveAssigningRandomId();
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postBlockComment(docId, blockId, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("Müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── postBlockComment — mentions ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
UUID mentionedId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||||
|
AppUser mentioned = AppUser.builder().id(mentionedId).email("anna@example.com").firstName("Anna").lastName("S").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna S").build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postBlockComment(docId, blockId, "Hey @Anna S", List.of(mentionedId), author);
|
||||||
|
|
||||||
|
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com")
|
||||||
|
.firstName("Hans").lastName("M").build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
stubSaveAssigningRandomId();
|
||||||
|
|
||||||
|
commentService.postBlockComment(docId, blockId, "Hi", null, author);
|
||||||
|
|
||||||
|
verify(userService, never()).findAllById(anyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_logsMentionCreated_oncePerMentionedUser() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
UUID savedId = UUID.randomUUID();
|
||||||
|
UUID mentionedId1 = UUID.randomUUID();
|
||||||
|
UUID mentionedId2 = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||||
|
AppUser mentioned1 = AppUser.builder().id(mentionedId1).email("anna@example.com").firstName("Anna").lastName("S").build();
|
||||||
|
AppUser mentioned2 = AppUser.builder().id(mentionedId2).email("bob@example.com").firstName("Bob").lastName("J").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(savedId).documentId(docId).blockId(blockId).authorName("Hans M").content("Hey @Anna @Bob").build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
when(userService.findAllById(List.of(mentionedId1, mentionedId2))).thenReturn(List.of(mentioned1, mentioned2));
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postBlockComment(docId, blockId, "Hey @Anna @Bob", List.of(mentionedId1, mentionedId2), author);
|
||||||
|
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
eq(AuditKind.MENTION_CREATED),
|
||||||
|
eq(author.getId()),
|
||||||
|
eq(docId),
|
||||||
|
argThat(p -> mentionedId1.toString().equals(p.get("mentionedUserId"))));
|
||||||
|
verify(auditService).logAfterCommit(
|
||||||
|
eq(AuditKind.MENTION_CREATED),
|
||||||
|
eq(author.getId()),
|
||||||
|
eq(docId),
|
||||||
|
argThat(p -> mentionedId2.toString().equals(p.get("mentionedUserId"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_doesNotLogMentionCreated_whenNoMentions() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).email("hans@example.com").firstName("Hans").lastName("M").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).blockId(blockId).authorName("Hans M").content("Hello").build();
|
||||||
|
stubBlock(docId, blockId);
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postBlockComment(docId, blockId, "Hello", List.of(), author);
|
||||||
|
|
||||||
|
verify(auditService, never()).logAfterCommit(eq(AuditKind.MENTION_CREATED), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubBlock(UUID docId, UUID blockId) {
|
||||||
|
when(transcriptionService.getBlock(docId, blockId))
|
||||||
|
.thenReturn(TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).annotationId(UUID.randomUUID()).sortOrder(0).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubSaveAssigningRandomId() {
|
||||||
|
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
DocumentComment c = inv.getArgument(0);
|
||||||
|
if (c.getId() == null) c.setId(UUID.randomUUID());
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
frontend/e2e/notification-deep-link.spec.ts
Normal file
117
frontend/e2e/notification-deep-link.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { AxeBuilder } from '@axe-core/playwright';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E test for the notification deep-link scroll flow — issue #276.
|
||||||
|
*
|
||||||
|
* Seeds a document + transcription block + block comment via API, then
|
||||||
|
* visits /documents/{id}?commentId=X&annotationId=Y and verifies:
|
||||||
|
* - page enters transcribe mode
|
||||||
|
* - the target comment is visible in the viewport
|
||||||
|
* - focus lands on the comment article
|
||||||
|
* - URL query params are stripped after handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
let docHref: string;
|
||||||
|
let docId: string;
|
||||||
|
let annotationId: string;
|
||||||
|
let commentId: string;
|
||||||
|
|
||||||
|
test.describe('Notification deep-link scroll', () => {
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Deep-Link Test', documentDate: '1945-05-08' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
docId = doc.id;
|
||||||
|
docHref = `${baseURL}/documents/${docId}`;
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${docId}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
documentDate: '1945-05-08',
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
const blockRes = await request.post(`/api/documents/${docId}/transcription-blocks`, {
|
||||||
|
data: {
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.1,
|
||||||
|
text: 'Seeded line',
|
||||||
|
label: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!blockRes.ok()) throw new Error(`Create block failed: ${blockRes.status()}`);
|
||||||
|
const block = await blockRes.json();
|
||||||
|
annotationId = block.annotationId;
|
||||||
|
|
||||||
|
const commentRes = await request.post(
|
||||||
|
`/api/documents/${docId}/transcription-blocks/${block.id}/comments`,
|
||||||
|
{
|
||||||
|
data: { content: 'Target comment for deep-link test' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!commentRes.ok()) throw new Error(`Create comment failed: ${commentRes.status()}`);
|
||||||
|
const comment = await commentRes.json();
|
||||||
|
commentId = comment.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openDeepLink(page: Page) {
|
||||||
|
const url = `${docHref}?commentId=${commentId}&annotationId=${annotationId}`;
|
||||||
|
await page.goto(url);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ width: 320, height: 700, name: 'mobile-320' },
|
||||||
|
{ width: 1440, height: 900, name: 'desktop-1440' }
|
||||||
|
]) {
|
||||||
|
test(`deep-link scrolls comment into view at ${viewport.name}`, async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||||||
|
await openDeepLink(page);
|
||||||
|
|
||||||
|
// Transcribe mode was auto-entered — Fertig button is visible
|
||||||
|
await expect(page.getByRole('button', { name: 'Fertig' })).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// The target comment article is in the DOM and visible
|
||||||
|
const article = page.locator(`#comment-${commentId}`);
|
||||||
|
await expect(article).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// URL query params are stripped after handling
|
||||||
|
await expect.poll(() => page.url()).not.toContain('commentId=');
|
||||||
|
await expect.poll(() => page.url()).not.toContain('annotationId=');
|
||||||
|
|
||||||
|
await page.screenshot({
|
||||||
|
path: `test-results/e2e/notification-deep-link-${viewport.name}.png`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('axe accessibility check passes on document detail with deep-link', async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await openDeepLink(page);
|
||||||
|
await expect(page.locator(`#comment-${commentId}`)).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
const results = await new AxeBuilder({ page }).analyze();
|
||||||
|
expect(results.violations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,12 @@ const wasEdited = $derived(message.updatedAt > message.createdAt);
|
|||||||
const parsed = $derived(extractQuote(message.content));
|
const parsed = $derived(extractQuote(message.content));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div role="article" class="flex gap-2">
|
<div
|
||||||
|
id="comment-{message.id}"
|
||||||
|
role="article"
|
||||||
|
tabindex="-1"
|
||||||
|
class="flex gap-2 rounded outline-none focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
<!-- Avatar circle with initials -->
|
<!-- Avatar circle with initials -->
|
||||||
<div
|
<div
|
||||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
||||||
|
|||||||
@@ -82,4 +82,25 @@ describe('CommentMessage', () => {
|
|||||||
await expect.element(textarea).toBeInTheDocument();
|
await expect.element(textarea).toBeInTheDocument();
|
||||||
await expect.element(textarea).toHaveValue('current edit text');
|
await expect.element(textarea).toHaveValue('current edit text');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('exposes id="comment-{message.id}" on the article wrapper for deep-link scroll', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
const article = page.getByRole('article').element();
|
||||||
|
expect(article.getAttribute('id')).toBe('comment-msg-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is focusable but not in tab order (tabindex="-1")', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
const article = page.getByRole('article').element();
|
||||||
|
expect(article.getAttribute('tabindex')).toBe('-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a focus-visible ring when focused via keyboard', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
const article = page.getByRole('article').element();
|
||||||
|
const classes = article.className;
|
||||||
|
expect(classes).toMatch(/focus-visible:ring-2/);
|
||||||
|
expect(classes).toMatch(/focus-visible:ring-brand-navy/);
|
||||||
|
expect(classes).toMatch(/outline-none/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
129
frontend/src/lib/utils/deepLinkScroll.spec.ts
Normal file
129
frontend/src/lib/utils/deepLinkScroll.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { scrollToCommentFromQuery, type DeepLinkScrollOptions } from './deepLinkScroll';
|
||||||
|
|
||||||
|
const COMMENT_ID = 'cccc1111-1111-1111-1111-111111111111';
|
||||||
|
const ANNOTATION_ID = 'aaaa2222-2222-2222-2222-222222222222';
|
||||||
|
|
||||||
|
function fakeElement() {
|
||||||
|
return {
|
||||||
|
scrollIntoView: vi.fn(),
|
||||||
|
focus: vi.fn()
|
||||||
|
} as unknown as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Overrides = Partial<DeepLinkScrollOptions>;
|
||||||
|
|
||||||
|
function buildOpts(overrides: Overrides = {}): DeepLinkScrollOptions {
|
||||||
|
const el = overrides.getElement ? null : fakeElement();
|
||||||
|
return {
|
||||||
|
transcribeMode: true,
|
||||||
|
setTranscribeMode: vi.fn(),
|
||||||
|
loadBlocks: vi.fn().mockResolvedValue(undefined),
|
||||||
|
setActiveAnnotationId: vi.fn(),
|
||||||
|
flashAnnotation: vi.fn(),
|
||||||
|
prefersReducedMotion: false,
|
||||||
|
afterTick: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getElement: vi.fn().mockReturnValue(el),
|
||||||
|
onStripUrl: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scrollToCommentFromQuery', () => {
|
||||||
|
it('is a no-op when commentId query param is absent', async () => {
|
||||||
|
const url = new URL('https://app/documents/doc-1');
|
||||||
|
const opts = buildOpts();
|
||||||
|
|
||||||
|
await scrollToCommentFromQuery(url, opts);
|
||||||
|
|
||||||
|
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
|
||||||
|
expect(opts.getElement).not.toHaveBeenCalled();
|
||||||
|
expect(opts.onStripUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when annotationId query param is absent even if commentId is present', async () => {
|
||||||
|
const url = new URL(`https://app/documents/doc-1?commentId=${COMMENT_ID}`);
|
||||||
|
const opts = buildOpts();
|
||||||
|
|
||||||
|
await scrollToCommentFromQuery(url, opts);
|
||||||
|
|
||||||
|
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
|
||||||
|
expect(opts.getElement).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls to the comment element and focuses it when both params are present', async () => {
|
||||||
|
const url = new URL(
|
||||||
|
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||||
|
);
|
||||||
|
const el = fakeElement();
|
||||||
|
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
|
||||||
|
|
||||||
|
await scrollToCommentFromQuery(url, opts);
|
||||||
|
|
||||||
|
expect(opts.getElement).toHaveBeenCalledWith(`comment-${COMMENT_ID}`);
|
||||||
|
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
|
||||||
|
expect(el.focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers the annotation flash after scrolling', async () => {
|
||||||
|
const url = new URL(
|
||||||
|
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||||
|
);
|
||||||
|
const el = fakeElement();
|
||||||
|
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
|
||||||
|
|
||||||
|
await scrollToCommentFromQuery(url, opts);
|
||||||
|
|
||||||
|
expect(opts.flashAnnotation).toHaveBeenCalledWith(ANNOTATION_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters transcribe mode and awaits loadBlocks when transcribe mode is off', async () => {
|
||||||
|
const url = new URL(
|
||||||
|
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||||
|
);
|
||||||
|
const opts = buildOpts({ transcribeMode: false });
|
||||||
|
|
||||||
|
await scrollToCommentFromQuery(url, opts);
|
||||||
|
|
||||||
|
expect(opts.setTranscribeMode).toHaveBeenCalledWith(true);
|
||||||
|
expect(opts.loadBlocks).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a graceful no-op when the target element is not in the DOM', async () => {
|
||||||
|
const url = new URL(
|
||||||
|
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||||
|
);
|
||||||
|
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(null) });
|
||||||
|
|
||||||
|
// Must not throw. Flash should not fire — nothing to highlight.
|
||||||
|
await expect(scrollToCommentFromQuery(url, opts)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(opts.flashAnnotation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses behavior "instant" when prefers-reduced-motion is set', async () => {
|
||||||
|
const url = new URL(
|
||||||
|
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||||
|
);
|
||||||
|
const el = fakeElement();
|
||||||
|
const opts = buildOpts({
|
||||||
|
prefersReducedMotion: true,
|
||||||
|
getElement: vi.fn().mockReturnValue(el)
|
||||||
|
});
|
||||||
|
|
||||||
|
await scrollToCommentFromQuery(url, opts);
|
||||||
|
|
||||||
|
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant', block: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips both commentId and annotationId from the URL after handling', async () => {
|
||||||
|
const url = new URL(
|
||||||
|
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
|
||||||
|
);
|
||||||
|
const opts = buildOpts();
|
||||||
|
|
||||||
|
await scrollToCommentFromQuery(url, opts);
|
||||||
|
|
||||||
|
expect(opts.onStripUrl).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
frontend/src/lib/utils/deepLinkScroll.ts
Normal file
40
frontend/src/lib/utils/deepLinkScroll.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type DeepLinkScrollOptions = {
|
||||||
|
transcribeMode: boolean;
|
||||||
|
setTranscribeMode: (value: boolean) => void;
|
||||||
|
loadBlocks: () => Promise<void>;
|
||||||
|
setActiveAnnotationId: (id: string) => void;
|
||||||
|
flashAnnotation: (annotationId: string) => void;
|
||||||
|
prefersReducedMotion: boolean;
|
||||||
|
afterTick: () => Promise<void>;
|
||||||
|
getElement: (id: string) => HTMLElement | null;
|
||||||
|
onStripUrl: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function scrollToCommentFromQuery(
|
||||||
|
url: URL,
|
||||||
|
opts: DeepLinkScrollOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const commentId = url.searchParams.get('commentId');
|
||||||
|
if (!commentId) return;
|
||||||
|
|
||||||
|
const annotationId = url.searchParams.get('annotationId');
|
||||||
|
if (!annotationId) return;
|
||||||
|
|
||||||
|
if (!opts.transcribeMode) {
|
||||||
|
opts.setTranscribeMode(true);
|
||||||
|
await opts.loadBlocks();
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.setActiveAnnotationId(annotationId);
|
||||||
|
await opts.afterTick();
|
||||||
|
|
||||||
|
const el = opts.getElement(`comment-${commentId}`);
|
||||||
|
if (el) {
|
||||||
|
const behavior: ScrollBehavior = opts.prefersReducedMotion ? 'instant' : 'smooth';
|
||||||
|
el.scrollIntoView({ behavior, block: 'center' });
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
opts.flashAnnotation(annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.onStripUrl();
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { replaceState } from '$app/navigation';
|
||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
||||||
@@ -10,6 +12,7 @@ import type { TranscriptionBlockData } from '$lib/types';
|
|||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
|
||||||
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
|
||||||
|
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -288,6 +291,11 @@ $effect(() => {
|
|||||||
|
|
||||||
let navHeight = $state(0);
|
let navHeight = $state(0);
|
||||||
|
|
||||||
|
async function waitForPanelRender(): Promise<void> {
|
||||||
|
await tick();
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
@@ -298,6 +306,21 @@ onMount(() => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scrollToCommentFromQuery(new URL(page.url), {
|
||||||
|
transcribeMode,
|
||||||
|
setTranscribeMode: (v) => (transcribeMode = v),
|
||||||
|
loadBlocks: loadTranscriptionBlocks,
|
||||||
|
setActiveAnnotationId: (id) => (activeAnnotationId = id),
|
||||||
|
flashAnnotation: (annotationId) => {
|
||||||
|
flashAnnotationId = annotationId;
|
||||||
|
setTimeout(() => (flashAnnotationId = null), prefersReducedMotion ? 2000 : 1500);
|
||||||
|
},
|
||||||
|
prefersReducedMotion,
|
||||||
|
afterTick: waitForPanelRender,
|
||||||
|
getElement: (id) => document.getElementById(id),
|
||||||
|
onStripUrl: () => replaceState(page.url.pathname, page.state ?? {})
|
||||||
|
}).catch((e) => console.error('deep-link scroll failed', e));
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && transcribeMode) {
|
if (e.key === 'Escape' && transcribeMode) {
|
||||||
transcribeMode = false;
|
transcribeMode = false;
|
||||||
|
|||||||
Reference in New Issue
Block a user