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:
parent
a1d1f99216
commit
0e2808ac88
3 changed files with 70 additions and 5 deletions
|
|
@ -10,6 +10,7 @@ vi.mock('@/lib/prisma', () => ({
|
|||
prisma: {
|
||||
product: { findMany: vi.fn() },
|
||||
claudeQuestion: { findMany: vi.fn() },
|
||||
idea: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue