diff --git a/db/custom.db b/db/custom.db index 9948d2e..b213396 100644 Binary files a/db/custom.db and b/db/custom.db differ 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 }) + }