Voegt een verplicht code-veld toe aan Sprint, sequentieel per product (consistent met PBI-N, ST-NNN, T-N). - **Schema** — `Sprint.code String @db.VarChar(30)` + `@@unique([product_id, code])` - **Migratie** — voegt kolom toe als nullable, backfillt bestaande sprints via `ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at)` als `SP-N`, en zet daarna NOT NULL + UNIQUE. - **Generator** — `generateNextSprintCode(productId)` in lib/code-server.ts volgt het patroon van story/pbi/task; createSprintAction gebruikt `createWithCodeRetry` voor race-bescherming. - **Seed** — sprint-counter per product (`SP-1`, `SP-2`, ...). Zichtbaar in: - Sprint-header (`Product › Sprint actief · SP-3`) - JobCard + JobDetailPane voor SPRINT_IMPLEMENTATION jobs - Insights: VelocityChart x-axis (compacter dan goal-truncated), AlignmentTrend tooltip, SprintInfoStrip - actions/jobs-page.ts: `sprintCode` is weer een echte code i.p.v. null Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
94 lines
2.9 KiB
TypeScript
94 lines
2.9 KiB
TypeScript
import { Prisma } from '@prisma/client'
|
|
import { prisma } from '@/lib/prisma'
|
|
|
|
const MAX_AUTO_CODE_ATTEMPTS = 3
|
|
|
|
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
|
|
if (!target) return false
|
|
if (Array.isArray(target)) return target.includes('code')
|
|
return target.includes('code')
|
|
}
|
|
|
|
/**
|
|
* Generate an auto code, then run the create. If the insert collides with an
|
|
* existing code (P2002 on the code column), regenerate and retry up to a small
|
|
* number of attempts. Protects against the SELECT-MAX → INSERT race when two
|
|
* concurrent requests pick the same next number.
|
|
*/
|
|
export async function createWithCodeRetry<T>(
|
|
generate: () => Promise<string>,
|
|
create: (code: string) => Promise<T>,
|
|
): Promise<T> {
|
|
let lastError: unknown
|
|
for (let attempt = 0; attempt < MAX_AUTO_CODE_ATTEMPTS; attempt++) {
|
|
const code = await generate()
|
|
try {
|
|
return await create(code)
|
|
} catch (e) {
|
|
if (isCodeUniqueConflict(e)) {
|
|
lastError = e
|
|
continue
|
|
}
|
|
throw e
|
|
}
|
|
}
|
|
throw lastError ?? new Error('Kon geen unieke code genereren')
|
|
}
|
|
|
|
const STORY_AUTO_RE = /^ST-(\d+)$/
|
|
const PBI_AUTO_RE = /^PBI-(\d+)$/
|
|
const TASK_AUTO_RE = /^T-(\d+)$/
|
|
const SPRINT_AUTO_RE = /^SP-(\d+)$/
|
|
|
|
function nextSequential(existing: (string | null)[], pattern: RegExp): number {
|
|
let max = 0
|
|
for (const c of existing) {
|
|
if (!c) continue
|
|
const m = c.match(pattern)
|
|
if (m) {
|
|
const n = Number.parseInt(m[1], 10)
|
|
if (!Number.isNaN(n) && n > max) max = n
|
|
}
|
|
}
|
|
return max + 1
|
|
}
|
|
|
|
export async function generateNextStoryCode(productId: string): Promise<string> {
|
|
const stories = await prisma.story.findMany({
|
|
where: { product_id: productId },
|
|
select: { code: true },
|
|
})
|
|
const next = nextSequential(stories.map((s) => s.code), STORY_AUTO_RE)
|
|
return `ST-${String(next).padStart(3, '0')}`
|
|
}
|
|
|
|
export async function generateNextPbiCode(productId: string): Promise<string> {
|
|
const pbis = await prisma.pbi.findMany({
|
|
where: { product_id: productId },
|
|
select: { code: true },
|
|
})
|
|
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}`
|
|
}
|
|
|
|
export async function generateNextSprintCode(productId: string): Promise<string> {
|
|
const sprints = await prisma.sprint.findMany({
|
|
where: { product_id: productId },
|
|
select: { code: true },
|
|
})
|
|
const next = nextSequential(sprints.map((s) => s.code), SPRINT_AUTO_RE)
|
|
return `SP-${next}`
|
|
}
|
|
|