diff --git a/app/api/realtime/user-settings/route.ts b/app/api/realtime/user-settings/route.ts new file mode 100644 index 0000000..6c3261f --- /dev/null +++ b/app/api/realtime/user-settings/route.ts @@ -0,0 +1,146 @@ +// PBI-76: User-scoped SSE stream voor user-settings cross-tab/cross-device sync. +// +// Wordt door in app/(app)/layout.tsx geopend zodra de +// gebruiker is ingelogd. Filtert pg_notify-payloads op +// `kind === 'user_settings' && userId === session.userId`. Settings worden +// via prop al gehydrateerd; deze route levert alleen incrementele patches. +// +// Auth: iron-session cookie. Demo-tokens openen geen subscription (bridge +// skipt voor isDemo). +// Output: text/event-stream — `data:` met de patch (Partial). +// Sluit zelf na 240s als safety-net; client herconnect. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { getSession } from '@/lib/auth' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_changes' +const HEARTBEAT_MS = 25_000 +const HARD_CLOSE_MS = 240_000 + +interface UserSettingsPayload { + kind: 'user_settings' + userId: string + patch: Record +} + +function isUserSettingsPayload(p: unknown): p is UserSettingsPayload { + if (typeof p !== 'object' || p === null) return false + const obj = p as Record + return ( + obj.kind === 'user_settings' && + typeof obj.userId === 'string' && + typeof obj.patch === 'object' && + obj.patch !== null + ) +} + +export async function GET(request: NextRequest) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + const userId = session.userId + + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json( + { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, + { status: 500 }, + ) + } + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString: directUrl }) + + let heartbeatTimer: ReturnType | null = null + let hardCloseTimer: ReturnType | null = null + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // controller already closed + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + await closePgClientSafely(pgClient, 'realtime/user-settings') + try { + controller.close() + } catch { + // already closed + } + if (process.env.NODE_ENV !== 'production') { + console.log(`[realtime/user-settings] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[realtime/user-settings] pg connect/listen failed:', err) + enqueue( + `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, + ) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + if (!msg.payload) return + let payload: unknown + try { + payload = JSON.parse(msg.payload) + } catch { + return + } + if (!isUserSettingsPayload(payload)) return + if (payload.userId !== userId) return + enqueue(`data: ${JSON.stringify(payload.patch)}\n\n`) + }) + + pgClient.on('error', (err) => { + console.error('[realtime/user-settings] pg client error:', err) + cleanup('pg error') + }) + + enqueue(`: connected\n\n`) + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat\n\n`) + }, HEARTBEAT_MS) + + hardCloseTimer = setTimeout(() => { + cleanup('hard close 240s') + }, HARD_CLOSE_MS) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +}