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
|
idea_id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
product_id?: string | null
|
product_id?: string | null
|
||||||
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
|
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' | 'PLAN_CHAT'
|
||||||
status: string
|
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 {
|
function isQuestionPayload(p: NotifyPayload): p is QuestionPayload {
|
||||||
return 'entity' in p && p.entity === 'question'
|
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 {
|
function isIdeaJobPayload(p: NotifyPayload): p is IdeaJobPayload {
|
||||||
return (
|
return (
|
||||||
'type' in p &&
|
'type' in p &&
|
||||||
(p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') &&
|
(p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') &&
|
||||||
'idea_id' in p &&
|
'idea_id' in p &&
|
||||||
'kind' 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
|
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
|
if (!isQuestionPayload(payload)) return
|
||||||
|
|
||||||
// Idea-question: alleen voor de eigenaar van het idee.
|
// 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))
|
].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(() => {
|
heartbeatTimer = setInterval(() => {
|
||||||
enqueue(`: heartbeat\n\n`)
|
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
|
idea_id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
product_id?: string | null
|
product_id?: string | null
|
||||||
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'
|
kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' | 'PLAN_CHAT'
|
||||||
status: string
|
status: string
|
||||||
error?: 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 {
|
function isQuestionPayload(p: AnyPayload): p is QuestionPayload {
|
||||||
return 'entity' in p && p.entity === 'question'
|
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 {
|
function isIdeaJobPayload(p: AnyPayload): p is IdeaJobPayload {
|
||||||
return (
|
return (
|
||||||
'type' in p &&
|
'type' in p &&
|
||||||
(p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') &&
|
(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 {
|
interface StateEvent {
|
||||||
questions: NotificationQuestion[]
|
questions: NotificationQuestion[]
|
||||||
|
userQuestions?: { id: string; idea_id: string; question: string; answer: string | null; status: 'pending' | 'answered'; created_at: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotificationsRealtime() {
|
export function useNotificationsRealtime() {
|
||||||
|
|
@ -99,6 +113,9 @@ export function useNotificationsRealtime() {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse((ev as MessageEvent).data) as StateEvent
|
const data = JSON.parse((ev as MessageEvent).data) as StateEvent
|
||||||
init(data.questions ?? [])
|
init(data.questions ?? [])
|
||||||
|
if (data.userQuestions) {
|
||||||
|
useIdeaStore.getState().initUserQuestions(data.userQuestions)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed
|
// ignore malformed
|
||||||
}
|
}
|
||||||
|
|
@ -125,6 +142,18 @@ export function useNotificationsRealtime() {
|
||||||
return
|
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
|
if (!isQuestionPayload(payload)) return
|
||||||
|
|
||||||
// M12 — idea-question events naar idea-store dispatchen.
|
// 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 { ClaudeJobStatusApi } from '@/lib/job-status'
|
||||||
import type { IdeaStatusApi } from '@/lib/idea-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 {
|
export interface IdeaJobState {
|
||||||
job_id: string
|
job_id: string
|
||||||
|
|
@ -70,19 +70,39 @@ export type IdeaQuestionEvent = {
|
||||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
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 {
|
interface IdeaStore {
|
||||||
jobByIdea: Record<string, IdeaJobState | undefined>
|
jobByIdea: Record<string, IdeaJobState | undefined>
|
||||||
ideaStatuses: Record<string, IdeaStatusApi | undefined>
|
ideaStatuses: Record<string, IdeaStatusApi | undefined>
|
||||||
openQuestionsByIdea: Record<string, IdeaQuestion[]>
|
openQuestionsByIdea: Record<string, IdeaQuestion[]>
|
||||||
|
userQuestions: IdeaUserQuestion[]
|
||||||
|
|
||||||
// Bulk-init bij mount van een page (server-component → client hydration).
|
// Bulk-init bij mount van een page (server-component → client hydration).
|
||||||
initJobs: (jobs: IdeaJobState[]) => void
|
initJobs: (jobs: IdeaJobState[]) => void
|
||||||
initStatuses: (statuses: Record<string, IdeaStatusApi>) => void
|
initStatuses: (statuses: Record<string, IdeaStatusApi>) => void
|
||||||
initQuestions: (ideaId: string, questions: IdeaQuestion[]) => void
|
initQuestions: (ideaId: string, questions: IdeaQuestion[]) => void
|
||||||
|
initUserQuestions: (uqs: IdeaUserQuestion[]) => void
|
||||||
|
|
||||||
// Realtime event handlers — aangeroepen door use-notifications-realtime.
|
// Realtime event handlers — aangeroepen door use-notifications-realtime.
|
||||||
handleIdeaJobEvent: (event: IdeaJobEvent) => void
|
handleIdeaJobEvent: (event: IdeaJobEvent) => void
|
||||||
handleIdeaQuestionEvent: (event: IdeaQuestionEvent) => void
|
handleIdeaQuestionEvent: (event: IdeaQuestionEvent) => void
|
||||||
|
handleUserQuestionEvent: (event: UserQuestionEvent) => void
|
||||||
|
|
||||||
// Optimistic updates vanuit server-actions in client-components.
|
// Optimistic updates vanuit server-actions in client-components.
|
||||||
setIdeaStatus: (ideaId: string, status: IdeaStatusApi) => void
|
setIdeaStatus: (ideaId: string, status: IdeaStatusApi) => void
|
||||||
|
|
@ -114,6 +134,7 @@ export const useIdeaStore = create<IdeaStore>((set) => ({
|
||||||
jobByIdea: {},
|
jobByIdea: {},
|
||||||
ideaStatuses: {},
|
ideaStatuses: {},
|
||||||
openQuestionsByIdea: {},
|
openQuestionsByIdea: {},
|
||||||
|
userQuestions: [],
|
||||||
|
|
||||||
initJobs: (jobs) =>
|
initJobs: (jobs) =>
|
||||||
set(() => {
|
set(() => {
|
||||||
|
|
@ -129,6 +150,8 @@ export const useIdeaStore = create<IdeaStore>((set) => ({
|
||||||
openQuestionsByIdea: { ...s.openQuestionsByIdea, [ideaId]: questions },
|
openQuestionsByIdea: { ...s.openQuestionsByIdea, [ideaId]: questions },
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
initUserQuestions: (uqs) => set({ userQuestions: uqs }),
|
||||||
|
|
||||||
handleIdeaJobEvent: (event) =>
|
handleIdeaJobEvent: (event) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const jobState: IdeaJobState = {
|
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) =>
|
setIdeaStatus: (ideaId, status) =>
|
||||||
set((s) => ({ ideaStatuses: { ...s.ideaStatuses, [ideaId]: status } })),
|
set((s) => ({ ideaStatuses: { ...s.ideaStatuses, [ideaId]: status } })),
|
||||||
|
|
||||||
|
|
@ -177,6 +212,11 @@ export const useIdeaStore = create<IdeaStore>((set) => ({
|
||||||
void _j
|
void _j
|
||||||
void _s
|
void _s
|
||||||
void _q
|
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