From c642c29b58a6d01872d757968ebd7fe8ee7abba6 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Tue, 28 Apr 2026 01:06:50 +0200 Subject: [PATCH] feat(ST-1103): add answerQuestion server action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/questions.ts: - answerQuestion(questionId, answer) — auth + Zod + demo-blok + access-check via productAccessFilter (anyone met product-membership mag antwoorden, consistent met Scrum self-organizing — niet alleen story-assignee) - Atomic prisma.claudeQuestion.updateMany WHERE id + status='open' + expires_at>now → status='answered'; concurrent dubbele submit: één wint (count=1), rest count=0 met disambiguatie via second findFirst - revalidatePath('/', 'layout') refresh't NavBar bell-count voor SSR-paths; realtime updates voor andere clients gaan via SSE in ST-1104/1105 - Begrijpelijke NL-foutmeldingen voor elk faalpad Tests __tests__/actions/questions.test.ts (6 cases): - happy: status update + revalidatePath called - demo-block: error + geen DB-call + geen revalidate - geen access: error + geen update - al-answered: race-error 'is al answered' - expired: race-error 'is verlopen' - lege answer: Zod-validatie Quality gates: lint 0 errors, tsc clean, vitest 145/145 (17 files). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/questions.test.ts | 122 ++++++++++++++++++++++++++++ actions/questions.ts | 84 +++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 __tests__/actions/questions.test.ts create mode 100644 actions/questions.ts diff --git a/__tests__/actions/questions.test.ts b/__tests__/actions/questions.test.ts new file mode 100644 index 0000000..22dd33d --- /dev/null +++ b/__tests__/actions/questions.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeQuestion: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + }, +})) + +import { revalidatePath } from 'next/cache' +import { prisma } from '@/lib/prisma' +import { answerQuestion } from '@/actions/questions' + +const mockPrisma = prisma as unknown as { + claudeQuestion: { + findFirst: ReturnType + updateMany: ReturnType + } +} +const mockRevalidate = revalidatePath as ReturnType + +const VALID_ID = 'cmohrz0jra1aaaaaaaaaaaaaa' +const VALID_ANSWER = 'Antwoord van de gebruiker' + +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } + +beforeEach(() => { + vi.clearAllMocks() +}) + +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.updateMany.mockResolvedValueOnce({ count: 1 }) + + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: true }) + + const updateArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0] + expect(updateArg.where).toMatchObject({ + id: VALID_ID, + status: 'open', + }) + expect(updateArg.where.expires_at).toMatchObject({ gt: expect.any(Date) }) + expect(updateArg.data).toMatchObject({ + status: 'answered', + answer: VALID_ANSWER, + answered_by: 'user-1', + }) + + expect(mockRevalidate).toHaveBeenCalledWith('/', 'layout') + }) + + it('demo-user wordt geblokkeerd, geen DB-call', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: false, error: 'Niet beschikbaar in demo-modus' }) + expect(mockPrisma.claudeQuestion.findFirst).not.toHaveBeenCalled() + expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled() + expect(mockRevalidate).not.toHaveBeenCalled() + }) + + it('user zonder product-access: error, geen update', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce(null) + + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: false, error: 'Vraag niet gevonden of geen toegang' }) + expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled() + }) + + it('al-answered: race-error met begrijpelijke melding', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check + mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + status: 'answered', + expires_at: new Date(Date.now() + 60_000), + }) + + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: false, error: 'Vraag is al answered' }) + expect(mockRevalidate).not.toHaveBeenCalled() + }) + + 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.updateMany.mockResolvedValueOnce({ count: 0 }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ + status: 'open', + expires_at: new Date(Date.now() - 60_000), + }) + + const res = await answerQuestion(VALID_ID, VALID_ANSWER) + expect(res).toEqual({ ok: false, error: 'Vraag is verlopen' }) + }) + + it('lege answer: Zod-validatie faalt', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + const res = await answerQuestion(VALID_ID, '') + expect(res.ok).toBe(false) + if (!res.ok) { + expect(res.error.toLowerCase()).toMatch(/string|character|leeg|empty|small/i) + } + expect(mockPrisma.claudeQuestion.findFirst).not.toHaveBeenCalled() + }) +}) diff --git a/actions/questions.ts b/actions/questions.ts new file mode 100644 index 0000000..19a45bc --- /dev/null +++ b/actions/questions.ts @@ -0,0 +1,84 @@ +'use server' + +// ST-1103: Server Action voor het beantwoorden van een Claude-vraag (M11). +// +// Volgt docs/patterns/server-action.md: getSession + Zod + demo-blok + +// productAccessFilter. Atomic updateMany sluit double-submit uit; bij race +// (count=0) doet een tweede findFirst de disambiguatie tussen 'al beantwoord', +// 'verlopen', en 'niet gevonden of geen toegang'. +// +// revalidatePath('/', 'layout') refresh't de NavBar-bell badge-count voor +// SSR-rendered pages — de Zustand store + SSE in ST-1104/1105 dekken de +// realtime updates voor andere clients. + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { productAccessFilter } from '@/lib/product-access' + +const inputSchema = z.object({ + questionId: z.string().cuid(), + answer: z.string().min(1).max(4000), +}) + +type ActionResult = { ok: true } | { ok: false; error: string } + +export async function answerQuestion( + questionId: string, + answer: string, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, error: 'Niet ingelogd' } + if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } + + const parsed = inputSchema.safeParse({ questionId, answer }) + if (!parsed.success) { + const first = parsed.error.issues[0]?.message ?? 'Ongeldige invoer' + 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. + const question = await prisma.claudeQuestion.findFirst({ + where: { + id: parsed.data.questionId, + product: productAccessFilter(session.userId), + }, + select: { id: true }, + }) + if (!question) return { ok: false, error: 'Vraag niet gevonden of geen toegang' } + + // 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. + const updated = await prisma.claudeQuestion.updateMany({ + where: { + id: parsed.data.questionId, + status: 'open', + expires_at: { gt: new Date() }, + }, + data: { + status: 'answered', + answer: parsed.data.answer, + answered_by: session.userId, + answered_at: new Date(), + }, + }) + + if (updated.count !== 1) { + const exists = await prisma.claudeQuestion.findFirst({ + where: { id: parsed.data.questionId }, + select: { status: true, expires_at: true }, + }) + if (!exists) return { ok: false, error: 'Vraag niet gevonden' } + if (exists.status !== 'open') { + return { ok: false, error: `Vraag is al ${exists.status}` } + } + return { ok: false, error: 'Vraag is verlopen' } + } + + revalidatePath('/', 'layout') + return { ok: true } +}