From 611b621d75b8d9b0953b702d649ae6523a2f0367 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 08:36:19 +0200 Subject: [PATCH 1/5] feat(codes): code NOT NULL voor PBI/Story + Task.code + product_id denorm - Pbi.code en Story.code worden NOT NULL (tot dusver optional) - Task krijgt code String + product_id String denorm + @@unique([product_id, code]) - Product krijgt back-relation tasks Task[] - Migratie backfillt bestaande NULL-rijen via PL/pgSQL: PBI-N (per product), ST-N (3-digit padded met GREATEST om truncatie van LPAD bij 4-digit nummers te voorkomen), T-N voor alle tasks - Codes zijn stabiele identifiers (Jira-stijl flat-per-product), zodat re-parenting de code niet muteert Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/erd.svg | 1 + .../migration.sql | 75 +++++++++++++++++++ prisma/schema.prisma | 10 ++- 3 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 docs/erd.svg create mode 100644 prisma/migrations/20260504055000_codes_required_and_task_code/migration.sql diff --git a/docs/erd.svg b/docs/erd.svg new file mode 100644 index 0000000..12b3637 --- /dev/null +++ b/docs/erd.svg @@ -0,0 +1 @@ +

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

product

sprint

enum:status

enum:verify_required

user

product

task

enum:status

claimed_by_token

enum:verify_result

user

token

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

VerifyResult

ALIGNED

ALIGNED

PARTIAL

PARTIAL

EMPTY

EMPTY

DIVERGENT

DIVERGENT

VerifyRequired

ALIGNED

ALIGNED

ALIGNED_OR_PARTIAL

ALIGNED_OR_PARTIAL

ANY

ANY

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

auto_pr

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

String

pr_url

DateTime

pr_merged_at

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

start_date

DateTime

end_date

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

code

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

Boolean

verify_only

VerifyRequired

verify_required

String

repo_url

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

DateTime

pushed_at

VerifyResult

verify_result

String

plan_snapshot

String

branch

String

pr_url

String

summary

String

error

Int

retry_count

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file diff --git a/prisma/migrations/20260504055000_codes_required_and_task_code/migration.sql b/prisma/migrations/20260504055000_codes_required_and_task_code/migration.sql new file mode 100644 index 0000000..3ed5f99 --- /dev/null +++ b/prisma/migrations/20260504055000_codes_required_and_task_code/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index acfcd92..130e322 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } From 829122d4375ae0a6a28b8ec3f243fc2dcb7b9e69 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 08:36:28 +0200 Subject: [PATCH 2/5] feat(codes): generateNextTaskCode + CODE_REGEX export + Zod regex op alle 3 schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/code.ts: rename VALID_CODE_RE naar geexporteerde CODE_REGEX, verwijder ongebruikte deriveTaskCode - lib/code-server.ts: generateNextTaskCode(productId) — flat per-product T-N teller, hergebruikt nextSequential helper. Export isCodeUniqueConflict zodat callers P2002 op code-veld kunnen detecteren - Zod schemas (pbi/story/task): codeField met trim + max-length + regex, optional input (server vult bij ontbreken). Task krijgt voor het eerst een codeField Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/code-server.ts | 13 ++++++++++++- lib/code.ts | 9 ++------- lib/schemas/pbi.ts | 10 ++++++++-- lib/schemas/story.ts | 10 ++++++++-- lib/schemas/task.ts | 8 ++++++++ 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/lib/code-server.ts b/lib/code-server.ts index 5c09fcb..461c859 100644 --- a/lib/code-server.ts +++ b/lib/code-server.ts @@ -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( 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 { const next = nextSequential(pbis.map((p) => p.code), PBI_AUTO_RE) return `PBI-${next}` } + +export async function generateNextTaskCode(productId: string): Promise { + 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}` +} + diff --git a/lib/code.ts b/lib/code.ts index 1ce387a..9a247de 100644 --- a/lib/code.ts +++ b/lib/code.ts @@ -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}` -} diff --git a/lib/schemas/pbi.ts b/lib/schemas/pbi.ts index 50468a9..dc8b97d 100644 --- a/lib/schemas/pbi.ts +++ b/lib/schemas/pbi.ts @@ -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({ diff --git a/lib/schemas/story.ts b/lib/schemas/story.ts index 65802af..8de5811 100644 --- a/lib/schemas/story.ts +++ b/lib/schemas/story.ts @@ -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(), diff --git a/lib/schemas/task.ts b/lib/schemas/task.ts index b4c0c3e..9f32282 100644 --- a/lib/schemas/task.ts +++ b/lib/schemas/task.ts @@ -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(), From 7c82a736f543242345bc1ebbad014081daadb402 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 08:36:41 +0200 Subject: [PATCH 3/5] 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) --- actions/pbis.ts | 4 +- actions/stories.ts | 4 +- actions/tasks.ts | 83 +++++++++++++++++++++++++------------ actions/todos.ts | 7 ++++ prisma/seed.ts | 4 ++ scripts/insert-milestone.ts | 12 +++++- 6 files changed, 82 insertions(+), 32 deletions(-) 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, From 081a0a51c349b7a21452fff9a0183b536c8d2f3e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 08:36:52 +0200 Subject: [PATCH 4/5] feat(codes): UI toont en accepteert code voor taken - TaskDialog: code-input boven titel (font-mono, optional, placeholder "auto"), CodeBadge in dialog header bij edit - EditTaskLoader: select code uit DB voor de dialog - Solo page: vervang inline deriveTaskCode-logica door directe task.code uit DB - Sprint page + TaskList + SprintBoardClient: Task-type krijgt verplicht code-veld; TaskList laat ongebruikte storyCode prop vallen omdat code-derivatie niet meer nodig is Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/products/[id]/solo/page.tsx | 35 ++++++++++------------ app/(app)/products/[id]/sprint/page.tsx | 1 + app/_components/tasks/edit-task-loader.tsx | 1 + app/_components/tasks/task-dialog.tsx | 34 +++++++++++++++++++-- components/sprint/sprint-board-client.tsx | 1 - components/sprint/task-list.tsx | 9 +++--- 6 files changed, 52 insertions(+), 29 deletions(-) diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 12fdd16..868e579 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -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, diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index e8a6b9e..e72bf56 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -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, diff --git a/app/_components/tasks/edit-task-loader.tsx b/app/_components/tasks/edit-task-loader.tsx index f66cce9..9c03194 100644 --- a/app/_components/tasks/edit-task-loader.tsx +++ b/app/_components/tasks/edit-task-loader.tsx @@ -25,6 +25,7 @@ export async function EditTaskLoader({ }, select: { id: true, + code: true, title: true, description: true, implementation_plan: true, diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx index abb9ce3..4e25f27 100644 --- a/app/_components/tasks/task-dialog.tsx +++ b/app/_components/tasks/task-dialog.tsx @@ -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 */}
- - {isEdit ? 'Taak bewerken' : 'Nieuwe taak'} - +
+ + {isEdit ? 'Taak bewerken' : 'Nieuwe taak'} + + {isEdit && task?.code && } +
{isEdit && ( Aangemaakt:{' '} @@ -190,6 +197,27 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false {/* Scrollable form body */}
+ {/* Code */} +
+ + { if (e.key === 'Enter') e.preventDefault() }} + /> + {form.formState.errors.code && ( +

+ {form.formState.errors.code.message} +

+ )} +
+ {/* Title */}