Compare commits
7 Commits
2bc3b3fb6c
...
9900d0b54b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9900d0b54b | ||
|
|
9ae6186e66 | ||
|
|
c21e19a15c | ||
|
|
7825c7749a | ||
|
|
d13422c65a | ||
|
|
23d0005514 | ||
|
|
dc6ea080c4 |
@@ -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;
|
||||||
@@ -23,7 +25,7 @@ public class NotificationController {
|
|||||||
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 +42,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);
|
||||||
@@ -48,12 +50,14 @@ public class NotificationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/users/me/notification-preferences")
|
@GetMapping("/api/users/me/notification-preferences")
|
||||||
|
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||||
public NotificationPreferenceDTO getPreferences(Authentication authentication) {
|
public NotificationPreferenceDTO getPreferences(Authentication authentication) {
|
||||||
AppUser user = resolveUser(authentication);
|
AppUser user = resolveUser(authentication);
|
||||||
return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention());
|
return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/api/users/me/notification-preferences")
|
@PutMapping("/api/users/me/notification-preferences")
|
||||||
|
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||||
public NotificationPreferenceDTO updatePreferences(
|
public NotificationPreferenceDTO updatePreferences(
|
||||||
@RequestBody NotificationPreferenceDTO dto,
|
@RequestBody NotificationPreferenceDTO dto,
|
||||||
Authentication authentication) {
|
Authentication 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, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||||
public class UserSearchController {
|
public class UserSearchController {
|
||||||
|
|
||||||
private final UserSearchService userSearchService;
|
private final UserSearchService userSearchService;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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,
|
||||||
|
UUID annotationId,
|
||||||
|
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"),
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class Notification {
|
|||||||
@Column(name = "reference_id")
|
@Column(name = "reference_id")
|
||||||
private UUID referenceId;
|
private UUID referenceId;
|
||||||
|
|
||||||
|
@Column(name = "annotation_id")
|
||||||
|
private UUID annotationId;
|
||||||
|
|
||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@@ -46,8 +49,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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public class PermissionAspect {
|
|||||||
RequirePermission permission = getAnnotation(joinPoint);
|
RequirePermission permission = getAnnotation(joinPoint);
|
||||||
|
|
||||||
if (permission != null) {
|
if (permission != null) {
|
||||||
validateUserAccess(permission.value());
|
validateUserAccess(permission.value()); // value() is now Permission[]
|
||||||
}
|
}
|
||||||
|
|
||||||
return joinPoint.proceed();
|
return joinPoint.proceed();
|
||||||
@@ -43,18 +43,23 @@ public class PermissionAspect {
|
|||||||
return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class);
|
return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateUserAccess(Permission requiredPerm) {
|
private void validateUserAccess(Permission[] requiredPerms) {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
throw DomainException.unauthorized("Not authenticated");
|
throw DomainException.unauthorized("Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasPermission = auth.getAuthorities().stream()
|
boolean hasAny = auth.getAuthorities().stream()
|
||||||
.anyMatch(a -> a.getAuthority().equals(requiredPerm.name()));
|
.anyMatch(a -> {
|
||||||
|
for (Permission p : requiredPerms) {
|
||||||
|
if (a.getAuthority().equals(p.name())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
if (!hasPermission) {
|
if (!hasAny) {
|
||||||
throw DomainException.forbidden("Missing required permission: " + requiredPerm.name());
|
throw DomainException.forbidden("Missing required permission");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ import java.lang.annotation.Target;
|
|||||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
public @interface RequirePermission {
|
public @interface RequirePermission {
|
||||||
Permission value(); // e.g. "ADMIN" or "WRITE_ALL"
|
Permission[] value(); // one or more — user needs any of the listed permissions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 @@ 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())
|
||||||
|
.annotationId(reply.getAnnotationId())
|
||||||
|
.actorName(reply.getAuthorName())
|
||||||
.build();
|
.build();
|
||||||
notificationRepository.save(notification);
|
notificationRepository.save(notification);
|
||||||
|
|
||||||
@@ -75,19 +68,21 @@ 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())
|
||||||
|
.annotationId(comment.getAnnotationId())
|
||||||
|
.actorName(comment.getAuthorName())
|
||||||
.build();
|
.build();
|
||||||
notificationRepository.save(notification);
|
notificationRepository.save(notification);
|
||||||
|
|
||||||
@@ -97,8 +92,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 +107,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 +115,27 @@ 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.getAnnotationId(),
|
||||||
});
|
n.isRead(),
|
||||||
return ids;
|
n.getCreatedAt(),
|
||||||
|
n.getActorName()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
|
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
|
||||||
@@ -152,7 +146,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 +173,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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE notifications ADD COLUMN annotation_id UUID;
|
||||||
@@ -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,27 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser")
|
@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 {
|
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(), null, 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 +81,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 +103,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 +117,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();
|
||||||
@@ -128,7 +147,14 @@ class NotificationControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(username = "testuser")
|
@WithMockUser(username = "testuser")
|
||||||
void getPreferences_returnsCurrentPreferences() throws Exception {
|
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")
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
.notifyOnReply(true).notifyOnMention(false).build();
|
.notifyOnReply(true).notifyOnMention(false).build();
|
||||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
@@ -139,10 +165,45 @@ class NotificationControllerTest {
|
|||||||
.andExpect(jsonPath("$.notifyOnMention").value(false));
|
.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 ──────────────────────────
|
// ─── 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();
|
||||||
@@ -159,4 +220,22 @@ class NotificationControllerTest {
|
|||||||
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
||||||
.andExpect(jsonPath("$.notifyOnMention").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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 @@ 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 = {"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 {
|
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 +69,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 +79,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,8 @@
|
|||||||
"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_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_btn_label": "Person erwähnen",
|
||||||
"mention_popup_empty": "Keine Nutzer gefunden"
|
"mention_popup_empty": "Keine Nutzer gefunden"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,8 @@
|
|||||||
"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_prefs_no_email": "Please add an email address above to receive notifications.",
|
||||||
|
"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,8 @@
|
|||||||
"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_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_btn_label": "Mencionar persona",
|
||||||
"mention_popup_empty": "No se encontraron usuarios"
|
"mention_popup_empty": "No se encontraron usuarios"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type Props = {
|
|||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
|
targetCommentId?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ let {
|
|||||||
canComment,
|
canComment,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
canAdmin,
|
canAdmin,
|
||||||
|
targetCommentId = null,
|
||||||
onClose
|
onClose
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ const visible = $derived(activeAnnotationId !== null);
|
|||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetCommentId}
|
||||||
loadOnMount={true}
|
loadOnMount={true}
|
||||||
/>
|
/>
|
||||||
{/key}
|
{/key}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type NotificationItem = {
|
|||||||
type: 'REPLY' | 'MENTION';
|
type: 'REPLY' | 'MENTION';
|
||||||
documentId: string;
|
documentId: string;
|
||||||
referenceId: string;
|
referenceId: string;
|
||||||
|
annotationId: string | null;
|
||||||
read: boolean;
|
read: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
actorName: string;
|
actorName: string;
|
||||||
@@ -62,7 +63,9 @@ async function markRead(notification: NotificationItem) {
|
|||||||
console.error('Failed to mark notification as read', e);
|
console.error('Failed to mark notification as read', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const url = `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
const url = notification.annotationId
|
||||||
|
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
|
||||||
|
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
goto(url);
|
goto(url);
|
||||||
}
|
}
|
||||||
@@ -117,16 +120,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 +234,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 +291,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,11 @@ 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('class="mention"');
|
||||||
expect(result).toContain('Hans Müller');
|
expect(result).toContain('Hans Müller');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,8 +109,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>');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { DocumentPanelTab } from '$lib/types';
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
||||||
|
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||||
@@ -95,8 +96,11 @@ onMount(() => {
|
|||||||
if (!isNaN(h) && h >= 80) panelHeight = h;
|
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetCommentId) {
|
if (targetAnnotationId) {
|
||||||
// Deep-link: always open discussion tab regardless of saved state
|
// Deep-link into an annotation comment: open the side panel
|
||||||
|
activeAnnotationId = targetAnnotationId;
|
||||||
|
} else if (targetCommentId) {
|
||||||
|
// Deep-link into a document-level comment: open discussion tab
|
||||||
panelOpen = true;
|
panelOpen = true;
|
||||||
activeTab = 'discussion';
|
activeTab = 'discussion';
|
||||||
} else if (savedOpen === 'true') {
|
} else if (savedOpen === 'true') {
|
||||||
@@ -169,6 +173,7 @@ $effect(() => {
|
|||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
|
targetCommentId={targetAnnotationId ? targetCommentId : null}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
activeAnnotationId = null;
|
activeAnnotationId = null;
|
||||||
activeAnnotationPage = null;
|
activeAnnotationPage = null;
|
||||||
|
|||||||
@@ -160,7 +160,28 @@
|
|||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 7. Base styles ───────────────────────────────────────────────────────── */
|
/* ─── 7. @mention chip ─────────────────────────────────────────────────────── */
|
||||||
|
/*
|
||||||
|
Rendered by renderBody() via {@html ...} in CommentThread.svelte.
|
||||||
|
Must live in global CSS — Svelte scoped styles don't reach injected HTML.
|
||||||
|
*/
|
||||||
|
.mention {
|
||||||
|
display: inline;
|
||||||
|
color: var(--c-ink);
|
||||||
|
background-color: var(--c-accent-bg);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
cursor: default;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--c-accent) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── 8. Base styles ───────────────────────────────────────────────────────── */
|
||||||
@layer base {
|
@layer base {
|
||||||
html {
|
html {
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import { createRawSnippet } from 'svelte';
|
import { createRawSnippet } from 'svelte';
|
||||||
|
|
||||||
|
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const emptySnippet = createRawSnippet(() => ({ render: () => '<span></span>' }));
|
const emptySnippet = createRawSnippet(() => ({ render: () => '<span></span>' }));
|
||||||
|
|||||||
@@ -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`, {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import PersonalInfoForm from './PersonalInfoForm.svelte';
|
import PersonalInfoForm from './PersonalInfoForm.svelte';
|
||||||
import PasswordChangeForm from './PasswordChangeForm.svelte';
|
import PasswordChangeForm from './PasswordChangeForm.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let notifyOnReply = $state(untrack(() => data.notificationPrefs?.notifyOnReply ?? false));
|
let notifyOnReply = $derived(data.notificationPrefs?.notifyOnReply ?? false);
|
||||||
let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMention ?? false));
|
let notifyOnMention = $derived(data.notificationPrefs?.notifyOnMention ?? false);
|
||||||
|
const hasEmail = $derived(!!data.user?.email);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||||
@@ -53,33 +53,49 @@ let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMenti
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" action="?/updateNotificationPrefs" use:enhance>
|
<form
|
||||||
<input type="hidden" name="notifyOnReply" value={notifyOnReply} />
|
method="POST"
|
||||||
<input type="hidden" name="notifyOnMention" value={notifyOnMention} />
|
action="?/updateNotificationPrefs"
|
||||||
|
use:enhance={() => async ({ update }) => update({ reset: false })}
|
||||||
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<label class="flex cursor-pointer items-start gap-3">
|
<label
|
||||||
|
class="flex items-start gap-3 {hasEmail ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
name="notifyOnReply"
|
||||||
bind:checked={notifyOnReply}
|
bind:checked={notifyOnReply}
|
||||||
|
disabled={!hasEmail}
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-ink">{m.notification_pref_reply()}</span>
|
<span class="text-sm text-ink">{m.notification_pref_reply()}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex cursor-pointer items-start gap-3">
|
<label
|
||||||
|
class="flex items-start gap-3 {hasEmail ? 'cursor-pointer' : 'cursor-not-allowed opacity-40'}"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
name="notifyOnMention"
|
||||||
bind:checked={notifyOnMention}
|
bind:checked={notifyOnMention}
|
||||||
|
disabled={!hasEmail}
|
||||||
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"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-ink">{m.notification_pref_mention()}</span>
|
<span class="text-sm text-ink">{m.notification_pref_mention()}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !hasEmail}
|
||||||
|
<p class="mt-3 text-xs text-ink-3">
|
||||||
|
{m.notification_prefs_no_email()}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
disabled={!hasEmail}
|
||||||
|
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity {hasEmail ? 'hover:opacity-80' : 'cursor-not-allowed opacity-40'}"
|
||||||
>
|
>
|
||||||
{m.btn_save()}
|
{m.btn_save()}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user