'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 { createSprintSchema, updateSprintDatesSchema, updateSprintGoalSchema, } from '@/lib/schemas/sprint' import { enforceUserRateLimit } from '@/lib/rate-limit' import { propagateStatusUpwards } from '@/lib/tasks-status-update' import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server' import { setActiveSprintCookie } from '@/lib/active-sprint' import { z } from 'zod' async function getSession() { return getIronSession(await cookies(), sessionOptions) } function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } type SprintFieldErrors = Record export async function createSprintAction(_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 limited = enforceUserRateLimit('create-sprint', session.userId) if (limited) return limited const parsed = createSprintSchema.safeParse({ productId: formData.get('productId'), sprint_goal: formData.get('sprint_goal'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), pbi_id: formData.get('pbi_id'), }) if (!parsed.success) { return { error: 'Validatie mislukt', code: 422, fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, } } const product = await getAccessibleProduct(parsed.data.productId, session.userId) if (!product) return { error: 'Product niet gevonden', code: 403 } const existing = await prisma.sprint.findFirst({ where: { product_id: parsed.data.productId, status: 'OPEN' }, }) if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 } const sprint = await createWithCodeRetry( () => generateNextSprintCode(parsed.data.productId), (code) => prisma.sprint.create({ data: { product_id: parsed.data.productId, code, sprint_goal: parsed.data.sprint_goal, status: 'OPEN', start_date: parsed.data.start_date, end_date: parsed.data.end_date, }, }), ) if (parsed.data.pbi_id) { const pbi = await prisma.pbi.findFirst({ where: { id: parsed.data.pbi_id, product_id: parsed.data.productId }, select: { id: true }, }) if (pbi) { const stories = await prisma.story.findMany({ where: { pbi_id: pbi.id, sprint_id: null }, orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true }, }) if (stories.length > 0) { const storyIds = stories.map(s => s.id) await prisma.$transaction([ ...stories.map((s, i) => prisma.story.update({ where: { id: s.id }, data: { sprint_id: sprint.id, status: 'IN_SPRINT', sort_order: i + 1 }, }), ), prisma.task.updateMany({ where: { story_id: { in: storyIds }, sprint_id: null }, data: { sprint_id: sprint.id }, }), ]) } } } await setActiveSprintCookie(parsed.data.productId, sprint.id) revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } } export async function updateSprintDatesAction(_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 = updateSprintDatesSchema.safeParse({ id: formData.get('id'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), }) if (!parsed.success) { return { error: 'Validatie mislukt', code: 422, fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, } } const sprint = await prisma.sprint.findFirst({ where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } await prisma.sprint.update({ where: { id: parsed.data.id }, data: { start_date: parsed.data.start_date, end_date: parsed.data.end_date }, }) revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } export async function updateSprintGoalAction(_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 = updateSprintGoalSchema.safeParse({ id: formData.get('id'), sprint_goal: formData.get('sprint_goal'), }) if (!parsed.success) { return { error: 'Validatie mislukt', code: 422, fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, } } const sprint = await prisma.sprint.findFirst({ where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } await prisma.sprint.update({ where: { id: parsed.data.id }, data: { sprint_goal: parsed.data.sprint_goal } }) revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } export async function addStoryToSprintAction(sprintId: string, storyId: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const sprint = await prisma.sprint.findFirst({ where: { id: sprintId, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden' } const story = await prisma.story.findFirst({ where: { id: storyId, product: productAccessFilter(session.userId) }, }) if (!story) return { error: 'Story niet gevonden' } if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' } const last = await prisma.story.findFirst({ where: { sprint_id: sprintId }, orderBy: { sort_order: 'desc' }, }) await prisma.story.update({ where: { id: storyId }, data: { sprint_id: sprintId, status: 'IN_SPRINT', sort_order: (last?.sort_order ?? 0) + 1.0 }, }) revalidatePath(`/products/${sprint.product_id}/sprint`) revalidatePath(`/products/${sprint.product_id}`) return { success: true } } export async function removeStoryFromSprintAction(storyId: 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 prisma.story.findFirst({ where: { id: storyId, product: productAccessFilter(session.userId) }, include: { sprint: true }, }) if (!story) return { error: 'Story niet gevonden' } await prisma.story.update({ where: { id: storyId }, data: { sprint_id: null, status: 'OPEN' }, }) revalidatePath(`/products/${story.product_id}/sprint`) revalidatePath(`/products/${story.product_id}`) return { success: true } } export async function reorderSprintStoriesAction(sprintId: 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 Sprint Backlog-volgorde' } const sprint = await prisma.sprint.findFirst({ where: { id: sprintId, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden' } const stories = await prisma.story.findMany({ where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id }, select: { id: true }, }) if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' } await prisma.$transaction( orderedIds.map((id, i) => prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } }) ) ) revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } export async function completeSprintAction( sprintId: string, decisions: Record ) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const sprint = await prisma.sprint.findFirst({ where: { id: sprintId, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden' } const entries = Object.entries(decisions) if (entries.some(([, status]) => status !== 'DONE' && status !== 'OPEN')) { return { error: 'Ongeldige Sprint-afronding' } } const storyIds = entries.map(([storyId]) => storyId) if (hasDuplicateIds(storyIds)) return { error: 'Ongeldige Sprint-afronding' } const stories = await prisma.story.findMany({ where: { id: { in: storyIds }, sprint_id: sprintId, product_id: sprint.product_id }, select: { id: true, pbi_id: true }, }) if (stories.length !== storyIds.length) return { error: 'Ongeldige Sprint-afronding' } const affectedPbiIds = [...new Set(stories.map((s) => s.pbi_id))] const candidatePbis = await prisma.pbi.findMany({ where: { id: { in: affectedPbiIds }, status: { not: 'DONE' } }, select: { id: true, stories: { select: { id: true, status: true } } }, }) const decisionByStoryId = new Map(entries) const pbiIdsToMarkDone = candidatePbis .filter( (pbi) => pbi.stories.length > 0 && pbi.stories.every((s) => { const next = decisionByStoryId.get(s.id) ?? s.status return next === 'DONE' }) ) .map((p) => p.id) await prisma.$transaction([ ...entries.map(([storyId, status]) => prisma.story.update({ where: { id: storyId }, data: { status, sprint_id: status === 'OPEN' ? null : undefined, }, }) ), ...pbiIdsToMarkDone.map((id) => prisma.pbi.update({ where: { id }, data: { status: 'DONE' } }) ), prisma.sprint.update({ where: { id: sprintId }, data: { status: 'CLOSED', completed_at: new Date() }, }), ]) revalidatePath(`/products/${sprint.product_id}`) revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } export async function setAllSprintTasksDoneAction( sprintId: string, ): Promise<{ ok: true } | { ok: false; error: string }> { 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 sprint = await prisma.sprint.findFirst({ where: { id: sprintId, product: productAccessFilter(session.userId) }, select: { id: true, product_id: true }, }) if (!sprint) return { ok: false, error: 'Sprint niet gevonden' } const tasks = await prisma.task.findMany({ where: { sprint_id: sprintId, product_id: sprint.product_id }, select: { id: true }, }) await prisma.$transaction(async (tx) => { for (const task of tasks) { await propagateStatusUpwards(task.id, 'DONE', tx) } }) revalidatePath(`/products/${sprint.product_id}/sprint`) return { ok: true } } const createSprintWithPbisSchema = z.object({ productId: z.string().min(1), sprint_goal: z.string().min(1).max(2000), start_date: z.string().nullable().optional(), end_date: z.string().nullable().optional(), pbi_ids: z.array(z.string().min(1)).min(1), }) function parseDate(value: string | null | undefined): Date | null { if (!value) return null const d = new Date(value) return Number.isNaN(d.getTime()) ? null : d } export async function createSprintWithPbisAction(input: { productId: string sprint_goal: string start_date?: string | null end_date?: string | null pbi_ids: string[] }): Promise<{ success: true; sprintId: string } | { error: string; code: number }> { 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 limited = enforceUserRateLimit('create-sprint', session.userId) if (limited) return { error: limited.error, code: limited.code } const parsed = createSprintWithPbisSchema.safeParse(input) if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } const product = await getAccessibleProduct(parsed.data.productId, session.userId) if (!product) return { error: 'Product niet gevonden', code: 403 } const pbis = await prisma.pbi.findMany({ where: { id: { in: parsed.data.pbi_ids }, product_id: parsed.data.productId }, select: { id: true }, }) if (pbis.length !== parsed.data.pbi_ids.length) { return { error: "Een of meer PBI's behoren niet tot dit product", code: 422 } } const sprint = await createWithCodeRetry( () => generateNextSprintCode(parsed.data.productId), (code) => prisma.$transaction(async (tx) => { const created = await tx.sprint.create({ data: { product_id: parsed.data.productId, code, sprint_goal: parsed.data.sprint_goal, status: 'OPEN', start_date: parseDate(parsed.data.start_date), end_date: parseDate(parsed.data.end_date), }, }) await tx.story.updateMany({ where: { pbi_id: { in: parsed.data.pbi_ids } }, data: { sprint_id: created.id, status: 'IN_SPRINT' }, }) await tx.task.updateMany({ where: { story: { pbi_id: { in: parsed.data.pbi_ids } } }, data: { sprint_id: created.id }, }) return created }), ) await setActiveSprintCookie(parsed.data.productId, sprint.id) revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } }