chore: merge main into feat/persons-redesign-concept-a
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled

Resolved conflicts in messages/de.json, en.json, es.json by keeping
both the persons-redesign keys (feature branch) and the notification
keys (main) in all three locale files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #159.
This commit is contained in:
Marcel
2026-03-29 21:30:54 +02:00
49 changed files with 1025 additions and 102 deletions

View File

@@ -34,6 +34,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>

View File

@@ -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<ErrorResponse> 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<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'";

View File

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

View File

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

View File

@@ -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<Document, UUID>, JpaSp
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);
long countByMetadataCompleteFalse();
List<Document> findByMetadataCompleteFalse(Sort sort);

View File

@@ -21,6 +21,9 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
UUID recipientId, NotificationType type, Pageable pageable);
Page<Notification> findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
UUID recipientId, Pageable pageable);
long countByRecipientIdAndReadFalse(UUID recipientId);
@Modifying

View File

@@ -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<UUID, String> findTitlesByIds(Collection<UUID> ids) {
if (ids.isEmpty()) return Map.of();
Map<UUID, String> 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.

View File

@@ -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<JavaMailSender> mailSender;
private final SseEmitterRegistry sseEmitterRegistry;
@@ -94,16 +98,26 @@ public class NotificationService {
}
public Page<NotificationDTO> getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) {
Page<Notification> 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<NotificationDTO> mapWithDocumentTitles(Page<Notification> page) {
Set<UUID> documentIds = page.getContent().stream()
.map(Notification::getDocumentId)
.filter(id -> id != null)
.collect(Collectors.toSet());
Map<UUID, String> 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<UUID, String> 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
);
}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ bun.lockb
/.svelte-kit-backup/
# Generated files
/.svelte-kit-backup/
/src/lib/generated/
/src/lib/paraglide/
/src/lib/paraglide_bak*/

View File

@@ -0,0 +1,31 @@
import type * as Kit from '@sveltejs/kit';
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
type RouteParams = { id: string };
type RouteId = '/persons/[id]/edit';
type MaybeWithVoid<T> = {} extends T ? T | void : T;
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
type EnsureDefined<T> = T extends null | undefined ? {} : T;
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
export type Snapshot<T = any> = Kit.Snapshot<T>;
type PageServerParentData = EnsureDefined<import('../../../$types.js').LayoutServerData>;
type PageParentData = EnsureDefined<import('../../../$types.js').LayoutData>;
export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
type ExcludeActionFailure<T> = T extends Kit.ActionFailure<any> ? never : T extends void ? never : T;
type ActionsSuccess<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: ExcludeActionFailure<Awaited<ReturnType<T[Key]>>>; }[keyof T];
type ExtractActionFailure<T> = T extends Kit.ActionFailure<infer X> ? X extends void ? never : X : never;
type ActionsFailure<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: Exclude<ExtractActionFailure<Awaited<ReturnType<T[Key]>>>, void>; }[keyof T];
type ActionsExport = typeof import('../../../../../../../src/routes/persons/[id]/edit/+page.server.js').actions
export type SubmitFunction = Kit.SubmitFunction<Expand<ActionsSuccess<ActionsExport>>, Expand<ActionsFailure<ActionsExport>>>
export type ActionData = Expand<Kit.AwaitedActions<ActionsExport>> | null;
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('../../../../../../../src/routes/persons/[id]/edit/+page.server.js').load>>>>>>;
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"branch": "feature/68-new-document-file-first",
"commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c",
"startedAt": "2026-03-27T10:49:23.106Z",
"description": null
}

View File

@@ -0,0 +1,6 @@
{
"branch": "feature/68-new-document-file-first",
"commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c",
"startedAt": "2026-03-27T10:49:39.204Z",
"description": null
}

View File

@@ -0,0 +1,6 @@
{
"branch": "feature/68-new-document-file-first",
"commitSha": "53b482c5f24688ca3426d434c832e8d24acfee4c",
"startedAt": "2026-03-27T10:51:02.177Z",
"description": null
}

View File

@@ -24,25 +24,34 @@ let { mentions }: Props = $props();
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.dashboard_notifications_heading()}
</h2>
{#each mentions as mention (mention.id)}
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
{#if mention.documentId}
<a
href={mention.annotationId
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
>{mention.actorName ?? ''}</a
>
<span class="font-sans text-xs text-gray-400">
{mention.type === 'MENTION'
? m.dashboard_notification_mentioned()
: m.dashboard_notification_replied()}
</span>
{:else}
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
{/if}
</div>
{/each}
<div>
{#each mentions as mention (mention.id)}
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
{#if mention.documentId}
<a
href={mention.annotationId
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
>{mention.actorName ?? ''}</a
>
<span class="font-sans text-xs text-gray-400">
{mention.type === 'MENTION'
? m.dashboard_notification_mentioned()
: m.dashboard_notification_replied()}
</span>
{:else}
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
{/if}
</div>
{/each}
</div>
<div class="mt-4 border-t border-line pt-4">
<a
href="/notifications"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>{m.notification_history_view_link()}</a
>
</div>
</div>
{/if}

View File

@@ -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}
</ul>
{/if}
<div class="border-t border-line px-4 py-2">
<a
href="/notifications"
onclick={closeDropdown}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.notification_view_all()}
</a>
</div>
</div>
{/if}
</div>

View File

@@ -1002,6 +1002,7 @@ export interface components {
/** Format: date-time */
createdAt: string;
actorName?: string;
documentTitle?: string;
};
StatsDTO: {
/** Format: int64 */

View File

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

View File

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

View File

@@ -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<DocumentPanelTab>('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));
});
</script>
<svelte:head>

View File

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

View File

@@ -0,0 +1,279 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
import { relativeTime, type NotificationItem } from '$lib/utils/notifications';
let { data } = $props();
let additionalNotifications = $state<NotificationItem[]>([]);
let loadMorePage = $state(1);
let isLoadingMore = $state(false);
const allNotifications = $derived([...data.notifications, ...additionalNotifications]);
const activeType = $derived(page.url.searchParams.get('type'));
const activeReadFilter = $derived(page.url.searchParams.get('read'));
const hasMore = $derived(loadMorePage < (data.totalPages ?? 1));
function setFilter(params: Record<string, string | null>) {
additionalNotifications = [];
loadMorePage = 1;
const url = new URL(page.url);
for (const [k, v] of Object.entries(params)) {
if (v === null) url.searchParams.delete(k);
else url.searchParams.set(k, v);
}
goto(url.toString());
}
async function loadMore() {
isLoadingMore = true;
try {
const typeParam = page.url.searchParams.get('type');
const readParam = page.url.searchParams.get('read');
let query = `page=${loadMorePage}&size=20`;
if (typeParam) query += `&type=${typeParam}`;
if (readParam !== null) query += `&read=${readParam}`;
const res = await fetch(`/api/notifications?${query}`);
if (res.ok) {
const json = await res.json();
additionalNotifications = [...additionalNotifications, ...(json.content ?? [])];
loadMorePage += 1;
}
} finally {
isLoadingMore = false;
}
}
async function navigateToNotification(n: NotificationItem) {
if (!n.read) {
await fetch(`/api/notifications/${n.id}/read`, { method: 'PATCH' });
}
const url = n.annotationId
? `/documents/${n.documentId}?commentId=${n.referenceId}&annotationId=${n.annotationId}`
: `/documents/${n.documentId}?commentId=${n.referenceId}`;
goto(url);
}
function typeBadgeLabel(type: NotificationItem['type']): string {
return type === 'MENTION' ? m.notification_filter_mention() : m.notification_filter_reply();
}
</script>
<svelte:head>
<title>{m.notification_history_heading()}</title>
</svelte:head>
<div class="min-h-screen bg-canvas">
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Back link -->
<a
href="/"
class="group mb-4 inline-flex items-center text-xs font-bold tracking-widest text-gray-500 uppercase transition-colors hover:text-ink"
>
<svg
class="mr-2 h-4 w-4 transform transition-transform group-hover:-translate-x-1"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
{m.btn_back_to_overview()}
</a>
<!-- Page header -->
<div class="mb-6 flex items-center justify-between">
<h1 class="font-serif text-2xl font-medium text-ink">
{m.notification_history_heading()}
</h1>
{#if data.unreadCount > 0}
<form method="POST" action="?/mark-all">
<button
type="submit"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
aria-label={m.notification_mark_all_read_aria()}
>
{m.notification_mark_all_read()}
</button>
</form>
{/if}
</div>
<!-- Filter pills -->
<div role="radiogroup" aria-label="Filter" class="mb-6 flex flex-wrap gap-2">
<!-- All -->
<button
role="radio"
aria-checked={activeType === null && activeReadFilter === null}
onclick={() => 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()}
</button>
<!-- Unread -->
<button
role="radio"
aria-checked={activeReadFilter === 'false'}
onclick={() => 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}
<span
class="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 font-sans text-xs font-bold text-ink"
aria-hidden="true"
>
{data.unreadCount}
</span>
{/if}
</button>
<!-- Mention -->
<button
role="radio"
aria-checked={activeType === 'MENTION'}
onclick={() => 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()}
</button>
<!-- Reply -->
<button
role="radio"
aria-checked={activeType === 'REPLY'}
onclick={() => 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()}
</button>
</div>
<!-- Notification list or empty state -->
{#if allNotifications.length === 0}
<div class="flex flex-col items-center gap-3 py-20 text-center">
<svg
class="h-10 w-10 text-ink-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
<h2 class="font-serif text-lg font-semibold text-ink">
{m.notification_empty_history()}
</h2>
<p class="max-w-xs font-sans text-sm text-ink-2">
{m.notification_empty_history_body()}
</p>
</div>
{:else}
<ul
role="list"
class="divide-y divide-line rounded-sm border border-line bg-canvas shadow-sm"
>
{#each allNotifications as n (n.id)}
<li class="relative bg-surface">
<a
href="/documents/{n.documentId}"
role="row"
class={[
'flex min-h-14 flex-col justify-center border-l-[3px] px-4 py-4 md:px-6 md:py-5',
'transition-colors hover:bg-accent-bg',
n.read
? 'border-l-transparent'
: 'border-l-accent'
].join(' ')}
aria-label={m.notification_row_aria({
actor: n.actorName,
type: typeBadgeLabel(n.type),
title: n.documentTitle ?? '',
time: relativeTime(n.createdAt),
readState: n.read ? m.notification_read_state_read() : m.notification_read_state_unread()
})}
onclick={(e) => {
e.preventDefault();
navigateToNotification(n);
}}
>
<!-- Unread dot indicator -->
{#if !n.read}
<span
class="absolute top-4 right-4 h-2 w-2 rounded-full bg-accent md:right-6"
aria-hidden="true"
></span>
{/if}
<!-- Line 1: actor name + type badge -->
<div class="flex items-center gap-2">
<span class="font-serif font-semibold text-ink">{n.actorName}</span>
<span
class="rounded-sm bg-muted px-2 py-0.5 font-sans text-xs tracking-wide text-ink-2 uppercase"
>
{typeBadgeLabel(n.type)}
</span>
</div>
<!-- Line 2: document title -->
{#if n.documentTitle}
<p
class="mt-0.5 font-serif text-sm text-ink hover:underline hover:decoration-accent"
>
{n.documentTitle}
</p>
{/if}
<!-- Line 3: relative time -->
<p class="mt-1 font-sans text-sm text-ink-3">
{relativeTime(n.createdAt)}
</p>
</a>
</li>
{/each}
</ul>
{/if}
<!-- Load more -->
{#if hasMore}
<button
onclick={loadMore}
disabled={isLoadingMore}
class="mt-6 w-full rounded-sm border border-line py-3 text-sm font-medium text-ink-2 transition-colors hover:bg-canvas disabled:opacity-50"
>
{isLoadingMore ? '…' : m.notification_load_more()}
</button>
{/if}
</div>
</div>

View File

@@ -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<string, string> = {}) {
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<never>;
await expect(markAll({ fetch: vi.fn() as unknown as typeof fetch })).rejects.toMatchObject({
location: '/notifications'
});
expect(mockPost).toHaveBeenCalledTimes(1);
});
});

View File

@@ -100,5 +100,14 @@ const hasEmail = $derived(!!data.user?.email);
{m.btn_save()}
</button>
</form>
<div class="mt-4 border-t border-line pt-4">
<a
href="/notifications"
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.notification_history_view_link()}
</a>
</div>
</div>
</div>

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

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