rspace-online/server/notification-service.ts

154 lines
4.4 KiB
TypeScript

/**
* 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<string, any>;
expiresAt?: Date;
}
// ============================================================================
// WS CONNECTION REGISTRY
// ============================================================================
const userConnections = new Map<string, Set<ServerWebSocket<any>>>();
export function registerUserConnection(userDid: string, ws: ServerWebSocket<any>): 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<any>): 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<StoredNotification> {
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<NotifyOptions, 'userDid'>,
): Promise<void> {
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 })),
);
}