realtime: route idea-jobs + idea-questions to /notifications channel (M12 T-502)

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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 20:00:05 +02:00
parent a1d1f99216
commit 0e2808ac88
3 changed files with 70 additions and 5 deletions

View file

@ -10,6 +10,7 @@ vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findMany: vi.fn() },
claudeQuestion: { findMany: vi.fn() },
idea: { findMany: vi.fn().mockResolvedValue([]) },
},
}))

View file

@ -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`)
})

View file

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