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>
This commit is contained in:
Scrum4Me Agent 2026-05-05 17:37:57 +02:00
parent 226bd05594
commit c0e8270e5f
3 changed files with 106 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

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