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 insert = (code: string | null) =>
const insert = (code: string) =>
prisma.pbi.create({
data: {
product_id: parsed.data.productId,
@ -156,7 +156,7 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
await prisma.pbi.update({
where: { id: parsed.data.id },
data: {
code,
...(code ? { code } : {}),
title: parsed.data.title,
description: parsed.data.description ?? null,
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 insert = (code: string | null) =>
const insert = (code: string) =>
prisma.story.create({
data: {
pbi_id: parsed.data.pbiId,
@ -163,7 +163,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData)
await prisma.story.update({
where: { id: parsed.data.id },
data: {
code,
...(code ? { code } : {}),
title: parsed.data.title,
description: parsed.data.description ?? 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 { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task'
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
import { normalizeCode } from '@/lib/code'
import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server'
async function getSession() {
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)
try {
@ -91,7 +94,7 @@ export async function saveTask(
const story = await prisma.story.findFirst({
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' }
@ -101,24 +104,43 @@ export async function saveTask(
select: { sort_order: true },
})
const task = await prisma.task.create({
data: {
story_id: context.storyId,
sprint_id: story.sprint_id ?? null,
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
sort_order: (last?.sort_order ?? 0) + 1.0,
status: 'TO_DO',
},
select: { id: true, title: true, status: true },
})
const productId = story.product_id
const sprintId = story.sprint_id ?? null
const sortOrder = (last?.sort_order ?? 0) + 1.0
const storyId = context.storyId
const task = await createWithCodeRetry(
() => (inputCode ? Promise.resolve(inputCode) : generateNextTaskCode(productId)),
(code) =>
prisma.task.create({
data: {
story_id: storyId,
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}`)
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' }
}
}
@ -179,17 +201,24 @@ export async function createTaskAction(_prevState: unknown, formData: FormData)
orderBy: { sort_order: 'desc' },
})
const task = await prisma.task.create({
data: {
story_id: storyId,
sprint_id: sprintId || null,
title: parsed.data.title,
description: parsed.data.description ?? null,
priority: parsed.data.priority,
sort_order: (last?.sort_order ?? 0) + 1.0,
status: 'TO_DO',
},
})
const productId = story.product_id
const task = await createWithCodeRetry(
() => generateNextTaskCode(productId),
(code) =>
prisma.task.create({
data: {
story_id: storyId,
product_id: productId,
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`)
return { success: true, task }

View file

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