'use server' import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' import { isValidCode, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' import { createStorySchema, updateStorySchema } from '@/lib/schemas/story' async function getSession() { return getIronSession(await cookies(), sessionOptions) } type StoryFieldErrors = Record async function verifyStoryAccess(storyId: string, userId: string) { return prisma.story.findFirst({ where: { id: storyId, product: productAccessFilter(userId) }, include: { product: true }, }) } function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } export async function createStoryAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = createStorySchema.safeParse({ pbiId: formData.get('pbiId'), productId: formData.get('productId'), code: (formData.get('code') as string) || undefined, title: formData.get('title'), description: formData.get('description') || undefined, acceptance_criteria: formData.get('acceptance_criteria') || undefined, priority: formData.get('priority') ?? 2, }) if (!parsed.success) { return { error: 'Validatie mislukt', code: 422, fieldErrors: parsed.error.flatten().fieldErrors as StoryFieldErrors, } } const pbi = await prisma.pbi.findFirst({ where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden', code: 403 } const manualCode = normalizeCode(parsed.data.code) if (manualCode !== null && !isValidCode(manualCode)) { return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as StoryFieldErrors, } } if (manualCode) { const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code: manualCode } }) if (dup) { return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as StoryFieldErrors, } } } const last = await prisma.story.findFirst({ where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, orderBy: { sort_order: 'desc' }, }) const sort_order = (last?.sort_order ?? 0) + 1.0 const insert = (code: string) => prisma.story.create({ data: { pbi_id: parsed.data.pbiId, product_id: pbi.product_id, code, title: parsed.data.title, description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, priority: parsed.data.priority, sort_order, status: 'OPEN', }, }) let story try { story = manualCode ? await insert(manualCode) : await createWithCodeRetry( () => generateNextStoryCode(pbi.product_id), (code) => insert(code), ) } catch { return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } as StoryFieldErrors, } } revalidatePath(`/products/${pbi.product_id}`) return { success: true, story } } export async function updateStoryAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd', code: 403 } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } const parsed = updateStorySchema.safeParse({ id: formData.get('id'), code: (formData.get('code') as string) || undefined, title: formData.get('title'), description: formData.get('description') || undefined, acceptance_criteria: formData.get('acceptance_criteria') || undefined, priority: formData.get('priority'), }) if (!parsed.success) { return { error: 'Validatie mislukt', code: 422, fieldErrors: parsed.error.flatten().fieldErrors as StoryFieldErrors, } } const story = await verifyStoryAccess(parsed.data.id, session.userId) if (!story) return { error: 'Story niet gevonden', code: 403 } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as StoryFieldErrors, } } if (code) { const dup = await prisma.story.findFirst({ where: { product_id: story.product_id, code, NOT: { id: parsed.data.id } }, }) if (dup) { return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as StoryFieldErrors, } } } await prisma.story.update({ where: { id: parsed.data.id }, data: { ...(code ? { code } : {}), title: parsed.data.title, description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, priority: parsed.data.priority, }, }) revalidatePath(`/products/${story.product_id}`) return { success: true } } export async function deleteStoryAction(id: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const story = await verifyStoryAccess(id, session.userId) if (!story) return { error: 'Story niet gevonden' } await prisma.story.delete({ where: { id } }) revalidatePath(`/products/${story.product_id}`) return { success: true } } export async function reorderPbisAction(productId: string, orderedIds: string[]) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige PBI-volgorde' } const product = await getAccessibleProduct(productId, session.userId) if (!product) return { error: 'Product niet gevonden' } const pbis = await prisma.pbi.findMany({ where: { id: { in: orderedIds }, product_id: productId }, select: { id: true }, }) if (pbis.length !== orderedIds.length) return { error: 'Ongeldige PBI-volgorde' } await prisma.$transaction( orderedIds.map((id, i) => prisma.pbi.update({ where: { id }, data: { sort_order: i + 1.0 } }) ) ) revalidatePath(`/products/${productId}`) return { success: true } } export async function updatePbiPriorityAction(pbiId: string, priority: number, _productId: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const pbi = await prisma.pbi.findFirst({ where: { id: pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } if (!Number.isInteger(priority) || priority < 1 || priority > 4) return { error: 'Ongeldige prioriteit' } const last = await prisma.pbi.findFirst({ where: { product_id: pbi.product_id, priority }, orderBy: { sort_order: 'desc' }, }) await prisma.pbi.update({ where: { id: pbiId }, data: { priority, sort_order: (last?.sort_order ?? 0) + 1.0 }, }) revalidatePath(`/products/${pbi.product_id}`) return { success: true } } export async function getStoryLogsAction(storyId: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } const story = await prisma.story.findFirst({ where: { id: storyId, product: productAccessFilter(session.userId) }, include: { product: { select: { repo_url: true } } }, }) if (!story) return { error: 'Story niet gevonden' } const logs = await prisma.storyLog.findMany({ where: { story_id: storyId }, orderBy: { created_at: 'asc' }, }) return { success: true, logs: logs.map((l: (typeof logs)[number]) => ({ id: l.id, type: l.type, content: l.content, status: l.status, commit_hash: l.commit_hash, commit_message: l.commit_message, created_at: l.created_at.toISOString(), })), repoUrl: story.product.repo_url, } } export async function claimStoryAction(storyId: string, productId: string) { try { await requireProductWriter(productId) } catch (e: unknown) { return { error: e instanceof Error ? e.message : 'Geen toegang' } } const session = await getSession() const story = await prisma.story.findFirst({ where: { id: storyId, product_id: productId } }) if (!story) return { error: 'Story niet gevonden' } await prisma.story.update({ where: { id: storyId }, data: { assignee_id: session.userId } }) revalidatePath(`/products/${productId}/sprint`) revalidatePath(`/products/${productId}/solo`) revalidatePath('/solo') return { success: true } } export async function unclaimStoryAction(storyId: string, productId: string) { try { await requireProductWriter(productId) } catch (e: unknown) { return { error: e instanceof Error ? e.message : 'Geen toegang' } } const story = await prisma.story.findFirst({ where: { id: storyId, product_id: productId } }) if (!story) return { error: 'Story niet gevonden' } await prisma.story.update({ where: { id: storyId }, data: { assignee_id: null } }) revalidatePath(`/products/${productId}/sprint`) revalidatePath('/solo') return { success: true } } export async function reassignStoryAction(storyId: string, productId: string, targetUserId: string) { try { await requireProductWriter(productId) } catch (e: unknown) { return { error: e instanceof Error ? e.message : 'Geen toegang' } } // Validate target user is owner or member of the product const product = await prisma.product.findFirst({ where: { id: productId, OR: [ { user_id: targetUserId }, { members: { some: { user_id: targetUserId } } }, ], }, }) if (!product) return { error: 'Gebruiker is geen lid van dit product' } const story = await prisma.story.findFirst({ where: { id: storyId, product_id: productId } }) if (!story) return { error: 'Story niet gevonden' } await prisma.story.update({ where: { id: storyId }, data: { assignee_id: targetUserId } }) revalidatePath(`/products/${productId}/sprint`) revalidatePath('/solo') return { success: true } } export async function claimAllUnassignedInActiveSprintAction(productId: string) { try { await requireProductWriter(productId) } catch (e: unknown) { return { error: e instanceof Error ? e.message : 'Geen toegang' } } const session = await getSession() const userId = session.userId const sprint = await prisma.sprint.findFirst({ where: { product_id: productId, status: 'ACTIVE' }, }) if (!sprint) return { error: 'Geen actieve sprint gevonden' } const result = await prisma.story.updateMany({ where: { sprint_id: sprint.id, product_id: productId, assignee_id: null }, data: { assignee_id: userId }, }) revalidatePath(`/products/${productId}/sprint`) revalidatePath('/solo') return { success: true, count: result.count } } export async function reorderStoriesAction( pbiId: string, productId: string, orderedIds: string[], newPriority?: number ) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' } if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) { return { error: 'Ongeldige prioriteit' } } const pbi = await prisma.pbi.findFirst({ where: { id: pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } const stories = await prisma.story.findMany({ where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id }, select: { id: true }, }) if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' } await prisma.$transaction( orderedIds.map((id, i) => prisma.story.update({ where: { id }, data: { sort_order: i + 1.0, ...(newPriority !== undefined ? { priority: newPriority } : {}), }, }) ) ) revalidatePath(`/products/${pbi.product_id}`) return { success: true } }