refactor(notifications): extract NotificationItem type and relativeTime to shared utility

Extracted from NotificationBell.svelte into $lib/utils/notifications.ts so the
history page can reuse them. relativeTime() now accepts an optional `now` param
for deterministic unit testing. Added parseNotificationEvent() for SSE payload
shape validation (NullX Finding 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-29 13:58:46 +02:00
committed by marcel
parent 03ee9ccec4
commit c8f7225506
3 changed files with 165 additions and 24 deletions

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

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