feat: Custom fields — JSONB with schema per entity (PRD §5.5)

Models: CustomFieldDef (schema definitions) + CustomFieldValue (entity values)
- Per entity type: company, person, deal, project, invoice, ticket, contract
- Field types: string, number, boolean, date, select, multiselect
- Options for select/multiselect stored as JSON
- Unique constraint: (entityType, key) for definitions, (entityType, entityId, fieldDefId) for values

API /api/aos/custom-fields:
- GET: list all definitions (+ filter by entityType)
- POST: create definition (Zod validated, lowercase_snake_case key, unique check)
- PATCH: set value on entity (upsert pattern)

Seed: 10 field definitions across 6 entity types
  - company: branche, mitarbeiterzahl, umsatzklasse
  - person: entscheider, kontaktweg
  - deal: wettbewerber, entscheidungsgrund
  - project: prioritaet
  - invoice: kostenstelle
  - ticket: auswirkung

Verified: 10 defs, 3 company fields, duplicate→409, invalid key→400
This commit is contained in:
Z User committed 2026-10-27 22:19:25 +00:00
1 parent d5dc2a1832
commit 81acd9df7a
5 files changed
+199

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { z } from 'zod'
import { checkRateLimit } from '@/lib/rate-limit'
export const dynamic = 'force-dynamic'
const ENTITY_TYPES = ['company', 'person', 'deal', 'project', 'invoice', 'ticket', 'contract'] as const
const FIELD_TYPES = ['string', 'number', 'boolean', 'date', 'select', 'multiselect'] as const
// GET /api/aos/custom-fields?entityType=company
export async function GET(req: Request) {
try {
const { searchParams } = new URL(req.url)
const entityType = searchParams.get('entityType')
const where = entityType ? { entityType } : {}
const [defs, values] = await Promise.all([
db.customFieldDef.findMany({ where, orderBy: [{ entityType: 'asc' }, { sortOrder: 'asc' }] }),
db.customFieldValue.findMany({ take: 500 }),
])
return NextResponse.json({
definitions: defs.map((d) => ({
id: d.id, entityType: d.entityType, key: d.key, label: d.label,
type: d.type, options: d.options ? JSON.parse(d.options) : null,
required: d.required, sortOrder: d.sortOrder,
})),
valueCount: values.length,
entityTypes: ENTITY_TYPES,
fieldTypes: FIELD_TYPES,
stats: {
total: defs.length,
byEntity: defs.reduce((acc, d) => { acc[d.entityType] = (acc[d.entityType] ?? 0) + 1; return acc }, {} as Record<string, number>),
},
})
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// POST /api/aos/custom-fields — create a field definition
const createSchema = z.object({
entityType: z.enum(ENTITY_TYPES),
key: z.string().min(1).max(50).regex(/^[a-z0-9_]+$/, 'key must be lowercase_snake_case'),
label: z.string().min(1).max(100),
type: z.enum(FIELD_TYPES),
options: z.array(z.string()).optional(),
required: z.boolean().optional().default(false),
sortOrder: z.number().int().optional().default(0),
})
export async function POST(req: Request) {
try {
const rateLimited = checkRateLimit(req, 'api')
if (rateLimited) return rateLimited
const parsed = createSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const body = parsed.data
const existing = await db.customFieldDef.findUnique({
where: { entityType_key: { entityType: body.entityType, key: body.key } },
})
if (existing) {
return NextResponse.json({ error: 'field already exists for this entity type' }, { status: 409 })
}
const def = await db.customFieldDef.create({
data: {
entityType: body.entityType,
key: body.key,
label: body.label,
type: body.type,
options: body.options ? JSON.stringify(body.options) : null,
required: body.required,
sortOrder: body.sortOrder,
},
})
await db.auditLog.create({
data: {
actor: 'max@nordlicht.digital', action: 'customfield.def:create', entity: 'CustomFieldDef', entityId: def.id,
after: JSON.stringify({ entityType: body.entityType, key: body.key, label: body.label, type: body.type }),
hash: 'sha256:' + Math.random().toString(36).slice(2),
},
})
return NextResponse.json({ definition: def })
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
// PATCH /api/aos/custom-fields — set a value on an entity
const setValueSchema = z.object({
fieldDefId: z.string().min(1),
entityType: z.enum(ENTITY_TYPES),
entityId: z.string().min(1),
value: z.unknown().optional(),
})
export async function PATCH(req: Request) {
try {
const parsed = setValueSchema.safeParse(await req.json())
if (!parsed.success) {
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
}
const body = parsed.data
const def = await db.customFieldDef.findUnique({ where: { id: body.fieldDefId } })
if (!def) return NextResponse.json({ error: 'field definition not found' }, { status: 404 })
const valueStr = body.value !== undefined ? JSON.stringify(body.value) : null
const existing = await db.customFieldValue.findUnique({
where: {
entityType_entityId_fieldDefId: {
entityType: body.entityType, entityId: body.entityId, fieldDefId: body.fieldDefId,
},
},
})
if (existing) {
const updated = await db.customFieldValue.update({
where: { id: existing.id },
data: { value: valueStr },
})
return NextResponse.json({ value: updated })
} else {
const created = await db.customFieldValue.create({
data: {
fieldDefId: body.fieldDefId, entityType: body.entityType,
entityId: body.entityId, value: valueStr,
},
})
return NextResponse.json({ value: created })
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'internal server error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
+2
View File
@@ -32,6 +32,8 @@ export async function POST() {
await db.euerEntry.deleteMany()
await db.buchungssatz.deleteMany()
await db.konto.deleteMany()
await db.customFieldValue.deleteMany()
await db.customFieldDef.deleteMany()
await db.estimateLine.deleteMany()
await db.estimate.deleteMany()
await db.ticketMessage.deleteMany()