From d91a10ef8ef7cf4a8bdcc2e43a7a170614ee42c4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 27 Mar 2026 19:55:40 +0100 Subject: [PATCH 01/19] feat(backend): add V16 migration for notifications table and user preference columns Co-Authored-By: Claude Sonnet 4.6 --- .../V16__notifications_and_preferences.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql diff --git a/backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql b/backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql new file mode 100644 index 00000000..bc3bcc2d --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql @@ -0,0 +1,16 @@ +-- Notification preferences on the user record — no separate entity needed +ALTER TABLE users ADD COLUMN notify_on_reply BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false; + +-- In-app notifications +CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION' + document_id UUID, + reference_id UUID, -- commentId that triggered this notification + read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC); -- 2.49.1 From 420f50b6d5e8e89d7298b6a97155970c01a17ec4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 27 Mar 2026 20:03:34 +0100 Subject: [PATCH 02/19] feat(backend): add Notification entity, NotificationService, NotificationController, and tests Co-Authored-By: Claude Sonnet 4.6 --- .../controller/NotificationController.java | 71 +++++++ .../dto/NotificationPreferenceDTO.java | 3 + .../familienarchiv/exception/ErrorCode.java | 4 + .../raddatz/familienarchiv/model/AppUser.java | 10 + .../familienarchiv/model/Notification.java | 53 +++++ .../model/NotificationType.java | 6 + .../repository/NotificationRepository.java | 25 +++ .../service/NotificationService.java | 187 ++++++++++++++++ .../NotificationControllerTest.java | 162 ++++++++++++++ .../service/NotificationServiceTest.java | 201 ++++++++++++++++++ 10 files changed, 722 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationPreferenceDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/Notification.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/model/NotificationType.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java new file mode 100644 index 00000000..7e6b40b9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java @@ -0,0 +1,71 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.Notification; +import org.raddatz.familienarchiv.service.NotificationService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + private final UserService userService; + + @GetMapping("/api/notifications") + public Page getNotifications( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + Authentication authentication) { + AppUser user = resolveUser(authentication); + PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + return notificationService.getNotifications(user.getId(), pageable); + } + + @PostMapping("/api/notifications/read-all") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void markAllRead(Authentication authentication) { + AppUser user = resolveUser(authentication); + notificationService.markAllRead(user.getId()); + } + + @PatchMapping("/api/notifications/{id}/read") + public Notification markOneRead( + @PathVariable UUID id, + Authentication authentication) { + AppUser user = resolveUser(authentication); + return notificationService.markRead(id, user.getId()); + } + + @GetMapping("/api/users/me/notification-preferences") + public NotificationPreferenceDTO getPreferences(Authentication authentication) { + AppUser user = resolveUser(authentication); + return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention()); + } + + @PutMapping("/api/users/me/notification-preferences") + public NotificationPreferenceDTO updatePreferences( + @RequestBody NotificationPreferenceDTO dto, + Authentication authentication) { + AppUser user = resolveUser(authentication); + AppUser updated = notificationService.updatePreferences( + user.getId(), dto.notifyOnReply(), dto.notifyOnMention()); + return new NotificationPreferenceDTO(updated.isNotifyOnReply(), updated.isNotifyOnMention()); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private AppUser resolveUser(Authentication authentication) { + return userService.findByUsername(authentication.getName()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationPreferenceDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationPreferenceDTO.java new file mode 100644 index 00000000..a789663c --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationPreferenceDTO.java @@ -0,0 +1,3 @@ +package org.raddatz.familienarchiv.dto; + +public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index bcf72ef8..5a0cbd73 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -50,6 +50,10 @@ public enum ErrorCode { /** The comment with the given ID does not exist. 404 */ COMMENT_NOT_FOUND, + // --- Notifications --- + /** The notification with the given ID does not exist. 404 */ + NOTIFICATION_NOT_FOUND, + // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ VALIDATION_ERROR, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java index 5a9ea965..34b189db 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java @@ -51,6 +51,16 @@ public class AppUser { @Schema(requiredMode = Schema.RequiredMode.REQUIRED) private boolean enabled = true; // Um User zu sperren ohne sie zu löschen + @Column(nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private boolean notifyOnReply = false; + + @Column(nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private boolean notifyOnMention = false; + // Ein User kann in mehreren Gruppen sein @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id")) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Notification.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Notification.java new file mode 100644 index 00000000..b888ad75 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Notification.java @@ -0,0 +1,53 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "notifications") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recipient_id", nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private AppUser recipient; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private NotificationType type; + + @Column(name = "document_id") + private UUID documentId; + + @Column(name = "reference_id") + private UUID referenceId; + + @Column(nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private boolean read = false; + + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; + + // Populated by NotificationService before serialization — not persisted. + @Transient + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String actorName; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/NotificationType.java b/backend/src/main/java/org/raddatz/familienarchiv/model/NotificationType.java new file mode 100644 index 00000000..deb5ec72 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/NotificationType.java @@ -0,0 +1,6 @@ +package org.raddatz.familienarchiv.model; + +public enum NotificationType { + REPLY, + MENTION +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java new file mode 100644 index 00000000..bf3b8a19 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java @@ -0,0 +1,25 @@ +package org.raddatz.familienarchiv.repository; + +import org.raddatz.familienarchiv.model.Notification; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.UUID; + +public interface NotificationRepository extends JpaRepository { + + Page findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable); + + long countByRecipientIdAndReadFalse(UUID recipientId); + + @Modifying + @Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId") + void markAllReadByRecipientId(@Param("userId") UUID userId); + + List findByRecipientIdOrderByCreatedAtDesc(UUID recipientId); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java new file mode 100644 index 00000000..cec6629e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java @@ -0,0 +1,187 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.model.Notification; +import org.raddatz.familienarchiv.model.NotificationType; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.raddatz.familienarchiv.repository.CommentRepository; +import org.raddatz.familienarchiv.repository.NotificationRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class NotificationService { + + private final NotificationRepository notificationRepository; + private final CommentRepository commentRepository; + private final AppUserRepository userRepository; + + @Autowired(required = false) + private JavaMailSender mailSender; + + @Value("${app.mail.from:noreply@familienarchiv.local}") + private String mailFrom; + + @Value("${app.base-url:http://localhost:3000}") + private String baseUrl; + + /** + * Creates REPLY notifications for all participants in the thread that the given reply belongs to, + * excluding the replier themselves. + */ + @Transactional + public void notifyReply(DocumentComment reply, DocumentComment root) { + Set participantIds = collectParticipantIds(root); + participantIds.remove(reply.getAuthorId()); + + for (UUID participantId : participantIds) { + Optional recipientOpt = userRepository.findById(participantId); + if (recipientOpt.isEmpty()) continue; + + AppUser recipient = recipientOpt.get(); + Notification notification = Notification.builder() + .recipient(recipient) + .type(NotificationType.REPLY) + .documentId(reply.getDocumentId()) + .referenceId(reply.getId()) + .build(); + notificationRepository.save(notification); + + if (recipient.isNotifyOnReply()) { + sendNotificationEmail(recipient, reply, NotificationType.REPLY); + } + } + } + + /** + * Creates MENTION notifications for each mentioned user. + */ + @Transactional + public void notifyMentions(List mentionedUserIds, DocumentComment comment) { + for (UUID mentionedUserId : mentionedUserIds) { + Optional recipientOpt = userRepository.findById(mentionedUserId); + if (recipientOpt.isEmpty()) continue; + + AppUser recipient = recipientOpt.get(); + Notification notification = Notification.builder() + .recipient(recipient) + .type(NotificationType.MENTION) + .documentId(comment.getDocumentId()) + .referenceId(comment.getId()) + .build(); + notificationRepository.save(notification); + + if (recipient.isNotifyOnMention()) { + sendNotificationEmail(recipient, comment, NotificationType.MENTION); + } + } + } + + public Page getNotifications(UUID userId, Pageable pageable) { + return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable); + } + + public long countUnread(UUID userId) { + return notificationRepository.countByRecipientIdAndReadFalse(userId); + } + + @Transactional + public void markAllRead(UUID userId) { + notificationRepository.markAllReadByRecipientId(userId); + } + + @Transactional + public Notification markRead(UUID notificationId, UUID userId) { + Notification notification = notificationRepository.findById(notificationId) + .orElseThrow(() -> DomainException.notFound( + ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId)); + if (!notification.getRecipient().getId().equals(userId)) { + throw DomainException.forbidden("Notification belongs to a different user"); + } + notification.setRead(true); + return notificationRepository.save(notification); + } + + @Transactional + public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) { + AppUser user = userRepository.findById(userId) + .orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "User not found: " + userId)); + user.setNotifyOnReply(notifyOnReply); + user.setNotifyOnMention(notifyOnMention); + return userRepository.save(user); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private Set collectParticipantIds(DocumentComment root) { + Set ids = new LinkedHashSet<>(); + if (root.getAuthorId() != null) ids.add(root.getAuthorId()); + + commentRepository.findByParentId(root.getId()) + .forEach(reply -> { + if (reply.getAuthorId() != null) ids.add(reply.getAuthorId()); + }); + return ids; + } + + private void buildCommentPath(DocumentComment comment, StringBuilder sb) { + sb.append("?commentId=").append(comment.getId()); + if (comment.getAnnotationId() != null) { + sb.append("&annotationId=").append(comment.getAnnotationId()); + } + } + + private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) { + if (mailSender == null) { + log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail()); + return; + } + if (recipient.getEmail() == null || recipient.getEmail().isBlank()) return; + + StringBuilder path = new StringBuilder("/documents/").append(comment.getDocumentId()); + buildCommentPath(comment, path); + String link = baseUrl + path; + + String subject = type == NotificationType.REPLY + ? "Neue Antwort auf deinen Kommentar — Familienarchiv" + : "Du wurdest in einem Kommentar erwähnt — Familienarchiv"; + + String body = type == NotificationType.REPLY + ? "Hallo,\n\njemand hat auf einen Kommentar geantwortet, an dem du beteiligt warst.\n\n" + + "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team" + : "Hallo,\n\njemand hat dich in einem Kommentar erwähnt.\n\n" + + "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team"; + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(mailFrom); + message.setTo(recipient.getEmail()); + message.setSubject(subject); + message.setText(body); + + try { + mailSender.send(message); + } catch (MailException e) { + log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage()); + } + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java new file mode 100644 index 00000000..10541df9 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java @@ -0,0 +1,162 @@ +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.model.Notification; +import org.raddatz.familienarchiv.model.NotificationType; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.NotificationService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(NotificationController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class NotificationControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean NotificationService notificationService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private static final UUID USER_ID = UUID.randomUUID(); + + // ─── GET /api/notifications ─────────────────────────────────────────────── + + @Test + void getNotifications_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "testuser") + void getNotifications_returns200WithList_whenAuthenticated() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + Notification n = Notification.builder() + .id(UUID.randomUUID()).recipient(user) + .type(NotificationType.REPLY).read(false).build(); + + when(userService.findByUsername("testuser")).thenReturn(user); + when(notificationService.getNotifications(eq(USER_ID), any())) + .thenReturn(new PageImpl<>(List.of(n), PageRequest.of(0, 10), 1)); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } + + @Test + @WithMockUser(username = "testuser") + void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + when(userService.findByUsername("testuser")).thenReturn(user); + when(notificationService.getNotifications(eq(USER_ID), any())) + .thenReturn(new PageImpl<>(List.of())); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()); + + verify(notificationService).getNotifications(eq(USER_ID), any()); + } + + // ─── POST /api/notifications/read-all ──────────────────────────────────── + + @Test + void markAllRead_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/notifications/read-all")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "testuser") + void markAllRead_returns204_whenAuthenticated() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + when(userService.findByUsername("testuser")).thenReturn(user); + + mockMvc.perform(post("/api/notifications/read-all")) + .andExpect(status().isNoContent()); + + verify(notificationService).markAllRead(USER_ID); + } + + // ─── PATCH /api/notifications/{id}/read ────────────────────────────────── + + @Test + @WithMockUser(username = "testuser") + void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + UUID notifId = UUID.randomUUID(); + + when(userService.findByUsername("testuser")).thenReturn(user); + org.mockito.Mockito.doThrow( + org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours")) + .when(notificationService).markRead(notifId, USER_ID); + + mockMvc.perform(patch("/api/notifications/" + notifId + "/read")) + .andExpect(status().isForbidden()); + } + + // ─── GET /api/users/me/notification-preferences ────────────────────────── + + @Test + void getPreferences_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/users/me/notification-preferences")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "testuser") + void getPreferences_returnsCurrentPreferences() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser") + .notifyOnReply(true).notifyOnMention(false).build(); + when(userService.findByUsername("testuser")).thenReturn(user); + + mockMvc.perform(get("/api/users/me/notification-preferences")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifyOnReply").value(true)) + .andExpect(jsonPath("$.notifyOnMention").value(false)); + } + + // ─── PUT /api/users/me/notification-preferences ────────────────────────── + + @Test + @WithMockUser(username = "testuser") + void updatePreferences_persistsBothBooleans() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser") + .notifyOnReply(false).notifyOnMention(false).build(); + when(userService.findByUsername("testuser")).thenReturn(user); + + AppUser updated = AppUser.builder().id(USER_ID).username("testuser") + .notifyOnReply(true).notifyOnMention(true).build(); + when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated); + + mockMvc.perform(put("/api/users/me/notification-preferences") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"notifyOnReply\":true,\"notifyOnMention\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifyOnReply").value(true)) + .andExpect(jsonPath("$.notifyOnMention").value(true)); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java new file mode 100644 index 00000000..c515cc7c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java @@ -0,0 +1,201 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.*; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.raddatz.familienarchiv.repository.CommentRepository; +import org.raddatz.familienarchiv.repository.NotificationRepository; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock NotificationRepository notificationRepository; + @Mock CommentRepository commentRepository; + @Mock AppUserRepository userRepository; + @Mock JavaMailSender mailSender; + + @InjectMocks NotificationService notificationService; + + private AppUser userA; + private AppUser userB; + private AppUser userC; + + @BeforeEach + void setUp() { + // mailSender is @Autowired(required=false) — not in the @RequiredArgsConstructor + // constructor, so Mockito won't inject it automatically. Inject explicitly. + ReflectionTestUtils.setField(notificationService, "mailSender", mailSender); + + userA = AppUser.builder().id(UUID.randomUUID()).username("userA") + .firstName("Anna").lastName("Smith").email("a@test.com") + .notifyOnReply(false).notifyOnMention(false).build(); + userB = AppUser.builder().id(UUID.randomUUID()).username("userB") + .firstName("Bob").lastName("Jones").email("b@test.com") + .notifyOnReply(false).notifyOnMention(false).build(); + userC = AppUser.builder().id(UUID.randomUUID()).username("userC") + .firstName("Clara").lastName("Doe").email("c@test.com") + .notifyOnReply(false).notifyOnMention(false).build(); + } + + // ─── notifyReply ────────────────────────────────────────────────────────── + + @Test + void notifyReply_createsNotificationForThreadParticipant() { + DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId()); + DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId()); + DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId()); + + when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply)); + when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA)); + when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB)); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + notificationService.notifyReply(reply, root); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository, times(2)).save(captor.capture()); + + List saved = captor.getAllValues(); + assertThat(saved).extracting(n -> n.getRecipient().getId()) + .containsExactlyInAnyOrder(userA.getId(), userB.getId()); + assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY); + assertThat(saved).allMatch(n -> !n.isRead()); + } + + @Test + void notifyReply_doesNotNotifyTheReplierThemselves() { + // userA is both a thread participant and the replier + DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId()); + DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userA.getId()); + + when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(reply)); + + notificationService.notifyReply(reply, root); + + verify(notificationRepository, never()).save(any()); + } + + @Test + void notifyReply_deduplicatesParticipants() { + // userB has posted twice in the thread — should get exactly one notification + DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId()); + DocumentComment first = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId()); + DocumentComment second = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId()); + DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId()); + + when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(first, second, reply)); + when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA)); + when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB)); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + notificationService.notifyReply(reply, root); + + // userA (root) + userB (deduplicated) = 2 notifications, not 3 + verify(notificationRepository, times(2)).save(any()); + } + + @Test + void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() { + userA.setNotifyOnReply(true); + userB.setNotifyOnReply(false); + + DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId()); + DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId()); + DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId()); + + when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply)); + when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA)); + when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB)); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + notificationService.notifyReply(reply, root); + + // Only userA has email enabled — one email sent + verify(mailSender, times(1)).send(any(SimpleMailMessage.class)); + } + + // ─── notifyMentions ─────────────────────────────────────────────────────── + + @Test + void notifyMentions_createsNotificationPerMentionedUser() { + DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId()); + when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA)); + when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB)); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Notification.class); + verify(notificationRepository, times(2)).save(captor.capture()); + + List saved = captor.getAllValues(); + assertThat(saved).extracting(n -> n.getRecipient().getId()) + .containsExactlyInAnyOrder(userA.getId(), userB.getId()); + assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION); + } + + @Test + void notifyMentions_sendsEmailOnlyToUsersWithMentionNotificationsEnabled() { + userA.setNotifyOnMention(true); + userB.setNotifyOnMention(false); + + DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId()); + when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA)); + when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB)); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment); + + verify(mailSender, times(1)).send(any(SimpleMailMessage.class)); + } + + // ─── markRead ───────────────────────────────────────────────────────────── + + @Test + void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() { + Notification notification = Notification.builder() + .id(UUID.randomUUID()) + .recipient(userA) + .type(NotificationType.REPLY) + .read(false) + .build(); + + when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification)); + + assertThatThrownBy(() -> notificationService.markRead(notification.getId(), userB.getId())) + .isInstanceOf(DomainException.class) + .hasMessageContaining("different user"); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId) { + return DocumentComment.builder() + .id(id) + .documentId(UUID.randomUUID()) + .parentId(parentId) + .authorId(authorId) + .authorName("Author") + .content("content") + .build(); + } +} -- 2.49.1 From bc62f3b0afc52ac390a73a062d93ff56180ab4c9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 27 Mar 2026 20:05:29 +0100 Subject: [PATCH 03/19] feat(backend): trigger reply notifications from CommentService Co-Authored-By: Claude Sonnet 4.6 --- .../service/CommentService.java | 5 ++++- .../service/CommentServiceTest.java | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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 84bf9f0b..fb856a53 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -17,6 +17,7 @@ import java.util.UUID; public class CommentService { private final CommentRepository commentRepository; + private final NotificationService notificationService; public List getCommentsForDocument(UUID documentId) { List roots = @@ -60,7 +61,9 @@ public class CommentService { .authorId(author.getId()) .authorName(resolveAuthorName(author)) .build(); - return commentRepository.save(reply); + DocumentComment saved = commentRepository.save(reply); + notificationService.notifyReply(saved, root); + return saved; } @Transactional 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 13d1906c..cbda7946 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/CommentServiceTest.java @@ -20,6 +20,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -30,6 +31,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; class CommentServiceTest { @Mock CommentRepository commentRepository; + @Mock NotificationService notificationService; @InjectMocks CommentService commentService; // ─── postComment ────────────────────────────────────────────────────────── @@ -119,6 +121,25 @@ class CommentServiceTest { assertThat(result.getParentId()).isEqualTo(rootId); } + @Test + void replyToComment_triggersNotification_afterSave() { + UUID docId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); + + DocumentComment root = DocumentComment.builder() + .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build(); + + when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); + when(commentRepository.save(any())).thenReturn(saved); + + commentService.replyToComment(docId, rootId, "Reply", author); + + verify(notificationService).notifyReply(eq(saved), eq(root)); + } + // ─── editComment ────────────────────────────────────────────────────────── @Test -- 2.49.1 From 1615a4ffa5615fb29f767bbdc20d294b7141aacf Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 27 Mar 2026 20:09:40 +0100 Subject: [PATCH 04/19] feat(backend): add V17 migration, @mention storage, MentionDTO, user search endpoint, and tests Co-Authored-By: Claude Sonnet 4.6 --- .../controller/CommentController.java | 8 +-- .../controller/UserSearchController.java | 29 ++++++++ .../familienarchiv/dto/CreateCommentDTO.java | 5 ++ .../familienarchiv/dto/MentionDTO.java | 11 +++ .../familienarchiv/model/DocumentComment.java | 19 +++++ .../repository/AppUserRepository.java | 10 ++- .../service/CommentService.java | 44 ++++++++++-- .../service/UserSearchService.java | 23 ++++++ .../db/migration/V17__comment_mentions.sql | 5 ++ .../controller/CommentControllerTest.java | 8 +-- .../controller/UserSearchControllerTest.java | 71 +++++++++++++++++++ .../service/CommentServiceTest.java | 14 ++-- 12 files changed, 225 insertions(+), 22 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/controller/UserSearchController.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/dto/MentionDTO.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/UserSearchService.java create mode 100644 backend/src/main/resources/db/migration/V17__comment_mentions.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/controller/UserSearchControllerTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java 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)); } -- 2.49.1 From e455efa670b65fcbbc76ec65830f419be5c056b9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 27 Mar 2026 20:20:58 +0100 Subject: [PATCH 05/19] feat(#71): add notification bell + preferences UI - NotificationBell.svelte: bell icon in header with unread badge, dropdown showing last 10 notifications, mark-all-read, click-outside close, keyboard Escape support, polls every PUBLIC_NOTIFICATION_POLL_MS ms - Wire NotificationBell into +layout.svelte between ThemeToggle and UserMenu (authenticated users only) - Profile page: add notification preferences card with notifyOnReply / notifyOnMention toggles, loaded via GET and saved via PUT /api/users/me/notification-preferences - i18n: de/en/es message keys for bell, notifications list, and preference labels Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 13 +- frontend/messages/en.json | 13 +- frontend/messages/es.json | 13 +- .../lib/components/NotificationBell.svelte | 304 ++++++++++++++++++ frontend/src/routes/+layout.svelte | 6 + frontend/src/routes/profile/+page.server.ts | 30 +- frontend/src/routes/profile/+page.svelte | 55 ++++ 7 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/components/NotificationBell.svelte diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 16718738..a7c1abdd 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -294,5 +294,16 @@ "enrich_done_body": "Alle Dokumente wurden bearbeitet.", "enrich_back_to_list": "Zurück zur Liste", "comment_empty_hint": "Noch keine Kommentare – starte die Diskussion!", - "comment_start_discussion": "Diskussion starten →" + "comment_start_discussion": "Diskussion starten →", + "notification_bell_label": "Benachrichtigungen", + "notification_bell_unread_label": "{count} ungelesene Benachrichtigungen", + "notification_mark_all_read": "Alle gelesen", + "notification_empty": "Keine neuen Benachrichtigungen", + "notification_type_reply": "{actor} hat auf deinen Kommentar geantwortet", + "notification_type_mention": "{actor} hat dich in einem Kommentar erwähnt", + "notification_prefs_heading": "Benachrichtigungen", + "notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet", + "notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt", + "mention_btn_label": "Person erwähnen", + "mention_popup_empty": "Keine Nutzer gefunden" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 462dea3b..7f5d0893 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -294,5 +294,16 @@ "enrich_done_body": "All documents have been processed.", "enrich_back_to_list": "Back to list", "comment_empty_hint": "No comments yet – start the discussion!", - "comment_start_discussion": "Start discussion →" + "comment_start_discussion": "Start discussion →", + "notification_bell_label": "Notifications", + "notification_bell_unread_label": "{count} unread notifications", + "notification_mark_all_read": "Mark all read", + "notification_empty": "No new notifications", + "notification_type_reply": "{actor} replied to your comment", + "notification_type_mention": "{actor} mentioned you in a comment", + "notification_prefs_heading": "Notifications", + "notification_pref_reply": "Email when someone replies to my comment", + "notification_pref_mention": "Email when someone mentions me in a comment", + "mention_btn_label": "Mention person", + "mention_popup_empty": "No users found" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 0ae0b91a..d145b9e9 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -294,5 +294,16 @@ "enrich_done_body": "Todos los documentos han sido procesados.", "enrich_back_to_list": "Volver a la lista", "comment_empty_hint": "Aún no hay comentarios – ¡inicia la discusión!", - "comment_start_discussion": "Iniciar discusión →" + "comment_start_discussion": "Iniciar discusión →", + "notification_bell_label": "Notificaciones", + "notification_bell_unread_label": "{count} notificaciones sin leer", + "notification_mark_all_read": "Marcar todo como leído", + "notification_empty": "No hay notificaciones nuevas", + "notification_type_reply": "{actor} respondió a tu comentario", + "notification_type_mention": "{actor} te mencionó en un comentario", + "notification_prefs_heading": "Notificaciones", + "notification_pref_reply": "Correo cuando alguien responde a mi comentario", + "notification_pref_mention": "Correo cuando alguien me menciona en un comentario", + "mention_btn_label": "Mencionar persona", + "mention_popup_empty": "No se encontraron usuarios" } diff --git a/frontend/src/lib/components/NotificationBell.svelte b/frontend/src/lib/components/NotificationBell.svelte new file mode 100644 index 00000000..533ce5b0 --- /dev/null +++ b/frontend/src/lib/components/NotificationBell.svelte @@ -0,0 +1,304 @@ + + + + +
+ + + + + {#if open} + + {/if} +
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3dac8238..4ae6c1d1 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { page } from '$app/state'; import { onMount } from 'svelte'; import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; +import NotificationBell from '$lib/components/NotificationBell.svelte'; import AppNav from './AppNav.svelte'; import UserMenu from './UserMenu.svelte'; @@ -52,6 +53,11 @@ const userInitials = $derived.by(() => { + + {#if data?.user} + + {/if} + diff --git a/frontend/src/routes/profile/+page.server.ts b/frontend/src/routes/profile/+page.server.ts index 7a14518a..6a39a472 100644 --- a/frontend/src/routes/profile/+page.server.ts +++ b/frontend/src/routes/profile/+page.server.ts @@ -1,10 +1,15 @@ import { fail } from '@sveltejs/kit'; +import { env } from '$env/dynamic/private'; import type { PageServerLoad, Actions } from './$types'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; -export const load: PageServerLoad = async ({ locals }) => { - return { user: locals.user }; +const apiBase = () => env.API_INTERNAL_URL || 'http://localhost:8080'; + +export const load: PageServerLoad = async ({ locals, fetch }) => { + const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`); + const notificationPrefs = res.ok ? await res.json() : null; + return { user: locals.user, notificationPrefs }; }; export const actions: Actions = { @@ -50,5 +55,26 @@ export const actions: Actions = { } return { passwordSuccess: true }; + }, + + updateNotificationPrefs: async ({ request, fetch }) => { + const formData = await request.formData(); + const body = { + notifyOnReply: formData.get('notifyOnReply') === 'true', + notifyOnMention: formData.get('notifyOnMention') === 'true' + }; + + const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + return fail(res.status, { prefsError: getErrorMessage(data?.code) }); + } + + return { prefsSuccess: true }; } }; diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 3447118e..2d0432dd 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -1,9 +1,14 @@
@@ -30,4 +35,54 @@ let { data, form } = $props();
+ + +
+

+ {m.notification_prefs_heading()} +

+ + {#if form?.prefsSuccess} +
+ {m.profile_saved()} +
+ {/if} + {#if form?.prefsError} +
+ {form.prefsError} +
+ {/if} + +
+ + + +
+ + + +
+ + +
+
-- 2.49.1 From 55cf1fb0a435c0cfbc61d74bd6dedc13df80010d Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 27 Mar 2026 20:32:54 +0100 Subject: [PATCH 06/19] feat(#72): add @mention support in comment editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mention.ts: detectMention (cursor-aware), extractContent (parse @Name → UUID), renderBody (XSS-safe: escape-first then inject anchor tags, replaceAll for all occurrences) - 19 unit tests in mention.spec.ts (all green) - MentionEditor.svelte: textarea with @-trigger popup, debounced /api/users/search, keyboard navigation (↑↓ Enter Esc), Ctrl+Enter submit, @ button for accessibility - CommentThread.svelte: replace plain textareas with MentionEditor, send mentionedUserIds on post/reply/edit, render comment bodies with {@html renderBody(...)} - types.ts: add MentionDTO, add optional mentionDTOs to Comment and CommentReply Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/CommentThread.svelte | 54 ++-- .../src/lib/components/MentionEditor.svelte | 235 ++++++++++++++++++ frontend/src/lib/types.ts | 8 + frontend/src/lib/utils/mention.spec.ts | 120 +++++++++ frontend/src/lib/utils/mention.ts | 67 +++++ 5 files changed, 468 insertions(+), 16 deletions(-) create mode 100644 frontend/src/lib/components/MentionEditor.svelte create mode 100644 frontend/src/lib/utils/mention.spec.ts create mode 100644 frontend/src/lib/utils/mention.ts diff --git a/frontend/src/lib/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 5fa6bbbf..f908d7a3 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -2,6 +2,9 @@ import { onMount, untrack } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import type { Comment, CommentReply } from '$lib/types'; +import MentionEditor from '$lib/components/MentionEditor.svelte'; +import { renderBody, extractContent } from '$lib/utils/mention'; +import type { MentionDTO } from '$lib/types'; type Props = { documentId: string; @@ -32,6 +35,9 @@ let replyText: string = $state(''); let editingId: string | null = $state(null); let editText: string = $state(''); let posting: boolean = $state(false); +let newMentionCandidates: MentionDTO[] = $state([]); +let replyMentionCandidates: MentionDTO[] = $state([]); +let editMentionCandidates: MentionDTO[] = $state([]); const commentsBase = $derived( annotationId @@ -76,13 +82,15 @@ async function postComment() { if (!text || posting) return; posting = true; try { + const { content, mentionedUserIds } = extractContent(text, newMentionCandidates); const res = await fetch(commentsBase, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: text }) + body: JSON.stringify({ content, mentionedUserIds }) }); if (res.ok) { newText = ''; + newMentionCandidates = []; await reload(); } } finally { @@ -95,13 +103,15 @@ async function postReply(threadId: string) { if (!text || posting) return; posting = true; try { + const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates); const res = await fetch(`${commentsBase}/${threadId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: text }) + body: JSON.stringify({ content, mentionedUserIds }) }); if (res.ok) { replyText = ''; + replyMentionCandidates = []; replyingTo = null; await reload(); } @@ -115,13 +125,15 @@ async function saveEdit(commentId: string) { if (!text || posting) return; posting = true; try { + const { content, mentionedUserIds } = extractContent(text, editMentionCandidates); const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: text }) + body: JSON.stringify({ content, mentionedUserIds }) }); if (res.ok) { editingId = null; + editMentionCandidates = []; await reload(); } } finally { @@ -147,6 +159,7 @@ async function deleteComment(commentId: string) { function startEdit(comment: Comment | CommentReply) { editingId = comment.id; editText = comment.content; + editMentionCandidates = []; } function cancelEdit() { @@ -181,11 +194,13 @@ onMount(() => { {#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)} {#if editingId === comment.id}
- + bind:mentionCandidates={editMentionCandidates} + rows={3} + disabled={posting} + onsubmit={() => saveEdit(comment.id)} + />
-

{comment.content}

+

+ + {@html renderBody(comment.content, comment.mentionDTOs ?? [])} +

{#if canModify(comment)}
@@ -283,12 +301,14 @@ onMount(() => { {#if replyingTo === thread.id}
- + disabled={posting} + onsubmit={() => postReply(thread.id)} + />
+ {/each} + {/if} +
+ {/if} + + +
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 28e9da0b..a2144e40 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,3 +1,9 @@ +export type MentionDTO = { + id: string; + firstName: string; + lastName: string; +}; + export type CommentReply = { id: string; authorId: string | null; @@ -5,6 +11,7 @@ export type CommentReply = { content: string; createdAt: string; updatedAt: string; + mentionDTOs?: MentionDTO[]; }; export type Comment = { @@ -15,6 +22,7 @@ export type Comment = { createdAt: string; updatedAt: string; replies: CommentReply[]; + mentionDTOs?: MentionDTO[]; }; export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history'; diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts new file mode 100644 index 00000000..2da73497 --- /dev/null +++ b/frontend/src/lib/utils/mention.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { detectMention, extractContent, renderBody } from './mention'; +import type { MentionDTO } from '$lib/types'; + +// ─── detectMention ──────────────────────────────────────────────────────────── + +describe('detectMention', () => { + it('returns null when text has no @', () => { + expect(detectMention('hello world', 11)).toBeNull(); + }); + + it('returns null when @ is not the most recent trigger word', () => { + // cursor is past a completed mention (next word started) + expect(detectMention('hello @Hans Müller more', 22)).toBeNull(); + }); + + it('returns empty string immediately after @', () => { + expect(detectMention('hello @', 7)).toBe(''); + }); + + it('returns query text after @', () => { + expect(detectMention('hello @Han', 10)).toBe('Han'); + }); + + it('returns null when @ is preceded by a letter (email address pattern)', () => { + expect(detectMention('user@example', 12)).toBeNull(); + }); + + it('returns query for @ at the very start of string', () => { + expect(detectMention('@Hans', 5)).toBe('Hans'); + }); + + it('returns null when cursor is before the @', () => { + expect(detectMention('@Hans', 0)).toBeNull(); + }); +}); + +// ─── extractContent ─────────────────────────────────────────────────────────── + +describe('extractContent', () => { + it('returns empty arrays for empty string', () => { + const result = extractContent('', []); + expect(result.content).toBe(''); + expect(result.mentionedUserIds).toEqual([]); + }); + + it('returns plain content unchanged when no candidates', () => { + const result = extractContent('Hello world', []); + expect(result.content).toBe('Hello world'); + expect(result.mentionedUserIds).toEqual([]); + }); + + it('extracts user id when @FirstName LastName is in content', () => { + const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; + const result = extractContent('Hey @Hans Müller how are you?', candidates); + expect(result.mentionedUserIds).toContain('uuid-1'); + }); + + it('deduplicates user ids when same user mentioned twice', () => { + const candidates: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; + const result = extractContent('@Hans Müller and @Hans Müller again', candidates); + expect(result.mentionedUserIds).toHaveLength(1); + expect(result.mentionedUserIds).toContain('uuid-1'); + }); + + it('collects multiple distinct users', () => { + const candidates: MentionDTO[] = [ + { id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }, + { id: 'uuid-2', firstName: 'Anna', lastName: 'Schmidt' } + ]; + const result = extractContent('@Hans Müller and @Anna Schmidt', candidates); + expect(result.mentionedUserIds).toContain('uuid-1'); + expect(result.mentionedUserIds).toContain('uuid-2'); + }); +}); + +// ─── renderBody ─────────────────────────────────────────────────────────────── + +describe('renderBody', () => { + it('returns escaped plain text when no mentions', () => { + expect(renderBody('Hello world', [])).toBe('Hello world'); + }); + + it('escapes < and > in content', () => { + const result = renderBody('', []); + expect(result).toContain('<script>'); + expect(result).not.toContain(' @@ -287,13 +309,23 @@ onMount(() => { {#each comments as thread, ti (thread.id)}
0 ? 'border-t border-line pt-4' : ''}> -
+
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
{#each thread.replies as reply, ri (reply.id)} -
+
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
{/each} diff --git a/frontend/src/lib/components/DocumentBottomPanel.svelte b/frontend/src/lib/components/DocumentBottomPanel.svelte index 6b7d5737..209bf76e 100644 --- a/frontend/src/lib/components/DocumentBottomPanel.svelte +++ b/frontend/src/lib/components/DocumentBottomPanel.svelte @@ -28,6 +28,7 @@ type Props = { open: boolean; height: number; activeTab: DocumentPanelTab; + targetCommentId?: string | null; }; let { @@ -38,7 +39,8 @@ let { canAdmin, open = $bindable(), height = $bindable(), - activeTab = $bindable() + activeTab = $bindable(), + targetCommentId = null }: Props = $props(); const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px) @@ -180,6 +182,7 @@ function handleCountChange(count: number) { canComment={canComment} currentUserId={currentUserId} canAdmin={canAdmin} + targetCommentId={targetCommentId} onCountChange={handleCountChange} /> {:else if activeTab === 'history'} diff --git a/frontend/src/lib/components/PanelDiscussion.svelte b/frontend/src/lib/components/PanelDiscussion.svelte index 291cf5c1..40d9af39 100644 --- a/frontend/src/lib/components/PanelDiscussion.svelte +++ b/frontend/src/lib/components/PanelDiscussion.svelte @@ -8,11 +8,19 @@ type Props = { canComment: boolean; currentUserId: string | null; canAdmin: boolean; + targetCommentId?: string | null; onCountChange?: (count: number) => void; }; -let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountChange }: Props = - $props(); +let { + documentId, + initialComments, + canComment, + currentUserId, + canAdmin, + targetCommentId = null, + onCountChange +}: Props = $props();
@@ -22,6 +30,7 @@ let { documentId, initialComments, canComment, currentUserId, canAdmin, onCountC canComment={canComment} currentUserId={currentUserId} canAdmin={canAdmin} + targetCommentId={targetCommentId} onCountChange={onCountChange} />
diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 24e11344..05558bc8 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -1,5 +1,6 @@ diff --git a/frontend/src/lib/components/NotificationBell.svelte b/frontend/src/lib/components/NotificationBell.svelte index 533ce5b0..639d0ba6 100644 --- a/frontend/src/lib/components/NotificationBell.svelte +++ b/frontend/src/lib/components/NotificationBell.svelte @@ -117,16 +117,15 @@ function attachClickOutside(node: HTMLElement) { } function relativeTime(isoString: string): string { - const now = Date.now(); - const then = new Date(isoString).getTime(); - const diffMs = now - then; + const diffMs = Date.now() - new Date(isoString).getTime(); const diffMin = Math.floor(diffMs / 60000); - if (diffMin < 1) return 'gerade eben'; - if (diffMin < 60) return `vor ${diffMin} Min.`; + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + if (diffMin < 1) return rtf.format(0, 'minute'); + if (diffMin < 60) return rtf.format(-diffMin, 'minute'); const diffH = Math.floor(diffMin / 60); - if (diffH < 24) return `vor ${diffH} Std.`; + if (diffH < 24) return rtf.format(-diffH, 'hour'); const diffD = Math.floor(diffH / 24); - return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`; + return rtf.format(-diffD, 'day'); } onMount(() => { @@ -232,12 +231,10 @@ onDestroy(() => {
    {#each notifications as notification (notification.id)}
  • -
    markRead(notification)} - onkeydown={(e) => e.key === 'Enter' && markRead(notification)} - class="flex cursor-pointer items-start gap-3 border-b border-line px-4 py-3 last:border-b-0 hover:bg-canvas + class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas {!notification.read ? 'bg-accent-bg/20' : ''}" > @@ -291,10 +288,10 @@ onDestroy(() => { {#if !notification.read} {/if} -
    +
  • {/each}
diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts index 2da73497..301cbabe 100644 --- a/frontend/src/lib/utils/mention.spec.ts +++ b/frontend/src/lib/utils/mention.spec.ts @@ -92,10 +92,10 @@ describe('renderBody', () => { expect(result).toContain('AT&T'); }); - it('wraps @mention in an anchor tag', () => { + it('wraps @mention in a mention span', () => { const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; const result = renderBody('Hey @Hans Müller!', mentions); - expect(result).toContain(' { it('replaces all occurrences of the same mention', () => { const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; const result = renderBody('@Hans Müller and @Hans Müller', mentions); - const linkCount = (result.match(/ { + const mentions: MentionDTO[] = [{ id: 'u1', firstName: '
@@ -53,32 +53,49 @@ let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMenti
{/if} -
+ async ({ update }) => update({ reset: false })} + >
-
+ {#if !hasEmail} +

+ {m.notification_prefs_no_email()} +

+ {/if} + -- 2.49.1 From 7825c7749abc200a3cf93b9c42323a0b70c7a9f0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 28 Mar 2026 11:44:51 +0100 Subject: [PATCH 11/19] fix(#73): open annotation side panel when deep-linking via ?annotationId= - NotificationBell now includes annotationId in the deep-link URL when available - +page.svelte reads ?annotationId= param and sets activeAnnotationId on mount, opening the side panel instead of the bottom discussion drawer - AnnotationSidePanel accepts and forwards targetCommentId to CommentThread so the specific comment is highlighted when navigating via a notification Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/AnnotationSidePanel.svelte | 3 +++ frontend/src/lib/components/NotificationBell.svelte | 5 ++++- frontend/src/routes/documents/[id]/+page.svelte | 9 +++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/AnnotationSidePanel.svelte b/frontend/src/lib/components/AnnotationSidePanel.svelte index 58424115..28d292e4 100644 --- a/frontend/src/lib/components/AnnotationSidePanel.svelte +++ b/frontend/src/lib/components/AnnotationSidePanel.svelte @@ -9,6 +9,7 @@ type Props = { canComment: boolean; currentUserId: string | null; canAdmin: boolean; + targetCommentId?: string | null; onClose: () => void; }; @@ -19,6 +20,7 @@ let { canComment, currentUserId, canAdmin, + targetCommentId = null, onClose }: Props = $props(); @@ -57,6 +59,7 @@ const visible = $derived(activeAnnotationId !== null); canComment={canComment} currentUserId={currentUserId} canAdmin={canAdmin} + targetCommentId={targetCommentId} loadOnMount={true} /> {/key} diff --git a/frontend/src/lib/components/NotificationBell.svelte b/frontend/src/lib/components/NotificationBell.svelte index 639d0ba6..8f344b10 100644 --- a/frontend/src/lib/components/NotificationBell.svelte +++ b/frontend/src/lib/components/NotificationBell.svelte @@ -9,6 +9,7 @@ type NotificationItem = { type: 'REPLY' | 'MENTION'; documentId: string; referenceId: string; + annotationId: string | null; read: boolean; createdAt: string; actorName: string; @@ -62,7 +63,9 @@ async function markRead(notification: NotificationItem) { console.error('Failed to mark notification as read', e); } } - const url = `/documents/${notification.documentId}?commentId=${notification.referenceId}`; + const url = notification.annotationId + ? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}` + : `/documents/${notification.documentId}?commentId=${notification.referenceId}`; closeDropdown(); goto(url); } diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 05558bc8..2d9f30e1 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -10,6 +10,7 @@ import type { DocumentPanelTab } from '$lib/types'; let { data } = $props(); const targetCommentId = $derived(page.url.searchParams.get('commentId')); +const targetAnnotationId = $derived(page.url.searchParams.get('annotationId')); const doc = $derived(data.document); const canComment = $derived((data.canAnnotate || data.canWrite) ?? false); @@ -95,8 +96,11 @@ onMount(() => { if (!isNaN(h) && h >= 80) panelHeight = h; } - if (targetCommentId) { - // Deep-link: always open discussion tab regardless of saved state + if (targetAnnotationId) { + // Deep-link into an annotation comment: open the side panel + activeAnnotationId = targetAnnotationId; + } else if (targetCommentId) { + // Deep-link into a document-level comment: open discussion tab panelOpen = true; activeTab = 'discussion'; } else if (savedOpen === 'true') { @@ -169,6 +173,7 @@ $effect(() => { canComment={canComment} currentUserId={currentUserId} canAdmin={canAdmin} + targetCommentId={targetAnnotationId ? targetCommentId : null} onClose={() => { activeAnnotationId = null; activeAnnotationPage = null; -- 2.49.1 From c21e19a15cab116e681bcd732ee6ff2ad8f75457 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 28 Mar 2026 11:45:20 +0100 Subject: [PATCH 12/19] fix(#71): disable notification preferences when user has no email address Profile page now greys out the notification checkboxes and save button when the user has no email set, with a hint to add one first. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + 3 files changed, 3 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 19480fed..8aaa2fc3 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -304,6 +304,7 @@ "notification_prefs_heading": "Benachrichtigungen", "notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet", "notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt", + "notification_prefs_no_email": "Bitte trage zuerst eine E-Mail-Adresse ein, um Benachrichtigungen zu erhalten.", "notification_unread": "ungelesen", "mention_btn_label": "Person erwähnen", "mention_popup_empty": "Keine Nutzer gefunden" diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a32a89db..0717d54e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -304,6 +304,7 @@ "notification_prefs_heading": "Notifications", "notification_pref_reply": "Email when someone replies to my comment", "notification_pref_mention": "Email when someone mentions me in a comment", + "notification_prefs_no_email": "Please add an email address above to receive notifications.", "notification_unread": "unread", "mention_btn_label": "Mention person", "mention_popup_empty": "No users found" diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 0126e943..ddf28bf2 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -304,6 +304,7 @@ "notification_prefs_heading": "Notificaciones", "notification_pref_reply": "Correo cuando alguien responde a mi comentario", "notification_pref_mention": "Correo cuando alguien me menciona en un comentario", + "notification_prefs_no_email": "Por favor, añade una dirección de correo electrónico para recibir notificaciones.", "notification_unread": "no leído", "mention_btn_label": "Mencionar persona", "mention_popup_empty": "No se encontraron usuarios" -- 2.49.1 From 9ae6186e660d0be1a65c337fb6d5f490e3b28318 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 28 Mar 2026 11:45:52 +0100 Subject: [PATCH 13/19] fix(#72): add mention chip styling for @mention rendering in comments Mention spans injected via {@html} need global CSS since scoped styles don't reach dynamically inserted content. Uses ink text on accent-bg background for visible but subtle chip appearance. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/layout.css | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index e065f4a6..c907cd47 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -160,7 +160,28 @@ filter: invert(1); } -/* ─── 7. Base styles ───────────────────────────────────────────────────────── */ +/* ─── 7. @mention chip ─────────────────────────────────────────────────────── */ +/* + Rendered by renderBody() via {@html ...} in CommentThread.svelte. + Must live in global CSS — Svelte scoped styles don't reach injected HTML. +*/ +.mention { + display: inline; + color: var(--c-ink); + background-color: var(--c-accent-bg); + border-radius: 3px; + padding: 0 3px; + font-weight: 600; + font-style: normal; + cursor: default; + transition: background-color 0.15s ease; +} + +.mention:hover { + background-color: color-mix(in srgb, var(--c-accent) 25%, transparent); +} + +/* ─── 8. Base styles ───────────────────────────────────────────────────────── */ @layer base { html { overscroll-behavior: none; -- 2.49.1 From 9900d0b54b97d3092b7bfa71e09f98a6accd3fa6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 28 Mar 2026 11:46:27 +0100 Subject: [PATCH 14/19] test: add AnnotationSidePanel spec and fix env mock in layout spec - AnnotationSidePanel: cover visibility (null vs set annotationId), close button callback, and targetCommentId forwarding - layout.svelte.spec: mock $env/static/public to satisfy PUBLIC_NOTIFICATION_POLL_MS import from NotificationBell - mention.spec: update assertion to match span-based mention rendering Co-Authored-By: Claude Sonnet 4.6 --- .../AnnotationSidePanel.svelte.spec.ts | 76 +++++++++++++++++++ frontend/src/lib/utils/mention.spec.ts | 1 + frontend/src/routes/layout.svelte.spec.ts | 4 +- 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts diff --git a/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts b/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts new file mode 100644 index 00000000..84745470 --- /dev/null +++ b/frontend/src/lib/components/AnnotationSidePanel.svelte.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import AnnotationSidePanel from './AnnotationSidePanel.svelte'; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => [] + }) +); + +const baseProps = { + documentId: 'doc-1', + activeAnnotationPage: 1, + canComment: true, + currentUserId: 'user-1', + canAdmin: false, + onClose: vi.fn() +}; + +describe('AnnotationSidePanel – visibility', () => { + it('is hidden (translated off-screen) when activeAnnotationId is null', async () => { + render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: null }); + const panel = document.querySelector('[data-testid="annotation-side-panel"]'); + expect(panel?.classList.contains('translate-x-full')).toBe(true); + expect(panel?.classList.contains('translate-x-0')).toBe(false); + }); + + it('is visible when activeAnnotationId is set', async () => { + render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1' }); + const panel = document.querySelector('[data-testid="annotation-side-panel"]'); + expect(panel?.classList.contains('translate-x-0')).toBe(true); + expect(panel?.classList.contains('translate-x-full')).toBe(false); + }); +}); + +describe('AnnotationSidePanel – close button', () => { + it('calls onClose when the close button is clicked', async () => { + const onClose = vi.fn(); + render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1', onClose }); + await page.getByRole('button', { name: /schließen/i }).click(); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); + +describe('AnnotationSidePanel – targetCommentId forwarding', () => { + it('renders CommentThread when annotation is active', async () => { + render(AnnotationSidePanel, { + ...baseProps, + activeAnnotationId: 'ann-1', + targetCommentId: 'comment-42' + }); + // CommentThread renders inside the panel when activeAnnotationId is set + const panel = document.querySelector('[data-testid="annotation-side-panel"]'); + expect(panel).not.toBeNull(); + expect(panel?.classList.contains('translate-x-0')).toBe(true); + }); + + it('does not render CommentThread when annotation is null', async () => { + render(AnnotationSidePanel, { + ...baseProps, + activeAnnotationId: null, + targetCommentId: 'comment-42' + }); + // Panel is hidden and no fetch should have been triggered for comments + const panel = document.querySelector('[data-testid="annotation-side-panel"]'); + expect(panel?.classList.contains('translate-x-full')).toBe(true); + }); +}); diff --git a/frontend/src/lib/utils/mention.spec.ts b/frontend/src/lib/utils/mention.spec.ts index 301cbabe..5a7982be 100644 --- a/frontend/src/lib/utils/mention.spec.ts +++ b/frontend/src/lib/utils/mention.spec.ts @@ -96,6 +96,7 @@ describe('renderBody', () => { const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }]; const result = renderBody('Hey @Hans Müller!', mentions); expect(result).toContain(' ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' })); + afterEach(cleanup); const emptySnippet = createRawSnippet(() => ({ render: () => '' })); -- 2.49.1 From f568c0aeb7a7bcb3b8d447852bf817c01d19c10c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 28 Mar 2026 15:41:35 +0100 Subject: [PATCH 15/19] feat(#71,#72,#73): SSE push notifications, mention chips, deep-link fixes - Add SseEmitterRegistry (ConcurrentHashMap, one emitter per user) - Add GET /api/notifications/stream SSE endpoint and unread-count endpoint - Push SSE event on every notifyReply / notifyMentions via saveAndPush() - Collapse V18/V19 migrations into V16 (actor_name + annotation_id upfront) - Add @Schema(requiredMode=REQUIRED) to NotificationDTO required fields - Switch NotificationBell from polling to EventSource; seed unread count on open - Fix MentionEditor: replace setTimeout with await tick(); div role=option - Add aria-modal=true to NotificationBell dialog - Tests: SseEmitterRegistryTest (3), NotificationServiceTest (+2), NotificationControllerTest (+5) Co-Authored-By: Claude Sonnet 4.6 --- .../controller/NotificationController.java | 22 +++++++ .../familienarchiv/dto/NotificationDTO.java | 9 +-- .../service/NotificationService.java | 10 ++- .../service/SseEmitterRegistry.java | 36 ++++++++++ .../V16__notifications_and_preferences.sql | 4 +- .../V18__add_actor_name_to_notifications.sql | 1 - ...19__add_annotation_id_to_notifications.sql | 1 - .../NotificationControllerTest.java | 65 +++++++++++++++++++ .../service/NotificationServiceTest.java | 32 ++++++++- .../service/SseEmitterRegistryTest.java | 37 +++++++++++ .../src/lib/components/MentionEditor.svelte | 53 ++++++++------- .../lib/components/NotificationBell.svelte | 31 +++++++-- .../components/user/UserGroupsSection.svelte | 4 +- frontend/src/routes/+layout.server.ts | 4 +- 14 files changed, 264 insertions(+), 45 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/SseEmitterRegistry.java delete mode 100644 backend/src/main/resources/db/migration/V18__add_actor_name_to_notifications.sql delete mode 100644 backend/src/main/resources/db/migration/V19__add_annotation_id_to_notifications.sql create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/service/SseEmitterRegistryTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java index 1a572403..cbdfc354 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java @@ -7,13 +7,18 @@ import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.service.NotificationService; +import org.raddatz.familienarchiv.service.SseEmitterRegistry; import org.raddatz.familienarchiv.service.UserService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; import java.util.UUID; @@ -23,6 +28,17 @@ public class NotificationController { private final NotificationService notificationService; private final UserService userService; + private final SseEmitterRegistry sseEmitterRegistry; + + // These endpoints are intentionally open to any authenticated user — + // they return and mutate only the current user's own notifications, scoped + // by the resolved user identity. No additional permission check is required. + + @GetMapping(value = "/api/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter stream(Authentication authentication) { + AppUser user = resolveUser(authentication); + return sseEmitterRegistry.register(user.getId()); + } @GetMapping("/api/notifications") public Page getNotifications( @@ -34,6 +50,12 @@ public class NotificationController { return notificationService.getNotifications(user.getId(), pageable); } + @GetMapping("/api/notifications/unread-count") + public Map countUnread(Authentication authentication) { + AppUser user = resolveUser(authentication); + return Map.of("count", notificationService.countUnread(user.getId())); + } + @PostMapping("/api/notifications/read-all") @ResponseStatus(HttpStatus.NO_CONTENT) public void markAllRead(Authentication authentication) { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java index cbe885ba..2a79864a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java @@ -1,17 +1,18 @@ package org.raddatz.familienarchiv.dto; +import io.swagger.v3.oas.annotations.media.Schema; import org.raddatz.familienarchiv.model.NotificationType; import java.time.LocalDateTime; import java.util.UUID; public record NotificationDTO( - UUID id, - NotificationType type, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) NotificationType type, UUID documentId, UUID referenceId, UUID annotationId, - boolean read, - LocalDateTime createdAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt, String actorName ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java index cee737c1..7e7a6f65 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java @@ -33,6 +33,7 @@ public class NotificationService { private final NotificationRepository notificationRepository; private final UserService userService; private final Optional mailSender; + private final SseEmitterRegistry sseEmitterRegistry; @Value("${app.mail.from:noreply@familienarchiv.local}") private String mailFrom; @@ -58,7 +59,7 @@ public class NotificationService { .annotationId(reply.getAnnotationId()) .actorName(reply.getAuthorName()) .build(); - notificationRepository.save(notification); + saveAndPush(notification); if (recipient.isNotifyOnReply()) { sendNotificationEmail(recipient, reply, NotificationType.REPLY); @@ -84,7 +85,7 @@ public class NotificationService { .annotationId(comment.getAnnotationId()) .actorName(comment.getAuthorName()) .build(); - notificationRepository.save(notification); + saveAndPush(notification); if (recipient.isNotifyOnMention()) { sendNotificationEmail(recipient, comment, NotificationType.MENTION); @@ -125,6 +126,11 @@ public class NotificationService { // ─── private helpers ────────────────────────────────────────────────────── + private void saveAndPush(Notification notification) { + Notification saved = notificationRepository.save(notification); + sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved)); + } + private NotificationDTO toDTO(Notification n) { return new NotificationDTO( n.getId(), diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/SseEmitterRegistry.java b/backend/src/main/java/org/raddatz/familienarchiv/service/SseEmitterRegistry.java new file mode 100644 index 00000000..d06b4612 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/SseEmitterRegistry.java @@ -0,0 +1,36 @@ +package org.raddatz.familienarchiv.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Component +@Slf4j +public class SseEmitterRegistry { + + private final ConcurrentHashMap emitters = new ConcurrentHashMap<>(); + + public SseEmitter register(UUID userId) { + SseEmitter emitter = new SseEmitter(0L); // 0 = no timeout; EventSource reconnects automatically + emitters.put(userId, emitter); + emitter.onCompletion(() -> emitters.remove(userId, emitter)); + emitter.onTimeout(() -> emitters.remove(userId, emitter)); + emitter.onError(e -> emitters.remove(userId, emitter)); + return emitter; + } + + public void send(UUID userId, Object data) { + SseEmitter emitter = emitters.get(userId); + if (emitter == null) return; + try { + emitter.send(SseEmitter.event().name("notification").data(data)); + } catch (IOException e) { + log.debug("SSE send failed for user {} — removing emitter", userId); + emitters.remove(userId, emitter); + } + } +} diff --git a/backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql b/backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql index bc3bcc2d..50782ce4 100644 --- a/backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql +++ b/backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql @@ -9,8 +9,10 @@ CREATE TABLE notifications ( type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION' document_id UUID, reference_id UUID, -- commentId that triggered this notification + annotation_id UUID, read BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMP NOT NULL DEFAULT now() + created_at TIMESTAMP NOT NULL DEFAULT now(), + actor_name VARCHAR(255) ); CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC); diff --git a/backend/src/main/resources/db/migration/V18__add_actor_name_to_notifications.sql b/backend/src/main/resources/db/migration/V18__add_actor_name_to_notifications.sql deleted file mode 100644 index 15f89449..00000000 --- a/backend/src/main/resources/db/migration/V18__add_actor_name_to_notifications.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE notifications ADD COLUMN actor_name VARCHAR(255); diff --git a/backend/src/main/resources/db/migration/V19__add_annotation_id_to_notifications.sql b/backend/src/main/resources/db/migration/V19__add_annotation_id_to_notifications.sql deleted file mode 100644 index 67e4d823..00000000 --- a/backend/src/main/resources/db/migration/V19__add_annotation_id_to_notifications.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE notifications ADD COLUMN annotation_id UUID; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java index 14c0590a..f0ab0859 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java @@ -3,11 +3,14 @@ package org.raddatz.familienarchiv.controller; import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.dto.NotificationDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.NotificationType; import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.NotificationService; +import org.raddatz.familienarchiv.service.SseEmitterRegistry; import org.raddatz.familienarchiv.service.UserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; @@ -26,8 +29,10 @@ import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -39,6 +44,7 @@ class NotificationControllerTest { @MockitoBean NotificationService notificationService; @MockitoBean UserService userService; + @MockitoBean SseEmitterRegistry sseEmitterRegistry; @MockitoBean CustomUserDetailsService customUserDetailsService; private static final UUID USER_ID = UUID.randomUUID(); @@ -238,4 +244,63 @@ class NotificationControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.notifyOnReply").value(true)); } + + // ─── GET /api/notifications/unread-count ───────────────────────────────── + + @Test + void countUnread_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/notifications/unread-count")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void countUnread_returns200WithCount_whenAuthenticated() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + when(userService.findByUsername("testuser")).thenReturn(user); + when(notificationService.countUnread(USER_ID)).thenReturn(3L); + + mockMvc.perform(get("/api/notifications/unread-count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.count").value(3)); + } + + // ─── PATCH /api/notifications/{id}/read — additional cases ─────────────── + + // ─── GET /api/notifications/stream ─────────────────────────────────────── + + @Test + void stream_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(get("/api/notifications/stream") + .accept(TEXT_EVENT_STREAM_VALUE)) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void stream_returns200_whenAuthenticated() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + when(userService.findByUsername("testuser")).thenReturn(user); + when(sseEmitterRegistry.register(USER_ID)).thenReturn(new org.springframework.web.servlet.mvc.method.annotation.SseEmitter()); + + mockMvc.perform(get("/api/notifications/stream") + .accept(TEXT_EVENT_STREAM_VALUE)) + .andExpect(status().isOk()); + } + + // ─── PATCH /api/notifications/{id}/read — additional cases ─────────────── + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void markOneRead_returns404_whenNotificationDoesNotExist() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + UUID notifId = UUID.randomUUID(); + + when(userService.findByUsername("testuser")).thenReturn(user); + doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId)) + .when(notificationService).markRead(notifId, USER_ID); + + mockMvc.perform(patch("/api/notifications/" + notifId + "/read")) + .andExpect(status().isNotFound()); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java index bee93f92..27d25f94 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java @@ -21,6 +21,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -29,6 +30,7 @@ class NotificationServiceTest { @Mock NotificationRepository notificationRepository; @Mock UserService userService; @Mock JavaMailSender mailSender; + @Mock SseEmitterRegistry sseEmitterRegistry; NotificationService notificationService; @@ -38,7 +40,7 @@ class NotificationServiceTest { @BeforeEach void setUp() { - notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender)); + notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender), sseEmitterRegistry); userA = AppUser.builder().id(UUID.randomUUID()).username("userA") .firstName("Anna").lastName("Smith").email("a@test.com") @@ -140,6 +142,34 @@ class NotificationServiceTest { verify(mailSender, times(1)).send(any(SimpleMailMessage.class)); } + // ─── SSE push ───────────────────────────────────────────────────────────── + + @Test + void notifyReply_pushesEventToRegistry_forEachRecipient() { + DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe"); + + when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB)); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId())); + + verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class)); + verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class)); + } + + @Test + void notifyMentions_pushesEventToRegistry_forEachMentionedUser() { + DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe"); + + when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB)); + when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment); + + verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class)); + verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class)); + } + // ─── markRead ───────────────────────────────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/SseEmitterRegistryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/SseEmitterRegistryTest.java new file mode 100644 index 00000000..d5950ac5 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/SseEmitterRegistryTest.java @@ -0,0 +1,37 @@ +package org.raddatz.familienarchiv.service; + +import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class SseEmitterRegistryTest { + + private final SseEmitterRegistry registry = new SseEmitterRegistry(); + + @Test + void register_returnsEmitter() { + SseEmitter emitter = registry.register(UUID.randomUUID()); + + assertThat(emitter).isNotNull(); + } + + @Test + void send_doesNothing_whenNoEmitterRegistered() { + assertThatCode(() -> registry.send(UUID.randomUUID(), "data")) + .doesNotThrowAnyException(); + } + + @Test + void register_replacesExistingEmitter_forSameUser() { + UUID userId = UUID.randomUUID(); + + SseEmitter first = registry.register(userId); + SseEmitter second = registry.register(userId); + + assertThat(first).isNotSameAs(second); + } +} diff --git a/frontend/src/lib/components/MentionEditor.svelte b/frontend/src/lib/components/MentionEditor.svelte index c6b169e6..3ceff6ac 100644 --- a/frontend/src/lib/components/MentionEditor.svelte +++ b/frontend/src/lib/components/MentionEditor.svelte @@ -1,5 +1,5 @@ @@ -189,6 +207,7 @@ onDestroy(() => { {#if open}