feat: auto-generate codes for PBI/Story/Task on create

Code field became required in schema (feat/entity-codes-required).
All three create tools now generate PBI-N / ST-001 / T-N via the same
SELECT-MAX + retry pattern used in the Scrum4Me app. Also bumps vendor
submodule to v1.0.0 and regenerates prisma/schema.prisma.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Madhura68 2026-05-04 16:14:36 +02:00
parent 85111f6dc7
commit 49defa9686
5 changed files with 198 additions and 71 deletions

View file

@ -140,6 +140,7 @@ model Product {
pbis Pbi[] pbis Pbi[]
sprints Sprint[] sprints Sprint[]
stories Story[] stories Story[]
tasks Task[]
todos Todo[] todos Todo[]
members ProductMember[] members ProductMember[]
active_for_users User[] @relation("UserActiveProduct") active_for_users User[] @relation("UserActiveProduct")
@ -156,7 +157,7 @@ model Pbi {
id String @id @default(cuid()) id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String product_id String
code String? @db.VarChar(30) code String @db.VarChar(30)
title String title String
description String? description String?
priority Int priority Int
@ -165,8 +166,8 @@ model Pbi {
pr_url String? pr_url String?
pr_merged_at DateTime? pr_merged_at DateTime?
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime @updatedAt updated_at DateTime @updatedAt
stories Story[] stories Story[]
@@unique([product_id, code]) @@unique([product_id, code])
@@index([product_id, priority, sort_order]) @@index([product_id, priority, sort_order])
@ -184,7 +185,7 @@ model Story {
sprint_id String? sprint_id String?
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
assignee_id String? assignee_id String?
code String? @db.VarChar(30) code String @db.VarChar(30)
title String title String
description String? description String?
acceptance_criteria String? acceptance_criteria String?
@ -242,8 +243,11 @@ model Task {
id String @id @default(cuid()) id String @id @default(cuid())
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
story_id String 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 Sprint? @relation(fields: [sprint_id], references: [id])
sprint_id String? sprint_id String?
code String @db.VarChar(30)
title String title String
description String? description String?
implementation_plan String? implementation_plan String?
@ -262,8 +266,10 @@ model Task {
claude_questions ClaudeQuestion[] claude_questions ClaudeQuestion[]
claude_jobs ClaudeJob[] claude_jobs ClaudeJob[]
@@unique([product_id, code])
@@index([story_id, priority, sort_order]) @@index([story_id, priority, sort_order])
@@index([sprint_id, status]) @@index([sprint_id, status])
@@index([product_id])
@@map("tasks") @@map("tasks")
} }

View file

@ -1,16 +1,44 @@
// MCP authoring tool: create een Product Backlog Item. // MCP authoring tool: create een Product Backlog Item.
// //
// Sort_order wordt automatisch op last+1 binnen de prioriteits-groep gezet als // Sort_order wordt automatisch op last+1 binnen de prioriteits-groep gezet als
// niet meegegeven. Code-veld blijft null — auto-codes (PBI-1, PBI-2, …) worden // niet meegegeven. Code wordt auto-gegenereerd als PBI-N (zelfde logica als de
// door de Scrum4Me-app gegenereerd, kan optioneel later via UI worden gezet. // Scrum4Me-app), met retry bij een race-condition op de unique constraint.
import { z } from 'zod' import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js' import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js' import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js' import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js' import { toolError, toolJson, withToolErrors } from '../errors.js'
const PBI_AUTO_RE = /^PBI-(\d+)$/
const MAX_CODE_ATTEMPTS = 3
async function generateNextPbiCode(productId: string): Promise<string> {
const pbis = await prisma.pbi.findMany({
where: { product_id: productId },
select: { code: true },
})
let max = 0
for (const p of pbis) {
const m = p.code?.match(PBI_AUTO_RE)
if (m) {
const n = Number.parseInt(m[1], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return `PBI-${max + 1}`
}
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
return Array.isArray(target) ? target.includes('code') : target.includes('code')
}
const inputSchema = z.object({ const inputSchema = z.object({
product_id: z.string().min(1), product_id: z.string().min(1),
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
@ -45,24 +73,36 @@ export function registerCreatePbiTool(server: McpServer) {
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
} }
const pbi = await prisma.pbi.create({ let lastError: unknown
data: { for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
product_id, const code = await generateNextPbiCode(product_id)
title, try {
description: description ?? null, const pbi = await prisma.pbi.create({
priority, data: {
sort_order: resolvedSortOrder, product_id,
}, code,
select: { title,
id: true, description: description ?? null,
title: true, priority,
description: true, sort_order: resolvedSortOrder,
priority: true, },
sort_order: true, select: {
created_at: true, id: true,
}, code: true,
}) title: true,
return toolJson(pbi) description: true,
priority: true,
sort_order: true,
created_at: true,
},
})
return toolJson(pbi)
} catch (e) {
if (isCodeUniqueConflict(e)) { lastError = e; continue }
throw e
}
}
throw lastError ?? new Error('Kon geen unieke PBI-code genereren')
}), }),
) )
} }

View file

@ -6,11 +6,39 @@
import { z } from 'zod' import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js' import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js' import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js' import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js' import { toolError, toolJson, withToolErrors } from '../errors.js'
const STORY_AUTO_RE = /^ST-(\d+)$/
const MAX_CODE_ATTEMPTS = 3
async function generateNextStoryCode(productId: string): Promise<string> {
const stories = await prisma.story.findMany({
where: { product_id: productId },
select: { code: true },
})
let max = 0
for (const s of stories) {
const m = s.code?.match(STORY_AUTO_RE)
if (m) {
const n = Number.parseInt(m[1], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return `ST-${String(max + 1).padStart(3, '0')}`
}
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
return Array.isArray(target) ? target.includes('code') : target.includes('code')
}
const inputSchema = z.object({ const inputSchema = z.object({
pbi_id: z.string().min(1), pbi_id: z.string().min(1),
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
@ -52,29 +80,41 @@ export function registerCreateStoryTool(server: McpServer) {
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
} }
const story = await prisma.story.create({ let lastError: unknown
data: { for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
pbi_id, const code = await generateNextStoryCode(pbi.product_id)
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input try {
title, const story = await prisma.story.create({
description: description ?? null, data: {
acceptance_criteria: acceptance_criteria ?? null, pbi_id,
priority, product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
sort_order: resolvedSortOrder, code,
status: 'OPEN', title,
}, description: description ?? null,
select: { acceptance_criteria: acceptance_criteria ?? null,
id: true, priority,
title: true, sort_order: resolvedSortOrder,
description: true, status: 'OPEN',
acceptance_criteria: true, },
priority: true, select: {
sort_order: true, id: true,
status: true, code: true,
created_at: true, title: true,
}, description: true,
}) acceptance_criteria: true,
return toolJson(story) priority: true,
sort_order: true,
status: true,
created_at: true,
},
})
return toolJson(story)
} catch (e) {
if (isCodeUniqueConflict(e)) { lastError = e; continue }
throw e
}
}
throw lastError ?? new Error('Kon geen unieke Story-code genereren')
}), }),
) )
} }

View file

@ -5,11 +5,39 @@
import { z } from 'zod' import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js' import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js' import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.js' import { userCanAccessProduct } from '../access.js'
import { toolError, toolJson, withToolErrors } from '../errors.js' import { toolError, toolJson, withToolErrors } from '../errors.js'
const TASK_AUTO_RE = /^T-(\d+)$/
const MAX_CODE_ATTEMPTS = 3
async function generateNextTaskCode(productId: string): Promise<string> {
const tasks = await prisma.task.findMany({
where: { product_id: productId },
select: { code: true },
})
let max = 0
for (const t of tasks) {
const m = t.code?.match(TASK_AUTO_RE)
if (m) {
const n = Number.parseInt(m[1], 10)
if (!Number.isNaN(n) && n > max) max = n
}
}
return `T-${max + 1}`
}
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
return Array.isArray(target) ? target.includes('code') : target.includes('code')
}
const inputSchema = z.object({ const inputSchema = z.object({
story_id: z.string().min(1), story_id: z.string().min(1),
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
@ -51,29 +79,42 @@ export function registerCreateTaskTool(server: McpServer) {
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0 resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
} }
const task = await prisma.task.create({ let lastError: unknown
data: { for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
story_id, const code = await generateNextTaskCode(story.product_id)
sprint_id: story.sprint_id, // denormalized — erf van story try {
title, const task = await prisma.task.create({
description: description ?? null, data: {
implementation_plan: implementation_plan ?? null, story_id,
priority, product_id: story.product_id, // denormalized — erf van story
sort_order: resolvedSortOrder, sprint_id: story.sprint_id, // denormalized — erf van story
status: 'TO_DO', code,
}, title,
select: { description: description ?? null,
id: true, implementation_plan: implementation_plan ?? null,
title: true, priority,
description: true, sort_order: resolvedSortOrder,
implementation_plan: true, status: 'TO_DO',
priority: true, },
sort_order: true, select: {
status: true, id: true,
created_at: true, code: true,
}, title: true,
}) description: true,
return toolJson(task) implementation_plan: true,
priority: true,
sort_order: true,
status: true,
created_at: true,
},
})
return toolJson(task)
} catch (e) {
if (isCodeUniqueConflict(e)) { lastError = e; continue }
throw e
}
}
throw lastError ?? new Error('Kon geen unieke Task-code genereren')
}), }),
) )
} }

2
vendor/scrum4me vendored

@ -1 +1 @@
Subproject commit a754acf13ba68c411d73060537ef356037230065 Subproject commit 90343573f399544e386b2833d23a74f0fa122fa6