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

@ -1,16 +1,44 @@
// MCP authoring tool: create een Product Backlog Item.
//
// 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
// door de Scrum4Me-app gegenereerd, kan optioneel later via UI worden gezet.
// niet meegegeven. Code wordt auto-gegenereerd als PBI-N (zelfde logica als de
// Scrum4Me-app), met retry bij een race-condition op de unique constraint.
import { z } from 'zod'
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.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({
product_id: z.string().min(1),
title: z.string().min(1).max(200),
@ -45,24 +73,36 @@ export function registerCreatePbiTool(server: McpServer) {
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
}
const pbi = await prisma.pbi.create({
data: {
product_id,
title,
description: description ?? null,
priority,
sort_order: resolvedSortOrder,
},
select: {
id: true,
title: true,
description: true,
priority: true,
sort_order: true,
created_at: true,
},
})
return toolJson(pbi)
let lastError: unknown
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
const code = await generateNextPbiCode(product_id)
try {
const pbi = await prisma.pbi.create({
data: {
product_id,
code,
title,
description: description ?? null,
priority,
sort_order: resolvedSortOrder,
},
select: {
id: true,
code: true,
title: true,
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 type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.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({
pbi_id: z.string().min(1),
title: z.string().min(1).max(200),
@ -52,29 +80,41 @@ export function registerCreateStoryTool(server: McpServer) {
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
}
const story = await prisma.story.create({
data: {
pbi_id,
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
title,
description: description ?? null,
acceptance_criteria: acceptance_criteria ?? null,
priority,
sort_order: resolvedSortOrder,
status: 'OPEN',
},
select: {
id: true,
title: true,
description: true,
acceptance_criteria: true,
priority: true,
sort_order: true,
status: true,
created_at: true,
},
})
return toolJson(story)
let lastError: unknown
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
const code = await generateNextStoryCode(pbi.product_id)
try {
const story = await prisma.story.create({
data: {
pbi_id,
product_id: pbi.product_id, // denormalized uit DB-parent, niet uit input
code,
title,
description: description ?? null,
acceptance_criteria: acceptance_criteria ?? null,
priority,
sort_order: resolvedSortOrder,
status: 'OPEN',
},
select: {
id: true,
code: true,
title: true,
description: true,
acceptance_criteria: true,
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 type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { Prisma } from '@prisma/client'
import { prisma } from '../prisma.js'
import { requireWriteAccess } from '../auth.js'
import { userCanAccessProduct } from '../access.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({
story_id: z.string().min(1),
title: z.string().min(1).max(200),
@ -51,29 +79,42 @@ export function registerCreateTaskTool(server: McpServer) {
resolvedSortOrder = (last?.sort_order ?? 0) + 1.0
}
const task = await prisma.task.create({
data: {
story_id,
sprint_id: story.sprint_id, // denormalized — erf van story
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
sort_order: resolvedSortOrder,
status: 'TO_DO',
},
select: {
id: true,
title: true,
description: true,
implementation_plan: true,
priority: true,
sort_order: true,
status: true,
created_at: true,
},
})
return toolJson(task)
let lastError: unknown
for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS; attempt++) {
const code = await generateNextTaskCode(story.product_id)
try {
const task = await prisma.task.create({
data: {
story_id,
product_id: story.product_id, // denormalized — erf van story
sprint_id: story.sprint_id, // denormalized — erf van story
code,
title,
description: description ?? null,
implementation_plan: implementation_plan ?? null,
priority,
sort_order: resolvedSortOrder,
status: 'TO_DO',
},
select: {
id: true,
code: true,
title: true,
description: true,
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')
}),
)
}