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:
@@ -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>
|
||||
|
||||
106
frontend/src/lib/utils/notifications.spec.ts
Normal file
106
frontend/src/lib/utils/notifications.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
42
frontend/src/lib/utils/notifications.ts
Normal file
42
frontend/src/lib/utils/notifications.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user