diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java index 1373f71f..f09029e1 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java @@ -39,7 +39,7 @@ public class CommentController { @RequestBody CreateCommentDTO dto, Authentication authentication) { AppUser author = resolveUser(authentication); - return commentService.postComment(documentId, null, dto.getContent(), author); + return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author); } @PostMapping("/api/documents/{documentId}/comments/{commentId}/replies") @@ -51,7 +51,7 @@ public class CommentController { @RequestBody CreateCommentDTO dto, Authentication authentication) { AppUser author = resolveUser(authentication); - return commentService.replyToComment(documentId, commentId, dto.getContent(), author); + return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); } // ─── Annotation comments ────────────────────────────────────────────────── @@ -70,7 +70,7 @@ public class CommentController { @RequestBody CreateCommentDTO dto, Authentication authentication) { AppUser author = resolveUser(authentication); - return commentService.postComment(documentId, annotationId, dto.getContent(), author); + return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author); } @PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies") @@ -82,7 +82,7 @@ public class CommentController { @RequestBody CreateCommentDTO dto, Authentication authentication) { AppUser author = resolveUser(authentication); - return commentService.replyToComment(documentId, commentId, dto.getContent(), author); + return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); } // ─── Edit and delete (shared) ───────────────────────────────────────────── diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/UserSearchController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserSearchController.java new file mode 100644 index 00000000..ae43f518 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserSearchController.java @@ -0,0 +1,29 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.dto.MentionDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.service.UserSearchService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class UserSearchController { + + private final UserSearchService userSearchService; + + @GetMapping("/api/users/search") + public List search(@RequestParam(defaultValue = "") String q) { + return userSearchService.search(q).stream() + .map(this::toMentionDTO) + .toList(); + } + + private MentionDTO toMentionDTO(AppUser user) { + return new MentionDTO(user.getId(), user.getFirstName(), user.getLastName()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateCommentDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateCommentDTO.java index 9caa4b1a..f1862978 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateCommentDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateCommentDTO.java @@ -2,7 +2,12 @@ package org.raddatz.familienarchiv.dto; import lombok.Data; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + @Data public class CreateCommentDTO { private String content; + private List mentionedUserIds = new ArrayList<>(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/MentionDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/MentionDTO.java new file mode 100644 index 00000000..09bf4ba5 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/MentionDTO.java @@ -0,0 +1,11 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +public record MentionDTO( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String firstName, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String lastName +) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java index b93b4244..e2376979 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java @@ -1,10 +1,12 @@ package org.raddatz.familienarchiv.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; +import org.raddatz.familienarchiv.dto.MentionDTO; import java.time.LocalDateTime; import java.util.ArrayList; @@ -60,4 +62,21 @@ public class DocumentComment { @Builder.Default @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private List replies = new ArrayList<>(); + + // JPA join table for structured mention references — not serialized directly + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "comment_mentions", + joinColumns = @JoinColumn(name = "comment_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + @JsonIgnore + @Builder.Default + private List mentions = new ArrayList<>(); + + // Populated by CommentService before serialization — not persisted. + @Transient + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private List mentionDTOs = new ArrayList<>(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/AppUserRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/AppUserRepository.java index 290f15a1..63179e07 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/AppUserRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/AppUserRepository.java @@ -1,10 +1,13 @@ package org.raddatz.familienarchiv.repository; - import org.raddatz.familienarchiv.model.AppUser; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -12,4 +15,9 @@ import java.util.UUID; public interface AppUserRepository extends JpaRepository { Optional findByUsername(String username); Optional findByEmail(String email); + + @Query("SELECT u FROM AppUser u WHERE " + + "LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " + + "OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))") + List searchByNameOrUsername(@Param("q") String q, Pageable pageable); } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java index fb856a53..29e16fca 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -1,10 +1,12 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.dto.MentionDTO; 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.AppUserRepository; import org.raddatz.familienarchiv.repository.CommentRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,21 +19,23 @@ import java.util.UUID; public class CommentService { private final CommentRepository commentRepository; + private final AppUserRepository userRepository; private final NotificationService notificationService; public List getCommentsForDocument(UUID documentId) { List roots = commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId); - return withReplies(roots); + return withRepliesAndMentions(roots); } public List getCommentsForAnnotation(UUID annotationId) { List roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId); - return withReplies(roots); + return withRepliesAndMentions(roots); } @Transactional - public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) { + public DocumentComment postComment(UUID documentId, UUID annotationId, String content, + List mentionedUserIds, AppUser author) { DocumentComment comment = DocumentComment.builder() .documentId(documentId) .annotationId(annotationId) @@ -39,11 +43,16 @@ public class CommentService { .authorId(author.getId()) .authorName(resolveAuthorName(author)) .build(); - return commentRepository.save(comment); + saveMentions(comment, mentionedUserIds); + DocumentComment saved = commentRepository.save(comment); + withMentionDTOs(saved); + notificationService.notifyMentions(mentionedUserIds, saved); + return saved; } @Transactional - public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) { + public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, + List mentionedUserIds, AppUser author) { DocumentComment target = commentRepository.findById(commentId) .orElseThrow(() -> DomainException.notFound( ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId)); @@ -61,8 +70,11 @@ public class CommentService { .authorId(author.getId()) .authorName(resolveAuthorName(author)) .build(); + saveMentions(reply, mentionedUserIds); DocumentComment saved = commentRepository.save(reply); + withMentionDTOs(saved); notificationService.notifyReply(saved, root); + notificationService.notifyMentions(mentionedUserIds, saved); return saved; } @@ -89,11 +101,29 @@ public class CommentService { // ─── private helpers ────────────────────────────────────────────────────── - private List withReplies(List roots) { - roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId()))); + private List withRepliesAndMentions(List roots) { + roots.forEach(root -> { + List replies = commentRepository.findByParentId(root.getId()); + replies.forEach(this::withMentionDTOs); + root.setReplies(replies); + withMentionDTOs(root); + }); return roots; } + private void saveMentions(DocumentComment comment, List mentionedUserIds) { + if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return; + List users = userRepository.findAllById(mentionedUserIds); + comment.setMentions(users); + } + + private void withMentionDTOs(DocumentComment comment) { + List dtos = comment.getMentions().stream() + .map(u -> new MentionDTO(u.getId(), u.getFirstName(), u.getLastName())) + .toList(); + comment.setMentionDTOs(dtos); + } + private DocumentComment findComment(UUID documentId, UUID commentId) { return commentRepository.findById(commentId) .filter(c -> documentId.equals(c.getDocumentId())) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/UserSearchService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserSearchService.java new file mode 100644 index 00000000..820622cd --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserSearchService.java @@ -0,0 +1,23 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserSearchService { + + private static final int MAX_RESULTS = 10; + + private final AppUserRepository userRepository; + + public List search(String query) { + if (query == null || query.isBlank()) return List.of(); + return userRepository.searchByNameOrUsername(query.trim(), PageRequest.of(0, MAX_RESULTS)); + } +} diff --git a/backend/src/main/resources/db/migration/V17__comment_mentions.sql b/backend/src/main/resources/db/migration/V17__comment_mentions.sql new file mode 100644 index 00000000..7a50e899 --- /dev/null +++ b/backend/src/main/resources/db/migration/V17__comment_mentions.sql @@ -0,0 +1,5 @@ +CREATE TABLE comment_mentions ( + comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (comment_id, user_id) +); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java index 9b3bfbe8..311e3802 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/CommentControllerTest.java @@ -81,7 +81,7 @@ class CommentControllerTest { 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); + 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)) @@ -104,7 +104,7 @@ class CommentControllerTest { 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); + 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)) @@ -179,7 +179,7 @@ class CommentControllerTest { 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); + 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)) @@ -194,7 +194,7 @@ class CommentControllerTest { 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); + 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)) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/UserSearchControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserSearchControllerTest.java new file mode 100644 index 00000000..571f561e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserSearchControllerTest.java @@ -0,0 +1,71 @@ +package org.raddatz.familienarchiv.controller; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.UserSearchService; +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.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.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(UserSearchController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class UserSearchControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean UserSearchService userSearchService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + @Test + void search_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/users/search").param("q", "Hans")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + void search_returns200_whenAuthenticated() throws Exception { + AppUser user = AppUser.builder().id(UUID.randomUUID()) + .firstName("Hans").lastName("Mueller").username("hans").build(); + when(userSearchService.search("Hans")).thenReturn(List.of(user)); + + mockMvc.perform(get("/api/users/search").param("q", "Hans")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].firstName").value("Hans")); + } + + @Test + @WithMockUser + void search_returnsEmptyList_whenQueryIsEmpty() throws Exception { + when(userSearchService.search("")).thenReturn(List.of()); + + mockMvc.perform(get("/api/users/search").param("q", "")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + @WithMockUser + void search_returnsAtMostTenResults() throws Exception { + when(userSearchService.search(anyString())).thenReturn(List.of()); + + mockMvc.perform(get("/api/users/search").param("q", "a")) + .andExpect(status().isOk()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java index cbda7946..50d74467 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -9,6 +9,7 @@ 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.AppUserRepository; import org.raddatz.familienarchiv.repository.CommentRepository; import java.time.LocalDateTime; @@ -31,6 +32,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; class CommentServiceTest { @Mock CommentRepository commentRepository; + @Mock AppUserRepository userRepository; @Mock NotificationService notificationService; @InjectMocks CommentService commentService; @@ -45,7 +47,7 @@ class CommentServiceTest { .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); + DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author); assertThat(result.getAuthorName()).isEqualTo("Hans Müller"); } @@ -58,7 +60,7 @@ class CommentServiceTest { .id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build(); when(commentRepository.save(any())).thenReturn(saved); - DocumentComment result = commentService.postComment(docId, null, "Test", author); + DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author); assertThat(result.getAuthorName()).isEqualTo("hans42"); } @@ -72,7 +74,7 @@ class CommentServiceTest { AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author)) + assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author)) .isInstanceOf(DomainException.class) .satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND)); @@ -97,7 +99,7 @@ class CommentServiceTest { .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); + DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", List.of(), author); assertThat(result.getParentId()).isEqualTo(rootId); } @@ -116,7 +118,7 @@ class CommentServiceTest { .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); + DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", List.of(), author); assertThat(result.getParentId()).isEqualTo(rootId); } @@ -135,7 +137,7 @@ class CommentServiceTest { when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); when(commentRepository.save(any())).thenReturn(saved); - commentService.replyToComment(docId, rootId, "Reply", author); + commentService.replyToComment(docId, rootId, "Reply", List.of(), author); verify(notificationService).notifyReply(eq(saved), eq(root)); }