diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 1d32a2f..ca756a6 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -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`) diff --git a/lib/realtime/use-notifications-realtime.ts b/lib/realtime/use-notifications-realtime.ts index b773861..6532ea8 100644 --- a/lib/realtime/use-notifications-realtime.ts +++ b/lib/realtime/use-notifications-realtime.ts @@ -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. diff --git a/stores/idea-store.ts b/stores/idea-store.ts index 0b95f16..c62a21b 100644 --- a/stores/idea-store.ts +++ b/stores/idea-store.ts @@ -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 ideaStatuses: Record openQuestionsByIdea: Record + userQuestions: IdeaUserQuestion[] // Bulk-init bij mount van een page (server-component → client hydration). initJobs: (jobs: IdeaJobState[]) => void initStatuses: (statuses: Record) => 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((set) => ({ jobByIdea: {}, ideaStatuses: {}, openQuestionsByIdea: {}, + userQuestions: [], initJobs: (jobs) => set(() => { @@ -129,6 +150,8 @@ export const useIdeaStore = create((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((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((set) => ({ void _j void _s void _q - return { jobByIdea, ideaStatuses, openQuestionsByIdea } + return { + jobByIdea, + ideaStatuses, + openQuestionsByIdea, + userQuestions: s.userQuestions.filter((uq) => uq.idea_id !== ideaId), + } }), }))