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 { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import {
|
||||||
type NotificationItem = {
|
type NotificationItem,
|
||||||
id: string;
|
relativeTime,
|
||||||
type: 'REPLY' | 'MENTION';
|
parseNotificationEvent
|
||||||
documentId: string;
|
} from '$lib/utils/notifications';
|
||||||
referenceId: string;
|
|
||||||
annotationId: string | null;
|
|
||||||
read: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
actorName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let notifications: NotificationItem[] = $state([]);
|
let notifications: NotificationItem[] = $state([]);
|
||||||
let unreadCount: number = $state(0);
|
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(() => {
|
onMount(() => {
|
||||||
fetchUnreadCount();
|
fetchUnreadCount();
|
||||||
eventSource = new EventSource('/api/notifications/stream');
|
eventSource = new EventSource('/api/notifications/stream');
|
||||||
eventSource.addEventListener('notification', (e) => {
|
eventSource.addEventListener('notification', (e) => {
|
||||||
const notification = JSON.parse(e.data) as NotificationItem;
|
const notification = parseNotificationEvent(e.data);
|
||||||
|
if (!notification) return;
|
||||||
notifications = [notification, ...notifications];
|
notifications = [notification, ...notifications];
|
||||||
if (!notification.read) unreadCount += 1;
|
if (!notification.read) unreadCount += 1;
|
||||||
});
|
});
|
||||||
@@ -317,6 +300,16 @@ onDestroy(() => {
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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