feat(PBI-96/T-1059): add Zod schemas, folder-mapping, slug helpers
- lib/schemas/product-doc.ts: PRODUCT_DOC_FOLDERS/STATUSES + create/update/ toggle/frontmatter schemas (MAX_PRODUCT_DOC_CONTENT_LEN=100k) - lib/product-doc-folder.ts: DB UPPER_SNAKE ↔ API lowercase mapper (spiegel van lib/task-status.ts) - lib/product-doc-slug.ts: pure slugify + suggestSlug (dedupe-suffix) + ADR-sequence helpers (nextAdrPrefix, parseAdrNumber, suggestAdrSlug) - lib/schemas/product-doc-frontmatter-defaults.ts: per-folder UI-templates voor "Nieuwe doc"-dialog (last_updated weggelaten — server normaliseert bij save, zie T-1060) - __tests__: 37 tests groen (Zod-schemas + slug-helpers); de pre-existing worktree-env fail in idea-timeline-merge.test.ts blijft buiten scope Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
667be61334
commit
55781e463a
6 changed files with 542 additions and 0 deletions
111
__tests__/lib/product-doc-slug.test.ts
Normal file
111
__tests__/lib/product-doc-slug.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
nextAdrPrefix,
|
||||
parseAdrNumber,
|
||||
slugify,
|
||||
suggestAdrSlug,
|
||||
suggestSlug,
|
||||
} from '@/lib/product-doc-slug'
|
||||
|
||||
describe('slugify', () => {
|
||||
it('maakt simpele titels lowercase met koppeltekens', () => {
|
||||
expect(slugify('Deploy stappen')).toBe('deploy-stappen')
|
||||
expect(slugify('Hello, World!')).toBe('hello-world')
|
||||
})
|
||||
|
||||
it('stript diakritieken', () => {
|
||||
expect(slugify('Café écrasé')).toBe('cafe-ecrase')
|
||||
expect(slugify('Ångström')).toBe('angstrom')
|
||||
})
|
||||
|
||||
it('verwijdert leading/trailing dashes', () => {
|
||||
expect(slugify(' --- hello --- ')).toBe('hello')
|
||||
})
|
||||
|
||||
it('capt lengte op 80 tekens', () => {
|
||||
const long = 'a'.repeat(100)
|
||||
expect(slugify(long).length).toBe(80)
|
||||
})
|
||||
|
||||
it('geeft lege string voor lege/whitespace-only input', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
expect(slugify(' ')).toBe('')
|
||||
expect(slugify('!@#$%')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestSlug', () => {
|
||||
it('returnt base-slug zonder collision', () => {
|
||||
expect(suggestSlug('Deploy', [])).toBe('deploy')
|
||||
})
|
||||
|
||||
it('voegt -2 suffix toe bij eerste collision', () => {
|
||||
expect(suggestSlug('Deploy', ['deploy'])).toBe('deploy-2')
|
||||
})
|
||||
|
||||
it('telt door bij meerdere collisions', () => {
|
||||
expect(suggestSlug('Deploy', ['deploy', 'deploy-2', 'deploy-3'])).toBe('deploy-4')
|
||||
})
|
||||
|
||||
it('geeft lege string voor lege titel', () => {
|
||||
expect(suggestSlug('', ['x'])).toBe('')
|
||||
})
|
||||
|
||||
it('respecteert max-len bij toevoegen suffix', () => {
|
||||
const long80 = 'a'.repeat(80)
|
||||
const result = suggestSlug(long80, [long80])
|
||||
expect(result.length).toBeLessThanOrEqual(80)
|
||||
expect(result.endsWith('-2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextAdrPrefix', () => {
|
||||
it('geeft 0001 als er nog geen ADRs zijn', () => {
|
||||
expect(nextAdrPrefix(null)).toBe('0001')
|
||||
})
|
||||
|
||||
it('telt door op currentMax', () => {
|
||||
expect(nextAdrPrefix(0)).toBe('0001')
|
||||
expect(nextAdrPrefix(41)).toBe('0042')
|
||||
expect(nextAdrPrefix(999)).toBe('1000')
|
||||
})
|
||||
|
||||
it('pad altijd tot minimaal 4 cijfers', () => {
|
||||
expect(nextAdrPrefix(null)).toMatch(/^\d{4}$/)
|
||||
expect(nextAdrPrefix(8)).toBe('0009')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAdrNumber', () => {
|
||||
it('parseert geldig NNNN-prefix', () => {
|
||||
expect(parseAdrNumber('0001-context')).toBe(1)
|
||||
expect(parseAdrNumber('0042-some-slug')).toBe(42)
|
||||
})
|
||||
|
||||
it('returns null voor slugs zonder geldig prefix', () => {
|
||||
expect(parseAdrNumber('context')).toBeNull()
|
||||
expect(parseAdrNumber('abc-context')).toBeNull()
|
||||
expect(parseAdrNumber('1-context')).toBeNull()
|
||||
expect(parseAdrNumber('12345-context')).toBeNull() // 5 cijfers
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestAdrSlug', () => {
|
||||
it('bouwt NNNN-{slug} format', () => {
|
||||
expect(suggestAdrSlug('Use base-ui not Radix', null)).toBe('0001-use-base-ui-not-radix')
|
||||
expect(suggestAdrSlug('Use base-ui not Radix', 41)).toBe('0042-use-base-ui-not-radix')
|
||||
})
|
||||
|
||||
it('geeft alleen prefix bij lege titel', () => {
|
||||
expect(suggestAdrSlug('', null)).toBe('0001')
|
||||
expect(suggestAdrSlug(' ', 5)).toBe('0006')
|
||||
})
|
||||
|
||||
it('respecteert max-len van 80 tekens', () => {
|
||||
const longTitle = 'x'.repeat(100)
|
||||
const slug = suggestAdrSlug(longTitle, null)
|
||||
expect(slug.length).toBeLessThanOrEqual(80)
|
||||
expect(slug.startsWith('0001-')).toBe(true)
|
||||
})
|
||||
})
|
||||
160
__tests__/lib/schemas/product-doc.test.ts
Normal file
160
__tests__/lib/schemas/product-doc.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import {
|
||||
PRODUCT_DOC_FOLDERS,
|
||||
PRODUCT_DOC_STATUSES,
|
||||
productDocCreateSchema,
|
||||
productDocFolderToggleSchema,
|
||||
productDocFrontmatterSchema,
|
||||
productDocSlugSchema,
|
||||
productDocUpdateSchema,
|
||||
} from '@/lib/schemas/product-doc'
|
||||
|
||||
const validProductId = 'cmohrysyj0000rd17clnjy4tc'
|
||||
|
||||
describe('productDocSlugSchema', () => {
|
||||
it('accepteert geldige slugs', () => {
|
||||
expect(productDocSlugSchema.safeParse('deploy').success).toBe(true)
|
||||
expect(productDocSlugSchema.safeParse('0001-context-decision').success).toBe(true)
|
||||
expect(productDocSlugSchema.safeParse('a').success).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert hoofdletters, spaties en speciale tekens', () => {
|
||||
expect(productDocSlugSchema.safeParse('Deploy').success).toBe(false)
|
||||
expect(productDocSlugSchema.safeParse('deploy stappen').success).toBe(false)
|
||||
expect(productDocSlugSchema.safeParse('deploy/stappen').success).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert slug die met streepje begint', () => {
|
||||
expect(productDocSlugSchema.safeParse('-deploy').success).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert slug > 80 tekens', () => {
|
||||
expect(productDocSlugSchema.safeParse('a'.repeat(81)).success).toBe(false)
|
||||
expect(productDocSlugSchema.safeParse('a'.repeat(80)).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productDocFrontmatterSchema', () => {
|
||||
it('accepteert minimaal valide frontmatter', () => {
|
||||
const r = productDocFrontmatterSchema.safeParse({ title: 'Deploy', status: 'draft' })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert ontbrekende title of status', () => {
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({ status: 'draft' }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({ title: 'Deploy' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert status die niet in de enum zit', () => {
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({ title: 'D', status: 'wip' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepteert audience als string of array', () => {
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({
|
||||
title: 'D',
|
||||
status: 'draft',
|
||||
audience: 'maintainer',
|
||||
}).success,
|
||||
).toBe(true)
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({
|
||||
title: 'D',
|
||||
status: 'draft',
|
||||
audience: ['maintainer', 'contributor'],
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert oversized title', () => {
|
||||
expect(
|
||||
productDocFrontmatterSchema.safeParse({
|
||||
title: 'x'.repeat(201),
|
||||
status: 'draft',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productDocCreateSchema', () => {
|
||||
const base = {
|
||||
product_id: validProductId,
|
||||
folder: 'runbooks' as const,
|
||||
slug: 'deploy',
|
||||
content_md: '---\ntitle: "Deploy"\nstatus: draft\n---\n\nbody',
|
||||
}
|
||||
|
||||
it('accepteert geldige input', () => {
|
||||
expect(productDocCreateSchema.safeParse(base).success).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert ongeldige folder', () => {
|
||||
expect(
|
||||
productDocCreateSchema.safeParse({ ...base, folder: 'wiki' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert ongeldige product_id (geen cuid)', () => {
|
||||
expect(
|
||||
productDocCreateSchema.safeParse({ ...base, product_id: 'not-a-cuid' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('weigert leeg of te lang content_md', () => {
|
||||
expect(productDocCreateSchema.safeParse({ ...base, content_md: '' }).success).toBe(false)
|
||||
expect(
|
||||
productDocCreateSchema.safeParse({ ...base, content_md: 'x'.repeat(100_001) }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productDocUpdateSchema', () => {
|
||||
it('accepteert valide content_md', () => {
|
||||
expect(
|
||||
productDocUpdateSchema.safeParse({ content_md: '---\ntitle: "x"\nstatus: draft\n---\n\nbody' })
|
||||
.success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert leeg content_md', () => {
|
||||
expect(productDocUpdateSchema.safeParse({ content_md: '' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('productDocFolderToggleSchema', () => {
|
||||
it('accepteert valide toggle-input', () => {
|
||||
expect(
|
||||
productDocFolderToggleSchema.safeParse({
|
||||
product_id: validProductId,
|
||||
folder: 'api',
|
||||
enabled: false,
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('weigert ontbrekende enabled-vlag', () => {
|
||||
expect(
|
||||
productDocFolderToggleSchema.safeParse({
|
||||
product_id: validProductId,
|
||||
folder: 'api',
|
||||
}).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PRODUCT_DOC_FOLDERS + STATUSES', () => {
|
||||
it('bevat exact 8 folders', () => {
|
||||
expect(PRODUCT_DOC_FOLDERS).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('bevat exact 4 statussen', () => {
|
||||
expect(PRODUCT_DOC_STATUSES).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
41
lib/product-doc-folder.ts
Normal file
41
lib/product-doc-folder.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// Bidirectionele case-mapper voor de REST API-boundary van ProductDocFolder.
|
||||
// DB houdt UPPER_SNAKE; API exposeert lowercase. Spiegel van lib/task-status.ts.
|
||||
|
||||
import type { ProductDocFolder } from '@prisma/client'
|
||||
|
||||
import {
|
||||
PRODUCT_DOC_FOLDERS,
|
||||
type ProductDocFolderApi,
|
||||
} from '@/lib/schemas/product-doc'
|
||||
|
||||
const FOLDER_DB_TO_API = {
|
||||
ADR: 'adr',
|
||||
ARCHITECTURE: 'architecture',
|
||||
PATTERNS: 'patterns',
|
||||
PLANS: 'plans',
|
||||
RUNBOOKS: 'runbooks',
|
||||
SPECS: 'specs',
|
||||
MANUAL: 'manual',
|
||||
API: 'api',
|
||||
} as const satisfies Record<ProductDocFolder, ProductDocFolderApi>
|
||||
|
||||
const FOLDER_API_TO_DB: Record<string, ProductDocFolder> = {
|
||||
adr: 'ADR',
|
||||
architecture: 'ARCHITECTURE',
|
||||
patterns: 'PATTERNS',
|
||||
plans: 'PLANS',
|
||||
runbooks: 'RUNBOOKS',
|
||||
specs: 'SPECS',
|
||||
manual: 'MANUAL',
|
||||
api: 'API',
|
||||
}
|
||||
|
||||
export function productDocFolderToApi(f: ProductDocFolder): ProductDocFolderApi {
|
||||
return FOLDER_DB_TO_API[f]
|
||||
}
|
||||
|
||||
export function productDocFolderFromApi(s: string): ProductDocFolder | null {
|
||||
return FOLDER_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export const PRODUCT_DOC_FOLDER_API_VALUES = PRODUCT_DOC_FOLDERS
|
||||
78
lib/product-doc-slug.ts
Normal file
78
lib/product-doc-slug.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Slug-helpers voor ProductDoc. Pure functies — geen DB-koppeling.
|
||||
// Caller (server-action) doet de DB-query om `existing` slugs te leveren.
|
||||
|
||||
const MAX_SLUG_LEN = 80
|
||||
|
||||
/**
|
||||
* Genereert een URL-safe slug uit een titel. Diakritieken worden gestript,
|
||||
* alles buiten [a-z0-9-] wordt vervangen door `-`, en het resultaat wordt
|
||||
* gecapped op MAX_SLUG_LEN tekens.
|
||||
*/
|
||||
export function slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, MAX_SLUG_LEN)
|
||||
.replace(/-+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggesteert een unieke slug door, bij conflict, een numeriek suffix
|
||||
* (`-2`, `-3`, ...) toe te voegen. Returns '' als de titel leeg slugified.
|
||||
*
|
||||
* Caller is verantwoordelijk voor het leveren van bestaande slugs binnen
|
||||
* dezelfde (product_id, folder)-scope.
|
||||
*/
|
||||
export function suggestSlug(title: string, existing: readonly string[]): string {
|
||||
const base = slugify(title)
|
||||
if (!base) return ''
|
||||
if (!existing.includes(base)) return base
|
||||
|
||||
for (let n = 2; n <= 999; n++) {
|
||||
const suffix = `-${n}`
|
||||
const trimmed = base.slice(0, MAX_SLUG_LEN - suffix.length).replace(/-+$/, '')
|
||||
const candidate = `${trimmed}${suffix}`
|
||||
if (!existing.includes(candidate)) return candidate
|
||||
}
|
||||
throw new Error('Te veel slug-collisions; kies een andere titel')
|
||||
}
|
||||
|
||||
const ADR_PREFIX_RE = /^(\d{4})-/
|
||||
|
||||
/**
|
||||
* Geeft het volgende ADR-nummer als 4-cijferige string (`'0042'`).
|
||||
* `currentMax` is het hoogste reeds gebruikte ADR-nummer binnen deze
|
||||
* (product_id, folder='adr')-scope, of `null` als er nog geen ADRs zijn
|
||||
* (eerste = `'0001'`).
|
||||
*/
|
||||
export function nextAdrPrefix(currentMax: number | null): string {
|
||||
const next = (currentMax ?? 0) + 1
|
||||
return next.toString().padStart(4, '0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parseert het ADR-volgnummer uit een slug die het `NNNN-...` pattern
|
||||
* volgt. Returns `null` voor slugs zonder geldig prefix.
|
||||
*/
|
||||
export function parseAdrNumber(slug: string): number | null {
|
||||
const m = ADR_PREFIX_RE.exec(slug)
|
||||
if (!m) return null
|
||||
const n = parseInt(m[1], 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouwt een ADR-slug `NNNN-{slugified-title}` waarbij `NNNN` volgt op
|
||||
* `currentMax`. Bij lege titel: alleen het prefix.
|
||||
*/
|
||||
export function suggestAdrSlug(title: string, currentMax: number | null): string {
|
||||
const prefix = nextAdrPrefix(currentMax)
|
||||
const titleSlug = slugify(title)
|
||||
if (!titleSlug) return prefix
|
||||
const maxTitleLen = MAX_SLUG_LEN - prefix.length - 1
|
||||
return `${prefix}-${titleSlug.slice(0, maxTitleLen).replace(/-+$/, '')}`
|
||||
}
|
||||
70
lib/schemas/product-doc-frontmatter-defaults.ts
Normal file
70
lib/schemas/product-doc-frontmatter-defaults.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// Per-folder template-strings voor het "Nieuwe doc"-dialog. UI-only:
|
||||
// server gebruikt deze niet. `last_updated` wordt bij save door de server
|
||||
// gezet (zie setProductDocFrontmatterFields), dus laten we het hier weg.
|
||||
|
||||
import type { ProductDocFolderApi } from '@/lib/schemas/product-doc'
|
||||
|
||||
export interface ProductDocFolderTemplate {
|
||||
hint: string
|
||||
template: string
|
||||
}
|
||||
|
||||
function template(body: string): string {
|
||||
return `---
|
||||
title: "..."
|
||||
status: draft
|
||||
audience: [contributor]
|
||||
---
|
||||
|
||||
${body}`
|
||||
}
|
||||
|
||||
export const PRODUCT_DOC_FOLDER_DEFAULTS: Record<
|
||||
ProductDocFolderApi,
|
||||
ProductDocFolderTemplate
|
||||
> = {
|
||||
adr: {
|
||||
hint: 'Context → Beslissing → Gevolgen. Gebruik NNNN-{slug} naamgeving (auto-suggested).',
|
||||
template: template(
|
||||
`## Context\n\nWelke situatie of vraag triggerde deze beslissing?\n\n## Beslissing\n\nWat is besloten en waarom?\n\n## Gevolgen\n\nWelke trade-offs neemt het team hiermee?\n`,
|
||||
),
|
||||
},
|
||||
architecture: {
|
||||
hint: 'Topisch overzicht van een component of subsysteem.',
|
||||
template: template(
|
||||
`## Overzicht\n\nWat is het doel van deze component?\n\n## Datastromen\n\nWelke data komt binnen, wat gebeurt ermee?\n\n## Aandachtspunten\n\n- ...\n`,
|
||||
),
|
||||
},
|
||||
patterns: {
|
||||
hint: 'Herbruikbaar code-pattern met voorbeeld.',
|
||||
template: template(
|
||||
`## Wanneer toepassen\n\n## Voorbeeld\n\n\`\`\`ts\n// ...\n\`\`\`\n\n## Valkuilen\n\n- ...\n`,
|
||||
),
|
||||
},
|
||||
plans: {
|
||||
hint: 'Feature/PBI-plan met scope, breakdown en verificatie.',
|
||||
template: template(`## Context\n\n## Scope\n\n## Breakdown\n\n## Verificatie\n`),
|
||||
},
|
||||
runbooks: {
|
||||
hint: 'Operationele procedure of incident-flow.',
|
||||
template: template(
|
||||
`## Wanneer gebruiken\n\n## Stappen\n\n1. ...\n\n## Verificatie\n\n## Troubleshooting\n`,
|
||||
),
|
||||
},
|
||||
specs: {
|
||||
hint: 'Functionele specificatie met acceptatiecriteria.',
|
||||
template: template(
|
||||
`## Doel\n\n## User flow\n\n## Acceptatiecriteria\n\n- [ ] ...\n`,
|
||||
),
|
||||
},
|
||||
manual: {
|
||||
hint: 'Onderdeel van de gebruikers- of developer-manual.',
|
||||
template: template(`## Inleiding\n\n## Stappen\n\n## Veelgestelde vragen\n`),
|
||||
},
|
||||
api: {
|
||||
hint: 'API-endpoint of contract-detail.',
|
||||
template: template(
|
||||
`## Endpoint\n\n\`POST /api/...\`\n\n## Request\n\n## Response\n\n## Fouten\n`,
|
||||
),
|
||||
},
|
||||
}
|
||||
82
lib/schemas/product-doc.ts
Normal file
82
lib/schemas/product-doc.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
// API-laag werkt met lowercase folder-namen en lowercase statuses.
|
||||
// DB-mapping (UPPER_SNAKE) leeft in `lib/product-doc-folder.ts`.
|
||||
|
||||
export const PRODUCT_DOC_FOLDERS = [
|
||||
'adr',
|
||||
'architecture',
|
||||
'patterns',
|
||||
'plans',
|
||||
'runbooks',
|
||||
'specs',
|
||||
'manual',
|
||||
'api',
|
||||
] as const
|
||||
|
||||
export const PRODUCT_DOC_STATUSES = [
|
||||
'draft',
|
||||
'active',
|
||||
'deprecated',
|
||||
'archived',
|
||||
] as const
|
||||
|
||||
export const productDocFolderSchema = z.enum(PRODUCT_DOC_FOLDERS)
|
||||
export const productDocStatusSchema = z.enum(PRODUCT_DOC_STATUSES)
|
||||
|
||||
// Slugs zijn URL-safe: kleine letters, cijfers, koppeltekens; begint met
|
||||
// letter/cijfer, max 80 tekens (matcht @db.VarChar(80) in schema.prisma).
|
||||
export const productDocSlugSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-z0-9][a-z0-9-]{0,79}$/,
|
||||
'Slug mag alleen kleine letters, cijfers en koppeltekens bevatten (1-80 tekens, niet starten met streepje)',
|
||||
)
|
||||
|
||||
// Maximum body-grootte gelijk aan `Idea.plan_md` (lib/schemas/idea.ts).
|
||||
export const MAX_PRODUCT_DOC_CONTENT_LEN = 100_000
|
||||
|
||||
// Frontmatter dat in `content_md` als YAML-block staat. `last_updated` is
|
||||
// optional omdat de server hem bij elke save normaliseert (P2-review-fix);
|
||||
// `title` en `status` worden bij save naar de gerepliceerde kolommen
|
||||
// gepushed (zie createProductDocAction in actions/product-docs.ts).
|
||||
export const productDocFrontmatterSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, 'Titel is verplicht')
|
||||
.max(200, 'Maximaal 200 tekens'),
|
||||
status: productDocStatusSchema,
|
||||
audience: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
applies_to: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
last_updated: z.string().optional(),
|
||||
})
|
||||
|
||||
export const productDocCreateSchema = z.object({
|
||||
product_id: z.string().cuid('Ongeldig product'),
|
||||
folder: productDocFolderSchema,
|
||||
slug: productDocSlugSchema,
|
||||
content_md: z
|
||||
.string()
|
||||
.min(1, 'Inhoud is verplicht')
|
||||
.max(MAX_PRODUCT_DOC_CONTENT_LEN, `Maximaal ${MAX_PRODUCT_DOC_CONTENT_LEN} tekens`),
|
||||
})
|
||||
|
||||
export const productDocUpdateSchema = z.object({
|
||||
content_md: z
|
||||
.string()
|
||||
.min(1, 'Inhoud is verplicht')
|
||||
.max(MAX_PRODUCT_DOC_CONTENT_LEN, `Maximaal ${MAX_PRODUCT_DOC_CONTENT_LEN} tekens`),
|
||||
})
|
||||
|
||||
export const productDocFolderToggleSchema = z.object({
|
||||
product_id: z.string().cuid('Ongeldig product'),
|
||||
folder: productDocFolderSchema,
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export type ProductDocFolderApi = z.infer<typeof productDocFolderSchema>
|
||||
export type ProductDocStatusApi = z.infer<typeof productDocStatusSchema>
|
||||
export type ProductDocFrontmatter = z.infer<typeof productDocFrontmatterSchema>
|
||||
export type ProductDocCreateInput = z.infer<typeof productDocCreateSchema>
|
||||
export type ProductDocUpdateInput = z.infer<typeof productDocUpdateSchema>
|
||||
export type ProductDocFolderToggleInput = z.infer<typeof productDocFolderToggleSchema>
|
||||
Loading…
Add table
Add a link
Reference in a new issue