From 81acd9df7a762aca739417ce9c3a10f797f64406 Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 22:19:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Custom=20fields=20=E2=80=94=20JSONB=20w?= =?UTF-8?q?ith=20schema=20per=20entity=20(PRD=20=C2=A75.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- db/custom.db | Bin 442368 -> 466944 bytes prisma/schema.prisma | 33 ++++++ src/app/api/aos/custom-fields/route.ts | 147 +++++++++++++++++++++++++ src/app/api/aos/seed/route.ts | 2 + src/lib/seed.ts | 17 +++ 5 files changed, 199 insertions(+) create mode 100644 src/app/api/aos/custom-fields/route.ts diff --git a/db/custom.db b/db/custom.db index 9948d2ebd15a4ddcc57e974a684e31d46406f415..b213396b3c651a4f78c0a3a14c3c8c4c8c646401 100644 GIT binary patch delta 36519 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 delta 33830 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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e73391a..9c67ed1 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -753,3 +753,36 @@ model Jahresarchiv { createdBy String notes String? } + +// --------------------------------------------------------------------------- +// Custom Fields (PRD §5.5: "Custom fields: JSONB with a schema table per entity") +// --------------------------------------------------------------------------- + +model CustomFieldDef { + id String @id @default(cuid()) + entityType String // company|person|deal|project|invoice|ticket|contract + key String // field key, e.g. "branche", "lead_source_detail" + label String // display label, e.g. "Branche" + type String // string|number|boolean|date|select|multiselect + options String? // JSON array for select/multiselect + required Boolean @default(false) + sortOrder Int @default(0) + values CustomFieldValue[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([entityType, key]) +} + +model CustomFieldValue { + id String @id @default(cuid()) + fieldDefId String + fieldDef CustomFieldDef @relation(fields: [fieldDefId], references: [id]) + entityType String // denormalized for fast lookup + entityId String // the entity's ID + value String? // JSON-encoded value (string, number, bool, array) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([entityType, entityId, fieldDefId]) +} diff --git a/src/app/api/aos/custom-fields/route.ts b/src/app/api/aos/custom-fields/route.ts new file mode 100644 index 0000000..ccd7431 --- /dev/null +++ b/src/app/api/aos/custom-fields/route.ts @@ -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), + }, + }) + } 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 }) + } +} diff --git a/src/app/api/aos/seed/route.ts b/src/app/api/aos/seed/route.ts index dae0344..bf03c2a 100755 --- a/src/app/api/aos/seed/route.ts +++ b/src/app/api/aos/seed/route.ts @@ -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() diff --git a/src/lib/seed.ts b/src/lib/seed.ts index f1c840d..214819d 100755 --- a/src/lib/seed.ts +++ b/src/lib/seed.ts @@ -1215,3 +1215,20 @@ main() .finally(async () => { await db.$disconnect() }) + + // --- Custom Fields (PRD §5.5: JSONB with schema per entity) --------------- + const customFields = [ + { entityType: 'company', key: 'branche', label: 'Branche', type: 'select', options: JSON.stringify(['Handwerk', 'Gastronomie', 'Einzelhandel', 'Dienstleistung', 'Industrie', 'Gesundheit', 'Technologie']), required: false, sortOrder: 1 }, + { entityType: 'company', key: 'mitarbeiterzahl', label: 'Mitarbeiterzahl', type: 'select', options: JSON.stringify(['1-10', '11-50', '51-200', '201-500', '500+']), required: false, sortOrder: 2 }, + { entityType: 'company', key: 'umsatzklasse', label: 'Umsatzklasse', type: 'select', options: JSON.stringify(['< 100k', '100k-500k', '500k-2M', '2M-10M', '10M+']), required: false, sortOrder: 3 }, + { entityType: 'person', key: 'entscheider', label: 'Entscheider?', type: 'boolean', required: false, sortOrder: 1 }, + { entityType: 'person', key: 'kontaktweg', label: 'Bevorzugter Kontaktweg', type: 'select', options: JSON.stringify(['E-Mail', 'Telefon', 'Teams', 'Persönlich']), required: false, sortOrder: 2 }, + { entityType: 'deal', key: 'wettbewerber', label: 'Wettbewerber', type: 'string', required: false, sortOrder: 1 }, + { entityType: 'deal', key: 'entscheidungsgrund', label: 'Entscheidungsgrund', type: 'select', options: JSON.stringify(['Preis', 'Qualität', 'Geschwindigkeit', 'Referenzen', 'Technologie']), required: false, sortOrder: 2 }, + { entityType: 'project', key: 'prioritaet', label: 'Priorität (kundenseitig)', type: 'select', options: JSON.stringify(['niedrig', 'normal', 'hoch', 'kritisch']), required: false, sortOrder: 1 }, + { entityType: 'invoice', key: 'kostenstelle', label: 'Kostenstelle', type: 'string', required: false, sortOrder: 1 }, + { entityType: 'ticket', key: 'auswirkung', label: 'Geschäftsauswirkung', type: 'select', options: JSON.stringify(['keine', 'niedrig', 'mittel', 'hoch', 'kritisch']), required: false, sortOrder: 1 }, + ] + for (const cf of customFields) { + await db.customFieldDef.create({ data: cf }) + }