feat(codes): server actions + seed/scripts gebruiken code overal

- actions/tasks.ts: saveTask + createTaskAction (legacy form) gebruiken
  createWithCodeRetry + generateNextTaskCode; persisten product_id
  denormalisatie. P2002 op user-supplied code wordt 422 met fieldError
- actions/pbis.ts + stories.ts: insert-helpers nemen verplichte string;
  update laat code-veld weg uit data wanneer null (kan niet meer leeg
  worden gemaakt nu DB NOT NULL is)
- actions/todos.ts: promoteTodoToPbi/Story genereren expliciet een code
  voor het transactiestart (kan niet binnen $transaction array retryen)
- prisma/seed.ts: per-product task counter geeft elke task een T-N code
- scripts/insert-milestone.ts: createMany berekent maxN voor product
  en assigneert T-{maxN+i+1} per nieuwe task

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 08:36:41 +02:00
parent 829122d437
commit 7c82a736f5
6 changed files with 82 additions and 32 deletions

View file

@ -68,7 +68,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined
const insert = (code: string | null) => const insert = (code: string) =>
prisma.pbi.create({ prisma.pbi.create({
data: { data: {
product_id: parsed.data.productId, product_id: parsed.data.productId,
@ -156,7 +156,7 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
await prisma.pbi.update({ await prisma.pbi.update({
where: { id: parsed.data.id }, where: { id: parsed.data.id },
data: { data: {
code, ...(code ? { code } : {}),
title: parsed.data.title, title: parsed.data.title,
description: parsed.data.description ?? null, description: parsed.data.description ?? null,
priority: parsed.data.priority, priority: parsed.data.priority,

View file

@ -80,7 +80,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
}) })
const sort_order = (last?.sort_order ?? 0) + 1.0 const sort_order = (last?.sort_order ?? 0) + 1.0
const insert = (code: string | null) => const insert = (code: string) =>
prisma.story.create({ prisma.story.create({
data: { data: {
pbi_id: parsed.data.pbiId, pbi_id: parsed.data.pbiId,
@ -163,7 +163,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData)
await prisma.story.update({ await prisma.story.update({
where: { id: parsed.data.id }, where: { id: parsed.data.id },
data: { data: {
code, ...(code ? { code } : {}),
title: parsed.data.title, title: parsed.data.title,
description: parsed.data.description ?? null, description: parsed.data.description ?? null,
acceptance_criteria: parsed.data.acceptance_criteria ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null,

View file

@ -10,6 +10,8 @@ import { productAccessFilter } from '@/lib/product-access'
import { requireProductWriter } from '@/lib/auth' import { requireProductWriter } from '@/lib/auth'
import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task'
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
import { normalizeCode } from '@/lib/code'
import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server'
async function getSession() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -47,7 +49,8 @@ export async function saveTask(
} }
} }
const { title, description, implementation_plan, priority, status } = parsed.data const { title, description, implementation_plan, priority, status, code: rawCode } = parsed.data
const inputCode = normalizeCode(rawCode ?? null)
const scope = productAccessFilter(session.userId) const scope = productAccessFilter(session.userId)
try { try {
@ -91,7 +94,7 @@ export async function saveTask(
const story = await prisma.story.findFirst({ const story = await prisma.story.findFirst({
where: { id: context.storyId, product: scope }, where: { id: context.storyId, product: scope },
select: { sprint_id: true }, select: { sprint_id: true, product_id: true },
}) })
if (!story) return { ok: false, code: 403, error: 'forbidden' } if (!story) return { ok: false, code: 403, error: 'forbidden' }
@ -101,24 +104,43 @@ export async function saveTask(
select: { sort_order: true }, select: { sort_order: true },
}) })
const task = await prisma.task.create({ const productId = story.product_id
data: { const sprintId = story.sprint_id ?? null
story_id: context.storyId, const sortOrder = (last?.sort_order ?? 0) + 1.0
sprint_id: story.sprint_id ?? null, const storyId = context.storyId
title,
description: description ?? null, const task = await createWithCodeRetry(
implementation_plan: implementation_plan ?? null, () => (inputCode ? Promise.resolve(inputCode) : generateNextTaskCode(productId)),
priority, (code) =>
sort_order: (last?.sort_order ?? 0) + 1.0, prisma.task.create({
status: 'TO_DO', data: {
}, story_id: storyId,
select: { id: true, title: true, status: true }, product_id: productId,
}) sprint_id: sprintId,
code,
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
sort_order: sortOrder,
status: 'TO_DO',
},
select: { id: true, title: true, status: true },
}),
)
revalidatePath(`/products/${context.productId}/sprint`) revalidatePath(`/products/${context.productId}/sprint`)
revalidatePath(`/products/${context.productId}`) revalidatePath(`/products/${context.productId}`)
return { ok: true, task: { ...task, status: task.status.toString() } } return { ok: true, task: { ...task, status: task.status.toString() } }
} catch { } catch (e) {
if (inputCode && isCodeUniqueConflict(e)) {
return {
ok: false,
code: 422,
error: 'validation',
fieldErrors: { code: ['Code bestaat al binnen dit product'] },
}
}
return { ok: false, code: 500, error: 'server_error' } return { ok: false, code: 500, error: 'server_error' }
} }
} }
@ -179,17 +201,24 @@ export async function createTaskAction(_prevState: unknown, formData: FormData)
orderBy: { sort_order: 'desc' }, orderBy: { sort_order: 'desc' },
}) })
const task = await prisma.task.create({ const productId = story.product_id
data: { const task = await createWithCodeRetry(
story_id: storyId, () => generateNextTaskCode(productId),
sprint_id: sprintId || null, (code) =>
title: parsed.data.title, prisma.task.create({
description: parsed.data.description ?? null, data: {
priority: parsed.data.priority, story_id: storyId,
sort_order: (last?.sort_order ?? 0) + 1.0, product_id: productId,
status: 'TO_DO', sprint_id: sprintId || null,
}, code,
}) title: parsed.data.title,
description: parsed.data.description ?? null,
priority: parsed.data.priority,
sort_order: (last?.sort_order ?? 0) + 1.0,
status: 'TO_DO',
},
}),
)
revalidatePath(`/products/${story.product_id}/sprint/planning`) revalidatePath(`/products/${story.product_id}/sprint/planning`)
return { success: true, task } return { success: true, task }

View file

@ -7,6 +7,7 @@ import { z } from 'zod'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session' import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { generateNextPbiCode, generateNextStoryCode } from '@/lib/code-server'
async function getSession() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -154,10 +155,13 @@ export async function promoteTodoToPbiAction(_prevState: unknown, formData: Form
orderBy: { sort_order: 'desc' }, orderBy: { sort_order: 'desc' },
}) })
const pbiCode = await generateNextPbiCode(parsed.data.productId)
await prisma.$transaction([ await prisma.$transaction([
prisma.pbi.create({ prisma.pbi.create({
data: { data: {
product_id: parsed.data.productId, product_id: parsed.data.productId,
code: pbiCode,
title: parsed.data.title, title: parsed.data.title,
priority: parsed.data.priority, priority: parsed.data.priority,
sort_order: (last?.sort_order ?? 0) + 1.0, sort_order: (last?.sort_order ?? 0) + 1.0,
@ -209,11 +213,14 @@ export async function promoteTodoToStoryAction(_prevState: unknown, formData: Fo
orderBy: { sort_order: 'desc' }, orderBy: { sort_order: 'desc' },
}) })
const storyCode = await generateNextStoryCode(pbi.product_id)
await prisma.$transaction([ await prisma.$transaction([
prisma.story.create({ prisma.story.create({
data: { data: {
pbi_id: parsed.data.pbiId, pbi_id: parsed.data.pbiId,
product_id: pbi.product_id, product_id: pbi.product_id,
code: storyCode,
title: parsed.data.title, title: parsed.data.title,
priority: parsed.data.priority, priority: parsed.data.priority,
sort_order: (last?.sort_order ?? 0) + 1.0, sort_order: (last?.sort_order ?? 0) + 1.0,

View file

@ -123,6 +123,7 @@ async function main() {
const milestones = await loadBacklog(root) const milestones = await loadBacklog(root)
console.log(`Loaded backlog: ${milestones.length} milestones, ${milestones.reduce((acc, m) => acc + m.stories.length, 0)} stories`) console.log(`Loaded backlog: ${milestones.length} milestones, ${milestones.reduce((acc, m) => acc + m.stories.length, 0)} stories`)
let productTaskCounter = 0
for (const ms of milestones) { for (const ms of milestones) {
const pbi = await prisma.pbi.create({ const pbi = await prisma.pbi.create({
data: { data: {
@ -174,10 +175,13 @@ async function main() {
}) })
for (const t of s.tasks) { for (const t of s.tasks) {
productTaskCounter += 1
await prisma.task.create({ await prisma.task.create({
data: { data: {
story_id: story.id, story_id: story.id,
product_id: product.id,
sprint_id: inSprint ? sprint.id : null, sprint_id: inSprint ? sprint.id : null,
code: `T-${productTaskCounter}`,
title: t.title, title: t.title,
description: t.description, description: t.description,
priority: ms.priority, priority: ms.priority,

View file

@ -163,9 +163,19 @@ async function main() {
// Tasks: alleen als de story op dit moment 0 tasks had // Tasks: alleen als de story op dit moment 0 tasks had
if (!hadTasks && s.tasks.length > 0) { if (!hadTasks && s.tasks.length > 0) {
if (!args.dryRun) { if (!args.dryRun) {
const allTasks = await prisma.task.findMany({
where: { product_id: product.id },
select: { code: true },
})
const maxN = allTasks.reduce((m, t) => {
const match = /^T-(\d+)$/.exec(t.code)
return match ? Math.max(m, Number(match[1])) : m
}, 0)
await prisma.task.createMany({ await prisma.task.createMany({
data: s.tasks.map((t) => ({ data: s.tasks.map((t, i) => ({
story_id: storyId, story_id: storyId,
product_id: product.id,
code: `T-${maxN + i + 1}`,
title: t.title, title: t.title,
description: t.description || null, description: t.description || null,
priority: ms.priority, priority: ms.priority,