Merge pull request #76 from madhura68/feat/entity-codes-required

feat(codes): codes verplicht en hiërarchisch zichtbaar voor PBI/Story/Task
This commit is contained in:
Janpeter Visser 2026-05-04 08:40:39 +02:00 committed by GitHub
commit e1f1f29db7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 266 additions and 76 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,

View file

@ -72,26 +72,21 @@ export default async function SoloProductPage({ params }: Props) {
}),
])
const tasks: SoloTask[] = rawTasks.map(t => {
const positionInStory = t.story.tasks.findIndex(st => st.id === t.id)
const taskCode =
t.story.code && positionInStory >= 0 ? `${t.story.code}.${positionInStory + 1}` : null
return {
id: t.id,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
verify_only: t.verify_only,
verify_required: t.verify_required as SoloTask['verify_required'],
story_id: t.story.id,
story_code: t.story.code,
story_title: t.story.title,
task_code: taskCode,
}
})
const tasks: SoloTask[] = rawTasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
verify_only: t.verify_only,
verify_required: t.verify_required as SoloTask['verify_required'],
story_id: t.story.id,
story_code: t.story.code,
story_title: t.story.title,
task_code: t.code,
}))
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
id: s.id,

View file

@ -81,6 +81,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
for (const story of sprintStories) {
tasksByStory[story.id] = story.tasks.map(t => ({
id: t.id,
code: t.code,
title: t.title,
description: t.description,
priority: t.priority,

View file

@ -25,6 +25,7 @@ export async function EditTaskLoader({
},
select: {
id: true,
code: true,
title: true,
description: true,
implementation_plan: true,

View file

@ -27,6 +27,8 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { CodeBadge } from '@/components/shared/code-badge'
import { MAX_CODE_LENGTH } from '@/lib/code'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
@ -45,6 +47,7 @@ import { cn } from '@/lib/utils'
export interface TaskDialogTask {
id: string
code: string | null
title: string
description: string | null
implementation_plan: string | null
@ -88,6 +91,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
resolver: zodResolver(taskSchema),
mode: 'onTouched',
defaultValues: {
code: task?.code ?? '',
title: task?.title ?? '',
description: task?.description ?? '',
implementation_plan: task?.implementation_plan ?? '',
@ -173,9 +177,12 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
>
{/* Sticky header */}
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
{isEdit ? 'Taak bewerken' : 'Nieuwe taak'}
</DialogTitle>
<div className="flex items-center gap-2">
<DialogTitle className="text-xl font-semibold">
{isEdit ? 'Taak bewerken' : 'Nieuwe taak'}
</DialogTitle>
{isEdit && task?.code && <CodeBadge code={task.code} />}
</div>
{isEdit && (
<span className="text-xs text-muted-foreground">
Aangemaakt:{' '}
@ -190,6 +197,27 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
{/* Scrollable form body */}
<div className={entityDialogBodyClasses}>
{/* Code */}
<div>
<label htmlFor="task-code" className="text-sm font-medium mb-2 block">
Code
</label>
<Input
id="task-code"
{...form.register('code')}
aria-invalid={!!form.formState.errors.code}
placeholder="auto (T-1, T-2, ...)"
className="font-mono"
maxLength={MAX_CODE_LENGTH}
onKeyDown={(e) => { if (e.key === 'Enter') e.preventDefault() }}
/>
{form.formState.errors.code && (
<p className="text-xs text-destructive mt-1">
{form.formState.errors.code.message}
</p>
)}
</div>
{/* Title */}
<div>
<label className="text-sm font-medium mb-2 block">

View file

@ -229,7 +229,6 @@ export function SprintBoardClient({
<TaskList
key="tasks"
storyId={selectedStoryId}
storyCode={stories.find(s => s.id === selectedStoryId)?.code ?? null}
sprintId={sprintId}
productId={productId}
tasks={selectedTasks}

View file

@ -17,7 +17,6 @@ import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { deriveTaskCode } from '@/lib/code'
import { useSprintStore } from '@/stores/sprint-store'
import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
@ -38,6 +37,7 @@ const STATUS_LABELS: Record<string, string> = { TO_DO: 'To Do', IN_PROGRESS: 'Be
export interface Task {
id: string
code: string
title: string
description: string | null
priority: number
@ -48,7 +48,6 @@ export interface Task {
interface TaskListProps {
storyId: string
storyCode: string | null
sprintId: string
productId: string
tasks: Task[]
@ -126,7 +125,7 @@ function SortableTaskRow({
)
}
export function TaskList({ storyId, storyCode, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) {
const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore()
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
@ -222,11 +221,11 @@ export function TaskList({ storyId, storyCode, sprintId: _sprintId, productId: _
onDragEnd={handleDragEnd}
>
<SortableContext items={orderedTasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
{orderedTasks.map((task, idx) => (
{orderedTasks.map((task) => (
<SortableTaskRow
key={task.id}
task={task}
code={deriveTaskCode(storyCode, idx + 1)}
code={task.code}
isDemo={isDemo}
onStatusToggle={() => handleStatusToggle(task)}
onEdit={() => openEditDialog(task.id)}

View file

@ -29,6 +29,15 @@ De API gebruikt **lowercase** statussen. De database gebruikt UPPER_SNAKE; de ve
| Task status | `todo`, `in_progress`, `review`, `done` |
| Story status | `open`, `in_sprint`, `done` |
## Entity codes
PBI's, stories en tasks hebben elk een verplichte `code` (max 30 chars, regex `^[A-Za-z0-9._-]+$`) die als stabiele identifier dient binnen het product:
- **Auto-generatie** wanneer niet meegegeven: `PBI-N`, `ST-N` (3-digit padded), `T-N` — eigen sequence per product.
- **Uniek per `(product_id, code)`** voor alle drie entiteiten.
- **Stabiel bij re-parenting**: een task die naar een andere story wordt verplaatst behoudt zijn `code` (Jira-stijl).
- POST-body `code` is **optioneel** (server vult bij ontbreken); response bevat `code` altijd.
## Foutcodes
| Code | Betekenis |
@ -142,7 +151,7 @@ Hoogst geprioriteerde open story in de actieve sprint.
"tasks": [
{
"id": "...",
"code": "ST-356.1",
"code": "T-42",
"title": "Store stores/solo-store.ts",
"description": "...",
"implementation_plan": null,

1
docs/erd.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 552 KiB

View file

@ -3,7 +3,7 @@ import { prisma } from '@/lib/prisma'
const MAX_AUTO_CODE_ATTEMPTS = 3
function isCodeUniqueConflict(error: unknown): boolean {
export function isCodeUniqueConflict(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false
if (error.code !== 'P2002') return false
const target = (error.meta as { target?: string[] | string } | undefined)?.target
@ -40,6 +40,7 @@ export async function createWithCodeRetry<T>(
const STORY_AUTO_RE = /^ST-(\d+)$/
const PBI_AUTO_RE = /^PBI-(\d+)$/
const TASK_AUTO_RE = /^T-(\d+)$/
function nextSequential(existing: (string | null)[], pattern: RegExp): number {
let max = 0
@ -71,3 +72,13 @@ export async function generateNextPbiCode(productId: string): Promise<string> {
const next = nextSequential(pbis.map((p) => p.code), PBI_AUTO_RE)
return `PBI-${next}`
}
export async function generateNextTaskCode(productId: string): Promise<string> {
const tasks = await prisma.task.findMany({
where: { product_id: productId },
select: { code: true },
})
const next = nextSequential(tasks.map((t) => t.code), TASK_AUTO_RE)
return `T-${next}`
}

View file

@ -1,12 +1,12 @@
// Pure helpers — safe to import from client components.
// DB-backed helpers (generateNextStoryCode/PbiCode) live in lib/code-server.ts.
const VALID_CODE_RE = /^[A-Za-z0-9._-]+$/
export const CODE_REGEX = /^[A-Za-z0-9._-]+$/
export const MAX_CODE_LENGTH = 30
export function isValidCode(code: string): boolean {
return code.length > 0 && code.length <= MAX_CODE_LENGTH && VALID_CODE_RE.test(code)
return code.length > 0 && code.length <= MAX_CODE_LENGTH && CODE_REGEX.test(code)
}
export function normalizeCode(input: string | null | undefined): string | null {
@ -14,8 +14,3 @@ export function normalizeCode(input: string | null | undefined): string | null {
const trimmed = input.trim()
return trimmed === '' ? null : trimmed
}
export function deriveTaskCode(storyCode: string | null, indexOneBased: number): string | null {
if (!storyCode) return null
return `${storyCode}.${indexOneBased}`
}

View file

@ -1,7 +1,13 @@
import { z } from 'zod'
import { MAX_CODE_LENGTH } from '@/lib/code'
import { CODE_REGEX, MAX_CODE_LENGTH } from '@/lib/code'
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
const codeField = z
.string()
.trim()
.max(MAX_CODE_LENGTH)
.regex(CODE_REGEX, 'Ongeldige code')
.optional()
.or(z.literal(''))
const statusField = z.enum(['ready', 'blocked', 'done']).optional()
export const createPbiSchema = z.object({

View file

@ -1,7 +1,13 @@
import { z } from 'zod'
import { MAX_CODE_LENGTH } from '@/lib/code'
import { CODE_REGEX, MAX_CODE_LENGTH } from '@/lib/code'
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
const codeField = z
.string()
.trim()
.max(MAX_CODE_LENGTH)
.regex(CODE_REGEX, 'Ongeldige code')
.optional()
.or(z.literal(''))
export const createStorySchema = z.object({
pbiId: z.string(),

View file

@ -1,7 +1,15 @@
import { z } from 'zod'
import { TaskStatus } from '@prisma/client'
import { CODE_REGEX, MAX_CODE_LENGTH } from '@/lib/code'
export const taskSchema = z.object({
code: z
.string()
.trim()
.max(MAX_CODE_LENGTH)
.regex(CODE_REGEX, 'Ongeldige code')
.optional()
.or(z.literal('')),
title: z.string().trim().min(1, 'Verplicht').max(120),
description: z.string().max(2000).optional(),
implementation_plan: z.string().max(10000).optional(),

View file

@ -0,0 +1,75 @@
-- Codes verplicht maken voor PBI/Story en code-kolom + product_id denorm
-- toevoegen aan Task. Bestaande NULL-rijen worden gevuld via PL/pgSQL backfill.
-- 1) Tasks: product_id denorm (eerst nullable, backfill, dan NOT NULL + FK)
ALTER TABLE "tasks" ADD COLUMN "product_id" TEXT;
UPDATE "tasks" t SET "product_id" = s."product_id" FROM "stories" s WHERE s."id" = t."story_id";
ALTER TABLE "tasks" ALTER COLUMN "product_id" SET NOT NULL;
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 2) Tasks: code (eerst nullable t.b.v. backfill)
ALTER TABLE "tasks" ADD COLUMN "code" VARCHAR(30);
-- 3) Backfill PBI codes (alleen NULL-rijen, per product, op created_at)
DO $$
DECLARE rec RECORD;
DECLARE n INT;
BEGIN
FOR rec IN SELECT DISTINCT product_id FROM "pbis" WHERE code IS NULL LOOP
SELECT COALESCE(MAX(CAST(SUBSTRING(code FROM 'PBI-(\d+)$') AS INTEGER)), 0)
INTO n
FROM "pbis"
WHERE product_id = rec.product_id AND code ~ '^PBI-\d+$';
UPDATE "pbis" SET code = 'PBI-' || (n + sub.row_num)
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at, id) AS row_num
FROM "pbis"
WHERE product_id = rec.product_id AND code IS NULL
) sub
WHERE "pbis".id = sub.id;
END LOOP;
END $$;
-- 4) Backfill Story codes (TO_CHAR met FM-format: padding tot minimaal 3 chars zonder truncatie)
DO $$
DECLARE rec RECORD;
DECLARE n INT;
BEGIN
FOR rec IN SELECT DISTINCT product_id FROM "stories" WHERE code IS NULL LOOP
SELECT COALESCE(MAX(CAST(SUBSTRING(code FROM 'ST-(\d+)$') AS INTEGER)), 0)
INTO n
FROM "stories"
WHERE product_id = rec.product_id AND code ~ '^ST-\d+$';
UPDATE "stories" SET code = 'ST-' || LPAD((n + sub.row_num)::TEXT, GREATEST(3, LENGTH((n + sub.row_num)::TEXT)), '0')
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at, id) AS row_num
FROM "stories"
WHERE product_id = rec.product_id AND code IS NULL
) sub
WHERE "stories".id = sub.id;
END LOOP;
END $$;
-- 5) Backfill Task codes (alle rijen — kolom net toegevoegd)
DO $$
DECLARE rec RECORD;
BEGIN
FOR rec IN SELECT DISTINCT product_id FROM "tasks" WHERE code IS NULL LOOP
UPDATE "tasks" SET code = 'T-' || sub.row_num
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at, id) AS row_num
FROM "tasks"
WHERE product_id = rec.product_id AND code IS NULL
) sub
WHERE "tasks".id = sub.id;
END LOOP;
END $$;
-- 6) NOT NULL constraints
ALTER TABLE "pbis" ALTER COLUMN "code" SET NOT NULL;
ALTER TABLE "stories" ALTER COLUMN "code" SET NOT NULL;
ALTER TABLE "tasks" ALTER COLUMN "code" SET NOT NULL;
-- 7) Unique + lookup index op Task
CREATE UNIQUE INDEX "tasks_product_id_code_key" ON "tasks"("product_id", "code");
CREATE INDEX "tasks_product_id_idx" ON "tasks"("product_id");

View file

@ -144,6 +144,7 @@ model Product {
pbis Pbi[]
sprints Sprint[]
stories Story[]
tasks Task[]
todos Todo[]
members ProductMember[]
active_for_users User[] @relation("UserActiveProduct")
@ -160,7 +161,7 @@ model Pbi {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
code String? @db.VarChar(30)
code String @db.VarChar(30)
title String
description String?
priority Int
@ -188,7 +189,7 @@ model Story {
sprint_id String?
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
assignee_id String?
code String? @db.VarChar(30)
code String @db.VarChar(30)
title String
description String?
acceptance_criteria String?
@ -246,8 +247,11 @@ model Task {
id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
sprint Sprint? @relation(fields: [sprint_id], references: [id])
sprint_id String?
code String @db.VarChar(30)
title String
description String?
implementation_plan String?
@ -266,8 +270,10 @@ model Task {
claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[]
@@unique([product_id, code])
@@index([story_id, priority, sort_order])
@@index([sprint_id, status])
@@index([product_id])
@@map("tasks")
}

View file

@ -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,

View file

@ -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,