rspace-online/server/notification-service.ts

360 lines
13 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 webpush from "web-push";
import {
createNotification,
getUnreadCount,
markNotificationDelivered,
listSpaceMembers,
getUserPushSubscriptions,
deletePushSubscriptionByEndpoint,
updatePushSubscriptionLastUsed,
getNotificationPreferences,
getProfileEmailsByDids,
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");
}
// ── SMTP setup (email delivery) ──
const SMTP_HOST = process.env.SMTP_HOST || "mail.rmail.online";
const SMTP_PORT = Number(process.env.SMTP_PORT) || 587;
const SMTP_USER = process.env.SMTP_USER || "";
const SMTP_PASS = process.env.SMTP_PASS || "";
let _smtpTransport: any = null;
export async function getSmtpTransport() {
if (_smtpTransport) return _smtpTransport;
const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix');
if (!SMTP_PASS && !isInternal) return null;
try {
const nodemailer = await import("nodemailer");
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
_smtpTransport = createTransport({
host: SMTP_HOST,
port: isInternal ? 25 : SMTP_PORT,
secure: !isInternal && SMTP_PORT === 465,
...(isInternal ? {} : SMTP_USER ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}),
tls: { rejectUnauthorized: false },
});
console.log("[email] SMTP transport configured");
return _smtpTransport;
} catch (e) {
console.warn("[email] Failed to create SMTP transport:", e);
return null;
}
}
// ============================================================================
// TYPES
// ============================================================================
export type NotificationCategory = 'space' | 'module' | 'system' | 'social' | 'payment';
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' | 'canvas_comment'
| 'module_comment' | 'module_mention'
// System
| 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated'
| 'recovery_approved' | 'device_linked' | 'security_alert'
// Social
| 'mention' | 'ping_user'
// Delegation
| 'delegation_received' | 'delegation_revoked' | 'delegation_expired'
// Commitment (rTime)
| 'commitment_requested' | 'commitment_accepted' | 'commitment_declined'
// Payment
| 'payment_sent' | 'payment_received' | 'payment_request_fulfilled'
// Chat
| 'chat_message' | 'chat_mention' | 'chat_dm';
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,
metadata: stored.metadata,
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(() => {});
}
// 4. Attempt email delivery (non-blocking)
sendEmailNotification(stored, opts).catch(() => {});
return stored;
}
/** Send Web Push to all of a user's subscriptions. */
async function sendWebPush(stored: StoredNotification, opts: NotifyOptions): Promise<void> {
// 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');
}
}
/** Send email notification from {space}-agent@rspace.online. */
async function sendEmailNotification(stored: StoredNotification, opts: NotifyOptions): Promise<void> {
// Check if user has email enabled
const prefs = await getNotificationPreferences(opts.userDid);
if (prefs && !prefs.emailEnabled) return;
// Look up user's email
const emailMap = await getProfileEmailsByDids([opts.userDid]);
const userEmail = emailMap.get(opts.userDid);
if (!userEmail) return;
const transport = await getSmtpTransport();
if (!transport) return;
const space = opts.spaceSlug || "rspace";
const agentAddr = `${space}-agent@rspace.online`;
const fromAddr = `${space} agent <${agentAddr}>`;
const actionLink = opts.actionUrl
? (opts.actionUrl.startsWith("http")
? opts.actionUrl
: `https://${space}.rspace.online${opts.actionUrl}`)
: `https://${space}.rspace.online`;
// Use richer template for space invites
const isSpaceInvite = stored.category === 'space' && stored.eventType === 'space_invite';
const role = (opts.metadata?.role as string) || 'member';
const inviterName = stored.actorUsername || 'an admin';
const subject = isSpaceInvite
? `${inviterName} invited you to join "${space}" on rSpace`
: stored.title;
const html = isSpaceInvite
? renderSpaceInviteEmail({ spaceSlug: space, inviterName, role, acceptUrl: actionLink })
: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<div style="background: #1e293b; border-radius: 10px; padding: 20px; color: #e2e8f0;">
<h2 style="margin: 0 0 8px; font-size: 16px; color: #f1f5f9;">${escapeHtml(stored.title)}</h2>
${stored.body ? `<p style="margin: 0 0 16px; font-size: 14px; color: #94a3b8;">${escapeHtml(stored.body)}</p>` : ""}
<a href="${actionLink}" style="display: inline-block; padding: 8px 20px; background: linear-gradient(135deg, #14b8a6, #0d9488); color: white; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600;">View in rSpace</a>
</div>
<p style="margin: 12px 0 0; font-size: 11px; color: #64748b; text-align: center;">
Sent by ${space}.rspace.online
</p>
</div>`;
try {
await transport.sendMail({
from: fromAddr,
to: userEmail,
subject,
html,
replyTo: agentAddr,
envelope: { from: agentAddr, to: userEmail },
});
await markNotificationDelivered(stored.id, 'email');
console.log(`[email] Sent "${subject}" to ${opts.userDid}`);
} catch (err: any) {
console.error(`[email] Failed to send to ${opts.userDid}:`, err.message);
}
}
function renderSpaceInviteEmail(opts: {
spaceSlug: string;
inviterName: string;
role: string;
acceptUrl: string;
}): string {
const safeSpace = escapeHtml(opts.spaceSlug);
const safeInviter = escapeHtml(opts.inviterName);
const safeRole = escapeHtml(opts.role);
return `
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#1e293b;background:#f8fafc;">
<div style="background:#ffffff;border-radius:14px;padding:32px 28px;box-shadow:0 1px 3px rgba(0,0,0,0.06);">
<p style="margin:0 0 6px;font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.1em;font-weight:600;">Invitation &middot; rSpace</p>
<h1 style="margin:0 0 14px;font-size:24px;color:#0f172a;line-height:1.3;font-weight:700;">You're invited to join <span style="color:#0d9488;">${safeSpace}</span></h1>
<p style="margin:0 0 14px;font-size:15px;color:#334155;line-height:1.6;"><strong>${safeInviter}</strong> invited you to join the <strong>${safeSpace}</strong> space on rSpace as a <strong>${safeRole}</strong>.</p>
<p style="margin:0 0 22px;font-size:14px;color:#475569;line-height:1.6;">rSpace is a privacy-first collaborative workspace. Once you accept, you'll be able to coordinate with other members of <strong>${safeSpace}</strong> using shared digital collaboration tools &mdash; notes, chat, maps, tasks, voting, calendar, files, shared wallets, and more.</p>
<div style="text-align:center;margin:28px 0 22px;">
<a href="${opts.acceptUrl}" style="display:inline-block;padding:16px 40px;background:linear-gradient(135deg,#14b8a6,#0d9488);color:#ffffff;text-decoration:none;border-radius:10px;font-weight:700;font-size:17px;box-shadow:0 4px 14px rgba(13,148,136,0.35);letter-spacing:0.01em;">Accept &amp; Join ${safeSpace} &rarr;</a>
</div>
<p style="margin:0 0 6px;font-size:12px;color:#64748b;text-align:center;">Or copy and paste this link into your browser:</p>
<p style="margin:0 0 22px;font-size:12px;color:#0d9488;text-align:center;word-break:break-all;">${opts.acceptUrl}</p>
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0 16px;">
<p style="margin:0;font-size:12px;color:#94a3b8;line-height:1.5;text-align:center;">This invite expires in 7 days. rSpace uses passkeys &mdash; no passwords, no seed phrases.</p>
</div>
</div>`;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ============================================================================
// 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 })),
);
}