'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 { setActiveSprintInSettings } from '@/lib/active-sprint' import { partitionByEligibility } from '@/lib/sprint-conflicts' import { z } from 'zod' const StoryOverrideSchema = z.object({ add: z.array(z.string()), remove: z.array(z.string()), }) const createSprintWithSelectionSchema = z.object({ productId: z.string().min(1), metadata: z.object({ goal: z.string().min(1).max(2000), startAt: z.string().date().optional(), endAt: z.string().date().optional(), }), pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), storyOverrides: z.record(z.string(), StoryOverrideSchema).default({}), }) export type CreateSprintWithSelectionInput = z.infer< typeof createSprintWithSelectionSchema > type SprintCreateConflicts = { notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] crossSprint: { storyId: string; sprintId: string; sprintName: string }[] } export type CreateSprintWithSelectionResult = | { success: true sprintId: string affectedStoryIds: string[] affectedPbiIds: string[] affectedTaskIds: string[] conflicts: SprintCreateConflicts } | { error: string; code: number } const updateSprintSchema = z.object({ sprintId: z.string().min(1), fields: z .object({ goal: z.string().min(1).max(2000).optional(), startAt: z.string().date().nullable().optional(), endAt: z.string().date().nullable().optional(), }) .refine( (data) => Object.keys(data).length > 0, 'Minstens één veld vereist', ), }) export type UpdateSprintInput = z.infer export type UpdateSprintResult = | { success: true; sprintId: string } | { error: string; code: number } export async function updateSprintAction( input: UpdateSprintInput, ): Promise { 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 = updateSprintSchema.safeParse(input) if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } const sprint = await prisma.sprint.findFirst({ where: { id: parsed.data.sprintId, product: productAccessFilter(session.userId), }, select: { id: true, product_id: true }, }) if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } const data: { sprint_goal?: string; start_date?: Date | null; end_date?: Date | null } = {} if (parsed.data.fields.goal !== undefined) { data.sprint_goal = parsed.data.fields.goal } if (parsed.data.fields.startAt !== undefined) { data.start_date = parseDate(parsed.data.fields.startAt) } if (parsed.data.fields.endAt !== undefined) { data.end_date = parseDate(parsed.data.fields.endAt) } await prisma.sprint.update({ where: { id: parsed.data.sprintId }, data, }) revalidatePath(`/products/${sprint.product_id}`, 'layout') return { success: true, sprintId: parsed.data.sprintId } } const commitSprintMembershipSchema = z.object({ activeSprintId: z.string().min(1), adds: z.array(z.string()), removes: z.array(z.string()), }) export type CommitSprintMembershipInput = z.infer< typeof commitSprintMembershipSchema > type CommitConflicts = { notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] alreadyRemoved: string[] } export type CommitSprintMembershipResult = | { success: true affectedStoryIds: string[] affectedPbiIds: string[] affectedTaskIds: string[] conflicts: CommitConflicts } | { error: string; code: number } export async function commitSprintMembershipAction( input: CommitSprintMembershipInput, ): Promise { 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 = commitSprintMembershipSchema.safeParse(input) if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } // Sprint moet bestaan en bereikbaar zijn via product-access. const sprint = await prisma.sprint.findFirst({ where: { id: parsed.data.activeSprintId, product: productAccessFilter(session.userId), }, select: { id: true, product_id: true }, }) if (!sprint) { return { error: 'Sprint niet gevonden of niet toegankelijk', code: 403 } } // Filter adds via eligibility (sprint_id IS NULL en niet DONE; andere OPEN // sprint → conflicts.notEligible + crossSprint). const addPartition = await partitionByEligibility( prisma, parsed.data.adds, parsed.data.activeSprintId, ) const eligibleAdds = addPartition.eligible const notEligibleAdds = addPartition.notEligible // Race-safety voor removes: alleen stories die feitelijk in de actieve // sprint zitten worden verwijderd. const removeRows = parsed.data.removes.length > 0 ? await prisma.story.findMany({ where: { id: { in: parsed.data.removes }, sprint_id: parsed.data.activeSprintId, }, select: { id: true }, }) : [] const validRemoves = removeRows.map((r) => r.id) const validRemoveSet = new Set(validRemoves) const alreadyRemoved = parsed.data.removes.filter( (id) => !validRemoveSet.has(id), ) if (eligibleAdds.length === 0 && validRemoves.length === 0) { // Geen werk te doen — geef toch een success-shape terug zodat de client // pending buffer kan resetten + conflicts kan tonen. return { success: true, affectedStoryIds: [], affectedPbiIds: [], affectedTaskIds: [], conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, } } await prisma.$transaction(async (tx) => { if (eligibleAdds.length > 0) { await tx.story.updateMany({ where: { id: { in: eligibleAdds } }, data: { sprint_id: parsed.data.activeSprintId, status: 'IN_SPRINT' }, }) await tx.task.updateMany({ where: { story_id: { in: eligibleAdds } }, data: { sprint_id: parsed.data.activeSprintId }, }) } if (validRemoves.length > 0) { await tx.story.updateMany({ where: { id: { in: validRemoves } }, data: { sprint_id: null, status: 'OPEN' }, }) await tx.task.updateMany({ where: { story_id: { in: validRemoves } }, data: { sprint_id: null }, }) } }) const affectedStoryIds = [...eligibleAdds, ...validRemoves] const affectedStories = affectedStoryIds.length > 0 ? await prisma.story.findMany({ where: { id: { in: affectedStoryIds } }, select: { pbi_id: true }, }) : [] const affectedPbiIds = Array.from( new Set(affectedStories.map((s) => s.pbi_id)), ) const affectedTasks = affectedStoryIds.length > 0 ? await prisma.task.findMany({ where: { story_id: { in: affectedStoryIds } }, select: { id: true }, }) : [] const affectedTaskIds = affectedTasks.map((t) => t.id) revalidatePath(`/products/${sprint.product_id}`, 'layout') return { success: true, affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, } } export async function createSprintWithSelectionAction( input: CreateSprintWithSelectionInput, ): Promise { 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 = createSprintWithSelectionSchema.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 } // Resolveer intent + per-PBI overrides naar concrete story-IDs. const allPbiAllIds = Object.entries(parsed.data.pbiIntent) .filter(([, intent]) => intent === 'all') .map(([pbiId]) => pbiId) // Stap 1: alle child-stories voor PBI's met intent='all'. let candidate: string[] = [] if (allPbiAllIds.length > 0) { const rows = await prisma.story.findMany({ where: { pbi_id: { in: allPbiAllIds }, product_id: parsed.data.productId }, select: { id: true, pbi_id: true }, }) const removedSet = new Set() for (const [pbiId, override] of Object.entries(parsed.data.storyOverrides)) { for (const id of override.remove) removedSet.add(`${pbiId}:${id}`) } candidate = rows .filter((row) => !removedSet.has(`${row.pbi_id}:${row.id}`)) .map((row) => row.id) } // Stap 2: storyOverrides.add — werkt voor zowel intent='none' als 'all' (extra // toevoegingen). Dedupliceren met candidates uit stap 1. const candidateSet = new Set(candidate) for (const override of Object.values(parsed.data.storyOverrides)) { for (const id of override.add) candidateSet.add(id) } const candidateIds = Array.from(candidateSet) // Eligibility-filter (incl. cross-sprint guard). const partition = await partitionByEligibility(prisma, candidateIds) if (partition.eligible.length === 0) { return { error: 'Geen eligible stories voor deze sprint', 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.metadata.goal, status: 'OPEN', start_date: parseDate(parsed.data.metadata.startAt), end_date: parseDate(parsed.data.metadata.endAt), }, }) await tx.story.updateMany({ where: { id: { in: partition.eligible } }, data: { sprint_id: created.id, status: 'IN_SPRINT' }, }) await tx.task.updateMany({ where: { story_id: { in: partition.eligible } }, data: { sprint_id: created.id }, }) return created }), ) // Snapshot affected pbi/task IDs voor client-store patches. const affectedStories = await prisma.story.findMany({ where: { id: { in: partition.eligible } }, select: { pbi_id: true }, }) const affectedPbiIds = Array.from(new Set(affectedStories.map((s) => s.pbi_id))) const affectedTasks = await prisma.task.findMany({ where: { story_id: { in: partition.eligible } }, select: { id: true }, }) const affectedTaskIds = affectedTasks.map((t) => t.id) await setActiveSprintInSettings( session.userId, parsed.data.productId, sprint.id, ) revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id, affectedStoryIds: partition.eligible, affectedPbiIds, affectedTaskIds, conflicts: { notEligible: partition.notEligible, crossSprint: partition.crossSprint, }, } } 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 } // PBI-79 / ST-1342: multi-OPEN sprints toegestaan. Bestaande OPEN sprints // op hetzelfde product zijn geen reden meer om aanmaak te blokkeren — // cross-sprint-conflicts worden per-story afgevangen in de membership- // commit-flow. 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 setActiveSprintInSettings(session.userId, 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 setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } }