From bec4c05e80a6b1123f5cf245de60dc8dcb8f2bea Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 5 May 2026 13:14:48 +0200 Subject: [PATCH] fix(m12): bell loses idea-questions on SSE reconnect The notifications-realtime hook (PR #92) does close+connect on every idea-question 'open' event, but the SSE state-event-handler in /api/realtime/notifications only re-fetched story-questions. Result: each reconnect wiped idea-questions from the bell within ~500ms, even though the bridge had loaded them on initial page-render. Symptom: clicking an idea-question in the bell sometimes hit a \"question gone\" race because the close+connect after the live event emptied them out. Fix: SSE initial-state now does a Promise.all on - story-questions (productAccessFilter, existing path) - idea-questions (idea.user_id === session.userId, M12 strict-private) and sends merged + sorted state with discriminated \`kind\` field. This mirrors the bridge's own initial fetch (PR #92), so a bridge-mount and an SSE-reconnect now produce identical state. Tests: 546/546 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/realtime/notifications/route.ts | 100 ++++++++++++++++-------- 1 file changed, 68 insertions(+), 32 deletions(-) diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 834ebff..1d32a2f 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -185,35 +185,55 @@ export async function GET(request: NextRequest) { // Initial state ná LISTEN actief — race-fix conform M10 ST-1004 / ST-1006. // Voorkomt dat een vraag die net vóór SSE-open landt verloren gaat. - const openQuestions = await prisma.claudeQuestion.findMany({ - where: { - status: 'open', - expires_at: { gt: new Date() }, - product_id: { in: products.map((p) => p.id) }, - // Skip idea-questions (story_id NULL) — story-questions only here. - // Narrowing happens in the flatMap below — Prisma 7 rejects - // `story_id: { not: null }` at runtime. - }, - orderBy: { created_at: 'desc' }, - take: 100, - select: { - id: true, - product_id: true, - story_id: true, - task_id: true, - question: true, - options: true, - created_at: true, - expires_at: true, - story: { select: { code: true, title: true, assignee_id: true } }, - }, - }) + // M12 hotfix: óók idea-questions (user-private), zodat de bel + // gehydrateerd blijft na elke close+reconnect-cycle. + const [storyOpen, ideaOpen] = await Promise.all([ + prisma.claudeQuestion.findMany({ + where: { + status: 'open', + expires_at: { gt: new Date() }, + product_id: { in: products.map((p) => p.id) }, + }, + orderBy: { created_at: 'desc' }, + take: 100, + select: { + id: true, + product_id: true, + story_id: true, + task_id: true, + question: true, + options: true, + created_at: true, + expires_at: true, + story: { select: { code: true, title: true, assignee_id: true } }, + }, + }), + prisma.claudeQuestion.findMany({ + where: { + status: 'open', + expires_at: { gt: new Date() }, + idea: { user_id: userId }, + }, + orderBy: { created_at: 'desc' }, + take: 100, + select: { + id: true, + product_id: true, + idea_id: true, + question: true, + options: true, + created_at: true, + expires_at: true, + idea: { select: { id: true, code: true, title: true } }, + }, + }), + ]) - enqueue( - `event: state\ndata: ${JSON.stringify({ - questions: openQuestions.flatMap((q) => { - if (!q.story || q.story_id === null) return [] - return [{ + const stateQuestions = [ + ...storyOpen.flatMap((q) => { + if (!q.story || q.story_id === null) return [] + return [{ + kind: 'story' as const, id: q.id, product_id: q.product_id, story_id: q.story_id, @@ -225,10 +245,26 @@ export async function GET(request: NextRequest) { options: q.options, created_at: q.created_at.toISOString(), expires_at: q.expires_at.toISOString(), - }] - }), - })}\n\n`, - ) + }] + }), + ...ideaOpen.flatMap((q) => { + if (!q.idea || q.idea_id === null) return [] + return [{ + kind: 'idea' as const, + id: q.id, + product_id: q.product_id, + idea_id: q.idea_id, + idea_code: q.idea.code, + idea_title: q.idea.title, + question: q.question, + options: q.options, + created_at: q.created_at.toISOString(), + expires_at: q.expires_at.toISOString(), + }] + }), + ].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) + + enqueue(`event: state\ndata: ${JSON.stringify({ questions: stateQuestions })}\n\n`) heartbeatTimer = setInterval(() => { enqueue(`: heartbeat\n\n`)