Add user notification inbox and delivery controls
This commit is contained in:
@@ -4,9 +4,10 @@
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { user, isacAccess, children }: {
|
||||
let { user, isacAccess, notificationSummary, children }: {
|
||||
user: { username: string; displayName: string; role: string } | null;
|
||||
isacAccess: boolean;
|
||||
notificationSummary: { unread: number };
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
@@ -19,9 +20,32 @@
|
||||
let showCookieNotice = $state(false);
|
||||
let mobileOpen = $state(false);
|
||||
let mobilePanel = $state<HTMLElement | null>(null);
|
||||
let unreadNotifications = $state(0);
|
||||
$effect(() => { unreadNotifications = notificationSummary.unread; });
|
||||
|
||||
onMount(() => {
|
||||
showCookieNotice = localStorage.getItem('shd-essential-cookie-notice') !== 'dismissed';
|
||||
if (!user) return;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/notifications/unread');
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
unreadNotifications = Number(result.count ?? 0);
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||
const shown = new Set<string>(JSON.parse(localStorage.getItem('shd-browser-notifications') ?? '[]'));
|
||||
for (const item of result.notifications as Array<{ id: string; title: string; body: string; link?: string }>) {
|
||||
if (shown.has(item.id)) continue;
|
||||
const alert = new Notification(item.title, { body: item.body, icon: '/favicon.svg', tag: `shd-${item.id}` });
|
||||
alert.onclick = () => { window.focus(); location.href = item.link || '/account/notifications'; alert.close(); };
|
||||
shown.add(item.id);
|
||||
}
|
||||
localStorage.setItem('shd-browser-notifications', JSON.stringify([...shown].slice(-100)));
|
||||
} catch { /* A transient poll failure must not interrupt navigation. */ }
|
||||
};
|
||||
void poll();
|
||||
const interval = window.setInterval(poll, 30_000);
|
||||
return () => window.clearInterval(interval);
|
||||
});
|
||||
|
||||
function dismissCookieNotice() {
|
||||
@@ -63,7 +87,7 @@
|
||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
|
||||
{/each}
|
||||
{#if user}
|
||||
<a class:active={page.url.pathname.startsWith('/account')} href="/account">My Profile</a>
|
||||
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
|
||||
{/if}
|
||||
{#if isacAccess}
|
||||
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin">ISAC</a>
|
||||
@@ -71,6 +95,7 @@
|
||||
</nav>
|
||||
{#if user}
|
||||
<div class="account">
|
||||
<a class="notification-link" href="/account/notifications" aria-label={`${unreadNotifications} unread notifications`}><span>◆</span>{#if unreadNotifications}<b>{unreadNotifications > 99 ? '99+' : unreadNotifications}</b>{/if}</a>
|
||||
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
|
||||
</div>
|
||||
@@ -90,11 +115,12 @@
|
||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
|
||||
{/each}
|
||||
{#if user}
|
||||
<a class:active={page.url.pathname.startsWith('/account')} href="/account"><small>04</small><span>My Profile</span><b>›</b></a>
|
||||
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>04</small><span>Inbox</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
|
||||
<a class:active={page.url.pathname === '/account'} href="/account"><small>05</small><span>My Profile</span><b>›</b></a>
|
||||
{:else}
|
||||
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>04</small><span>Sign In</span><b>›</b></a>
|
||||
{/if}
|
||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>05</small><span>ISAC</span><b>›</b></a>{/if}
|
||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/admin')} href="/admin"><small>06</small><span>ISAC</span><b>›</b></a>{/if}
|
||||
{#if user}
|
||||
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
type BreadcrumbItem = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
let { items }: { items: BreadcrumbItem[] } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="eyebrow page-breadcrumb" aria-label="Breadcrumb">
|
||||
<span class="breadcrumb-line" aria-hidden="true"></span>
|
||||
<ol>
|
||||
{#each items as item, index}
|
||||
<li>
|
||||
{#if item.href}<a href={item.href}>{item.label}</a>{:else}<span aria-current={index === items.length - 1 ? 'page' : undefined}>{item.label}</span>{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
let { body, expanded = false }: { body: string; expanded?: boolean } = $props();
|
||||
|
||||
const parsed = $derived.by(() => {
|
||||
const detailMatch = body.match(/^(.*?)(?:\s+)(Oversight reason|Reason|Required action|Details):\s*(.+)$/i);
|
||||
if (!detailMatch) return { summary: body, detailLabel: null, detail: null };
|
||||
return {
|
||||
summary: detailMatch[1].trim(),
|
||||
detailLabel: detailMatch[2].toUpperCase(),
|
||||
detail: detailMatch[3].trim()
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="system-message-body" class:expanded>
|
||||
<div class="system-message-kicker"><span aria-hidden="true">◇</span><b>ISAC TRANSMISSION</b></div>
|
||||
<p>{parsed.summary}</p>
|
||||
{#if parsed.detail}
|
||||
<div class="system-message-detail">
|
||||
<span>{parsed.detailLabel}</span>
|
||||
<p>{parsed.detail}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -41,3 +41,18 @@ export async function sendAuthenticationLink(email: string, token: string, purpo
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD IDENTITY NETWORK</p><h1 style="font-size:24px">${subject}</h1><p style="color:#a5aaab">This one-time link expires in 15 minutes.</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">${action}</a></p><p style="color:#737a7c;font-size:12px">If you did not request this, you can ignore this message.</p></div>`
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendNotificationEmail(email: string, title: string, body: string, link: string | null): Promise<void> {
|
||||
const url = link ? `${applicationOrigin()}${link.startsWith('/') ? link : `/${link}`}` : `${applicationOrigin()}/account/notifications`;
|
||||
await mailer().sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to: email,
|
||||
subject: `${title} — SHD.build`,
|
||||
text: `${title}\n\n${body}\n\nOpen SHD.build: ${url}`,
|
||||
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD NOTIFICATION NETWORK</p><h1 style="font-size:24px">${escapeHtml(title)}</h1><p style="color:#a5aaab;line-height:1.6">${escapeHtml(body)}</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">OPEN SHD INBOX</a></p></div>`
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { sendNotificationEmail } from '$lib/server/auth/mail';
|
||||
|
||||
export const NOTIFICATION_CATEGORIES = ['isac', 'contributions', 'comments', 'builds', 'guides', 'system'] as const;
|
||||
export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number];
|
||||
|
||||
export async function createNotification(input: {
|
||||
userId: string;
|
||||
category: NotificationCategory;
|
||||
source: string;
|
||||
title: string;
|
||||
body: string;
|
||||
link?: string | null;
|
||||
mandatory?: boolean;
|
||||
}) {
|
||||
const database = await getDatabase();
|
||||
const preference = await (await database.prepare(
|
||||
'SELECT in_app_enabled,browser_enabled,email_frequency FROM notification_preferences WHERE user_id=? AND category=?'
|
||||
)).get(input.userId, input.category);
|
||||
const mandatory = Boolean(input.mandatory || input.category === 'isac');
|
||||
const inApp = mandatory || preference?.in_app_enabled === undefined || Boolean(preference.in_app_enabled);
|
||||
const browser = Boolean(preference?.browser_enabled);
|
||||
const emailFrequency = String(preference?.email_frequency ?? 'none');
|
||||
if (!inApp && !browser && emailFrequency === 'none') return null;
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO notifications
|
||||
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`)).run(
|
||||
id, input.userId, input.category, input.source, input.title.slice(0, 140),
|
||||
input.body.slice(0, 2000), input.link ?? null, mandatory ? 1 : 0, now
|
||||
);
|
||||
if (emailFrequency === 'immediate') {
|
||||
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(input.userId);
|
||||
if (typeof identity?.email === 'string') {
|
||||
try {
|
||||
await sendNotificationEmail(identity.email, input.title, input.body, input.link ?? null);
|
||||
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
} catch (error) {
|
||||
console.error('Notification email delivery failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function notificationSummary(userId: string) {
|
||||
const database = await getDatabase();
|
||||
const row = await (await database.prepare(`SELECT COUNT(*) AS unread FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(userId);
|
||||
return { unread: Number(row?.unread ?? 0) };
|
||||
}
|
||||
Reference in New Issue
Block a user