trails/apps/journal/app/lib/events.server.ts
Ullrich Schäfer e61179ab27 Implement notifications + supporting fixes
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>
2026-04-26 01:28:55 +02:00

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;
}