diff --git a/__tests__/actions/questions.test.ts b/__tests__/actions/questions.test.ts index 22dd33d..ece85ff 100644 --- a/__tests__/actions/questions.test.ts +++ b/__tests__/actions/questions.test.ts @@ -16,6 +16,9 @@ vi.mock('@/lib/prisma', () => ({ findFirst: vi.fn(), updateMany: vi.fn(), }, + product: { + findFirst: vi.fn().mockResolvedValue({ id: 'product-1' }), + }, }, })) @@ -44,7 +47,13 @@ beforeEach(() => { describe('actions/questions — answerQuestion', () => { it('happy: status pending→answered, revalidatePath geroepen', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + id: VALID_ID, + story_id: 'story-1', + idea_id: null, + product_id: 'product-1', + idea: null, + }) mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 1 }) const res = await answerQuestion(VALID_ID, VALID_ANSWER) @@ -85,7 +94,13 @@ describe('actions/questions — answerQuestion', () => { it('al-answered: race-error met begrijpelijke melding', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + id: VALID_ID, + story_id: 'story-1', + idea_id: null, + product_id: 'product-1', + idea: null, + }) mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ status: 'answered', @@ -99,7 +114,13 @@ describe('actions/questions — answerQuestion', () => { it('verlopen: updateMany count=0, nog open status maar voorbij expiry', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + id: VALID_ID, + story_id: 'story-1', + idea_id: null, + product_id: 'product-1', + idea: null, + }) mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ status: 'open', diff --git a/actions/questions.ts b/actions/questions.ts index 13ae730..5a35f10 100644 --- a/actions/questions.ts +++ b/actions/questions.ts @@ -37,18 +37,43 @@ export async function answerQuestion( return { ok: false, error: first } } - // Access-check: gebruiker moet toegang hebben tot het product van de vraag. - // Iedereen met product-membership mag antwoorden — niet alleen de story- - // assignee — consistent met Scrum self-organizing. + // Access-check (M12-aware): + // - Story-questions: iedereen met product-membership mag antwoorden + // (consistent met Scrum self-organizing). + // - Idea-questions: strikt user_id-only — alleen de eigenaar van het + // gekoppelde idee mag antwoorden (M12 grill-keuze 8). + // App-level routing omdat Prisma 7 `{ not: null }` filters in WHERE niet + // accepteert; we fetchen de relevante FK-keys en checken in TS. const question = await prisma.claudeQuestion.findFirst({ - where: { - id: parsed.data.questionId, - product: productAccessFilter(session.userId), + where: { id: parsed.data.questionId }, + select: { + id: true, + story_id: true, + idea_id: true, + product_id: true, + idea: { select: { user_id: true } }, }, - select: { id: true }, }) if (!question) return { ok: false, error: 'Vraag niet gevonden of geen toegang' } + if (question.idea_id) { + // Idea-question: alleen idea-eigenaar. + if (question.idea?.user_id !== session.userId) { + return { ok: false, error: 'Vraag niet gevonden of geen toegang' } + } + } else if (question.story_id) { + // Story-question: bestaand product-access-pad. + const productAccess = await prisma.product.findFirst({ + where: { id: question.product_id, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!productAccess) { + return { ok: false, error: 'Vraag niet gevonden of geen toegang' } + } + } else { + return { ok: false, error: 'Vraag heeft geen story of idea' } + } + // Atomic state-transitie: alleen open + niet-verlopen vragen worden beantwoord. // Concurrent dubbele submit: PostgreSQL row-locking laat één caller count=1 // zien, de rest count=0 → disambiguatie hieronder. diff --git a/components/ideas/idea-timeline.tsx b/components/ideas/idea-timeline.tsx index 2211655..81a506b 100644 --- a/components/ideas/idea-timeline.tsx +++ b/components/ideas/idea-timeline.tsx @@ -5,7 +5,11 @@ // detail-layout dus client is simpler (geen rsc-boundary doorbreken). // // Iconen + kleur per log-type voor snelle herkenning. +// Open questions krijgen een inline answer-form (M12 hotfix — zie +// notifications-bell-pad in M11; voor idee-vragen blijft de bel buiten beeld). +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' import { ClipboardList, FileText, @@ -15,6 +19,11 @@ import { StickyNote, Wrench, } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' +import { answerQuestion } from '@/actions/questions' import type { IdeaLogType } from '@prisma/client' @@ -139,21 +148,27 @@ export function IdeaTimeline({ logs, questions }: Props) {

{q.question}

- {q.options && q.options.length > 0 ? ( - - ) : null} - {q.answer ? ( -

- - Antwoord - - {q.answer} -

- ) : null} + {q.status === 'open' ? ( + + ) : ( + <> + {q.options && q.options.length > 0 ? ( + + ) : null} + {q.answer ? ( +

+ + Antwoord + + {q.answer} +

+ ) : null} + + )} ) @@ -161,3 +176,91 @@ export function IdeaTimeline({ logs, questions }: Props) { ) } + +// --------------------------------------------------------------------------- +// AnswerForm — inline antwoord op een open Claude-vraag. +// Voor multi-choice (options): knoppen die direct submitten met de gekozen +// optie. Anders: textarea + Submit knop. + +function AnswerForm({ + questionId, + options, +}: { + questionId: string + options: string[] | null +}) { + const router = useRouter() + const [text, setText] = useState('') + const [pending, startTransition] = useTransition() + + function submit(answer: string) { + const trimmed = answer.trim() + if (!trimmed) { + toast.error('Antwoord mag niet leeg zijn') + return + } + startTransition(async () => { + const r = await answerQuestion(questionId, trimmed) + if (!r.ok) { + toast.error(r.error) + return + } + toast.success('Antwoord verzonden — agent gaat door.') + setText('') + router.refresh() + }) + } + + if (options && options.length > 0) { + return ( +
+ {options.map((opt, i) => ( + + ))} +
+ Of typ een eigen antwoord +
+