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:
commit
e1f1f29db7
21 changed files with 266 additions and 76 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 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export async function EditTaskLoader({
|
|||
},
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
description: true,
|
||||
implementation_plan: true,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
1
docs/erd.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 552 KiB |
|
|
@ -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}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue