diff --git a/backend/pom.xml b/backend/pom.xml index da863de2..2f36d8d7 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -34,6 +34,10 @@ org.springframework.boot spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-validation + org.springframework.boot spring-boot-starter-data-jpa diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java index afeb052a..dff7d648 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.controller; import java.util.stream.Collectors; +import jakarta.validation.ConstraintViolationException; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.springframework.http.ResponseEntity; @@ -32,6 +33,14 @@ public class GlobalExceptionHandler { return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message)); } + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + String message = ex.getConstraintViolations().stream() + .map(v -> v.getPropertyPath() + ": " + v.getMessage()) + .collect(Collectors.joining(", ")); + return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message)); + } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'"; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java index 61bef6c6..ac85ae46 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/NotificationController.java @@ -1,6 +1,9 @@ package org.raddatz.familienarchiv.controller; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.Parameter; import org.raddatz.familienarchiv.dto.NotificationDTO; import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO; import org.raddatz.familienarchiv.model.AppUser; @@ -16,6 +19,7 @@ import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -25,6 +29,7 @@ import java.util.UUID; @RestController @RequiredArgsConstructor +@Validated public class NotificationController { private final NotificationService notificationService; @@ -44,9 +49,9 @@ public class NotificationController { @GetMapping("/api/notifications") public Page getNotifications( @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size, - @RequestParam(required = false) NotificationType type, - @RequestParam(required = false) Boolean read, + @RequestParam(defaultValue = "10") @Min(1) @Max(100) int size, + @Parameter(description = "Filter by notification type") @RequestParam(required = false) NotificationType type, + @Parameter(description = "Filter by read status") @RequestParam(required = false) Boolean read, Authentication authentication) { AppUser user = resolveUser(authentication); PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java index 2a79864a..537bf0ee 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/NotificationDTO.java @@ -14,5 +14,6 @@ public record NotificationDTO( UUID annotationId, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt, - String actorName + String actorName, + String documentTitle ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java index 5614fa3b..c7db9cac 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/DocumentRepository.java @@ -12,7 +12,9 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDate; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -44,6 +46,9 @@ public interface DocumentRepository extends JpaRepository, JpaSp List findByFileHashIsNullAndFilePathIsNotNull(); + @Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids") + List findIdAndTitleByIdIn(@Param("ids") Collection ids); + long countByMetadataCompleteFalse(); List findByMetadataCompleteFalse(Sort sort); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java index b2759a99..d9d4ca03 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/NotificationRepository.java @@ -21,6 +21,9 @@ public interface NotificationRepository extends JpaRepository findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc( UUID recipientId, NotificationType type, Pageable pageable); + Page findByRecipientIdAndReadFalseOrderByCreatedAtDesc( + UUID recipientId, Pageable pageable); + long countByRecipientIdAndReadFalse(UUID recipientId); @Modifying diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index e1c7434d..55c5ab98 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -25,8 +25,11 @@ import java.security.NoSuchAlgorithmException; import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -46,6 +49,15 @@ public class DocumentService { public record StoreResult(Document document, boolean isNew) {} + public Map findTitlesByIds(Collection ids) { + if (ids.isEmpty()) return Map.of(); + Map titles = new HashMap<>(); + for (Object[] row : documentRepository.findIdAndTitleByIdIn(ids)) { + titles.put((UUID) row[0], (String) row[1]); + } + return titles; + } + /** * Lädt eine Datei hoch. * - Prüft, ob ein Eintrag (aus Excel) schon existiert. diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java index 2f88931d..5a07e5ad 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/NotificationService.java @@ -20,10 +20,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -32,6 +35,7 @@ public class NotificationService { private final NotificationRepository notificationRepository; private final UserService userService; + private final DocumentService documentService; private final Optional mailSender; private final SseEmitterRegistry sseEmitterRegistry; @@ -94,16 +98,26 @@ public class NotificationService { } public Page getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) { + Page page; if (type != null && Boolean.FALSE.equals(read)) { - return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable) - .map(this::toDTO); + page = notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable); + } else if (type != null) { + page = notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable); + } else if (Boolean.FALSE.equals(read)) { + page = notificationRepository.findByRecipientIdAndReadFalseOrderByCreatedAtDesc(userId, pageable); + } else { + page = notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable); } - if (type != null) { - return notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable) - .map(this::toDTO); - } - return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable) - .map(this::toDTO); + return mapWithDocumentTitles(page); + } + + private Page mapWithDocumentTitles(Page page) { + Set documentIds = page.getContent().stream() + .map(Notification::getDocumentId) + .filter(id -> id != null) + .collect(Collectors.toSet()); + Map titles = documentService.findTitlesByIds(documentIds); + return page.map(n -> toDTO(n, titles)); } public long countUnread(UUID userId) { @@ -124,7 +138,7 @@ public class NotificationService { throw DomainException.forbidden("Notification belongs to a different user"); } notification.setRead(true); - return toDTO(notificationRepository.save(notification)); + return toDTO(notificationRepository.save(notification), Map.of()); } @Transactional @@ -136,10 +150,10 @@ public class NotificationService { private void saveAndPush(Notification notification) { Notification saved = notificationRepository.save(notification); - sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved)); + sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved, Map.of())); } - private NotificationDTO toDTO(Notification n) { + private NotificationDTO toDTO(Notification n, Map titles) { return new NotificationDTO( n.getId(), n.getType(), @@ -148,7 +162,8 @@ public class NotificationService { n.getAnnotationId(), n.isRead(), n.getCreatedAt(), - n.getActorName() + n.getActorName(), + n.getDocumentId() != null ? titles.get(n.getDocumentId()) : null ); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java index d7cf088b..c85d8e12 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/NotificationControllerTest.java @@ -75,7 +75,7 @@ class NotificationControllerTest { AppUser user = AppUser.builder().id(USER_ID).username("testuser").build(); NotificationDTO dto = new NotificationDTO( UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(), - UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith"); + UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith", "Testdokument"); when(userService.findByUsername("testuser")).thenReturn(user); when(notificationService.getNotifications(eq(USER_ID), any(), any(), any())) @@ -123,6 +123,20 @@ class NotificationControllerTest { .andExpect(status().isBadRequest()); } + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getNotifications_returns400_whenSizeExceedsMaximum() throws Exception { + mockMvc.perform(get("/api/notifications").param("size", "200")) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(username = "testuser", authorities = {"READ_ALL"}) + void getNotifications_returns400_whenSizeIsZero() throws Exception { + mockMvc.perform(get("/api/notifications").param("size", "0")) + .andExpect(status().isBadRequest()); + } + // ─── POST /api/notifications/read-all ──────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java index 892d35d8..af59cd25 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/NotificationServiceTest.java @@ -10,6 +10,7 @@ import org.raddatz.familienarchiv.dto.NotificationDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.model.*; import org.raddatz.familienarchiv.repository.NotificationRepository; +import org.springframework.data.domain.PageImpl; import org.springframework.mail.MailException; import org.springframework.mail.MailSendException; import org.springframework.mail.SimpleMailMessage; @@ -19,6 +20,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -34,6 +36,7 @@ class NotificationServiceTest { @Mock NotificationRepository notificationRepository; @Mock UserService userService; + @Mock DocumentService documentService; @Mock JavaMailSender mailSender; @Mock SseEmitterRegistry sseEmitterRegistry; @@ -45,7 +48,7 @@ class NotificationServiceTest { @BeforeEach void setUp() { - notificationService = new NotificationService(notificationRepository, userService, Optional.of(mailSender), sseEmitterRegistry); + notificationService = new NotificationService(notificationRepository, userService, documentService, Optional.of(mailSender), sseEmitterRegistry); userA = AppUser.builder().id(UUID.randomUUID()).username("userA") .firstName("Anna").lastName("Smith").email("a@test.com") @@ -258,7 +261,7 @@ class NotificationServiceTest { @Test void notifyReply_skipsEmail_whenMailSenderIsAbsent() { NotificationService serviceWithoutMail = new NotificationService( - notificationRepository, userService, Optional.empty(), sseEmitterRegistry); + notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry); userA.setNotifyOnReply(true); DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe"); @@ -274,7 +277,7 @@ class NotificationServiceTest { @Test void notifyMentions_skipsEmail_whenMailSenderIsAbsent() { NotificationService serviceWithoutMail = new NotificationService( - notificationRepository, userService, Optional.empty(), sseEmitterRegistry); + notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry); userA.setNotifyOnMention(true); DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe"); @@ -401,6 +404,63 @@ class NotificationServiceTest { .findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any()); } + @Test + void getNotifications_withReadFalseAndNoType_usesUnreadOnlyRepoMethod() { + when(notificationRepository.findByRecipientIdAndReadFalseOrderByCreatedAtDesc( + eq(userA.getId()), any())) + .thenReturn(Page.empty()); + + notificationService.getNotifications(userA.getId(), null, false, Pageable.ofSize(10)); + + verify(notificationRepository).findByRecipientIdAndReadFalseOrderByCreatedAtDesc( + eq(userA.getId()), any()); + verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any()); + } + + @Test + void getNotifications_mapsDocumentTitleFromDocumentService() { + UUID docId = UUID.randomUUID(); + Notification notification = Notification.builder() + .id(UUID.randomUUID()) + .recipient(userA) + .type(NotificationType.REPLY) + .documentId(docId) + .referenceId(UUID.randomUUID()) + .actorName("Clara Doe") + .build(); + when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any())) + .thenReturn(new PageImpl<>(List.of(notification))); + when(documentService.findTitlesByIds(Set.of(docId))) + .thenReturn(Map.of(docId, "Geburtsurkunde Opa Karl")); + + Page result = notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().documentTitle()).isEqualTo("Geburtsurkunde Opa Karl"); + } + + @Test + void getNotifications_mapsDocumentTitleAsNull_whenDocumentDoesNotExist() { + UUID docId = UUID.randomUUID(); + Notification notification = Notification.builder() + .id(UUID.randomUUID()) + .recipient(userA) + .type(NotificationType.MENTION) + .documentId(docId) + .referenceId(UUID.randomUUID()) + .actorName("Bob Jones") + .build(); + when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any())) + .thenReturn(new PageImpl<>(List.of(notification))); + when(documentService.findTitlesByIds(Set.of(docId))) + .thenReturn(Map.of()); + + Page result = notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10)); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().documentTitle()).isNull(); + } + @Test void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() { // read=true with a type filter falls through to the type-only branch — diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 9424d65f..9b133bc7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -320,5 +320,19 @@ "dashboard_needs_metadata_show_all": "Alle anzeigen", "dashboard_recent_heading": "Zuletzt aktiv", "dashboard_resume_label": "Zuletzt geöffnet:", - "dashboard_resume_fallback": "Unbekanntes Dokument" + "dashboard_resume_fallback": "Unbekanntes Dokument", + "notification_view_all": "Alle anzeigen →", + "notification_history_heading": "Benachrichtigungen", + "notification_history_view_link": "Benachrichtigungsverlauf ansehen →", + "notification_filter_all": "Alle", + "notification_filter_unread": "Ungelesen", + "notification_filter_mention": "Erwähnung", + "notification_filter_reply": "Antwort", + "notification_mark_all_read_aria": "Alle Benachrichtigungen als gelesen markieren", + "notification_load_more": "Ältere laden", + "notification_empty_history": "Keine Benachrichtigungen", + "notification_empty_history_body": "Hier erscheinen Erwähnungen und Antworten auf deine Kommentare.", + "notification_row_aria": "{actor} {type} auf \u201e{title}\u201c \u2014 {time} \u2014 {readState}", + "notification_read_state_read": "gelesen", + "notification_read_state_unread": "ungelesen" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4e1e071d..a452d12d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -320,5 +320,19 @@ "dashboard_needs_metadata_show_all": "Show all", "dashboard_recent_heading": "Recent Activity", "dashboard_resume_label": "Last opened:", - "dashboard_resume_fallback": "Unknown document" + "dashboard_resume_fallback": "Unknown document", + "notification_view_all": "View all →", + "notification_history_heading": "Notifications", + "notification_history_view_link": "View notification history →", + "notification_filter_all": "All", + "notification_filter_unread": "Unread", + "notification_filter_mention": "Mention", + "notification_filter_reply": "Reply", + "notification_mark_all_read_aria": "Mark all notifications as read", + "notification_load_more": "Load older", + "notification_empty_history": "No notifications", + "notification_empty_history_body": "Mentions and replies to your comments will appear here.", + "notification_row_aria": "{actor} {type} on \"{title}\" — {time} — {readState}", + "notification_read_state_read": "read", + "notification_read_state_unread": "unread" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 14c9f868..ab9c31e0 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -320,5 +320,19 @@ "dashboard_needs_metadata_show_all": "Ver todos", "dashboard_recent_heading": "Actividad reciente", "dashboard_resume_label": "Último abierto:", - "dashboard_resume_fallback": "Documento desconocido" + "dashboard_resume_fallback": "Documento desconocido", + "notification_view_all": "Ver todas →", + "notification_history_heading": "Notificaciones", + "notification_history_view_link": "Ver historial de notificaciones →", + "notification_filter_all": "Todas", + "notification_filter_unread": "No leídas", + "notification_filter_mention": "Mención", + "notification_filter_reply": "Respuesta", + "notification_mark_all_read_aria": "Marcar todas las notificaciones como leídas", + "notification_load_more": "Cargar anteriores", + "notification_empty_history": "Sin notificaciones", + "notification_empty_history_body": "Aquí aparecerán las menciones y respuestas a tus comentarios.", + "notification_row_aria": "{actor} {type} en \"{title}\" — {time} — {readState}", + "notification_read_state_read": "leído", + "notification_read_state_unread": "no leído" } diff --git a/frontend/src/lib/components/DashboardMentions.svelte b/frontend/src/lib/components/DashboardMentions.svelte index 375fcd47..176d5fa3 100644 --- a/frontend/src/lib/components/DashboardMentions.svelte +++ b/frontend/src/lib/components/DashboardMentions.svelte @@ -24,25 +24,34 @@ let { mentions }: Props = $props(); {m.dashboard_notifications_heading()} - {#each mentions as mention (mention.id)} - - {#if mention.documentId} - {mention.actorName ?? ''} - - {mention.type === 'MENTION' - ? m.dashboard_notification_mentioned() - : m.dashboard_notification_replied()} - - {:else} - {mention.actorName ?? ''} - {/if} - - {/each} + + {#each mentions as mention (mention.id)} + + {#if mention.documentId} + {mention.actorName ?? ''} + + {mention.type === 'MENTION' + ? m.dashboard_notification_mentioned() + : m.dashboard_notification_replied()} + + {:else} + {mention.actorName ?? ''} + {/if} + + {/each} + + + {m.notification_history_view_link()} + {/if} diff --git a/frontend/src/lib/components/NotificationBell.svelte b/frontend/src/lib/components/NotificationBell.svelte index f8edc540..cb62dbfa 100644 --- a/frontend/src/lib/components/NotificationBell.svelte +++ b/frontend/src/lib/components/NotificationBell.svelte @@ -2,17 +2,11 @@ import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { m } from '$lib/paraglide/messages.js'; - -type NotificationItem = { - id: string; - type: 'REPLY' | 'MENTION'; - documentId: string; - referenceId: string; - annotationId: string | null; - read: boolean; - createdAt: string; - actorName: string; -}; +import { + type NotificationItem, + relativeTime, + parseNotificationEvent +} from '$lib/utils/notifications'; let notifications: NotificationItem[] = $state([]); let unreadCount: number = $state(0); @@ -131,23 +125,12 @@ function attachClickOutside(node: HTMLElement) { }; } -function relativeTime(isoString: string): string { - const diffMs = Date.now() - new Date(isoString).getTime(); - const diffMin = Math.floor(diffMs / 60000); - 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 rtf.format(-diffH, 'hour'); - const diffD = Math.floor(diffH / 24); - return rtf.format(-diffD, 'day'); -} - onMount(() => { fetchUnreadCount(); eventSource = new EventSource('/api/notifications/stream'); eventSource.addEventListener('notification', (e) => { - const notification = JSON.parse(e.data) as NotificationItem; + const notification = parseNotificationEvent(e.data); + if (!notification) return; notifications = [notification, ...notifications]; if (!notification.read) unreadCount += 1; }); @@ -317,6 +300,16 @@ onDestroy(() => { {/each} {/if} + + + + {m.notification_view_all()} + + {/if} diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 696d48a2..63cb9060 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1002,6 +1002,7 @@ export interface components { /** Format: date-time */ createdAt: string; actorName?: string; + documentTitle?: string; }; PageNotificationDTO: { /** Format: int64 */ diff --git a/frontend/src/lib/utils/notifications.spec.ts b/frontend/src/lib/utils/notifications.spec.ts new file mode 100644 index 00000000..e1333d5a --- /dev/null +++ b/frontend/src/lib/utils/notifications.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { relativeTime, parseNotificationEvent } from '$lib/utils/notifications'; + +const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + +function msAgo(ms: number, now: Date): string { + return new Date(now.getTime() - ms).toISOString(); +} + +describe('relativeTime', () => { + const now = new Date('2024-06-15T12:00:00.000Z'); + + it('should use minute bucket for timestamps under 60 seconds ago', () => { + const ts = msAgo(30_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(0, 'minute')); + }); + + it('should use minute bucket for exactly 59 minutes ago', () => { + const ts = msAgo(59 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-59, 'minute')); + }); + + it('should use minute bucket for exactly 1 minute ago', () => { + const ts = msAgo(60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'minute')); + }); + + it('should use hour bucket for exactly 1 hour ago', () => { + const ts = msAgo(60 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'hour')); + }); + + it('should use hour bucket for 23 hours ago', () => { + const ts = msAgo(23 * 60 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-23, 'hour')); + }); + + it('should use day bucket for exactly 24 hours ago', () => { + const ts = msAgo(24 * 60 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'day')); + }); + + it('should use day bucket for 6 days ago', () => { + const ts = msAgo(6 * 24 * 60 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-6, 'day')); + }); + + it('should default now to current time when omitted', () => { + // Just verify it returns a non-empty string — exact value depends on runtime clock + const ts = new Date(Date.now() - 5 * 60_000).toISOString(); + expect(relativeTime(ts)).toBeTruthy(); + }); +}); + +describe('parseNotificationEvent', () => { + const valid = { + id: '00000000-0000-0000-0000-000000000001', + documentId: '00000000-0000-0000-0000-000000000002', + actorName: 'Anna Müller', + type: 'MENTION', + referenceId: '00000000-0000-0000-0000-000000000003', + annotationId: null, + read: false, + createdAt: '2024-06-15T10:00:00', + documentTitle: 'Geburtsurkunde Opa Karl' + }; + + it('should return parsed object for a valid payload', () => { + const result = parseNotificationEvent(JSON.stringify(valid)); + expect(result).not.toBeNull(); + expect(result?.id).toBe(valid.id); + expect(result?.actorName).toBe('Anna Müller'); + }); + + it('should return null for invalid JSON', () => { + expect(parseNotificationEvent('not-json')).toBeNull(); + }); + + it('should return null when id is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, ...noId } = valid; + expect(parseNotificationEvent(JSON.stringify(noId))).toBeNull(); + }); + + it('should return null when documentId is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { documentId, ...noDocId } = valid; + expect(parseNotificationEvent(JSON.stringify(noDocId))).toBeNull(); + }); + + it('should return null when actorName is missing', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { actorName, ...noActor } = valid; + expect(parseNotificationEvent(JSON.stringify(noActor))).toBeNull(); + }); + + it('should return null for unknown notification type', () => { + expect(parseNotificationEvent(JSON.stringify({ ...valid, type: 'UNKNOWN' }))).toBeNull(); + }); + + it('should accept REPLY as a valid type', () => { + const result = parseNotificationEvent(JSON.stringify({ ...valid, type: 'REPLY' })); + expect(result).not.toBeNull(); + expect(result?.type).toBe('REPLY'); + }); +}); diff --git a/frontend/src/lib/utils/notifications.ts b/frontend/src/lib/utils/notifications.ts new file mode 100644 index 00000000..a58f1b11 --- /dev/null +++ b/frontend/src/lib/utils/notifications.ts @@ -0,0 +1,42 @@ +export type NotificationItem = { + id: string; + type: 'REPLY' | 'MENTION'; + documentId: string; + referenceId: string; + annotationId: string | null; + read: boolean; + createdAt: string; + actorName: string; + documentTitle: string | null; +}; + +const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + +export function relativeTime(isoString: string, now: Date = new Date()): string { + const diffMs = now.getTime() - new Date(isoString).getTime(); + const diffMin = Math.floor(diffMs / 60_000); + 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 rtf.format(-diffH, 'hour'); + const diffD = Math.floor(diffH / 24); + return rtf.format(-diffD, 'day'); +} + +export function parseNotificationEvent(raw: string): NotificationItem | null { + try { + const parsed = JSON.parse(raw); + if ( + typeof parsed.id !== 'string' || + typeof parsed.documentId !== 'string' || + typeof parsed.actorName !== 'string' || + !['REPLY', 'MENTION'].includes(parsed.type) + ) { + console.warn('Unexpected SSE payload shape:', parsed); + return null; + } + return parsed as NotificationItem; + } catch { + return null; + } +} diff --git a/frontend/src/routes/notifications/+page.server.ts b/frontend/src/routes/notifications/+page.server.ts new file mode 100644 index 00000000..42485660 --- /dev/null +++ b/frontend/src/routes/notifications/+page.server.ts @@ -0,0 +1,35 @@ +import { error, redirect } from '@sveltejs/kit'; +import { createApiClient } from '$lib/api.server'; +import { getErrorMessage } from '$lib/errors'; +import type { PageServerLoad, Actions } from './$types'; + +export const load: PageServerLoad = async ({ fetch, url }) => { + const api = createApiClient(fetch); + + const type = url.searchParams.get('type') ?? undefined; + const readParam = url.searchParams.get('read'); + const read = readParam !== null ? readParam === 'true' : undefined; + + const result = await api.GET('/api/notifications', { + params: { query: { type: type as 'MENTION' | 'REPLY' | undefined, read, page: 0, size: 20 } } + }); + + if (!result.response.ok) { + const code = (result.error as unknown as { code?: string })?.code; + throw error(result.response.status, getErrorMessage(code)); + } + + const page = result.data!; + const notifications = page.content ?? []; + const unreadCount = notifications.filter((n) => !n.read).length; + + return { notifications, unreadCount, totalPages: page.totalPages ?? 1 }; +}; + +export const actions: Actions = { + 'mark-all': async ({ fetch }) => { + const api = createApiClient(fetch); + await api.POST('/api/notifications/read-all'); + redirect(303, '/notifications'); + } +}; diff --git a/frontend/src/routes/notifications/+page.svelte b/frontend/src/routes/notifications/+page.svelte new file mode 100644 index 00000000..9d750773 --- /dev/null +++ b/frontend/src/routes/notifications/+page.svelte @@ -0,0 +1,279 @@ + + + + {m.notification_history_heading()} + + + + + + + + + + {m.btn_back_to_overview()} + + + + + + {m.notification_history_heading()} + + {#if data.unreadCount > 0} + + + {m.notification_mark_all_read()} + + + {/if} + + + + + + setFilter({ type: null, read: null })} + class={[ + 'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2', + activeType === null && activeReadFilter === null + ? 'bg-primary text-primary-fg' + : 'bg-muted text-ink' + ].join(' ')} + > + {m.notification_filter_all()} + + + + setFilter({ read: 'false', type: null })} + class={[ + 'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2', + activeReadFilter === 'false' + ? 'bg-primary text-primary-fg' + : 'bg-muted text-ink' + ].join(' ')} + > + {m.notification_filter_unread()} + {#if data.unreadCount > 0 && activeType === null && activeReadFilter === null} + + {data.unreadCount} + + {/if} + + + + setFilter({ type: 'MENTION', read: null })} + class={[ + 'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2', + activeType === 'MENTION' + ? 'bg-primary text-primary-fg' + : 'bg-muted text-ink' + ].join(' ')} + > + {m.notification_filter_mention()} + + + + setFilter({ type: 'REPLY', read: null })} + class={[ + 'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2', + activeType === 'REPLY' + ? 'bg-primary text-primary-fg' + : 'bg-muted text-ink' + ].join(' ')} + > + {m.notification_filter_reply()} + + + + + {#if allNotifications.length === 0} + + + + + + {m.notification_empty_history()} + + + {m.notification_empty_history_body()} + + + {:else} + + {#each allNotifications as n (n.id)} + + { + e.preventDefault(); + navigateToNotification(n); + }} + > + + {#if !n.read} + + {/if} + + + + {n.actorName} + + {typeBadgeLabel(n.type)} + + + + + {#if n.documentTitle} + + {n.documentTitle} + + {/if} + + + + {relativeTime(n.createdAt)} + + + + {/each} + + {/if} + + + {#if hasMore} + + {isLoadingMore ? '…' : m.notification_load_more()} + + {/if} + + diff --git a/frontend/src/routes/notifications/page.server.spec.ts b/frontend/src/routes/notifications/page.server.spec.ts new file mode 100644 index 00000000..05d4fb2a --- /dev/null +++ b/frontend/src/routes/notifications/page.server.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/api.server', () => ({ createApiClient: vi.fn() })); + +import { load, actions } from './+page.server'; +import { createApiClient } from '$lib/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +function makeUrl(params: Record = {}) { + const url = new URL('http://localhost/notifications'); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return url; +} + +// ─── load ───────────────────────────────────────────────────────────────────── + +describe('notifications page load', () => { + it('returns notifications and unreadCount from API response', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { + content: [ + { id: 'n1', read: false }, + { id: 'n2', read: true }, + { id: 'n3', read: false } + ], + totalElements: 3, + totalPages: 1, + number: 0 + } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(result.notifications).toHaveLength(3); + expect(result.unreadCount).toBe(2); + }); + + it('passes type param to API when ?type=MENTION is in URL', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [], totalElements: 0, totalPages: 0, number: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl({ type: 'MENTION' }), fetch: vi.fn() as unknown as typeof fetch }); + + const queryParams = mockGet.mock.calls[0][1].params.query; + expect(queryParams.type).toBe('MENTION'); + }); + + it('passes read=false to API when ?read=false is in URL', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [], totalElements: 0, totalPages: 0, number: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl({ read: 'false' }), fetch: vi.fn() as unknown as typeof fetch }); + + const queryParams = mockGet.mock.calls[0][1].params.query; + expect(queryParams.read).toBe(false); + }); + + it('passes no filter params when no search params present', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [], totalElements: 0, totalPages: 0, number: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + const queryParams = mockGet.mock.calls[0][1].params.query; + expect(queryParams.type).toBeUndefined(); + expect(queryParams.read).toBeUndefined(); + }); + + it('calls the API exactly once — no separate round-trip for unreadCount', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: true }, + data: { content: [], totalElements: 0, totalPages: 0, number: 0 } + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it('throws 401 error when API returns 401', async () => { + const mockGet = vi.fn().mockResolvedValueOnce({ + response: { ok: false, status: 401 }, + data: null + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await expect( + load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch }) + ).rejects.toMatchObject({ status: 401 }); + }); +}); + +// ─── mark-all action ────────────────────────────────────────────────────────── + +describe('notifications mark-all action', () => { + it('calls POST /api/notifications/read-all and redirects', async () => { + const mockPost = vi.fn().mockResolvedValueOnce({ response: { ok: true } }); + vi.mocked(createApiClient).mockReturnValue({ POST: mockPost } as ReturnType< + typeof createApiClient + >); + + const markAll = actions['mark-all'] as (ctx: { fetch: typeof fetch }) => Promise; + await expect(markAll({ fetch: vi.fn() as unknown as typeof fetch })).rejects.toMatchObject({ + location: '/notifications' + }); + + expect(mockPost).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 547b6603..39de19a7 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -100,5 +100,14 @@ const hasEmail = $derived(!!data.user?.email); {m.btn_save()} + + + + {m.notification_history_view_link()} + + diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-1440-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-1440-dark.png new file mode 100644 index 00000000..1093c363 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-1440-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-1440-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-1440-light.png new file mode 100644 index 00000000..9869b034 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-1440-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-320-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-320-dark.png new file mode 100644 index 00000000..680d6d62 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-320-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-320-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-320-light.png new file mode 100644 index 00000000..3960cb5a Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-320-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-768-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-768-dark.png new file mode 100644 index 00000000..5402f34d Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-768-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-768-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-768-light.png new file mode 100644 index 00000000..6c55c25d Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/3msgs-768-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-1440-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-1440-dark.png new file mode 100644 index 00000000..11a47b52 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-1440-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-1440-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-1440-light.png new file mode 100644 index 00000000..28df2b60 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-1440-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-320-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-320-dark.png new file mode 100644 index 00000000..b5a6249f Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-320-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-320-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-320-light.png new file mode 100644 index 00000000..eff60541 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-320-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-768-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-768-dark.png new file mode 100644 index 00000000..ff6f8178 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-768-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-768-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-768-light.png new file mode 100644 index 00000000..1777928e Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/50msgs-768-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/empty-1440-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-1440-dark.png new file mode 100644 index 00000000..c3107c0b Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-1440-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/empty-1440-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-1440-light.png new file mode 100644 index 00000000..17bfb8c5 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-1440-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/empty-320-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-320-dark.png new file mode 100644 index 00000000..13220476 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-320-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/empty-320-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-320-light.png new file mode 100644 index 00000000..11f06b08 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-320-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/empty-768-dark.png b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-768-dark.png new file mode 100644 index 00000000..230a66b7 Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-768-dark.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/empty-768-light.png b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-768-light.png new file mode 100644 index 00000000..5c092a5a Binary files /dev/null and b/proofshot-artifacts/2026-03-29_notifications-page-153/empty-768-light.png differ diff --git a/proofshot-artifacts/2026-03-29_notifications-page-153/metadata.json b/proofshot-artifacts/2026-03-29_notifications-page-153/metadata.json new file mode 100644 index 00000000..00b1de93 --- /dev/null +++ b/proofshot-artifacts/2026-03-29_notifications-page-153/metadata.json @@ -0,0 +1,6 @@ +{ + "branch": "feature/153-notification-history", + "commitSha": "edeff353931e9d625083eec983fe174c138c340d", + "startedAt": "2026-03-29T00:00:00.000Z", + "description": "Notification history page (/notifications): empty state, list view (20 items), load-more view (40 items) — 320/768/1440px, light/dark" +}
+ {m.notification_empty_history_body()} +
+ {n.documentTitle} +
+ {relativeTime(n.createdAt)} +