Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
Scrum4Me Agent
c0e8270e5f feat(ST-qcmqlv2i): SSE + idea-store — user_question events verwerken
- IdeaUserQuestion type + UserQuestionEvent type in idea-store
- userQuestions state + initUserQuestions + handleUserQuestionEvent
- clearForIdea filtert ook userQuestions voor het idea
- IdeaJobKind uitgebreid met PLAN_CHAT
- notifications/route.ts: UserQuestionPayload, isUserQuestionPayload,
  user_question events forwarden naar SSE, userQuestionsInit in state-event
- use-notifications-realtime.ts: UserQuestionPayload handler +
  initUserQuestions aanroepen bij state-event

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 17:37:57 +02:00
Scrum4Me Agent
226bd05594 feat(ST-imalmyr7): POST /api/user-questions/[id]/answer — worker-antwoord endpoint
- Bearer-auth via authenticateApiRequest (token_hash SHA-256)
- Zod-validatie van body.answer (max 8000 chars)
- Guard: 404 bij onbekend id, 409 als al beantwoord
- Slaat answer op en zet status naar answered
- pg_notify scrum4me_changes met user_question entity-event

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 17:32:41 +02:00
4 changed files with 168 additions and 9 deletions

View file

@ -49,23 +49,37 @@ interface IdeaJobPayload {
idea_id: string
user_id: string
product_id?: string | null
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' | 'PLAN_CHAT'
status: string
}
type NotifyPayload = QuestionPayload | IdeaJobPayload
// UserQuestion-payloads: emitted by app/api/user-questions/[id]/answer and
// actions/user-questions.ts via prisma.$executeRaw pg_notify.
interface UserQuestionPayload {
op: 'I' | 'U'
entity: 'user_question'
id: string
idea_id: string
status: 'pending' | 'answered'
}
type NotifyPayload = QuestionPayload | IdeaJobPayload | UserQuestionPayload
function isQuestionPayload(p: NotifyPayload): p is QuestionPayload {
return 'entity' in p && p.entity === 'question'
}
function isUserQuestionPayload(p: NotifyPayload): p is UserQuestionPayload {
return 'entity' in p && p.entity === 'user_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')
(p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN' || p.kind === 'PLAN_CHAT')
)
}
@ -164,6 +178,13 @@ export async function GET(request: NextRequest) {
return
}
// UserQuestion (PLAN_CHAT answer-event): user-scoped via idea ownership.
if (isUserQuestionPayload(payload)) {
if (!accessibleIdeaIds.has(payload.idea_id)) return
enqueue(`data: ${msg.payload}\n\n`)
return
}
if (!isQuestionPayload(payload)) return
// Idea-question: alleen voor de eigenaar van het idee.
@ -264,7 +285,14 @@ export async function GET(request: NextRequest) {
}),
].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
enqueue(`event: state\ndata: ${JSON.stringify({ questions: stateQuestions })}\n\n`)
const userQuestionsInit = await prisma.userQuestion.findMany({
where: { idea: { user_id: userId } },
orderBy: { created_at: 'desc' },
take: 100,
select: { id: true, idea_id: true, question: true, answer: true, status: true, created_at: true },
})
enqueue(`event: state\ndata: ${JSON.stringify({ questions: stateQuestions, userQuestions: userQuestionsInit.map(uq => ({ ...uq, created_at: uq.created_at.toISOString() })) })}\n\n`)
heartbeatTimer = setInterval(() => {
enqueue(`: heartbeat\n\n`)

View file

@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
interface RouteContext {
params: Promise<{ id: string }>
}
const bodySchema = z.object({
answer: z.string().min(1).max(8000),
})
export async function POST(request: NextRequest, ctx: RouteContext) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return NextResponse.json({ error: auth.error }, { status: auth.status })
}
const { id } = await ctx.params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Malformed JSON' }, { status: 400 })
}
const parsed = bodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Ongeldige invoer', details: parsed.error.flatten() }, { status: 422 })
}
const uq = await prisma.userQuestion.findFirst({
where: { id },
select: { id: true, idea_id: true, status: true },
})
if (!uq) {
return NextResponse.json({ error: 'UserQuestion niet gevonden' }, { status: 404 })
}
if (uq.status !== 'pending') {
return NextResponse.json({ error: 'Vraag is al beantwoord' }, { status: 409 })
}
await prisma.userQuestion.update({
where: { id },
data: { answer: parsed.data.answer, status: 'answered' },
})
await prisma.$executeRaw`
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
op: 'U',
entity: 'user_question',
id,
idea_id: uq.idea_id,
status: 'answered',
})}::text)
`
return NextResponse.json({ ok: true })
}

View file

@ -40,27 +40,41 @@ interface IdeaJobPayload {
idea_id: string
user_id: string
product_id?: string | null
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' | 'PLAN_CHAT'
status: string
error?: string
}
type AnyPayload = QuestionPayload | IdeaJobPayload
// UserQuestion answer-events: emitted door pg_notify in answer-route en action.
interface UserQuestionPayload {
op: 'I' | 'U'
entity: 'user_question'
id: string
idea_id: string
status: 'pending' | 'answered'
}
type AnyPayload = QuestionPayload | IdeaJobPayload | UserQuestionPayload
function isQuestionPayload(p: AnyPayload): p is QuestionPayload {
return 'entity' in p && p.entity === 'question'
}
function isUserQuestionPayload(p: AnyPayload): p is UserQuestionPayload {
return 'entity' in p && p.entity === 'user_question'
}
function isIdeaJobPayload(p: AnyPayload): p is IdeaJobPayload {
return (
'type' in p &&
(p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') &&
(p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN')
(p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN' || p.kind === 'PLAN_CHAT')
)
}
interface StateEvent {
questions: NotificationQuestion[]
userQuestions?: { id: string; idea_id: string; question: string; answer: string | null; status: 'pending' | 'answered'; created_at: string }[]
}
export function useNotificationsRealtime() {
@ -99,6 +113,9 @@ export function useNotificationsRealtime() {
try {
const data = JSON.parse((ev as MessageEvent).data) as StateEvent
init(data.questions ?? [])
if (data.userQuestions) {
useIdeaStore.getState().initUserQuestions(data.userQuestions)
}
} catch {
// ignore malformed
}
@ -125,6 +142,18 @@ export function useNotificationsRealtime() {
return
}
// PLAN_CHAT — user_question events naar idea-store dispatchen.
if (isUserQuestionPayload(payload)) {
useIdeaStore.getState().handleUserQuestionEvent({
op: payload.op,
entity: 'user_question',
id: payload.id,
idea_id: payload.idea_id,
status: payload.status,
})
return
}
if (!isQuestionPayload(payload)) return
// M12 — idea-question events naar idea-store dispatchen.

View file

@ -14,7 +14,7 @@ import { create } from 'zustand'
import type { ClaudeJobStatusApi } from '@/lib/job-status'
import type { IdeaStatusApi } from '@/lib/idea-status'
export type IdeaJobKind = 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
export type IdeaJobKind = 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' | 'PLAN_CHAT'
export interface IdeaJobState {
job_id: string
@ -70,19 +70,39 @@ export type IdeaQuestionEvent = {
status: 'open' | 'answered' | 'cancelled' | 'expired'
}
export interface IdeaUserQuestion {
id: string
idea_id: string
question: string
answer: string | null
status: 'pending' | 'answered'
created_at: string
}
export type UserQuestionEvent = {
op: 'I' | 'U'
entity: 'user_question'
id: string
idea_id: string
status: 'pending' | 'answered'
}
interface IdeaStore {
jobByIdea: Record<string, IdeaJobState | undefined>
ideaStatuses: Record<string, IdeaStatusApi | undefined>
openQuestionsByIdea: Record<string, IdeaQuestion[]>
userQuestions: IdeaUserQuestion[]
// Bulk-init bij mount van een page (server-component → client hydration).
initJobs: (jobs: IdeaJobState[]) => void
initStatuses: (statuses: Record<string, IdeaStatusApi>) => void
initQuestions: (ideaId: string, questions: IdeaQuestion[]) => void
initUserQuestions: (uqs: IdeaUserQuestion[]) => void
// Realtime event handlers — aangeroepen door use-notifications-realtime.
handleIdeaJobEvent: (event: IdeaJobEvent) => void
handleIdeaQuestionEvent: (event: IdeaQuestionEvent) => void
handleUserQuestionEvent: (event: UserQuestionEvent) => void
// Optimistic updates vanuit server-actions in client-components.
setIdeaStatus: (ideaId: string, status: IdeaStatusApi) => void
@ -114,6 +134,7 @@ export const useIdeaStore = create<IdeaStore>((set) => ({
jobByIdea: {},
ideaStatuses: {},
openQuestionsByIdea: {},
userQuestions: [],
initJobs: (jobs) =>
set(() => {
@ -129,6 +150,8 @@ export const useIdeaStore = create<IdeaStore>((set) => ({
openQuestionsByIdea: { ...s.openQuestionsByIdea, [ideaId]: questions },
})),
initUserQuestions: (uqs) => set({ userQuestions: uqs }),
handleIdeaJobEvent: (event) =>
set((s) => {
const jobState: IdeaJobState = {
@ -163,6 +186,18 @@ export const useIdeaStore = create<IdeaStore>((set) => ({
}
}),
handleUserQuestionEvent: (event) =>
set((s) => {
if (event.op === 'I') {
return { userQuestions: [...s.userQuestions, { id: event.id, idea_id: event.idea_id, question: '', answer: null, status: event.status, created_at: new Date().toISOString() }] }
}
return {
userQuestions: s.userQuestions.map((uq) =>
uq.id === event.id ? { ...uq, status: event.status } : uq,
),
}
}),
setIdeaStatus: (ideaId, status) =>
set((s) => ({ ideaStatuses: { ...s.ideaStatuses, [ideaId]: status } })),
@ -177,6 +212,11 @@ export const useIdeaStore = create<IdeaStore>((set) => ({
void _j
void _s
void _q
return { jobByIdea, ideaStatuses, openQuestionsByIdea }
return {
jobByIdea,
ideaStatuses,
openQuestionsByIdea,
userQuestions: s.userQuestions.filter((uq) => uq.idea_id !== ideaId),
}
}),
}))