Adds the notifications system end-to-end (4 types, payload-versioned JSONB, SSE-based live unread badge, /notifications page, mark-read API, fan-out job for activity_published, daily 90-day retention purge). Bell icon in the navbar with unread badge. Side-findings from exercising the change: - Add 6-digit magic code to registration (mirrors login UX, mobile paste-friendly), with `[Register Magic Link]` console line in dev so the code is reachable without a real email transport. - Manual passkey/magic-link toggle on the register form (login already had it). - Restrict ALPN to http/1.1 in HTTPS dev so React Router's singleFetchAction CSRF check (Origin vs. Host) passes — Node doesn't synthesize Host from h2's :authority. Plain HTTP dev unaffected. - Followers/Following routes now use the locked-account rule from the profile route (owner + accepted followers see the list; others 404). Profile page renders the count chips as plain spans for viewers who can't see the lists, so private profiles don't surface dead links. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
48 lines
1.5 KiB
TypeScript
48 lines
1.5 KiB
TypeScript
// In-process Server-Sent Events broker. Generation hooks call
|
|
// `emitTo(userId, event, data)` after they commit; any open SSE
|
|
// connection for that user gets the event written to its stream.
|
|
//
|
|
// Single-process today. When the Journal goes multi-process, swap the
|
|
// in-memory `Map` for a Redis pub/sub adapter behind the same
|
|
// emitTo/register interface — no caller changes needed.
|
|
|
|
interface Connection {
|
|
send: (event: string, data: unknown) => void;
|
|
close: () => void;
|
|
}
|
|
|
|
const connections = new Map<string /* userId */, Set<Connection>>();
|
|
|
|
export function register(userId: string, conn: Connection): () => void {
|
|
let set = connections.get(userId);
|
|
if (!set) {
|
|
set = new Set();
|
|
connections.set(userId, set);
|
|
}
|
|
set.add(conn);
|
|
return () => {
|
|
const s = connections.get(userId);
|
|
if (!s) return;
|
|
s.delete(conn);
|
|
if (s.size === 0) connections.delete(userId);
|
|
};
|
|
}
|
|
|
|
export function emitTo(userId: string, event: string, data: unknown): void {
|
|
const set = connections.get(userId);
|
|
if (!set) return;
|
|
for (const conn of set) {
|
|
try {
|
|
conn.send(event, data);
|
|
} catch {
|
|
// Broken pipe — connection will get cleaned up on its own
|
|
// teardown path; defensively close here too.
|
|
try { conn.close(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Test/diagnostic helper: how many connections does `userId` have? */
|
|
export function connectionCount(userId: string): number {
|
|
return connections.get(userId)?.size ?? 0;
|
|
}
|