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:
1 parent
d5dc2a1832
commit
81acd9df7a
5 files changed
+199
No files matched your search
Binary file not shown.
@@ -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])
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user