diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java index ce0e0f29..43b715d7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AnnotationController.java @@ -35,7 +35,7 @@ public class AnnotationController { @PostMapping @ResponseStatus(HttpStatus.CREATED) - @RequirePermission(Permission.ANNOTATE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public DocumentAnnotation createAnnotation( @PathVariable UUID documentId, @RequestBody CreateAnnotationDTO dto, @@ -47,7 +47,7 @@ public class AnnotationController { @DeleteMapping("/{annotationId}") @ResponseStatus(HttpStatus.NO_CONTENT) - @RequirePermission(Permission.ANNOTATE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public void deleteAnnotation( @PathVariable UUID documentId, @PathVariable UUID annotationId, 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..c9f9fac8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/CommentController.java @@ -33,25 +33,25 @@ public class CommentController { @PostMapping("/api/documents/{documentId}/comments") @ResponseStatus(HttpStatus.CREATED) - @RequirePermission(Permission.ANNOTATE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public DocumentComment postDocumentComment( @PathVariable UUID documentId, @RequestBody CreateCommentDTO dto, Authentication authentication) { AppUser author = resolveUser(authentication); - return commentService.postComment(documentId, null, dto.getContent(), author); + return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author); } @PostMapping("/api/documents/{documentId}/comments/{commentId}/replies") @ResponseStatus(HttpStatus.CREATED) - @RequirePermission(Permission.ANNOTATE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public DocumentComment replyToDocumentComment( @PathVariable UUID documentId, @PathVariable UUID commentId, @RequestBody CreateCommentDTO dto, Authentication authentication) { AppUser author = resolveUser(authentication); - return commentService.replyToComment(documentId, commentId, dto.getContent(), author); + return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); } // ─── Annotation comments ────────────────────────────────────────────────── @@ -63,32 +63,32 @@ public class CommentController { @PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments") @ResponseStatus(HttpStatus.CREATED) - @RequirePermission(Permission.ANNOTATE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public DocumentComment postAnnotationComment( @PathVariable UUID documentId, @PathVariable UUID annotationId, @RequestBody CreateCommentDTO dto, Authentication authentication) { AppUser author = resolveUser(authentication); - return commentService.postComment(documentId, annotationId, dto.getContent(), author); + return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author); } @PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies") @ResponseStatus(HttpStatus.CREATED) - @RequirePermission(Permission.ANNOTATE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public DocumentComment replyToAnnotationComment( @PathVariable UUID documentId, @PathVariable UUID commentId, @RequestBody CreateCommentDTO dto, Authentication authentication) { AppUser author = resolveUser(authentication); - return commentService.replyToComment(documentId, commentId, dto.getContent(), author); + return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author); } // ─── Edit and delete (shared) ───────────────────────────────────────────── @PatchMapping("/api/documents/{documentId}/comments/{commentId}") - @RequirePermission(Permission.ANNOTATE_ALL) + @RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL}) public DocumentComment editComment( @PathVariable UUID documentId, @PathVariable UUID commentId, 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..cbdfc354 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java @@ -0,0 +1,97 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.dto.NotificationDTO; +import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO; +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; + +@RestController +@RequiredArgsConstructor +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( + @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); + } + + @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) { + AppUser user = resolveUser(authentication); + notificationService.markAllRead(user.getId()); + } + + @PatchMapping("/api/notifications/{id}/read") + public NotificationDTO markOneRead( + @PathVariable UUID id, + Authentication authentication) { + AppUser user = resolveUser(authentication); + return notificationService.markRead(id, user.getId()); + } + + @GetMapping("/api/users/me/notification-preferences") + @RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL}) + public NotificationPreferenceDTO getPreferences(Authentication authentication) { + AppUser user = resolveUser(authentication); + return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention()); + } + + @PutMapping("/api/users/me/notification-preferences") + @RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL}) + 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/controller/UserSearchController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserSearchController.java new file mode 100644 index 00000000..5dbb51c8 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/UserSearchController.java @@ -0,0 +1,32 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.dto.MentionDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +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 +@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL}) +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/dto/NotificationDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java new file mode 100644 index 00000000..2a79864a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java @@ -0,0 +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( + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) NotificationType type, + UUID documentId, + UUID referenceId, + UUID annotationId, + @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/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/DocumentComment.java b/backend/src/main/java/org/raddatz/familienarchiv/model/DocumentComment.java index b93b4244..26294bb8 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.EAGER) + @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/model/Notification.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Notification.java new file mode 100644 index 00000000..ee5d3b9e --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Notification.java @@ -0,0 +1,55 @@ +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(name = "annotation_id") + private UUID annotationId; + + @Column(nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private boolean read = false; + + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; + + @Column(name = "actor_name") + @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/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/repository/NotificationRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java new file mode 100644 index 00000000..da161912 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java @@ -0,0 +1,22 @@ +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.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); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/PermissionAspect.java b/backend/src/main/java/org/raddatz/familienarchiv/security/PermissionAspect.java index 4a8c17f5..0b11ba31 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/PermissionAspect.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/PermissionAspect.java @@ -23,7 +23,7 @@ public class PermissionAspect { RequirePermission permission = getAnnotation(joinPoint); if (permission != null) { - validateUserAccess(permission.value()); + validateUserAccess(permission.value()); // value() is now Permission[] } return joinPoint.proceed(); @@ -43,18 +43,23 @@ public class PermissionAspect { return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class); } - private void validateUserAccess(Permission requiredPerm) { + private void validateUserAccess(Permission[] requiredPerms) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) { throw DomainException.unauthorized("Not authenticated"); } - boolean hasPermission = auth.getAuthorities().stream() - .anyMatch(a -> a.getAuthority().equals(requiredPerm.name())); + boolean hasAny = auth.getAuthorities().stream() + .anyMatch(a -> { + for (Permission p : requiredPerms) { + if (a.getAuthority().equals(p.name())) return true; + } + return false; + }); - if (!hasPermission) { - throw DomainException.forbidden("Missing required permission: " + requiredPerm.name()); + if (!hasAny) { + throw DomainException.forbidden("Missing required permission"); } } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/security/RequirePermission.java b/backend/src/main/java/org/raddatz/familienarchiv/security/RequirePermission.java index e71c5ba7..f2c89f12 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/security/RequirePermission.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/security/RequirePermission.java @@ -8,5 +8,5 @@ import java.lang.annotation.Target; @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { - Permission value(); // e.g. "ADMIN" or "WRITE_ALL" + Permission[] value(); // one or more — user needs any of the listed permissions } 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..4d932c84 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/CommentService.java @@ -1,6 +1,7 @@ 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; @@ -9,7 +10,9 @@ import org.raddatz.familienarchiv.repository.CommentRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.UUID; @Service @@ -17,20 +20,23 @@ import java.util.UUID; public class CommentService { private final CommentRepository commentRepository; + private final UserService userService; + 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) @@ -38,11 +44,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)); @@ -60,7 +71,15 @@ public class CommentService { .authorId(author.getId()) .authorName(resolveAuthorName(author)) .build(); - return commentRepository.save(reply); + saveMentions(reply, mentionedUserIds); + DocumentComment saved = commentRepository.save(reply); + withMentionDTOs(saved); + + Set participantIds = collectParticipantIds(root); + participantIds.remove(author.getId()); + notificationService.notifyReply(saved, participantIds); + notificationService.notifyMentions(mentionedUserIds, saved); + return saved; } @Transactional @@ -84,13 +103,45 @@ public class CommentService { commentRepository.delete(comment); } + public List findReplies(UUID parentId) { + return commentRepository.findByParentId(parentId); + } + // ─── 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 = userService.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 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 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/NotificationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java new file mode 100644 index 00000000..7e7a6f65 --- /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.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.DocumentComment; +import org.raddatz.familienarchiv.model.Notification; +import org.raddatz.familienarchiv.model.NotificationType; +import org.raddatz.familienarchiv.repository.NotificationRepository; +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.Propagation; +import org.springframework.transaction.annotation.Transactional; + +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 UserService userService; + private final Optional mailSender; + private final SseEmitterRegistry sseEmitterRegistry; + + @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, excluding the replier. + * Runs in a separate transaction so a notification failure cannot roll back the parent comment. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void notifyReply(DocumentComment reply, Set participantIds) { + if (participantIds.isEmpty()) return; + + List recipients = userService.findAllById(participantIds); + for (AppUser recipient : recipients) { + Notification notification = Notification.builder() + .recipient(recipient) + .type(NotificationType.REPLY) + .documentId(reply.getDocumentId()) + .referenceId(reply.getId()) + .annotationId(reply.getAnnotationId()) + .actorName(reply.getAuthorName()) + .build(); + saveAndPush(notification); + + if (recipient.isNotifyOnReply()) { + sendNotificationEmail(recipient, reply, NotificationType.REPLY); + } + } + } + + /** + * Creates MENTION notifications for each mentioned user. + * Runs in a separate transaction so a notification failure cannot roll back the parent comment. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void notifyMentions(List mentionedUserIds, DocumentComment comment) { + if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return; + + List recipients = userService.findAllById(mentionedUserIds); + for (AppUser recipient : recipients) { + Notification notification = Notification.builder() + .recipient(recipient) + .type(NotificationType.MENTION) + .documentId(comment.getDocumentId()) + .referenceId(comment.getId()) + .annotationId(comment.getAnnotationId()) + .actorName(comment.getAuthorName()) + .build(); + saveAndPush(notification); + + if (recipient.isNotifyOnMention()) { + sendNotificationEmail(recipient, comment, NotificationType.MENTION); + } + } + } + + public Page getNotifications(UUID userId, Pageable pageable) { + return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable) + .map(this::toDTO); + } + + public long countUnread(UUID userId) { + return notificationRepository.countByRecipientIdAndReadFalse(userId); + } + + @Transactional + public void markAllRead(UUID userId) { + notificationRepository.markAllReadByRecipientId(userId); + } + + @Transactional + public NotificationDTO 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 toDTO(notificationRepository.save(notification)); + } + + @Transactional + public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) { + return userService.updateNotificationPreferences(userId, notifyOnReply, notifyOnMention); + } + + // ─── 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(), + n.getType(), + n.getDocumentId(), + n.getReferenceId(), + n.getAnnotationId(), + n.isRead(), + n.getCreatedAt(), + n.getActorName() + ); + } + + 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.isEmpty()) { + 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.get().send(message); + } catch (MailException e) { + log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage()); + } + } +} 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/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/java/org/raddatz/familienarchiv/service/UserService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java index c1988b95..fd639751 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/UserService.java @@ -18,6 +18,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Optional; @@ -78,6 +79,18 @@ public class UserService { .orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id)); } + public List findAllById(Collection ids) { + return userRepository.findAllById(ids); + } + + @Transactional + public AppUser updateNotificationPreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) { + AppUser user = getById(userId); + user.setNotifyOnReply(notifyOnReply); + user.setNotifyOnMention(notifyOnMention); + return userRepository.save(user); + } + @Transactional public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) { AppUser user = getById(userId); 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..50782ce4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__notifications_and_preferences.sql @@ -0,0 +1,18 @@ +-- 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 + annotation_id UUID, + read BOOLEAN NOT NULL DEFAULT false, + 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/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/AnnotationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java index 686ccf3e..f316bb31 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AnnotationControllerTest.java @@ -81,6 +81,29 @@ class AnnotationControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception { + UUID docId = UUID.randomUUID(); + DocumentAnnotation saved = DocumentAnnotation.builder() + .id(UUID.randomUUID()).documentId(docId).pageNumber(1) + .x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build(); + when(documentService.getDocumentById(any())).thenReturn(Document.builder().build()); + when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + docId + "/annotations") + .contentType(MediaType.APPLICATION_JSON) + .content(ANNOTATION_JSON)) + .andExpect(status().isCreated()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception { + mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID())) + .andExpect(status().isNoContent()); + } + @Test @WithMockUser(authorities = "ANNOTATE_ALL") void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception { 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..40f01a39 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)) @@ -89,6 +89,18 @@ class CommentControllerTest { .andExpect(jsonPath("$.content").value("Test comment")); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void postDocumentComment_returns201_whenHasWriteAllPermission() throws Exception { + DocumentComment saved = DocumentComment.builder() + .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); + when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved); + + mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments") + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isCreated()); + } + // ─── POST /api/documents/{documentId}/comments/{commentId}/replies ──────── @Test @@ -104,7 +116,20 @@ 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)) + .andExpect(status().isCreated()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void replyToComment_returns201_whenHasWriteAllPermission() throws Exception { + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID) + .authorName("Anna").content("Test comment").build(); + when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies") .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) @@ -163,6 +188,18 @@ class CommentControllerTest { .andExpect(status().isOk()); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void editComment_returns200_whenHasWriteAllPermission() throws Exception { + DocumentComment updated = DocumentComment.builder() + .id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build(); + when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated); + + mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID) + .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) + .andExpect(status().isOk()); + } + // ─── POST /api/documents/{documentId}/annotations/{annId}/comments ──────── @Test @@ -179,7 +216,20 @@ 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)) + .andExpect(status().isCreated()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void postAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception { + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) + .authorName("Hans").content("Test comment").build(); + when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved); mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments") .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) @@ -194,7 +244,20 @@ 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)) + .andExpect(status().isCreated()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") + void replyToAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception { + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID) + .parentId(COMMENT_ID).authorName("Anna").content("Test comment").build(); + when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved); mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies") .contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON)) 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..f0ab0859 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java @@ -0,0 +1,306 @@ +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; +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.time.LocalDateTime; +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.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.*; + +@WebMvcTest(NotificationController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class NotificationControllerTest { + + @Autowired MockMvc mockMvc; + + @MockitoBean NotificationService notificationService; + @MockitoBean UserService userService; + @MockitoBean SseEmitterRegistry sseEmitterRegistry; + @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_returns200_whenAuthenticatedWithNoPermissions() 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()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getNotifications_returns200WithList_whenAuthenticated() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); + NotificationDTO dto = new NotificationDTO( + UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(), + UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith"); + + when(userService.findByUsername("testuser")).thenReturn(user); + when(notificationService.getNotifications(eq(USER_ID), any())) + .thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1)); + + mockMvc.perform(get("/api/notifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").isArray()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + 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", authorities = {"READ_ALL"}) + 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 + void markOneRead_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + 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_returns403_whenUserHasNoPermission() throws Exception { + mockMvc.perform(get("/api/users/me/notification-preferences")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getPreferences_returns200_whenUserHasReadAll() 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)); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) + void getPreferences_returns200_whenUserHasWriteAll() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser") + .notifyOnReply(false).notifyOnMention(true).build(); + when(userService.findByUsername("testuser")).thenReturn(user); + + mockMvc.perform(get("/api/users/me/notification-preferences")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.notifyOnMention").value(true)); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"ANNOTATE_ALL"}) + void getPreferences_returns200_whenUserHasAnnotateAll() throws Exception { + AppUser user = AppUser.builder().id(USER_ID).username("testuser") + .notifyOnReply(false).notifyOnMention(false).build(); + when(userService.findByUsername("testuser")).thenReturn(user); + + mockMvc.perform(get("/api/users/me/notification-preferences")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) + void getNotifications_returns200_whenUserHasOnlyWriteAll() 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()); + } + + // ─── PUT /api/users/me/notification-preferences ────────────────────────── + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + 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)); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"WRITE_ALL"}) + void updatePreferences_returns200_whenUserHasWriteAll() 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(false).build(); + when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated); + + mockMvc.perform(put("/api/users/me/notification-preferences") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"notifyOnReply\":true,\"notifyOnMention\":false}")) + .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/controller/UserSearchControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserSearchControllerTest.java new file mode 100644 index 00000000..0020a76c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/UserSearchControllerTest.java @@ -0,0 +1,94 @@ +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 java.util.stream.IntStream; + +import static org.hamcrest.Matchers.lessThanOrEqualTo; +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_returns403_whenUserLacksPermission() throws Exception { + mockMvc.perform(get("/api/users/search").param("q", "Hans")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = {"ANNOTATE_ALL"}) + void search_returns200_whenUserHasAnnotateAll() throws Exception { + when(userSearchService.search("Hans")).thenReturn(List.of()); + + mockMvc.perform(get("/api/users/search").param("q", "Hans")) + .andExpect(status().isOk()); + } + + @Test + @WithMockUser(authorities = {"READ_ALL"}) + 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(authorities = {"READ_ALL"}) + 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(authorities = {"READ_ALL"}) + void search_returnsAtMostTenResults() throws Exception { + List elevenUsers = IntStream.range(0, 11) + .mapToObj(i -> AppUser.builder().id(UUID.randomUUID()) + .firstName("User").lastName(String.valueOf(i)).username("u" + i).build()) + .toList(); + when(userSearchService.search(anyString())).thenReturn(elevenUsers.subList(0, 10)); + + mockMvc.perform(get("/api/users/search").param("q", "a")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10))); + } +} 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..6d3e8abe 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,9 @@ 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.anyList; +import static org.mockito.ArgumentMatchers.anySet; +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 +33,8 @@ import static org.springframework.http.HttpStatus.NOT_FOUND; class CommentServiceTest { @Mock CommentRepository commentRepository; + @Mock UserService userService; + @Mock NotificationService notificationService; @InjectMocks CommentService commentService; // ─── postComment ────────────────────────────────────────────────────────── @@ -43,7 +48,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"); } @@ -56,11 +61,28 @@ 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"); } + @Test + void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() { + UUID docId = UUID.randomUUID(); + UUID mentionedId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("M").build(); + AppUser mentioned = AppUser.builder().id(mentionedId).username("anna").firstName("Anna").lastName("S").build(); + DocumentComment saved = DocumentComment.builder() + .id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build(); + + when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned)); + when(commentRepository.save(any())).thenReturn(saved); + + commentService.postComment(docId, null, "Hey @Anna S", List.of(mentionedId), author); + + verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved)); + } + // ─── replyToComment ─────────────────────────────────────────────────────── @Test @@ -70,7 +92,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)); @@ -91,11 +113,12 @@ class CommentServiceTest { when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply)); when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); + when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply)); DocumentComment saved = DocumentComment.builder() .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build(); when(commentRepository.save(any())).thenReturn(saved); - DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author); + DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", List.of(), author); assertThat(result.getParentId()).isEqualTo(rootId); } @@ -110,15 +133,59 @@ class CommentServiceTest { .id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build(); when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); + when(commentRepository.findByParentId(rootId)).thenReturn(List.of()); DocumentComment saved = DocumentComment.builder() .id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build(); when(commentRepository.save(any())).thenReturn(saved); - DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author); + DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", List.of(), author); assertThat(result.getParentId()).isEqualTo(rootId); } + @Test + void replyToComment_triggersNotifyReply_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.findByParentId(rootId)).thenReturn(List.of()); + when(commentRepository.save(any())).thenReturn(saved); + + commentService.replyToComment(docId, rootId, "Reply", List.of(), author); + + verify(notificationService).notifyReply(eq(saved), anySet()); + } + + @Test + void replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided() { + UUID docId = UUID.randomUUID(); + UUID rootId = UUID.randomUUID(); + UUID mentionedId = UUID.randomUUID(); + AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build(); + AppUser mentioned = AppUser.builder().id(mentionedId).username("bob").firstName("Bob").lastName("J").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("Hey @Bob J").authorName("anna").build(); + + when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned)); + when(commentRepository.findById(rootId)).thenReturn(Optional.of(root)); + when(commentRepository.findByParentId(rootId)).thenReturn(List.of()); + when(commentRepository.save(any())).thenReturn(saved); + + commentService.replyToComment(docId, rootId, "Hey @Bob J", List.of(mentionedId), author); + + verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved)); + } + // ─── editComment ────────────────────────────────────────────────────────── @Test 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..27d25f94 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java @@ -0,0 +1,231 @@ +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.NotificationDTO; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.*; +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.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @Mock NotificationRepository notificationRepository; + @Mock UserService userService; + @Mock JavaMailSender mailSender; + @Mock SseEmitterRegistry sseEmitterRegistry; + + NotificationService notificationService; + + private AppUser userA; + private AppUser userB; + private AppUser userC; + + @BeforeEach + void setUp() { + 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") + .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_createsNotificationForThreadParticipants() { + 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())); + + 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()); + assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName())); + } + + @Test + void notifyReply_doesNothing_whenParticipantSetIsEmpty() { + DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith"); + + notificationService.notifyReply(reply, Set.of()); + + verify(notificationRepository, never()).save(any()); + } + + @Test + void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() { + userA.setNotifyOnReply(true); + userB.setNotifyOnReply(false); + + 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(mailSender, times(1)).send(any(SimpleMailMessage.class)); + } + + // ─── notifyMentions ─────────────────────────────────────────────────────── + + @Test + void notifyMentions_createsNotificationPerMentionedUser() { + 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); + + 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); + assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName())); + } + + @Test + void notifyMentions_doesNothing_whenListIsEmpty() { + DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith"); + + notificationService.notifyMentions(List.of(), comment); + + verify(notificationRepository, never()).save(any()); + } + + @Test + void notifyMentions_sendsEmailOnlyToUsersWithMentionNotificationsEnabled() { + userA.setNotifyOnMention(true); + userB.setNotifyOnMention(false); + + 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(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 + void markRead_throwsNotFound_whenNotificationDoesNotExist() { + UUID notifId = UUID.randomUUID(); + when(notificationRepository.findById(notifId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> notificationService.markRead(notifId, userA.getId())) + .isInstanceOf(DomainException.class) + .hasMessageContaining("Notification not found"); + } + + @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"); + } + + // ─── markAllRead ────────────────────────────────────────────────────────── + + @Test + void markAllRead_delegatesToRepository() { + notificationService.markAllRead(userA.getId()); + + verify(notificationRepository).markAllReadByRecipientId(userA.getId()); + } + + // ─── countUnread ────────────────────────────────────────────────────────── + + @Test + void countUnread_delegatesToRepository() { + when(notificationRepository.countByRecipientIdAndReadFalse(userA.getId())).thenReturn(3L); + + assertThat(notificationService.countUnread(userA.getId())).isEqualTo(3L); + } + + // ─── private helpers ────────────────────────────────────────────────────── + + private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) { + return DocumentComment.builder() + .id(id) + .documentId(UUID.randomUUID()) + .parentId(parentId) + .authorId(authorId) + .authorName(authorName) + .content("content") + .build(); + } +} 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/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java index 7a58dd45..1e032979 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/UserServiceTest.java @@ -5,17 +5,20 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest; import org.raddatz.familienarchiv.dto.ChangePasswordDTO; import org.raddatz.familienarchiv.dto.CreateUserRequest; import org.raddatz.familienarchiv.dto.UpdateProfileDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.UserGroup; import org.raddatz.familienarchiv.repository.AppUserRepository; import org.raddatz.familienarchiv.repository.UserGroupRepository; import org.springframework.security.crypto.password.PasswordEncoder; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -216,6 +219,78 @@ class UserServiceTest { verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword()))); } + // ─── adminUpdateUser ────────────────────────────────────────────────────── + + @Test + void adminUpdateUser_updatesNameFields() { + UUID id = UUID.randomUUID(); + AppUser user = AppUser.builder().id(id).username("admin").build(); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); + dto.setFirstName("Ada"); dto.setLastName("Lovelace"); + + AppUser result = userService.adminUpdateUser(id, dto); + + assertThat(result.getFirstName()).isEqualTo("Ada"); + assertThat(result.getLastName()).isEqualTo("Lovelace"); + } + + @Test + void adminUpdateUser_preservesGroups_whenGroupIdsIsNull() { + UUID id = UUID.randomUUID(); + UserGroup adminGroup = UserGroup.builder().id(UUID.randomUUID()).name("Administrators").build(); + AppUser user = AppUser.builder().id(id).username("admin").groups(Set.of(adminGroup)).build(); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); + dto.setFirstName("Ada"); // groupIds left null → don't change groups + + AppUser result = userService.adminUpdateUser(id, dto); + + assertThat(result.getGroups()).containsExactly(adminGroup); + } + + @Test + void adminUpdateUser_updatesGroups_whenGroupIdsProvided() { + UUID id = UUID.randomUUID(); + UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").build(); + UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").build(); + AppUser user = AppUser.builder().id(id).username("max").groups(Set.of(oldGroup)).build(); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); + dto.setGroupIds(List.of(newGroup.getId())); + + AppUser result = userService.adminUpdateUser(id, dto); + + assertThat(result.getGroups()).containsExactly(newGroup); + } + + @Test + void adminUpdateUser_clearsGroups_whenGroupIdsIsEmptyList() { + // Sending groupIds:[] is the explicit "remove from all groups" signal. + // The frontend must NEVER send [] accidentally — it must always include + // the currently-selected group checkboxes. + UUID id = UUID.randomUUID(); + UserGroup adminGroup = UserGroup.builder().id(UUID.randomUUID()).name("Administrators").build(); + AppUser user = AppUser.builder().id(id).username("admin").groups(Set.of(adminGroup)).build(); + when(userRepository.findById(id)).thenReturn(Optional.of(user)); + when(groupRepository.findAllById(List.of())).thenReturn(List.of()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + AdminUpdateUserRequest dto = new AdminUpdateUserRequest(); + dto.setGroupIds(List.of()); // empty list → intentional "remove all groups" + + AppUser result = userService.adminUpdateUser(id, dto); + + assertThat(result.getGroups()).isEmpty(); + } + // ─── getGroupById ───────────────────────────────────────────────────────── @Test diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 16718738..8aaa2fc3 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -294,5 +294,18 @@ "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", + "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 462dea3b..0717d54e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -294,5 +294,18 @@ "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", + "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 0ae0b91a..ddf28bf2 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -294,5 +294,18 @@ "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", + "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" } 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/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/components/CommentThread.svelte b/frontend/src/lib/components/CommentThread.svelte index 5fa6bbbf..de350d72 100644 --- a/frontend/src/lib/components/CommentThread.svelte +++ b/frontend/src/lib/components/CommentThread.svelte @@ -1,7 +1,10 @@ @@ -181,11 +216,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)}
@@ -269,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} @@ -283,12 +333,14 @@ onMount(() => { {#if replyingTo === thread.id}
- + disabled={posting} + onsubmit={() => postReply(thread.id)} + />
+
diff --git a/frontend/src/lib/components/NotificationBell.svelte b/frontend/src/lib/components/NotificationBell.svelte new file mode 100644 index 00000000..f8edc540 --- /dev/null +++ b/frontend/src/lib/components/NotificationBell.svelte @@ -0,0 +1,322 @@ + + + + +
+ + + + + {#if open} + + {/if} +
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/lib/components/user/UserGroupsSection.svelte b/frontend/src/lib/components/user/UserGroupsSection.svelte index 911cde23..760989f1 100644 --- a/frontend/src/lib/components/user/UserGroupsSection.svelte +++ b/frontend/src/lib/components/user/UserGroupsSection.svelte @@ -6,6 +6,8 @@ let { groups: { id: string; name: string }[]; selectedGroupIds?: string[]; } = $props(); + +let selected = $derived([...selectedGroupIds]);
@@ -15,7 +17,7 @@ let { type="checkbox" name="groupIds" value={group.id} - checked={selectedGroupIds.includes(group.id)} + bind:group={selected} class="rounded border-line text-ink focus:ring-accent" /> {group.name} 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..5a7982be --- /dev/null +++ b/frontend/src/lib/utils/mention.spec.ts @@ -0,0 +1,128 @@ +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('
@@ -30,4 +35,70 @@ let { data, form } = $props();
+ + +
+

+ {m.notification_prefs_heading()} +

+ + {#if form?.prefsSuccess} +
+ {m.profile_saved()} +
+ {/if} + {#if form?.prefsError} +
+ {form.prefsError} +
+ {/if} + +
async ({ update }) => update({ reset: false })} + > +
+ + + +
+ + {#if !hasEmail} +

+ {m.notification_prefs_no_email()} +

+ {/if} + + +
+