From 0e2808ac88c8877278e115d249178966122c65c0 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 20:00:05 +0200 Subject: [PATCH] realtime: route idea-jobs + idea-questions to /notifications channel (M12 T-502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idea-jobs and idea-questions are user-private (M12 grill-keuze 8) — they flow through /api/realtime/notifications, not /api/realtime/solo. app/api/realtime/notifications/route.ts: - Pre-fetch user's idea-ids → accessibleIdeaIds Set (avoids per-event DB lookup) - New IdeaJobPayload type (claude_job_enqueued/_status with kind=IDEA_*) - New QuestionPayload narrows: story_id and idea_id mutually exclusive (DB check-constraint enforces it) - Routing: idea-jobs filtered on user_id; idea-questions on accessibleIdeaIds; story-questions on accessibleProductIds (existing path) app/api/realtime/solo/route.ts: - JobPayload extended with optional kind + idea_id - shouldEmit filters out kind=IDEA_GRILL/IDEA_MAKE_PLAN — they don't belong on the product/sprint Solo Paneel Tests: 539/539 green; notifications-stream test mock updated for idea.findMany. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/api/notifications-stream.test.ts | 1 + app/api/realtime/notifications/route.ts | 66 ++++++++++++++++++++-- app/api/realtime/solo/route.ts | 8 ++- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/__tests__/api/notifications-stream.test.ts b/__tests__/api/notifications-stream.test.ts index 53fc590..59fd1a8 100644 --- a/__tests__/api/notifications-stream.test.ts +++ b/__tests__/api/notifications-stream.test.ts @@ -10,6 +10,7 @@ vi.mock('@/lib/prisma', () => ({ prisma: { product: { findMany: vi.fn() }, claudeQuestion: { findMany: vi.fn() }, + idea: { findMany: vi.fn().mockResolvedValue([]) }, }, })) diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 4fad600..834ebff 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -26,17 +26,49 @@ const CHANNEL = 'scrum4me_changes' const HEARTBEAT_MS = 25_000 const HARD_CLOSE_MS = 240_000 -interface NotifyPayload { +// Question-payloads: emitted by the notify_question_change trigger on +// claude_questions. story_id and idea_id are mutually exclusive (DB-level +// check-constraint added in M12). +interface QuestionPayload { op: 'I' | 'U' - entity: 'task' | 'story' | 'question' + entity: 'question' id: string product_id: string - story_id?: string + story_id?: string | null task_id?: string | null + idea_id?: string | null assignee_id?: string | null status?: string } +// Idea-job-payloads: emitted by actions/ideas.ts (startGrillJobAction etc.) +// via prisma.$executeRaw pg_notify. Always carries user_id + idea_id + kind. +interface IdeaJobPayload { + type: 'claude_job_enqueued' | 'claude_job_status' + job_id: string + idea_id: string + user_id: string + product_id?: string | null + kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' + status: string +} + +type NotifyPayload = QuestionPayload | IdeaJobPayload + +function isQuestionPayload(p: NotifyPayload): p is QuestionPayload { + return 'entity' in p && p.entity === 'question' +} + +function isIdeaJobPayload(p: NotifyPayload): p is IdeaJobPayload { + return ( + 'type' in p && + (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') && + 'idea_id' in p && + 'kind' in p && + (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN') + ) +} + export async function GET(request: NextRequest) { const session = await getSession() if (!session.userId) { @@ -53,6 +85,15 @@ export async function GET(request: NextRequest) { }) const accessibleProductIds = new Set(products.map((p) => p.id)) + // M12: idea-questions zijn strikt user_id-only (geen productAccessFilter). + // We pre-fetchen de user's idea-ids zodat we snel kunnen filteren op het + // SSE-pad — geen DB-call per event. + const userIdeas = await prisma.idea.findMany({ + where: { user_id: userId }, + select: { id: true }, + }) + const accessibleIdeaIds = new Set(userIdeas.map((i) => i.id)) + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL if (!directUrl) { return Response.json( @@ -115,7 +156,24 @@ export async function GET(request: NextRequest) { } catch { return } - if (payload.entity !== 'question') return + + if (isIdeaJobPayload(payload)) { + // M12: idea-jobs zijn user-scoped, niet product-scoped. + if (payload.user_id !== userId) return + enqueue(`data: ${msg.payload}\n\n`) + return + } + + if (!isQuestionPayload(payload)) return + + // Idea-question: alleen voor de eigenaar van het idee. + if (payload.idea_id) { + if (!accessibleIdeaIds.has(payload.idea_id)) return + enqueue(`data: ${msg.payload}\n\n`) + return + } + + // Story-question: bestaande product-access-check. if (!accessibleProductIds.has(payload.product_id)) return enqueue(`data: ${msg.payload}\n\n`) }) diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 0553cf6..e514797 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -41,7 +41,11 @@ type EntityPayload = { type JobPayload = { type: 'claude_job_enqueued' | 'claude_job_status' job_id: string - task_id: string + task_id?: string | null + // M12: idea-jobs zetten kind + idea_id ipv task_id. Solo filtert die weg + // (idea-jobs horen op /api/realtime/notifications, niet op het Solo Paneel). + idea_id?: string | null + kind?: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' user_id: string product_id: string status: string @@ -77,6 +81,8 @@ function shouldEmit( userId: string, ): boolean { if (isJobPayload(payload)) { + // M12: skip idea-jobs (kind=IDEA_*) — die horen op /api/realtime/notifications. + if (payload.kind === 'IDEA_GRILL' || payload.kind === 'IDEA_MAKE_PLAN') return false return payload.user_id === userId && payload.product_id === productId }