diff --git a/actions/pbis.ts b/actions/pbis.ts index 2ebf7f9..407fa16 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -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, diff --git a/actions/stories.ts b/actions/stories.ts index d744b98..d980f56 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -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, diff --git a/actions/tasks.ts b/actions/tasks.ts index d3cc5c6..d9d1080 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -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(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 } diff --git a/actions/todos.ts b/actions/todos.ts index ecfde5f..04e3fae 100644 --- a/actions/todos.ts +++ b/actions/todos.ts @@ -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(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, diff --git a/prisma/seed.ts b/prisma/seed.ts index 53b645a..efb88b7 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -123,6 +123,7 @@ async function main() { const milestones = await loadBacklog(root) 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) { const pbi = await prisma.pbi.create({ data: { @@ -174,10 +175,13 @@ async function main() { }) for (const t of s.tasks) { + productTaskCounter += 1 await prisma.task.create({ data: { story_id: story.id, + product_id: product.id, sprint_id: inSprint ? sprint.id : null, + code: `T-${productTaskCounter}`, title: t.title, description: t.description, priority: ms.priority, diff --git a/scripts/insert-milestone.ts b/scripts/insert-milestone.ts index 97a0457..8bc371d 100644 --- a/scripts/insert-milestone.ts +++ b/scripts/insert-milestone.ts @@ -163,9 +163,19 @@ async function main() { // Tasks: alleen als de story op dit moment 0 tasks had if (!hadTasks && s.tasks.length > 0) { 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({ - data: s.tasks.map((t) => ({ + data: s.tasks.map((t, i) => ({ story_id: storyId, + product_id: product.id, + code: `T-${maxN + i + 1}`, title: t.title, description: t.description || null, priority: ms.priority,