fix(#71-#73): address all review findings from Markus and Sara
BLOCKERs: - Remove direct AppUserRepository/CommentRepository access from CommentService and NotificationService — replaced with UserService.findAllById() and UserService (fixes layering contract from CLAUDE.md) - Switch Optional<JavaMailSender> constructor injection — removes @Autowired(required=false) field and ReflectionTestUtils hack in tests - Add @RequirePermission(READ_ALL) to UserSearchController — prevents user enumeration without read access Data bug: - Promote actorName from @Transient to persisted VARCHAR column (V18 migration) - Set actorName in notifyReply and notifyMentions from comment.getAuthorName() Architecture: - Add @RequirePermission(READ_ALL) to NotificationController - Introduce NotificationDTO — controller returns DTO instead of Notification entity, eliminating lazy-load N+1 and AppUser field leakage - Change mentions FetchType to EAGER — fixes LazyInitializationException outside transaction - Add @Transactional(propagation=REQUIRES_NEW) to notifyReply/notifyMentions so a notification failure cannot roll back the parent comment - N+1 fix: replace per-ID findById loops with single findAllById bulk fetch - Move collectParticipantIds to CommentService; notifyReply accepts Set<UUID> directly Security: - Escape displayName before injecting into renderBody HTML span - Replace <a href="#"> with <span class="mention"> — no profile page to link to, and the anchor's scroll-to-top behaviour is harmful Tests added/fixed: - markRead_throwsNotFound, markAllRead_delegatesToRepository, countUnread_delegatesToRepository - markOneRead_returns401, @RequirePermission 403 coverage for both controllers - postComment/replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided - search_returnsAtMostTenResults now asserts $.length() <= 10 - XSS regression test for escaped displayName in mention.spec.ts Frontend minors: - relativeTime() uses Intl.RelativeTimeFormat (locale-aware, not German-hardcoded) - aria-label uses m.notification_unread() Paraglide key (de/en/es added) - <div role="button"> replaced with <button> (native Enter+Space handling) - onDestroy clears debounceTimer in MentionEditor - setTimeout(100) replaced with await tick() + requestAnimationFrame in CommentThread - Notification prefs form uses checkbox name attributes + formData.has() pattern Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Notification;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.NotificationService;
|
import org.raddatz.familienarchiv.service.NotificationService;
|
||||||
import org.raddatz.familienarchiv.service.UserService;
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -17,13 +19,14 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public class NotificationController {
|
public class NotificationController {
|
||||||
|
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
|
||||||
@GetMapping("/api/notifications")
|
@GetMapping("/api/notifications")
|
||||||
public Page<Notification> getNotifications(
|
public Page<NotificationDTO> getNotifications(
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "10") int size,
|
@RequestParam(defaultValue = "10") int size,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
@@ -40,7 +43,7 @@ public class NotificationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/api/notifications/{id}/read")
|
@PatchMapping("/api/notifications/{id}/read")
|
||||||
public Notification markOneRead(
|
public NotificationDTO markOneRead(
|
||||||
@PathVariable UUID id,
|
@PathVariable UUID id,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser user = resolveUser(authentication);
|
AppUser user = resolveUser(authentication);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.raddatz.familienarchiv.dto.MentionDTO;
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
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.raddatz.familienarchiv.service.UserSearchService;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -12,6 +14,7 @@ import java.util.List;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
public class UserSearchController {
|
public class UserSearchController {
|
||||||
|
|
||||||
private final UserSearchService userSearchService;
|
private final UserSearchService userSearchService;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record NotificationDTO(
|
||||||
|
UUID id,
|
||||||
|
NotificationType type,
|
||||||
|
UUID documentId,
|
||||||
|
UUID referenceId,
|
||||||
|
boolean read,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
String actorName
|
||||||
|
) {}
|
||||||
@@ -64,7 +64,7 @@ public class DocumentComment {
|
|||||||
private List<DocumentComment> replies = new ArrayList<>();
|
private List<DocumentComment> replies = new ArrayList<>();
|
||||||
|
|
||||||
// JPA join table for structured mention references — not serialized directly
|
// JPA join table for structured mention references — not serialized directly
|
||||||
@ManyToMany(fetch = FetchType.LAZY)
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
@JoinTable(
|
@JoinTable(
|
||||||
name = "comment_mentions",
|
name = "comment_mentions",
|
||||||
joinColumns = @JoinColumn(name = "comment_id"),
|
joinColumns = @JoinColumn(name = "comment_id"),
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ public class Notification {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
// Populated by NotificationService before serialization — not persisted.
|
@Column(name = "actor_name")
|
||||||
@Transient
|
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private String actorName;
|
private String actorName;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.springframework.data.jpa.repository.Modifying;
|
|||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
|
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
|
||||||
@@ -20,6 +19,4 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
|
|||||||
@Modifying
|
@Modifying
|
||||||
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
|
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
|
||||||
void markAllReadByRecipientId(@Param("userId") UUID userId);
|
void markAllReadByRecipientId(@Param("userId") UUID userId);
|
||||||
|
|
||||||
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
|||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -19,7 +20,7 @@ import java.util.UUID;
|
|||||||
public class CommentService {
|
public class CommentService {
|
||||||
|
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final AppUserRepository userRepository;
|
private final UserService userService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||||
@@ -73,7 +74,10 @@ public class CommentService {
|
|||||||
saveMentions(reply, mentionedUserIds);
|
saveMentions(reply, mentionedUserIds);
|
||||||
DocumentComment saved = commentRepository.save(reply);
|
DocumentComment saved = commentRepository.save(reply);
|
||||||
withMentionDTOs(saved);
|
withMentionDTOs(saved);
|
||||||
notificationService.notifyReply(saved, root);
|
|
||||||
|
Set<UUID> participantIds = collectParticipantIds(root);
|
||||||
|
participantIds.remove(author.getId());
|
||||||
|
notificationService.notifyReply(saved, participantIds);
|
||||||
notificationService.notifyMentions(mentionedUserIds, saved);
|
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,10 @@ public class CommentService {
|
|||||||
commentRepository.delete(comment);
|
commentRepository.delete(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<DocumentComment> findReplies(UUID parentId) {
|
||||||
|
return commentRepository.findByParentId(parentId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
|
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
|
||||||
@@ -113,7 +121,7 @@ public class CommentService {
|
|||||||
|
|
||||||
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
|
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
|
||||||
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
|
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
|
||||||
List<AppUser> users = userRepository.findAllById(mentionedUserIds);
|
List<AppUser> users = userService.findAllById(mentionedUserIds);
|
||||||
comment.setMentions(users);
|
comment.setMentions(users);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +132,16 @@ public class CommentService {
|
|||||||
comment.setMentionDTOs(dtos);
|
comment.setMentionDTOs(dtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Set<UUID> collectParticipantIds(DocumentComment root) {
|
||||||
|
Set<UUID> 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) {
|
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
||||||
return commentRepository.findById(commentId)
|
return commentRepository.findById(commentId)
|
||||||
.filter(c -> documentId.equals(c.getDocumentId()))
|
.filter(c -> documentId.equals(c.getDocumentId()))
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ package org.raddatz.familienarchiv.service;
|
|||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
import org.raddatz.familienarchiv.model.Notification;
|
import org.raddatz.familienarchiv.model.Notification;
|
||||||
import org.raddatz.familienarchiv.model.NotificationType;
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -19,9 +17,9 @@ import org.springframework.mail.MailException;
|
|||||||
import org.springframework.mail.SimpleMailMessage;
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
import org.springframework.mail.javamail.JavaMailSender;
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -33,11 +31,8 @@ import java.util.UUID;
|
|||||||
public class NotificationService {
|
public class NotificationService {
|
||||||
|
|
||||||
private final NotificationRepository notificationRepository;
|
private final NotificationRepository notificationRepository;
|
||||||
private final CommentRepository commentRepository;
|
private final UserService userService;
|
||||||
private final AppUserRepository userRepository;
|
private final Optional<JavaMailSender> mailSender;
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private JavaMailSender mailSender;
|
|
||||||
|
|
||||||
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
||||||
private String mailFrom;
|
private String mailFrom;
|
||||||
@@ -46,24 +41,21 @@ public class NotificationService {
|
|||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates REPLY notifications for all participants in the thread that the given reply belongs to,
|
* Creates REPLY notifications for all participants in the thread, excluding the replier.
|
||||||
* excluding the replier themselves.
|
* Runs in a separate transaction so a notification failure cannot roll back the parent comment.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public void notifyReply(DocumentComment reply, DocumentComment root) {
|
public void notifyReply(DocumentComment reply, Set<UUID> participantIds) {
|
||||||
Set<UUID> participantIds = collectParticipantIds(root);
|
if (participantIds.isEmpty()) return;
|
||||||
participantIds.remove(reply.getAuthorId());
|
|
||||||
|
|
||||||
for (UUID participantId : participantIds) {
|
List<AppUser> recipients = userService.findAllById(participantIds);
|
||||||
Optional<AppUser> recipientOpt = userRepository.findById(participantId);
|
for (AppUser recipient : recipients) {
|
||||||
if (recipientOpt.isEmpty()) continue;
|
|
||||||
|
|
||||||
AppUser recipient = recipientOpt.get();
|
|
||||||
Notification notification = Notification.builder()
|
Notification notification = Notification.builder()
|
||||||
.recipient(recipient)
|
.recipient(recipient)
|
||||||
.type(NotificationType.REPLY)
|
.type(NotificationType.REPLY)
|
||||||
.documentId(reply.getDocumentId())
|
.documentId(reply.getDocumentId())
|
||||||
.referenceId(reply.getId())
|
.referenceId(reply.getId())
|
||||||
|
.actorName(reply.getAuthorName())
|
||||||
.build();
|
.build();
|
||||||
notificationRepository.save(notification);
|
notificationRepository.save(notification);
|
||||||
|
|
||||||
@@ -75,19 +67,20 @@ public class NotificationService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates MENTION notifications for each mentioned user.
|
* Creates MENTION notifications for each mentioned user.
|
||||||
|
* Runs in a separate transaction so a notification failure cannot roll back the parent comment.
|
||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {
|
public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {
|
||||||
for (UUID mentionedUserId : mentionedUserIds) {
|
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
|
||||||
Optional<AppUser> recipientOpt = userRepository.findById(mentionedUserId);
|
|
||||||
if (recipientOpt.isEmpty()) continue;
|
|
||||||
|
|
||||||
AppUser recipient = recipientOpt.get();
|
List<AppUser> recipients = userService.findAllById(mentionedUserIds);
|
||||||
|
for (AppUser recipient : recipients) {
|
||||||
Notification notification = Notification.builder()
|
Notification notification = Notification.builder()
|
||||||
.recipient(recipient)
|
.recipient(recipient)
|
||||||
.type(NotificationType.MENTION)
|
.type(NotificationType.MENTION)
|
||||||
.documentId(comment.getDocumentId())
|
.documentId(comment.getDocumentId())
|
||||||
.referenceId(comment.getId())
|
.referenceId(comment.getId())
|
||||||
|
.actorName(comment.getAuthorName())
|
||||||
.build();
|
.build();
|
||||||
notificationRepository.save(notification);
|
notificationRepository.save(notification);
|
||||||
|
|
||||||
@@ -97,8 +90,9 @@ public class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<Notification> getNotifications(UUID userId, Pageable pageable) {
|
public Page<NotificationDTO> getNotifications(UUID userId, Pageable pageable) {
|
||||||
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable);
|
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
|
||||||
|
.map(this::toDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countUnread(UUID userId) {
|
public long countUnread(UUID userId) {
|
||||||
@@ -111,7 +105,7 @@ public class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Notification markRead(UUID notificationId, UUID userId) {
|
public NotificationDTO markRead(UUID notificationId, UUID userId) {
|
||||||
Notification notification = notificationRepository.findById(notificationId)
|
Notification notification = notificationRepository.findById(notificationId)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId));
|
ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId));
|
||||||
@@ -119,29 +113,26 @@ public class NotificationService {
|
|||||||
throw DomainException.forbidden("Notification belongs to a different user");
|
throw DomainException.forbidden("Notification belongs to a different user");
|
||||||
}
|
}
|
||||||
notification.setRead(true);
|
notification.setRead(true);
|
||||||
return notificationRepository.save(notification);
|
return toDTO(notificationRepository.save(notification));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
|
public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
|
||||||
AppUser user = userRepository.findById(userId)
|
return userService.updateNotificationPreferences(userId, notifyOnReply, notifyOnMention);
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "User not found: " + userId));
|
|
||||||
user.setNotifyOnReply(notifyOnReply);
|
|
||||||
user.setNotifyOnMention(notifyOnMention);
|
|
||||||
return userRepository.save(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private Set<UUID> collectParticipantIds(DocumentComment root) {
|
private NotificationDTO toDTO(Notification n) {
|
||||||
Set<UUID> ids = new LinkedHashSet<>();
|
return new NotificationDTO(
|
||||||
if (root.getAuthorId() != null) ids.add(root.getAuthorId());
|
n.getId(),
|
||||||
|
n.getType(),
|
||||||
commentRepository.findByParentId(root.getId())
|
n.getDocumentId(),
|
||||||
.forEach(reply -> {
|
n.getReferenceId(),
|
||||||
if (reply.getAuthorId() != null) ids.add(reply.getAuthorId());
|
n.isRead(),
|
||||||
});
|
n.getCreatedAt(),
|
||||||
return ids;
|
n.getActorName()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
|
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
|
||||||
@@ -152,7 +143,7 @@ public class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) {
|
private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) {
|
||||||
if (mailSender == null) {
|
if (mailSender.isEmpty()) {
|
||||||
log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail());
|
log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -179,7 +170,7 @@ public class NotificationService {
|
|||||||
message.setText(body);
|
message.setText(body);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mailSender.send(message);
|
mailSender.get().send(message);
|
||||||
} catch (MailException e) {
|
} catch (MailException e) {
|
||||||
log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage());
|
log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -78,6 +79,18 @@ public class UserService {
|
|||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<AppUser> findAllById(Collection<UUID> 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
|
@Transactional
|
||||||
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
||||||
AppUser user = getById(userId);
|
AppUser user = getById(userId);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE notifications ADD COLUMN actor_name VARCHAR(255);
|
||||||
@@ -2,8 +2,8 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.Notification;
|
|
||||||
import org.raddatz.familienarchiv.model.NotificationType;
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
@@ -20,6 +20,7 @@ import org.springframework.security.test.context.support.WithMockUser;
|
|||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -52,15 +53,22 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser")
|
@WithMockUser(username = "testuser")
|
||||||
|
void getNotifications_returns403_whenUserLacksPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
|
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
|
||||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
Notification n = Notification.builder()
|
NotificationDTO dto = new NotificationDTO(
|
||||||
.id(UUID.randomUUID()).recipient(user)
|
UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
|
||||||
.type(NotificationType.REPLY).read(false).build();
|
UUID.randomUUID(), false, LocalDateTime.now(), "Anna Smith");
|
||||||
|
|
||||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||||
.thenReturn(new PageImpl<>(List.of(n), PageRequest.of(0, 10), 1));
|
.thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/notifications"))
|
mockMvc.perform(get("/api/notifications"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -68,7 +76,7 @@ class NotificationControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser")
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
|
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
|
||||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
@@ -90,7 +98,7 @@ class NotificationControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser")
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void markAllRead_returns204_whenAuthenticated() throws Exception {
|
void markAllRead_returns204_whenAuthenticated() throws Exception {
|
||||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
@@ -104,7 +112,13 @@ class NotificationControllerTest {
|
|||||||
// ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
|
// ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser")
|
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 {
|
void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception {
|
||||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
UUID notifId = UUID.randomUUID();
|
UUID notifId = UUID.randomUUID();
|
||||||
@@ -127,7 +141,7 @@ class NotificationControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser")
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void getPreferences_returnsCurrentPreferences() throws Exception {
|
void getPreferences_returnsCurrentPreferences() throws Exception {
|
||||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
.notifyOnReply(true).notifyOnMention(false).build();
|
.notifyOnReply(true).notifyOnMention(false).build();
|
||||||
@@ -142,7 +156,7 @@ class NotificationControllerTest {
|
|||||||
// ─── PUT /api/users/me/notification-preferences ──────────────────────────
|
// ─── PUT /api/users/me/notification-preferences ──────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser")
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
void updatePreferences_persistsBothBooleans() throws Exception {
|
void updatePreferences_persistsBothBooleans() throws Exception {
|
||||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
.notifyOnReply(false).notifyOnMention(false).build();
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import org.springframework.test.web.servlet.MockMvc;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
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.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -40,6 +42,13 @@ class UserSearchControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
|
void search_returns403_whenUserLacksPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = {"READ_ALL"})
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||||
.firstName("Hans").lastName("Mueller").username("hans").build();
|
.firstName("Hans").lastName("Mueller").username("hans").build();
|
||||||
@@ -51,7 +60,7 @@ class UserSearchControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser(authorities = {"READ_ALL"})
|
||||||
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
|
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
|
||||||
when(userSearchService.search("")).thenReturn(List.of());
|
when(userSearchService.search("")).thenReturn(List.of());
|
||||||
|
|
||||||
@@ -61,11 +70,16 @@ class UserSearchControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser(authorities = {"READ_ALL"})
|
||||||
void search_returnsAtMostTenResults() throws Exception {
|
void search_returnsAtMostTenResults() throws Exception {
|
||||||
when(userSearchService.search(anyString())).thenReturn(List.of());
|
List<AppUser> 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"))
|
mockMvc.perform(get("/api/users/search").param("q", "a"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
|||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
import org.raddatz.familienarchiv.model.UserGroup;
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -21,6 +20,8 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
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.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -32,7 +33,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
|
|||||||
class CommentServiceTest {
|
class CommentServiceTest {
|
||||||
|
|
||||||
@Mock CommentRepository commentRepository;
|
@Mock CommentRepository commentRepository;
|
||||||
@Mock AppUserRepository userRepository;
|
@Mock UserService userService;
|
||||||
@Mock NotificationService notificationService;
|
@Mock NotificationService notificationService;
|
||||||
@InjectMocks CommentService commentService;
|
@InjectMocks CommentService commentService;
|
||||||
|
|
||||||
@@ -65,6 +66,23 @@ class CommentServiceTest {
|
|||||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
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 ───────────────────────────────────────────────────────
|
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -95,6 +113,7 @@ class CommentServiceTest {
|
|||||||
|
|
||||||
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
||||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
@@ -114,6 +133,7 @@ class CommentServiceTest {
|
|||||||
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||||
|
|
||||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
@@ -124,7 +144,7 @@ class CommentServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void replyToComment_triggersNotification_afterSave() {
|
void replyToComment_triggersNotifyReply_afterSave() {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
UUID rootId = UUID.randomUUID();
|
UUID rootId = UUID.randomUUID();
|
||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
@@ -135,11 +155,35 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
|
|
||||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
||||||
|
|
||||||
verify(notificationService).notifyReply(eq(saved), eq(root));
|
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 ──────────────────────────────────────────────────────────
|
// ─── editComment ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,20 +4,18 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.*;
|
import org.raddatz.familienarchiv.model.*;
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
|
||||||
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||||
import org.springframework.mail.SimpleMailMessage;
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
import org.springframework.mail.javamail.JavaMailSender;
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -29,11 +27,10 @@ import static org.mockito.Mockito.*;
|
|||||||
class NotificationServiceTest {
|
class NotificationServiceTest {
|
||||||
|
|
||||||
@Mock NotificationRepository notificationRepository;
|
@Mock NotificationRepository notificationRepository;
|
||||||
@Mock CommentRepository commentRepository;
|
@Mock UserService userService;
|
||||||
@Mock AppUserRepository userRepository;
|
|
||||||
@Mock JavaMailSender mailSender;
|
@Mock JavaMailSender mailSender;
|
||||||
|
|
||||||
@InjectMocks NotificationService notificationService;
|
NotificationService notificationService;
|
||||||
|
|
||||||
private AppUser userA;
|
private AppUser userA;
|
||||||
private AppUser userB;
|
private AppUser userB;
|
||||||
@@ -41,9 +38,7 @@ class NotificationServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
// mailSender is @Autowired(required=false) — not in the @RequiredArgsConstructor
|
notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender));
|
||||||
// constructor, so Mockito won't inject it automatically. Inject explicitly.
|
|
||||||
ReflectionTestUtils.setField(notificationService, "mailSender", mailSender);
|
|
||||||
|
|
||||||
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
||||||
.firstName("Anna").lastName("Smith").email("a@test.com")
|
.firstName("Anna").lastName("Smith").email("a@test.com")
|
||||||
@@ -59,17 +54,13 @@ class NotificationServiceTest {
|
|||||||
// ─── notifyReply ──────────────────────────────────────────────────────────
|
// ─── notifyReply ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void notifyReply_createsNotificationForThreadParticipant() {
|
void notifyReply_createsNotificationForThreadParticipants() {
|
||||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
|
||||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
|
||||||
|
|
||||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply));
|
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
|
||||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
|
||||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
notificationService.notifyReply(reply, root);
|
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
|
||||||
|
|
||||||
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||||
verify(notificationRepository, times(2)).save(captor.capture());
|
verify(notificationRepository, times(2)).save(captor.capture());
|
||||||
@@ -79,57 +70,30 @@ class NotificationServiceTest {
|
|||||||
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||||
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
|
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
|
||||||
assertThat(saved).allMatch(n -> !n.isRead());
|
assertThat(saved).allMatch(n -> !n.isRead());
|
||||||
|
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void notifyReply_doesNotNotifyTheReplierThemselves() {
|
void notifyReply_doesNothing_whenParticipantSetIsEmpty() {
|
||||||
// userA is both a thread participant and the replier
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
|
||||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
|
||||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userA.getId());
|
|
||||||
|
|
||||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(reply));
|
notificationService.notifyReply(reply, Set.of());
|
||||||
|
|
||||||
notificationService.notifyReply(reply, root);
|
|
||||||
|
|
||||||
verify(notificationRepository, never()).save(any());
|
verify(notificationRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void notifyReply_deduplicatesParticipants() {
|
|
||||||
// userB has posted twice in the thread — should get exactly one notification
|
|
||||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
|
||||||
DocumentComment first = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
|
||||||
DocumentComment second = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
|
||||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
|
||||||
|
|
||||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(first, second, reply));
|
|
||||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
|
||||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
|
||||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
|
||||||
|
|
||||||
notificationService.notifyReply(reply, root);
|
|
||||||
|
|
||||||
// userA (root) + userB (deduplicated) = 2 notifications, not 3
|
|
||||||
verify(notificationRepository, times(2)).save(any());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
|
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
|
||||||
userA.setNotifyOnReply(true);
|
userA.setNotifyOnReply(true);
|
||||||
userB.setNotifyOnReply(false);
|
userB.setNotifyOnReply(false);
|
||||||
|
|
||||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
|
||||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
|
||||||
|
|
||||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply));
|
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
|
||||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
|
||||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
notificationService.notifyReply(reply, root);
|
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
|
||||||
|
|
||||||
// Only userA has email enabled — one email sent
|
|
||||||
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +101,8 @@ class NotificationServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void notifyMentions_createsNotificationPerMentionedUser() {
|
void notifyMentions_createsNotificationPerMentionedUser() {
|
||||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
|
||||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||||
@@ -151,6 +114,16 @@ class NotificationServiceTest {
|
|||||||
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
||||||
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||||
assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION);
|
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
|
@Test
|
||||||
@@ -158,9 +131,8 @@ class NotificationServiceTest {
|
|||||||
userA.setNotifyOnMention(true);
|
userA.setNotifyOnMention(true);
|
||||||
userB.setNotifyOnMention(false);
|
userB.setNotifyOnMention(false);
|
||||||
|
|
||||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
|
||||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||||
@@ -170,6 +142,16 @@ class NotificationServiceTest {
|
|||||||
|
|
||||||
// ─── markRead ─────────────────────────────────────────────────────────────
|
// ─── 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
|
@Test
|
||||||
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
|
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
|
||||||
Notification notification = Notification.builder()
|
Notification notification = Notification.builder()
|
||||||
@@ -186,15 +168,33 @@ class NotificationServiceTest {
|
|||||||
.hasMessageContaining("different user");
|
.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 helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId) {
|
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
|
||||||
return DocumentComment.builder()
|
return DocumentComment.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.documentId(UUID.randomUUID())
|
.documentId(UUID.randomUUID())
|
||||||
.parentId(parentId)
|
.parentId(parentId)
|
||||||
.authorId(authorId)
|
.authorId(authorId)
|
||||||
.authorName("Author")
|
.authorName(authorName)
|
||||||
.content("content")
|
.content("content")
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
"notification_prefs_heading": "Benachrichtigungen",
|
"notification_prefs_heading": "Benachrichtigungen",
|
||||||
"notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet",
|
"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_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt",
|
||||||
|
"notification_unread": "ungelesen",
|
||||||
"mention_btn_label": "Person erwähnen",
|
"mention_btn_label": "Person erwähnen",
|
||||||
"mention_popup_empty": "Keine Nutzer gefunden"
|
"mention_popup_empty": "Keine Nutzer gefunden"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
"notification_prefs_heading": "Notifications",
|
"notification_prefs_heading": "Notifications",
|
||||||
"notification_pref_reply": "Email when someone replies to my comment",
|
"notification_pref_reply": "Email when someone replies to my comment",
|
||||||
"notification_pref_mention": "Email when someone mentions me in a comment",
|
"notification_pref_mention": "Email when someone mentions me in a comment",
|
||||||
|
"notification_unread": "unread",
|
||||||
"mention_btn_label": "Mention person",
|
"mention_btn_label": "Mention person",
|
||||||
"mention_popup_empty": "No users found"
|
"mention_popup_empty": "No users found"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
"notification_prefs_heading": "Notificaciones",
|
"notification_prefs_heading": "Notificaciones",
|
||||||
"notification_pref_reply": "Correo cuando alguien responde a mi comentario",
|
"notification_pref_reply": "Correo cuando alguien responde a mi comentario",
|
||||||
"notification_pref_mention": "Correo cuando alguien me menciona en un comentario",
|
"notification_pref_mention": "Correo cuando alguien me menciona en un comentario",
|
||||||
|
"notification_unread": "no leído",
|
||||||
"mention_btn_label": "Mencionar persona",
|
"mention_btn_label": "Mencionar persona",
|
||||||
"mention_popup_empty": "No se encontraron usuarios"
|
"mention_popup_empty": "No se encontraron usuarios"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, tick, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { Comment, CommentReply } from '$lib/types';
|
import type { Comment, CommentReply } from '$lib/types';
|
||||||
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||||
@@ -180,7 +180,7 @@ function cancelReply() {
|
|||||||
replyText = '';
|
replyText = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
if (loadOnMount) {
|
if (loadOnMount) {
|
||||||
reload();
|
reload();
|
||||||
} else {
|
} else {
|
||||||
@@ -189,11 +189,11 @@ onMount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetCommentId) {
|
if (targetCommentId) {
|
||||||
// Scroll to target after a tick so the DOM is settled
|
await tick();
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}, 100);
|
});
|
||||||
|
|
||||||
// Remove highlight on first user interaction
|
// Remove highlight on first user interaction
|
||||||
const clearHighlight = () => {
|
const clearHighlight = () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
import { detectMention } from '$lib/utils/mention';
|
import { detectMention } from '$lib/utils/mention';
|
||||||
import type { MentionDTO } from '$lib/types';
|
import type { MentionDTO } from '$lib/types';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
@@ -180,6 +181,8 @@ function handleAtButtonClick() {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDestroy(() => clearTimeout(debounceTimer));
|
||||||
|
|
||||||
const popupOpen = $derived(query !== null);
|
const popupOpen = $derived(query !== null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -117,16 +117,15 @@ function attachClickOutside(node: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(isoString: string): string {
|
function relativeTime(isoString: string): string {
|
||||||
const now = Date.now();
|
const diffMs = Date.now() - new Date(isoString).getTime();
|
||||||
const then = new Date(isoString).getTime();
|
|
||||||
const diffMs = now - then;
|
|
||||||
const diffMin = Math.floor(diffMs / 60000);
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
if (diffMin < 1) return 'gerade eben';
|
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||||
if (diffMin < 60) return `vor ${diffMin} Min.`;
|
if (diffMin < 1) return rtf.format(0, 'minute');
|
||||||
|
if (diffMin < 60) return rtf.format(-diffMin, 'minute');
|
||||||
const diffH = Math.floor(diffMin / 60);
|
const diffH = Math.floor(diffMin / 60);
|
||||||
if (diffH < 24) return `vor ${diffH} Std.`;
|
if (diffH < 24) return rtf.format(-diffH, 'hour');
|
||||||
const diffD = Math.floor(diffH / 24);
|
const diffD = Math.floor(diffH / 24);
|
||||||
return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`;
|
return rtf.format(-diffD, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -232,12 +231,10 @@ onDestroy(() => {
|
|||||||
<ul role="list">
|
<ul role="list">
|
||||||
{#each notifications as notification (notification.id)}
|
{#each notifications as notification (notification.id)}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<button
|
||||||
role="button"
|
type="button"
|
||||||
tabindex="0"
|
|
||||||
onclick={() => markRead(notification)}
|
onclick={() => markRead(notification)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && markRead(notification)}
|
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
||||||
class="flex cursor-pointer items-start gap-3 border-b border-line px-4 py-3 last:border-b-0 hover:bg-canvas
|
|
||||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||||
>
|
>
|
||||||
<!-- Type icon -->
|
<!-- Type icon -->
|
||||||
@@ -291,10 +288,10 @@ onDestroy(() => {
|
|||||||
{#if !notification.read}
|
{#if !notification.read}
|
||||||
<span
|
<span
|
||||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||||
aria-label="ungelesen"
|
aria-label={m.notification_unread()}
|
||||||
></span>
|
></span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -92,10 +92,10 @@ describe('renderBody', () => {
|
|||||||
expect(result).toContain('AT&T');
|
expect(result).toContain('AT&T');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wraps @mention in an anchor tag', () => {
|
it('wraps @mention in a mention span', () => {
|
||||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||||
const result = renderBody('Hey @Hans Müller!', mentions);
|
const result = renderBody('Hey @Hans Müller!', mentions);
|
||||||
expect(result).toContain('<a');
|
expect(result).toContain('<span');
|
||||||
expect(result).toContain('Hans Müller');
|
expect(result).toContain('Hans Müller');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,8 +108,15 @@ describe('renderBody', () => {
|
|||||||
it('replaces all occurrences of the same mention', () => {
|
it('replaces all occurrences of the same mention', () => {
|
||||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||||
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
|
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
|
||||||
const linkCount = (result.match(/<a /g) ?? []).length;
|
const spanCount = (result.match(/<span /g) ?? []).length;
|
||||||
expect(linkCount).toBe(2);
|
expect(spanCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML special chars in mention display names', () => {
|
||||||
|
const mentions: MentionDTO[] = [{ id: 'u1', firstName: '<script>', lastName: 'alert(1)' }];
|
||||||
|
const result = renderBody('@<script> alert(1)', mentions);
|
||||||
|
expect(result).not.toContain('<script>');
|
||||||
|
expect(result).toContain('<script>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts newlines to <br>', () => {
|
it('converts newlines to <br>', () => {
|
||||||
|
|||||||
@@ -59,8 +59,13 @@ export function renderBody(content: string, mentions: MentionDTO[]): string {
|
|||||||
|
|
||||||
for (const mention of mentions) {
|
for (const mention of mentions) {
|
||||||
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||||
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
|
const escapedDisplayName = displayName
|
||||||
escaped = escaped.replaceAll(`@${displayName}`, link);
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
const span = `<span class="mention" data-user-id="${mention.id}">@${escapedDisplayName}</span>`;
|
||||||
|
escaped = escaped.replaceAll(`@${escapedDisplayName}`, span);
|
||||||
}
|
}
|
||||||
|
|
||||||
return escaped.replaceAll('\n', '<br>');
|
return escaped.replaceAll('\n', '<br>');
|
||||||
|
|||||||
@@ -60,8 +60,8 @@ export const actions: Actions = {
|
|||||||
updateNotificationPrefs: async ({ request, fetch }) => {
|
updateNotificationPrefs: async ({ request, fetch }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const body = {
|
const body = {
|
||||||
notifyOnReply: formData.get('notifyOnReply') === 'true',
|
notifyOnReply: formData.has('notifyOnReply'),
|
||||||
notifyOnMention: formData.get('notifyOnMention') === 'true'
|
notifyOnMention: formData.has('notifyOnMention')
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, {
|
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, {
|
||||||
|
|||||||
@@ -54,13 +54,11 @@ let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMenti
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" action="?/updateNotificationPrefs" use:enhance>
|
<form method="POST" action="?/updateNotificationPrefs" use:enhance>
|
||||||
<input type="hidden" name="notifyOnReply" value={notifyOnReply} />
|
|
||||||
<input type="hidden" name="notifyOnMention" value={notifyOnMention} />
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<label class="flex cursor-pointer items-start gap-3">
|
<label class="flex cursor-pointer items-start gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
name="notifyOnReply"
|
||||||
bind:checked={notifyOnReply}
|
bind:checked={notifyOnReply}
|
||||||
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
||||||
/>
|
/>
|
||||||
@@ -70,6 +68,7 @@ let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMenti
|
|||||||
<label class="flex cursor-pointer items-start gap-3">
|
<label class="flex cursor-pointer items-start gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
name="notifyOnMention"
|
||||||
bind:checked={notifyOnMention}
|
bind:checked={notifyOnMention}
|
||||||
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user