Compare commits
2 commits
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e8270e5f | ||
|
|
226bd05594 |
4 changed files with 168 additions and 9 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
62
app/api/user-questions/[id]/answer/route.ts
Normal file
62
app/api/user-questions/[id]/answer/route.ts
Normal 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 })
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue