Add user notification inbox and delivery controls

This commit is contained in:
JB
2026-07-25 15:29:21 -04:00
parent ff7f3a6d34
commit 29f4de807a
24 changed files with 606 additions and 18 deletions
+15
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!);
}
+54
View File
@@ -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) };
}