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;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.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.UserService;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -23,7 +25,7 @@ public class NotificationController {
|
||||
private final UserService userService;
|
||||
|
||||
@GetMapping("/api/notifications")
|
||||
public Page<Notification> getNotifications(
|
||||
public Page<NotificationDTO> getNotifications(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size,
|
||||
Authentication authentication) {
|
||||
@@ -40,7 +42,7 @@ public class NotificationController {
|
||||
}
|
||||
|
||||
@PatchMapping("/api/notifications/{id}/read")
|
||||
public Notification markOneRead(
|
||||
public NotificationDTO markOneRead(
|
||||
@PathVariable UUID id,
|
||||
Authentication authentication) {
|
||||
AppUser user = resolveUser(authentication);
|
||||
@@ -48,12 +50,14 @@ public class NotificationController {
|
||||
}
|
||||
|
||||
@GetMapping("/api/users/me/notification-preferences")
|
||||
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||
public NotificationPreferenceDTO getPreferences(Authentication authentication) {
|
||||
AppUser user = resolveUser(authentication);
|
||||
return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention());
|
||||
}
|
||||
|
||||
@PutMapping("/api/users/me/notification-preferences")
|
||||
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||
public NotificationPreferenceDTO updatePreferences(
|
||||
@RequestBody NotificationPreferenceDTO dto,
|
||||
Authentication authentication) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.raddatz.familienarchiv.controller;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.service.UserSearchService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -12,6 +14,7 @@ import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||
public class UserSearchController {
|
||||
|
||||
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<>();
|
||||
|
||||
// JPA join table for structured mention references — not serialized directly
|
||||
@ManyToMany(fetch = FetchType.LAZY)
|
||||
@ManyToMany(fetch = FetchType.EAGER)
|
||||
@JoinTable(
|
||||
name = "comment_mentions",
|
||||
joinColumns = @JoinColumn(name = "comment_id"),
|
||||
|
||||
@@ -37,6 +37,9 @@ public class Notification {
|
||||
@Column(name = "reference_id")
|
||||
private UUID referenceId;
|
||||
|
||||
@Column(name = "annotation_id")
|
||||
private UUID annotationId;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@@ -46,8 +49,7 @@ public class Notification {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// Populated by NotificationService before serialization — not persisted.
|
||||
@Transient
|
||||
@Column(name = "actor_name")
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
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.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
|
||||
@@ -20,6 +19,4 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
|
||||
@Modifying
|
||||
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
|
||||
void markAllReadByRecipientId(@Param("userId") UUID userId);
|
||||
|
||||
List<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class PermissionAspect {
|
||||
RequirePermission permission = getAnnotation(joinPoint);
|
||||
|
||||
if (permission != null) {
|
||||
validateUserAccess(permission.value());
|
||||
validateUserAccess(permission.value()); // value() is now Permission[]
|
||||
}
|
||||
|
||||
return joinPoint.proceed();
|
||||
@@ -43,18 +43,23 @@ public class PermissionAspect {
|
||||
return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class);
|
||||
}
|
||||
|
||||
private void validateUserAccess(Permission requiredPerm) {
|
||||
private void validateUserAccess(Permission[] requiredPerms) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Not authenticated");
|
||||
}
|
||||
|
||||
boolean hasPermission = auth.getAuthorities().stream()
|
||||
.anyMatch(a -> a.getAuthority().equals(requiredPerm.name()));
|
||||
boolean hasAny = auth.getAuthorities().stream()
|
||||
.anyMatch(a -> {
|
||||
for (Permission p : requiredPerms) {
|
||||
if (a.getAuthority().equals(p.name())) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw DomainException.forbidden("Missing required permission: " + requiredPerm.name());
|
||||
if (!hasAny) {
|
||||
throw DomainException.forbidden("Missing required permission");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@ import java.lang.annotation.Target;
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RequirePermission {
|
||||
Permission value(); // e.g. "ADMIN" or "WRITE_ALL"
|
||||
Permission[] value(); // one or more — user needs any of the listed permissions
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@@ -19,7 +20,7 @@ import java.util.UUID;
|
||||
public class CommentService {
|
||||
|
||||
private final CommentRepository commentRepository;
|
||||
private final AppUserRepository userRepository;
|
||||
private final UserService userService;
|
||||
private final NotificationService notificationService;
|
||||
|
||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||
@@ -73,7 +74,10 @@ public class CommentService {
|
||||
saveMentions(reply, mentionedUserIds);
|
||||
DocumentComment saved = commentRepository.save(reply);
|
||||
withMentionDTOs(saved);
|
||||
notificationService.notifyReply(saved, root);
|
||||
|
||||
Set<UUID> participantIds = collectParticipantIds(root);
|
||||
participantIds.remove(author.getId());
|
||||
notificationService.notifyReply(saved, participantIds);
|
||||
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||
return saved;
|
||||
}
|
||||
@@ -99,6 +103,10 @@ public class CommentService {
|
||||
commentRepository.delete(comment);
|
||||
}
|
||||
|
||||
public List<DocumentComment> findReplies(UUID parentId) {
|
||||
return commentRepository.findByParentId(parentId);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
|
||||
@@ -113,7 +121,7 @@ public class CommentService {
|
||||
|
||||
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
|
||||
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
|
||||
List<AppUser> users = userRepository.findAllById(mentionedUserIds);
|
||||
List<AppUser> users = userService.findAllById(mentionedUserIds);
|
||||
comment.setMentions(users);
|
||||
}
|
||||
|
||||
@@ -124,6 +132,16 @@ public class CommentService {
|
||||
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) {
|
||||
return commentRepository.findById(commentId)
|
||||
.filter(c -> documentId.equals(c.getDocumentId()))
|
||||
|
||||
@@ -2,16 +2,14 @@ package org.raddatz.familienarchiv.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||
import org.raddatz.familienarchiv.model.Notification;
|
||||
import org.raddatz.familienarchiv.model.NotificationType;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -19,9 +17,9 @@ import org.springframework.mail.MailException;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -33,11 +31,8 @@ import java.util.UUID;
|
||||
public class NotificationService {
|
||||
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final AppUserRepository userRepository;
|
||||
|
||||
@Autowired(required = false)
|
||||
private JavaMailSender mailSender;
|
||||
private final UserService userService;
|
||||
private final Optional<JavaMailSender> mailSender;
|
||||
|
||||
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
||||
private String mailFrom;
|
||||
@@ -46,24 +41,22 @@ public class NotificationService {
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* Creates REPLY notifications for all participants in the thread that the given reply belongs to,
|
||||
* excluding the replier themselves.
|
||||
* Creates REPLY notifications for all participants in the thread, excluding the replier.
|
||||
* Runs in a separate transaction so a notification failure cannot roll back the parent comment.
|
||||
*/
|
||||
@Transactional
|
||||
public void notifyReply(DocumentComment reply, DocumentComment root) {
|
||||
Set<UUID> participantIds = collectParticipantIds(root);
|
||||
participantIds.remove(reply.getAuthorId());
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void notifyReply(DocumentComment reply, Set<UUID> participantIds) {
|
||||
if (participantIds.isEmpty()) return;
|
||||
|
||||
for (UUID participantId : participantIds) {
|
||||
Optional<AppUser> recipientOpt = userRepository.findById(participantId);
|
||||
if (recipientOpt.isEmpty()) continue;
|
||||
|
||||
AppUser recipient = recipientOpt.get();
|
||||
List<AppUser> recipients = userService.findAllById(participantIds);
|
||||
for (AppUser recipient : recipients) {
|
||||
Notification notification = Notification.builder()
|
||||
.recipient(recipient)
|
||||
.type(NotificationType.REPLY)
|
||||
.documentId(reply.getDocumentId())
|
||||
.referenceId(reply.getId())
|
||||
.annotationId(reply.getAnnotationId())
|
||||
.actorName(reply.getAuthorName())
|
||||
.build();
|
||||
notificationRepository.save(notification);
|
||||
|
||||
@@ -75,19 +68,21 @@ public class NotificationService {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
for (UUID mentionedUserId : mentionedUserIds) {
|
||||
Optional<AppUser> recipientOpt = userRepository.findById(mentionedUserId);
|
||||
if (recipientOpt.isEmpty()) continue;
|
||||
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
|
||||
|
||||
AppUser recipient = recipientOpt.get();
|
||||
List<AppUser> recipients = userService.findAllById(mentionedUserIds);
|
||||
for (AppUser recipient : recipients) {
|
||||
Notification notification = Notification.builder()
|
||||
.recipient(recipient)
|
||||
.type(NotificationType.MENTION)
|
||||
.documentId(comment.getDocumentId())
|
||||
.referenceId(comment.getId())
|
||||
.annotationId(comment.getAnnotationId())
|
||||
.actorName(comment.getAuthorName())
|
||||
.build();
|
||||
notificationRepository.save(notification);
|
||||
|
||||
@@ -97,8 +92,9 @@ public class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
public Page<Notification> getNotifications(UUID userId, Pageable pageable) {
|
||||
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable);
|
||||
public Page<NotificationDTO> getNotifications(UUID userId, Pageable pageable) {
|
||||
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
|
||||
.map(this::toDTO);
|
||||
}
|
||||
|
||||
public long countUnread(UUID userId) {
|
||||
@@ -111,7 +107,7 @@ public class NotificationService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Notification markRead(UUID notificationId, UUID userId) {
|
||||
public NotificationDTO markRead(UUID notificationId, UUID userId) {
|
||||
Notification notification = notificationRepository.findById(notificationId)
|
||||
.orElseThrow(() -> DomainException.notFound(
|
||||
ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId));
|
||||
@@ -119,29 +115,27 @@ public class NotificationService {
|
||||
throw DomainException.forbidden("Notification belongs to a different user");
|
||||
}
|
||||
notification.setRead(true);
|
||||
return notificationRepository.save(notification);
|
||||
return toDTO(notificationRepository.save(notification));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
|
||||
AppUser user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "User not found: " + userId));
|
||||
user.setNotifyOnReply(notifyOnReply);
|
||||
user.setNotifyOnMention(notifyOnMention);
|
||||
return userRepository.save(user);
|
||||
return userService.updateNotificationPreferences(userId, notifyOnReply, notifyOnMention);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
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 NotificationDTO toDTO(Notification n) {
|
||||
return new NotificationDTO(
|
||||
n.getId(),
|
||||
n.getType(),
|
||||
n.getDocumentId(),
|
||||
n.getReferenceId(),
|
||||
n.getAnnotationId(),
|
||||
n.isRead(),
|
||||
n.getCreatedAt(),
|
||||
n.getActorName()
|
||||
);
|
||||
}
|
||||
|
||||
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
|
||||
@@ -152,7 +146,7 @@ public class NotificationService {
|
||||
}
|
||||
|
||||
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());
|
||||
return;
|
||||
}
|
||||
@@ -179,7 +173,7 @@ public class NotificationService {
|
||||
message.setText(body);
|
||||
|
||||
try {
|
||||
mailSender.send(message);
|
||||
mailSender.get().send(message);
|
||||
} catch (MailException e) {
|
||||
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.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -78,6 +79,18 @@ public class UserService {
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
||||
}
|
||||
|
||||
public List<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
|
||||
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
||||
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.raddatz.familienarchiv.config.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.Notification;
|
||||
import org.raddatz.familienarchiv.model.NotificationType;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||
@@ -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.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -52,15 +53,27 @@ class NotificationControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
void getNotifications_returns200_whenAuthenticatedWithNoPermissions() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
Notification n = Notification.builder()
|
||||
.id(UUID.randomUUID()).recipient(user)
|
||||
.type(NotificationType.REPLY).read(false).build();
|
||||
NotificationDTO dto = new NotificationDTO(
|
||||
UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
|
||||
UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith");
|
||||
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||
.thenReturn(new PageImpl<>(List.of(n), PageRequest.of(0, 10), 1));
|
||||
.thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -68,7 +81,7 @@ class NotificationControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
@@ -90,7 +103,7 @@ class NotificationControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void markAllRead_returns204_whenAuthenticated() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
@@ -104,7 +117,13 @@ class NotificationControllerTest {
|
||||
// ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
|
||||
|
||||
@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 {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
UUID notifId = UUID.randomUUID();
|
||||
@@ -128,7 +147,14 @@ class NotificationControllerTest {
|
||||
|
||||
@Test
|
||||
@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")
|
||||
.notifyOnReply(true).notifyOnMention(false).build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
@@ -139,10 +165,45 @@ class NotificationControllerTest {
|
||||
.andExpect(jsonPath("$.notifyOnMention").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||
void getPreferences_returns200_whenUserHasWriteAll() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||
.notifyOnReply(false).notifyOnMention(true).build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
|
||||
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notifyOnMention").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"ANNOTATE_ALL"})
|
||||
void getPreferences_returns200_whenUserHasAnnotateAll() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||
.notifyOnReply(false).notifyOnMention(false).build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
|
||||
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||
void getNotifications_returns200_whenUserHasOnlyWriteAll() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
when(notificationService.getNotifications(eq(USER_ID), any()))
|
||||
.thenReturn(new PageImpl<>(List.of()));
|
||||
|
||||
mockMvc.perform(get("/api/notifications"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
// ─── PUT /api/users/me/notification-preferences ──────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser")
|
||||
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||
void updatePreferences_persistsBothBooleans() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||
.notifyOnReply(false).notifyOnMention(false).build();
|
||||
@@ -159,4 +220,22 @@ class NotificationControllerTest {
|
||||
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
||||
.andExpect(jsonPath("$.notifyOnMention").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||
void updatePreferences_returns200_whenUserHasWriteAll() throws Exception {
|
||||
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||
.notifyOnReply(false).notifyOnMention(false).build();
|
||||
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||
|
||||
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
|
||||
.notifyOnReply(true).notifyOnMention(false).build();
|
||||
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
||||
|
||||
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.notifyOnReply").value(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import static org.hamcrest.Matchers.lessThanOrEqualTo;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
@@ -40,6 +42,22 @@ class UserSearchControllerTest {
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void search_returns403_whenUserLacksPermission() throws Exception {
|
||||
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = {"ANNOTATE_ALL"})
|
||||
void search_returns200_whenUserHasAnnotateAll() throws Exception {
|
||||
when(userSearchService.search("Hans")).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = {"READ_ALL"})
|
||||
void search_returns200_whenAuthenticated() throws Exception {
|
||||
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||
.firstName("Hans").lastName("Mueller").username("hans").build();
|
||||
@@ -51,7 +69,7 @@ class UserSearchControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = {"READ_ALL"})
|
||||
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
|
||||
when(userSearchService.search("")).thenReturn(List.of());
|
||||
|
||||
@@ -61,11 +79,16 @@ class UserSearchControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = {"READ_ALL"})
|
||||
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"))
|
||||
.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.DocumentComment;
|
||||
import org.raddatz.familienarchiv.model.UserGroup;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
|
||||
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.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anySet;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -32,7 +33,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
class CommentServiceTest {
|
||||
|
||||
@Mock CommentRepository commentRepository;
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock UserService userService;
|
||||
@Mock NotificationService notificationService;
|
||||
@InjectMocks CommentService commentService;
|
||||
|
||||
@@ -65,6 +66,23 @@ class CommentServiceTest {
|
||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID mentionedId = UUID.randomUUID();
|
||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("M").build();
|
||||
AppUser mentioned = AppUser.builder().id(mentionedId).username("anna").firstName("Anna").lastName("S").build();
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build();
|
||||
|
||||
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.postComment(docId, null, "Hey @Anna S", List.of(mentionedId), author);
|
||||
|
||||
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
||||
}
|
||||
|
||||
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -95,6 +113,7 @@ class CommentServiceTest {
|
||||
|
||||
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
@@ -114,6 +133,7 @@ class CommentServiceTest {
|
||||
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||
|
||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||
DocumentComment saved = DocumentComment.builder()
|
||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
@@ -124,7 +144,7 @@ class CommentServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void replyToComment_triggersNotification_afterSave() {
|
||||
void replyToComment_triggersNotifyReply_afterSave() {
|
||||
UUID docId = UUID.randomUUID();
|
||||
UUID rootId = UUID.randomUUID();
|
||||
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();
|
||||
|
||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||
when(commentRepository.save(any())).thenReturn(saved);
|
||||
|
||||
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
||||
|
||||
verify(notificationService).notifyReply(eq(saved), 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -4,20 +4,18 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.*;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -29,11 +27,10 @@ import static org.mockito.Mockito.*;
|
||||
class NotificationServiceTest {
|
||||
|
||||
@Mock NotificationRepository notificationRepository;
|
||||
@Mock CommentRepository commentRepository;
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock UserService userService;
|
||||
@Mock JavaMailSender mailSender;
|
||||
|
||||
@InjectMocks NotificationService notificationService;
|
||||
NotificationService notificationService;
|
||||
|
||||
private AppUser userA;
|
||||
private AppUser userB;
|
||||
@@ -41,9 +38,7 @@ class NotificationServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// mailSender is @Autowired(required=false) — not in the @RequiredArgsConstructor
|
||||
// constructor, so Mockito won't inject it automatically. Inject explicitly.
|
||||
ReflectionTestUtils.setField(notificationService, "mailSender", mailSender);
|
||||
notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender));
|
||||
|
||||
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
||||
.firstName("Anna").lastName("Smith").email("a@test.com")
|
||||
@@ -59,17 +54,13 @@ class NotificationServiceTest {
|
||||
// ─── notifyReply ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void notifyReply_createsNotificationForThreadParticipant() {
|
||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
||||
void notifyReply_createsNotificationForThreadParticipants() {
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||
|
||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply));
|
||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
notificationService.notifyReply(reply, root);
|
||||
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
|
||||
|
||||
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||
verify(notificationRepository, times(2)).save(captor.capture());
|
||||
@@ -79,57 +70,30 @@ class NotificationServiceTest {
|
||||
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
|
||||
assertThat(saved).allMatch(n -> !n.isRead());
|
||||
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyReply_doesNotNotifyTheReplierThemselves() {
|
||||
// userA is both a thread participant and the replier
|
||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userA.getId());
|
||||
void notifyReply_doesNothing_whenParticipantSetIsEmpty() {
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
|
||||
|
||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(reply));
|
||||
|
||||
notificationService.notifyReply(reply, root);
|
||||
notificationService.notifyReply(reply, Set.of());
|
||||
|
||||
verify(notificationRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyReply_deduplicatesParticipants() {
|
||||
// userB has posted twice in the thread — should get exactly one notification
|
||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||
DocumentComment first = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
||||
DocumentComment second = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
||||
|
||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(first, second, reply));
|
||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
notificationService.notifyReply(reply, root);
|
||||
|
||||
// userA (root) + userB (deduplicated) = 2 notifications, not 3
|
||||
verify(notificationRepository, times(2)).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
|
||||
userA.setNotifyOnReply(true);
|
||||
userB.setNotifyOnReply(false);
|
||||
|
||||
DocumentComment root = commentWithAuthor(UUID.randomUUID(), null, userA.getId());
|
||||
DocumentComment existing = commentWithAuthor(UUID.randomUUID(), root.getId(), userB.getId());
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), root.getId(), userC.getId());
|
||||
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||
|
||||
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of(existing, reply));
|
||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
notificationService.notifyReply(reply, 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));
|
||||
}
|
||||
|
||||
@@ -137,9 +101,8 @@ class NotificationServiceTest {
|
||||
|
||||
@Test
|
||||
void notifyMentions_createsNotificationPerMentionedUser() {
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
|
||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||
@@ -151,6 +114,16 @@ class NotificationServiceTest {
|
||||
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
||||
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||
assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION);
|
||||
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyMentions_doesNothing_whenListIsEmpty() {
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
|
||||
|
||||
notificationService.notifyMentions(List.of(), comment);
|
||||
|
||||
verify(notificationRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -158,9 +131,8 @@ class NotificationServiceTest {
|
||||
userA.setNotifyOnMention(true);
|
||||
userB.setNotifyOnMention(false);
|
||||
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId());
|
||||
when(userRepository.findById(userA.getId())).thenReturn(Optional.of(userA));
|
||||
when(userRepository.findById(userB.getId())).thenReturn(Optional.of(userB));
|
||||
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||
@@ -170,6 +142,16 @@ class NotificationServiceTest {
|
||||
|
||||
// ─── markRead ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void markRead_throwsNotFound_whenNotificationDoesNotExist() {
|
||||
UUID notifId = UUID.randomUUID();
|
||||
when(notificationRepository.findById(notifId)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> notificationService.markRead(notifId, userA.getId()))
|
||||
.isInstanceOf(DomainException.class)
|
||||
.hasMessageContaining("Notification not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
|
||||
Notification notification = Notification.builder()
|
||||
@@ -186,15 +168,33 @@ class NotificationServiceTest {
|
||||
.hasMessageContaining("different user");
|
||||
}
|
||||
|
||||
// ─── markAllRead ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void markAllRead_delegatesToRepository() {
|
||||
notificationService.markAllRead(userA.getId());
|
||||
|
||||
verify(notificationRepository).markAllReadByRecipientId(userA.getId());
|
||||
}
|
||||
|
||||
// ─── countUnread ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void countUnread_delegatesToRepository() {
|
||||
when(notificationRepository.countByRecipientIdAndReadFalse(userA.getId())).thenReturn(3L);
|
||||
|
||||
assertThat(notificationService.countUnread(userA.getId())).isEqualTo(3L);
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId) {
|
||||
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
|
||||
return DocumentComment.builder()
|
||||
.id(id)
|
||||
.documentId(UUID.randomUUID())
|
||||
.parentId(parentId)
|
||||
.authorId(authorId)
|
||||
.authorName("Author")
|
||||
.authorName(authorName)
|
||||
.content("content")
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -304,6 +304,8 @@
|
||||
"notification_prefs_heading": "Benachrichtigungen",
|
||||
"notification_pref_reply": "E-Mail, wenn jemand auf meinen Kommentar antwortet",
|
||||
"notification_pref_mention": "E-Mail, wenn jemand mich in einem Kommentar erwähnt",
|
||||
"notification_prefs_no_email": "Bitte trage zuerst eine E-Mail-Adresse ein, um Benachrichtigungen zu erhalten.",
|
||||
"notification_unread": "ungelesen",
|
||||
"mention_btn_label": "Person erwähnen",
|
||||
"mention_popup_empty": "Keine Nutzer gefunden"
|
||||
}
|
||||
|
||||
@@ -304,6 +304,8 @@
|
||||
"notification_prefs_heading": "Notifications",
|
||||
"notification_pref_reply": "Email when someone replies to my comment",
|
||||
"notification_pref_mention": "Email when someone mentions me in a comment",
|
||||
"notification_prefs_no_email": "Please add an email address above to receive notifications.",
|
||||
"notification_unread": "unread",
|
||||
"mention_btn_label": "Mention person",
|
||||
"mention_popup_empty": "No users found"
|
||||
}
|
||||
|
||||
@@ -304,6 +304,8 @@
|
||||
"notification_prefs_heading": "Notificaciones",
|
||||
"notification_pref_reply": "Correo cuando alguien responde a mi comentario",
|
||||
"notification_pref_mention": "Correo cuando alguien me menciona en un comentario",
|
||||
"notification_prefs_no_email": "Por favor, añade una dirección de correo electrónico para recibir notificaciones.",
|
||||
"notification_unread": "no leído",
|
||||
"mention_btn_label": "Mencionar persona",
|
||||
"mention_popup_empty": "No se encontraron usuarios"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type Props = {
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
targetCommentId?: string | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -19,6 +20,7 @@ let {
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
targetCommentId = null,
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
@@ -57,6 +59,7 @@ const visible = $derived(activeAnnotationId !== null);
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
targetCommentId={targetCommentId}
|
||||
loadOnMount={true}
|
||||
/>
|
||||
{/key}
|
||||
|
||||
@@ -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">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { onMount, tick, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { Comment, CommentReply } from '$lib/types';
|
||||
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||
@@ -180,7 +180,7 @@ function cancelReply() {
|
||||
replyText = '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
if (loadOnMount) {
|
||||
reload();
|
||||
} else {
|
||||
@@ -189,11 +189,11 @@ onMount(() => {
|
||||
}
|
||||
|
||||
if (targetCommentId) {
|
||||
// Scroll to target after a tick so the DOM is settled
|
||||
setTimeout(() => {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Remove highlight on first user interaction
|
||||
const clearHighlight = () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { detectMention } from '$lib/utils/mention';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
@@ -180,6 +181,8 @@ function handleAtButtonClick() {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
onDestroy(() => clearTimeout(debounceTimer));
|
||||
|
||||
const popupOpen = $derived(query !== null);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ type NotificationItem = {
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId: string;
|
||||
referenceId: string;
|
||||
annotationId: string | null;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName: string;
|
||||
@@ -62,7 +63,9 @@ async function markRead(notification: NotificationItem) {
|
||||
console.error('Failed to mark notification as read', e);
|
||||
}
|
||||
}
|
||||
const url = `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||
const url = notification.annotationId
|
||||
? `/documents/${notification.documentId}?commentId=${notification.referenceId}&annotationId=${notification.annotationId}`
|
||||
: `/documents/${notification.documentId}?commentId=${notification.referenceId}`;
|
||||
closeDropdown();
|
||||
goto(url);
|
||||
}
|
||||
@@ -117,16 +120,15 @@ function attachClickOutside(node: HTMLElement) {
|
||||
}
|
||||
|
||||
function relativeTime(isoString: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoString).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffMs = Date.now() - new Date(isoString).getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
if (diffMin < 1) return 'gerade eben';
|
||||
if (diffMin < 60) return `vor ${diffMin} Min.`;
|
||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||
if (diffMin < 1) return rtf.format(0, 'minute');
|
||||
if (diffMin < 60) return rtf.format(-diffMin, 'minute');
|
||||
const diffH = Math.floor(diffMin / 60);
|
||||
if (diffH < 24) return `vor ${diffH} Std.`;
|
||||
if (diffH < 24) return rtf.format(-diffH, 'hour');
|
||||
const diffD = Math.floor(diffH / 24);
|
||||
return `vor ${diffD} Tag${diffD !== 1 ? 'en' : ''}`;
|
||||
return rtf.format(-diffD, 'day');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -232,12 +234,10 @@ onDestroy(() => {
|
||||
<ul role="list">
|
||||
{#each notifications as notification (notification.id)}
|
||||
<li>
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => markRead(notification)}
|
||||
onkeydown={(e) => e.key === 'Enter' && markRead(notification)}
|
||||
class="flex cursor-pointer items-start gap-3 border-b border-line px-4 py-3 last:border-b-0 hover:bg-canvas
|
||||
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
|
||||
{!notification.read ? 'bg-accent-bg/20' : ''}"
|
||||
>
|
||||
<!-- Type icon -->
|
||||
@@ -291,10 +291,10 @@ onDestroy(() => {
|
||||
{#if !notification.read}
|
||||
<span
|
||||
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
|
||||
aria-label="ungelesen"
|
||||
aria-label={m.notification_unread()}
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -92,10 +92,11 @@ describe('renderBody', () => {
|
||||
expect(result).toContain('AT&T');
|
||||
});
|
||||
|
||||
it('wraps @mention in an anchor tag', () => {
|
||||
it('wraps @mention in a mention span', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = renderBody('Hey @Hans Müller!', mentions);
|
||||
expect(result).toContain('<a');
|
||||
expect(result).toContain('<span');
|
||||
expect(result).toContain('class="mention"');
|
||||
expect(result).toContain('Hans Müller');
|
||||
});
|
||||
|
||||
@@ -108,8 +109,15 @@ describe('renderBody', () => {
|
||||
it('replaces all occurrences of the same mention', () => {
|
||||
const mentions: MentionDTO[] = [{ id: 'uuid-1', firstName: 'Hans', lastName: 'Müller' }];
|
||||
const result = renderBody('@Hans Müller and @Hans Müller', mentions);
|
||||
const linkCount = (result.match(/<a /g) ?? []).length;
|
||||
expect(linkCount).toBe(2);
|
||||
const spanCount = (result.match(/<span /g) ?? []).length;
|
||||
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>', () => {
|
||||
|
||||
@@ -59,8 +59,13 @@ export function renderBody(content: string, mentions: MentionDTO[]): string {
|
||||
|
||||
for (const mention of mentions) {
|
||||
const displayName = `${mention.firstName} ${mention.lastName}`.trim();
|
||||
const link = `<a class="mention" data-user-id="${mention.id}" href="#">@${displayName}</a>`;
|
||||
escaped = escaped.replaceAll(`@${displayName}`, link);
|
||||
const escapedDisplayName = displayName
|
||||
.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>');
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { DocumentPanelTab } from '$lib/types';
|
||||
let { data } = $props();
|
||||
|
||||
const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
||||
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
|
||||
|
||||
const doc = $derived(data.document);
|
||||
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||
@@ -95,8 +96,11 @@ onMount(() => {
|
||||
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||
}
|
||||
|
||||
if (targetCommentId) {
|
||||
// Deep-link: always open discussion tab regardless of saved state
|
||||
if (targetAnnotationId) {
|
||||
// Deep-link into an annotation comment: open the side panel
|
||||
activeAnnotationId = targetAnnotationId;
|
||||
} else if (targetCommentId) {
|
||||
// Deep-link into a document-level comment: open discussion tab
|
||||
panelOpen = true;
|
||||
activeTab = 'discussion';
|
||||
} else if (savedOpen === 'true') {
|
||||
@@ -169,6 +173,7 @@ $effect(() => {
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
targetCommentId={targetAnnotationId ? targetCommentId : null}
|
||||
onClose={() => {
|
||||
activeAnnotationId = null;
|
||||
activeAnnotationPage = null;
|
||||
|
||||
@@ -160,7 +160,28 @@
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* ─── 7. Base styles ───────────────────────────────────────────────────────── */
|
||||
/* ─── 7. @mention chip ─────────────────────────────────────────────────────── */
|
||||
/*
|
||||
Rendered by renderBody() via {@html ...} in CommentThread.svelte.
|
||||
Must live in global CSS — Svelte scoped styles don't reach injected HTML.
|
||||
*/
|
||||
.mention {
|
||||
display: inline;
|
||||
color: var(--c-ink);
|
||||
background-color: var(--c-accent-bg);
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
cursor: default;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.mention:hover {
|
||||
background-color: color-mix(in srgb, var(--c-accent) 25%, transparent);
|
||||
}
|
||||
|
||||
/* ─── 8. Base styles ───────────────────────────────────────────────────────── */
|
||||
@layer base {
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
|
||||
@@ -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 { page, userEvent } from 'vitest/browser';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
|
||||
vi.mock('$env/static/public', () => ({ PUBLIC_NOTIFICATION_POLL_MS: '60000' }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const emptySnippet = createRawSnippet(() => ({ render: () => '<span></span>' }));
|
||||
|
||||
@@ -60,8 +60,8 @@ export const actions: Actions = {
|
||||
updateNotificationPrefs: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const body = {
|
||||
notifyOnReply: formData.get('notifyOnReply') === 'true',
|
||||
notifyOnMention: formData.get('notifyOnMention') === 'true'
|
||||
notifyOnReply: formData.has('notifyOnReply'),
|
||||
notifyOnMention: formData.has('notifyOnMention')
|
||||
};
|
||||
|
||||
const res = await fetch(`${apiBase()}/api/users/me/notification-preferences`, {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import PersonalInfoForm from './PersonalInfoForm.svelte';
|
||||
import PasswordChangeForm from './PasswordChangeForm.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let notifyOnReply = $state(untrack(() => data.notificationPrefs?.notifyOnReply ?? false));
|
||||
let notifyOnMention = $state(untrack(() => data.notificationPrefs?.notifyOnMention ?? false));
|
||||
let notifyOnReply = $derived(data.notificationPrefs?.notifyOnReply ?? false);
|
||||
let notifyOnMention = $derived(data.notificationPrefs?.notifyOnMention ?? false);
|
||||
const hasEmail = $derived(!!data.user?.email);
|
||||
</script>
|
||||
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/updateNotificationPrefs" use:enhance>
|
||||
<input type="hidden" name="notifyOnReply" value={notifyOnReply} />
|
||||
<input type="hidden" name="notifyOnMention" value={notifyOnMention} />
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateNotificationPrefs"
|
||||
use:enhance={() => async ({ update }) => update({ reset: false })}
|
||||
>
|
||||
<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
|
||||
type="checkbox"
|
||||
name="notifyOnReply"
|
||||
bind:checked={notifyOnReply}
|
||||
disabled={!hasEmail}
|
||||
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
||||
/>
|
||||
<span class="text-sm text-ink">{m.notification_pref_reply()}</span>
|
||||
</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
|
||||
type="checkbox"
|
||||
name="notifyOnMention"
|
||||
bind:checked={notifyOnMention}
|
||||
disabled={!hasEmail}
|
||||
class="mt-0.5 h-4 w-4 rounded border-line accent-primary"
|
||||
/>
|
||||
<span class="text-sm text-ink">{m.notification_pref_mention()}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !hasEmail}
|
||||
<p class="mt-3 text-xs text-ink-3">
|
||||
{m.notification_prefs_no_email()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
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()}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user