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:
parent
829122d437
commit
7c82a736f5
6 changed files with 82 additions and 32 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue