/** * Notification Service — Core notification dispatch + WebSocket delivery. * * Modules call `notify()` to persist a notification to PostgreSQL and * attempt real-time delivery via WebSocket. The WS registry tracks * which user DIDs have active connections. */ import type { ServerWebSocket } from "bun"; import webpush from "web-push"; import { createNotification, getUnreadCount, markNotificationDelivered, listSpaceMembers, getUserPushSubscriptions, deletePushSubscriptionByEndpoint, updatePushSubscriptionLastUsed, getNotificationPreferences, type StoredNotification, } from "../src/encryptid/db"; // ── VAPID setup ── const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY; const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY; const VAPID_SUBJECT = process.env.VAPID_SUBJECT || "mailto:admin@rspace.online"; if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) { webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); console.log("[push] VAPID configured"); } else { console.warn("[push] VAPID keys not set — Web Push disabled"); } // ============================================================================ // TYPES // ============================================================================ export type NotificationCategory = 'space' | 'module' | 'system' | 'social'; export type NotificationEventType = // Space | 'access_request' | 'access_approved' | 'access_denied' | 'member_joined' | 'member_left' | 'role_changed' | 'nest_request' | 'nest_created' | 'space_invite' // Module | 'inbox_new_mail' | 'inbox_approval_needed' | 'choices_result' | 'notes_shared' | 'canvas_mention' // System | 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated' | 'recovery_approved' | 'device_linked' | 'security_alert' // Social | 'mention' | 'ping_user'; export interface NotifyOptions { userDid: string; category: NotificationCategory; eventType: NotificationEventType; title: string; body?: string; spaceSlug?: string; moduleId?: string; actionUrl?: string; actorDid?: string; actorUsername?: string; metadata?: Record; expiresAt?: Date; } // ============================================================================ // WS CONNECTION REGISTRY // ============================================================================ const userConnections = new Map>>(); export function registerUserConnection(userDid: string, ws: ServerWebSocket): void { let conns = userConnections.get(userDid); if (!conns) { conns = new Set(); userConnections.set(userDid, conns); } conns.add(ws); } export function unregisterUserConnection(userDid: string, ws: ServerWebSocket): void { const conns = userConnections.get(userDid); if (!conns) return; conns.delete(ws); if (conns.size === 0) userConnections.delete(userDid); } // ============================================================================ // CORE DISPATCH // ============================================================================ export async function notify(opts: NotifyOptions): Promise { const id = crypto.randomUUID(); // 1. Persist to DB const stored = await createNotification({ id, userDid: opts.userDid, category: opts.category, eventType: opts.eventType, title: opts.title, body: opts.body, spaceSlug: opts.spaceSlug, moduleId: opts.moduleId, actionUrl: opts.actionUrl, actorDid: opts.actorDid, actorUsername: opts.actorUsername, metadata: opts.metadata, expiresAt: opts.expiresAt, }); // 2. Attempt WS delivery const conns = userConnections.get(opts.userDid); if (conns && conns.size > 0) { const unreadCount = await getUnreadCount(opts.userDid); const payload = JSON.stringify({ type: "notification", notification: { id: stored.id, category: stored.category, eventType: stored.eventType, title: stored.title, body: stored.body, spaceSlug: stored.spaceSlug, actorUsername: stored.actorUsername, actionUrl: stored.actionUrl, createdAt: stored.createdAt, }, unreadCount, }); let delivered = false; for (const ws of conns) { try { if (ws.readyState === WebSocket.OPEN) { ws.send(payload); delivered = true; } } catch { // Connection may have closed between check and send } } if (delivered) { await markNotificationDelivered(stored.id, 'ws'); } } // 3. Attempt Web Push delivery (non-blocking) if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) { sendWebPush(stored, opts).catch(() => {}); } return stored; } /** Send Web Push to all of a user's subscriptions. */ async function sendWebPush(stored: StoredNotification, opts: NotifyOptions): Promise { // Check if user has push enabled const prefs = await getNotificationPreferences(opts.userDid); if (prefs && !prefs.pushEnabled) return; const subs = await getUserPushSubscriptions(opts.userDid); if (subs.length === 0) return; const pushPayload = JSON.stringify({ title: stored.title, body: stored.body || "", icon: "/icons/icon-192.png", badge: "/icons/icon-192.png", tag: `${stored.category}-${stored.eventType}`, data: { url: stored.actionUrl || "/", notificationId: stored.id, }, }); let anyDelivered = false; await Promise.allSettled( subs.map(async (sub) => { try { await webpush.sendNotification( { endpoint: sub.endpoint, keys: { p256dh: sub.keyP256dh, auth: sub.keyAuth }, }, pushPayload, ); anyDelivered = true; updatePushSubscriptionLastUsed(sub.id).catch(() => {}); } catch (err: any) { // 404/410 = subscription expired, clean up if (err?.statusCode === 404 || err?.statusCode === 410) { await deletePushSubscriptionByEndpoint(sub.endpoint); } } }), ); if (anyDelivered) { await markNotificationDelivered(stored.id, 'push'); } } // ============================================================================ // CONVENIENCE: NOTIFY SPACE ADMINS/MODS // ============================================================================ export async function notifySpaceAdmins( spaceSlug: string, opts: Omit, ): Promise { const members = await listSpaceMembers(spaceSlug); const targets = members.filter(m => m.role === 'admin' || m.role === 'moderator'); await Promise.all( targets.map(m => notify({ ...opts, userDid: m.userDID })), ); }