Compare commits

...

7 Commits

Author SHA1 Message Date
Marcel
9900d0b54b test: add AnnotationSidePanel spec and fix env mock in layout spec
Some checks failed
CI / Unit & Component Tests (push) Successful in 3m47s
CI / Backend Unit Tests (push) Successful in 2m41s
CI / E2E Tests (push) Failing after 2h25m30s
CI / Unit & Component Tests (pull_request) Successful in 2m48s
CI / Backend Unit Tests (pull_request) Successful in 2m29s
CI / E2E Tests (pull_request) Failing after 2h29m1s
- AnnotationSidePanel: cover visibility (null vs set annotationId),
  close button callback, and targetCommentId forwarding
- layout.svelte.spec: mock $env/static/public to satisfy
  PUBLIC_NOTIFICATION_POLL_MS import from NotificationBell
- mention.spec: update assertion to match span-based mention rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:46:27 +01:00
Marcel
9ae6186e66 fix(#72): add mention chip styling for @mention rendering in comments
Mention spans injected via {@html} need global CSS since scoped styles
don't reach dynamically inserted content. Uses ink text on accent-bg
background for visible but subtle chip appearance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:45:52 +01:00
Marcel
c21e19a15c fix(#71): disable notification preferences when user has no email address
Profile page now greys out the notification checkboxes and save button when
the user has no email set, with a hint to add one first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:45:20 +01:00
Marcel
7825c7749a fix(#73): open annotation side panel when deep-linking via ?annotationId=
- NotificationBell now includes annotationId in the deep-link URL when available
- +page.svelte reads ?annotationId= param and sets activeAnnotationId on mount,
  opening the side panel instead of the bottom discussion drawer
- AnnotationSidePanel accepts and forwards targetCommentId to CommentThread
  so the specific comment is highlighted when navigating via a notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:44:51 +01:00
Marcel
d13422c65a fix(#71,#73): remove class-level permission gate and add annotationId to notifications
- Remove @RequirePermission(READ_ALL) from NotificationController class level so
  authenticated users with any permission (or none) can access their own notifications
- Add V19 migration, annotationId field to Notification entity and NotificationDTO
- NotificationService now stores annotationId from comment on both REPLY and MENTION
- Update controller tests: permission tests now expect 200, DTO constructor includes annotationId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 11:44:17 +01:00
Marcel
23d0005514 fix: allow any user permission to read/update own notification preferences
@RequirePermission now accepts Permission[] so a single annotation can
express "any of these" rather than a single required permission.

PermissionAspect updated accordingly — all existing single-value usages
compile unchanged (Java auto-wraps scalars in arrays for annotation attrs).

NotificationController: preference endpoints (GET/PUT /api/users/me/
notification-preferences) override the class-level READ_ALL gate with
{READ_ALL, WRITE_ALL, ANNOTATE_ALL} so users without READ_ALL can still
manage their own settings. Notification list endpoints retain READ_ALL.

UserSearchController: same broadened set so ANNOTATE_ALL users can search
for users to @mention when writing comments.

Tests: added WRITE_ALL and ANNOTATE_ALL passing cases for preferences and
user search; added 403 case for preferences with no permission; confirmed
WRITE_ALL cannot reach notification list endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 08:05:15 +01:00
Marcel
dc6ea080c4 fix(#71-#73): address all review findings from Markus and Sara
BLOCKERs:
- Remove direct AppUserRepository/CommentRepository access from CommentService and
  NotificationService — replaced with UserService.findAllById() and UserService
  (fixes layering contract from CLAUDE.md)
- Switch Optional<JavaMailSender> constructor injection — removes @Autowired(required=false)
  field and ReflectionTestUtils hack in tests
- Add @RequirePermission(READ_ALL) to UserSearchController — prevents user enumeration
  without read access

Data bug:
- Promote actorName from @Transient to persisted VARCHAR column (V18 migration)
- Set actorName in notifyReply and notifyMentions from comment.getAuthorName()

Architecture:
- Add @RequirePermission(READ_ALL) to NotificationController
- Introduce NotificationDTO — controller returns DTO instead of Notification entity,
  eliminating lazy-load N+1 and AppUser field leakage
- Change mentions FetchType to EAGER — fixes LazyInitializationException outside transaction
- Add @Transactional(propagation=REQUIRES_NEW) to notifyReply/notifyMentions so a
  notification failure cannot roll back the parent comment
- N+1 fix: replace per-ID findById loops with single findAllById bulk fetch
- Move collectParticipantIds to CommentService; notifyReply accepts Set<UUID> directly

Security:
- Escape displayName before injecting into renderBody HTML span
- Replace <a href="#"> with <span class="mention"> — no profile page to link to, and
  the anchor's scroll-to-top behaviour is harmful

Tests added/fixed:
- markRead_throwsNotFound, markAllRead_delegatesToRepository, countUnread_delegatesToRepository
- markOneRead_returns401, @RequirePermission 403 coverage for both controllers
- postComment/replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided
- search_returnsAtMostTenResults now asserts $.length() <= 10
- XSS regression test for escaped displayName in mention.spec.ts

Frontend minors:
- relativeTime() uses Intl.RelativeTimeFormat (locale-aware, not German-hardcoded)
- aria-label uses m.notification_unread() Paraglide key (de/en/es added)
- <div role="button"> replaced with <button> (native Enter+Space handling)
- onDestroy clears debounceTimer in MentionEditor
- setTimeout(100) replaced with await tick() + requestAnimationFrame in CommentThread
- Notification prefs form uses checkbox name attributes + formData.has() pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:31:38 +01:00
32 changed files with 531 additions and 185 deletions

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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
) {}

View File

@@ -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"),

View File

@@ -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;
} }

View File

@@ -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);
} }

View File

@@ -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");
} }
} }
} }

View File

@@ -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
} }

View File

@@ -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()))

View File

@@ -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());
} }

View File

@@ -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);

View File

@@ -0,0 +1 @@
ALTER TABLE notifications ADD COLUMN actor_name VARCHAR(255);

View File

@@ -0,0 +1 @@
ALTER TABLE notifications ADD COLUMN annotation_id UUID;

View File

@@ -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));
}
} }

View File

@@ -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)));
} }
} }

View File

@@ -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 ──────────────────────────────────────────────────────────

View File

@@ -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();
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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}

View File

@@ -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);
});
});

View File

@@ -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 = () => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -92,10 +92,11 @@ describe('renderBody', () => {
expect(result).toContain('AT&amp;T'); expect(result).toContain('AT&amp;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('&lt;script&gt;');
}); });
it('converts newlines to <br>', () => { it('converts newlines to <br>', () => {

View File

@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
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>');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>' }));

View File

@@ -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`, {

View File

@@ -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>