/** * 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 { createNotification, getUnreadCount, markNotificationDelivered, listSpaceMembers, type StoredNotification, } from "../src/encryptid/db"; // ============================================================================ // 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'); } } return stored; } // ============================================================================ // 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 })), ); }