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/.prettierignore b/frontend/.prettierignore index 507fbd90..852473db 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -13,6 +13,7 @@ bun.lockb /.svelte-kit-backup/ # Generated files +/.svelte-kit-backup/ /src/lib/generated/ /src/lib/paraglide/ /src/lib/paraglide_bak*/ diff --git a/frontend/.svelte-kit-backup/types/src/routes/persons/[id]/edit/$types.d.ts b/frontend/.svelte-kit-backup/types/src/routes/persons/[id]/edit/$types.d.ts new file mode 100644 index 00000000..67b7b99b --- /dev/null +++ b/frontend/.svelte-kit-backup/types/src/routes/persons/[id]/edit/$types.d.ts @@ -0,0 +1,31 @@ +import type * as Kit from '@sveltejs/kit'; + +type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; +type MatcherParam = M extends (param : string) => param is (infer U extends string) ? U : string; +type RouteParams = { id: string }; +type RouteId = '/persons/[id]/edit'; +type MaybeWithVoid = {} extends T ? T | void : T; +export type RequiredKeys = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T]; +type OutputDataShape = MaybeWithVoid> & Partial> & Record> +type EnsureDefined = T extends null | undefined ? {} : T; +type OptionalUnion, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude]?: never } & U : never; +export type Snapshot = Kit.Snapshot; +type PageServerParentData = EnsureDefined; +type PageParentData = EnsureDefined; + +export type EntryGenerator = () => Promise> | Array; +export type PageServerLoad = OutputDataShape> = Kit.ServerLoad; +export type PageServerLoadEvent = Parameters[0]; +type ExcludeActionFailure = T extends Kit.ActionFailure ? never : T extends void ? never : T; +type ActionsSuccess any>> = { [Key in keyof T]: ExcludeActionFailure>>; }[keyof T]; +type ExtractActionFailure = T extends Kit.ActionFailure ? X extends void ? never : X : never; +type ActionsFailure any>> = { [Key in keyof T]: Exclude>>, void>; }[keyof T]; +type ActionsExport = typeof import('../../../../../../../src/routes/persons/[id]/edit/+page.server.js').actions +export type SubmitFunction = Kit.SubmitFunction>, Expand>> +export type ActionData = Expand> | null; +export type PageServerData = Expand>>>>>; +export type PageData = Expand & EnsureDefined>; +export type Action | void = Record | void> = Kit.Action +export type Actions | void = Record | void> = Kit.Actions +export type PageProps = { params: RouteParams; data: PageData; form: ActionData } +export type RequestEvent = Kit.RequestEvent; \ No newline at end of file diff --git a/frontend/e2e/.auth/user.json b/frontend/e2e/.auth/user.json index de4f774a..b7cc34db 100644 --- a/frontend/e2e/.auth/user.json +++ b/frontend/e2e/.auth/user.json @@ -5,7 +5,7 @@ "value": "de", "domain": "localhost", "path": "/", - "expires": 1808896929.897686, + "expires": 1809337570.90398, "httpOnly": false, "secure": false, "sameSite": "Lax" @@ -15,7 +15,7 @@ "value": "Basic%20YWRtaW46YWRtaW4xMjM%3D", "domain": "localhost", "path": "/", - "expires": 1774423330.233039, + "expires": 1774863971.187596, "httpOnly": true, "secure": false, "sameSite": "Strict" diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 54b4af81..e8cf82d4 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -345,5 +345,19 @@ "persons_new_birth_year": "Geburtsjahr", "persons_new_death_year": "Todesjahr", "persons_new_notes": "Notizen", - "person_save_changes": "Änderungen speichern" + "person_save_changes": "Änderungen speichern", + "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 ab6e3bc0..527a4f48 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -345,5 +345,19 @@ "persons_new_birth_year": "Birth year", "persons_new_death_year": "Death year", "persons_new_notes": "Notes", - "person_save_changes": "Save changes" + "person_save_changes": "Save changes", + "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 552b6849..3d72309e 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -345,5 +345,19 @@ "persons_new_birth_year": "Año de nacimiento", "persons_new_death_year": "Año de fallecimiento", "persons_new_notes": "Notas", - "person_save_changes": "Guardar cambios" + "person_save_changes": "Guardar cambios", + "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/proofshot-artifacts/2026-03-27_10-49-23/metadata.json b/frontend/proofshot-artifacts/2026-03-27_10-49-23/metadata.json new file mode 100644 index 00000000..c044b4f8 --- /dev/null +++ b/frontend/proofshot-artifacts/2026-03-27_10-49-23/metadata.json @@ -0,0 +1,6 @@ +{ + "branch": "feature/68-new-document-file-first", + "commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c", + "startedAt": "2026-03-27T10:49:23.106Z", + "description": null +} diff --git a/frontend/proofshot-artifacts/2026-03-27_10-49-39/metadata.json b/frontend/proofshot-artifacts/2026-03-27_10-49-39/metadata.json new file mode 100644 index 00000000..805936c7 --- /dev/null +++ b/frontend/proofshot-artifacts/2026-03-27_10-49-39/metadata.json @@ -0,0 +1,6 @@ +{ + "branch": "feature/68-new-document-file-first", + "commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c", + "startedAt": "2026-03-27T10:49:39.204Z", + "description": null +} diff --git a/frontend/proofshot-artifacts/2026-03-27_10-51-02/metadata.json b/frontend/proofshot-artifacts/2026-03-27_10-51-02/metadata.json new file mode 100644 index 00000000..26393dea --- /dev/null +++ b/frontend/proofshot-artifacts/2026-03-27_10-51-02/metadata.json @@ -0,0 +1,6 @@ +{ + "branch": "feature/68-new-document-file-first", + "commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c", + "startedAt": "2026-03-27T10:51:02.177Z", + "description": null +} 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} +
+ {/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} + + {/if} diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 3090e5aa..b7be22ff 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; }; StatsDTO: { /** 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/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 46c17737..fd986cfd 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -69,32 +69,16 @@ $effect(() => { // ── Bottom panel state ──────────────────────────────────────────────────────── -const LS_KEY_HEIGHT = 'doc-panel-height'; -const LS_KEY_TAB = 'doc-panel-tab'; -const LS_KEY_OPEN = 'doc-panel-open'; - let panelOpen = $state(false); let panelHeight = $state(0); // set to full height on mount let navHeight = $state(0); let activeTab = $state('metadata'); -let localStorageRestored = $state(false); onMount(() => { navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0; - const savedHeight = localStorage.getItem(LS_KEY_HEIGHT); - const savedTab = localStorage.getItem(LS_KEY_TAB); - const savedOpen = localStorage.getItem(LS_KEY_OPEN); - - if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) { - activeTab = savedTab as DocumentPanelTab; - } const topbar = document.querySelector('[data-topbar]'); panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0); - if (savedHeight) { - const h = parseInt(savedHeight, 10); - if (!isNaN(h) && h >= 80) panelHeight = h; - } if (targetAnnotationId) { // Deep-link into an annotation comment: open the side panel @@ -103,16 +87,12 @@ onMount(() => { // Deep-link into a document-level comment: open discussion tab panelOpen = true; activeTab = 'discussion'; - } else if (savedOpen === 'true') { - panelOpen = true; - } else if (savedOpen === null && !doc?.filePath) { - // No prior state and no file — open to metadata so the panel is immediately useful. + } else if (!doc?.filePath) { + // No file yet — open to metadata so the panel is immediately useful. panelOpen = true; activeTab = 'metadata'; } - localStorageRestored = true; - // Track last-visited document for the dashboard resume strip if (doc?.id) { localStorage.setItem( @@ -134,14 +114,6 @@ onMount(() => { document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }); - -// Persist panel state whenever it changes (after initial restore). -$effect(() => { - if (!localStorageRestored) return; - localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight)); - localStorage.setItem(LS_KEY_TAB, activeTab); - localStorage.setItem(LS_KEY_OPEN, String(panelOpen)); -}); 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} +
+ +
+ {/if} +
+ + +
+ + + + + + + + + + + +
+ + + {#if allNotifications.length === 0} +
+ +

+ {m.notification_empty_history()} +

+

+ {m.notification_empty_history_body()} +

+
+ {:else} + + {/if} + + + {#if hasMore} + + {/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()} + + diff --git a/frontend/yarn.lock b/frontend/yarn.lock index c75d7088..0c70bf19 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2,6 +2,13 @@ # yarn lockfile v1 +"@axe-core/playwright@^4.11.1": + version "4.11.1" + resolved "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz" + integrity sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw== + dependencies: + axe-core "~4.11.1" + "@babel/code-frame@^7.26.2": version "7.29.0" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz" @@ -11,11 +18,36 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.28.5": version "7.28.5" resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz" integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== +"@babel/parser@^7.29.0": + version "7.29.2" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz" + integrity sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA== + dependencies: + "@babel/types" "^7.29.0" + +"@babel/types@^7.29.0": + version "7.29.0" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@bcoe/v8-coverage@^1.0.2": + version "1.0.2" + resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz" + integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== + "@blazediff/core@1.9.1": version "1.9.1" resolved "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz" @@ -181,7 +213,7 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.31": version "0.3.31" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== @@ -212,6 +244,11 @@ resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz" integrity sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg== +"@napi-rs/canvas-linux-x64-musl@0.1.97": + version "0.1.97" + resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz" + integrity sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA== + "@napi-rs/canvas@^0.1.95": version "0.1.97" resolved "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz" @@ -625,6 +662,22 @@ tinyrainbow "^3.0.3" ws "^8.19.0" +"@vitest/coverage-v8@^4.1.0": + version "4.1.0" + resolved "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz" + integrity sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ== + dependencies: + "@bcoe/v8-coverage" "^1.0.2" + "@vitest/utils" "4.1.0" + ast-v8-to-istanbul "^1.0.0" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-reports "^3.2.0" + magicast "^0.5.2" + obug "^2.1.1" + std-env "^4.0.0-rc.1" + tinyrainbow "^3.0.3" + "@vitest/expect@4.1.0": version "4.1.0" resolved "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz" @@ -742,6 +795,20 @@ assertion-error@^2.0.1: resolved "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz" integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== +ast-v8-to-istanbul@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz" + integrity sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg== + dependencies: + "@jridgewell/trace-mapping" "^0.3.31" + estree-walker "^3.0.3" + js-tokens "^10.0.0" + +axe-core@~4.11.1: + version "4.11.1" + resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz" + integrity sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A== + axobject-query@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz" @@ -1209,6 +1276,11 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + https-proxy-agent@7.0.6: version "7.0.6" resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" @@ -1293,6 +1365,28 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.2: + version "3.2.2" + resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-reports@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + jiti@*, jiti@^2.6.1, jiti@>=1.21.0: version "2.6.1" resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" @@ -1308,6 +1402,11 @@ js-sha256@^0.11.0: resolved "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz" integrity sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg== +js-tokens@^10.0.0: + version "10.0.0" + resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz" + integrity sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz" @@ -1433,6 +1532,22 @@ magic-string@^0.30.11, magic-string@^0.30.21, magic-string@^0.30.3, magic-string dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" +magicast@^0.5.2: + version "0.5.2" + resolved "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz" + integrity sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + source-map-js "^1.2.1" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + mini-svg-data-uri@^1.2.3: version "1.4.4" resolved "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz" @@ -1598,7 +1713,7 @@ picocolors@^1.0.0, picocolors@^1.1.1: resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== -playwright-core@1.58.2: +"playwright-core@>= 1.0.0", playwright-core@1.58.2: version "1.58.2" resolved "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz" integrity sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg== @@ -1755,7 +1870,7 @@ sade@^1.7.4: dependencies: mri "^1.1.0" -semver@^7.6.3, semver@^7.7.2, semver@^7.7.3: +semver@^7.5.3, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3: version "7.7.4" resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== 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" +}