diff --git a/actions/user-questions.ts b/actions/user-questions.ts new file mode 100644 index 0000000..3a076fa --- /dev/null +++ b/actions/user-questions.ts @@ -0,0 +1,102 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' + +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { enforceUserRateLimit } from '@/lib/rate-limit' +import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +type ActionResult = + | { success: true; data?: T } + | { error: string; code?: number } + +const createSchema = z.object({ + ideaId: z.string().cuid(), + question: z.string().min(1).max(2000), +}) + +export async function createUserQuestionAction( + ideaId: string, + question: string, +): Promise> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 401 } + if (session.isDemo) return { error: 'Demo-gebruikers kunnen geen vragen stellen', code: 403 } + + const limited = enforceUserRateLimit('create-user-question', session.userId) + if (limited) return limited + + const parsed = createSchema.safeParse({ ideaId, question }) + if (!parsed.success) return { error: 'Ongeldige invoer', code: 422 } + + const idea = await prisma.idea.findFirst({ + where: { id: parsed.data.ideaId, user_id: session.userId }, + select: { id: true, plan_md: true, product_id: true }, + }) + if (!idea) return { error: 'Idee niet gevonden', code: 404 } + if (!idea.plan_md) return { error: 'Er is nog geen plan voor dit idee', code: 422 } + if (!idea.product_id) return { error: 'Koppel eerst een product aan dit idee', code: 422 } + + // Idempotency: weiger als er al een actieve PLAN_CHAT job loopt voor dit idee. + const existing = await prisma.claudeJob.findFirst({ + where: { + idea_id: parsed.data.ideaId, + kind: 'PLAN_CHAT', + status: { in: ACTIVE_JOB_STATUSES }, + }, + select: { id: true }, + }) + if (existing) return { error: 'Er loopt al een actieve PLAN_CHAT voor dit idee', code: 409 } + + const [uq, job] = await prisma.$transaction([ + prisma.userQuestion.create({ + data: { + idea_id: parsed.data.ideaId, + user_id: session.userId, + question: parsed.data.question, + }, + }), + prisma.claudeJob.create({ + data: { + user_id: session.userId, + product_id: idea.product_id, + idea_id: parsed.data.ideaId, + kind: 'PLAN_CHAT', + status: 'QUEUED', + }, + }), + ]) + + await prisma.ideaLog.create({ + data: { + idea_id: parsed.data.ideaId, + type: 'JOB_EVENT', + content: 'PLAN_CHAT queued', + metadata: { job_id: job.id, kind: 'PLAN_CHAT', user_question_id: uq.id }, + }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + idea_id: parsed.data.ideaId, + user_id: session.userId, + product_id: idea.product_id, + kind: 'PLAN_CHAT', + status: 'queued', + })}::text) + ` + + revalidatePath('/ideas/' + parsed.data.ideaId, 'page') + + return { success: true, data: { questionId: uq.id, jobId: job.id } } +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index a1d5311..3d99843 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -32,6 +32,7 @@ const CONFIGS: Record = { 'edit-idea-md': { windowMs: 60_000, max: 60 }, // grill_md / plan_md edits 'start-idea-job': { windowMs: 60_000, max: 10 }, // Grill / Make Plan triggers 'materialize-idea': { windowMs: 60_000, max: 5 }, + 'create-user-question': { windowMs: 60_000, max: 20 }, // PLAN_CHAT vragen } const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 }