import { and, eq, count } from 'drizzle-orm'; import { db } from '@/lib/db'; import { customFieldDefinitions, customFieldValues } from '@/lib/db/schema/system'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { NotFoundError, ValidationError, ConflictError } from '@/lib/errors'; import type { CreateFieldInput, UpdateFieldInput } from '@/lib/validators/custom-fields'; import type { CustomFieldDefinition } from '@/lib/db/schema/system'; // ─── Types ──────────────────────────────────────────────────────────────────── // ─── Value Validation ───────────────────────────────────────────────────────── function validateCustomFieldValue( definition: CustomFieldDefinition, value: unknown, ): string | null { if (value === null || value === undefined) { return definition.isRequired ? 'This field is required' : null; } switch (definition.fieldType) { case 'text': return typeof value !== 'string' ? 'Must be text' : value.length > 1000 ? 'Max 1000 chars' : null; case 'number': return typeof value !== 'number' || isNaN(value) ? 'Must be a number' : null; case 'date': return typeof value !== 'string' || isNaN(Date.parse(value)) ? 'Must be a valid date' : null; case 'boolean': return typeof value !== 'boolean' ? 'Must be true or false' : null; case 'select': { const options = (definition.selectOptions as string[] | null) ?? []; return !options.includes(value as string) ? `Must be one of: ${options.join(', ')}` : null; } default: return 'Unknown field type'; } } // ─── Definitions ────────────────────────────────────────────────────────────── export async function listDefinitions(portId: string, entityType?: string) { const conditions = [eq(customFieldDefinitions.portId, portId)]; if (entityType) { conditions.push(eq(customFieldDefinitions.entityType, entityType)); } return db.query.customFieldDefinitions.findMany({ where: and(...conditions), orderBy: (fields, { asc }) => [asc(fields.sortOrder), asc(fields.createdAt)], }); } export async function createDefinition( portId: string, userId: string, data: CreateFieldInput, meta: AuditMeta, ) { // Check for duplicate fieldName within portId + entityType const existing = await db.query.customFieldDefinitions.findFirst({ where: and( eq(customFieldDefinitions.portId, portId), eq(customFieldDefinitions.entityType, data.entityType), eq(customFieldDefinitions.fieldName, data.fieldName), ), }); if (existing) { throw new ConflictError( `A custom field named "${data.fieldName}" already exists for ${data.entityType}`, ); } const rows = await db .insert(customFieldDefinitions) .values({ portId, entityType: data.entityType, fieldName: data.fieldName, fieldLabel: data.fieldLabel, fieldType: data.fieldType, selectOptions: data.selectOptions ?? null, isRequired: data.isRequired, sortOrder: data.sortOrder, }) .returning(); const created = rows[0]; if (!created) throw new Error('Insert failed - no row returned'); void createAuditLog({ userId, portId, action: 'create', entityType: 'custom_field_definition', entityId: created.id, newValue: { fieldName: created.fieldName, fieldLabel: created.fieldLabel, fieldType: created.fieldType, entityType: created.entityType, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return created; } export async function updateDefinition( portId: string, fieldId: string, userId: string, data: UpdateFieldInput & { fieldType?: unknown }, meta: AuditMeta, ) { // Immutability guard - fieldType must never change if ('fieldType' in data && data.fieldType !== undefined) { throw new ValidationError('Field type cannot be changed after creation'); } const existing = await db.query.customFieldDefinitions.findFirst({ where: and(eq(customFieldDefinitions.id, fieldId), eq(customFieldDefinitions.portId, portId)), }); if (!existing) { throw new NotFoundError('Custom field definition'); } const updateRows = await db .update(customFieldDefinitions) .set({ ...(data.fieldLabel !== undefined && { fieldLabel: data.fieldLabel }), ...(data.selectOptions !== undefined && { selectOptions: data.selectOptions }), ...(data.isRequired !== undefined && { isRequired: data.isRequired }), ...(data.sortOrder !== undefined && { sortOrder: data.sortOrder }), }) .where(eq(customFieldDefinitions.id, fieldId)) .returning(); const updated = updateRows[0]; if (!updated) throw new Error('Update failed - no row returned'); void createAuditLog({ userId, portId, action: 'update', entityType: 'custom_field_definition', entityId: fieldId, oldValue: { fieldLabel: existing.fieldLabel, selectOptions: existing.selectOptions, isRequired: existing.isRequired, sortOrder: existing.sortOrder, }, newValue: { fieldLabel: updated.fieldLabel, selectOptions: updated.selectOptions, isRequired: updated.isRequired, sortOrder: updated.sortOrder, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return updated; } export async function deleteDefinition( portId: string, fieldId: string, userId: string, meta: AuditMeta, ) { const existing = await db.query.customFieldDefinitions.findFirst({ where: and(eq(customFieldDefinitions.id, fieldId), eq(customFieldDefinitions.portId, portId)), }); if (!existing) { throw new NotFoundError('Custom field definition'); } // Count associated values before deletion const countResult = await db .select({ count: count() }) .from(customFieldValues) .where(eq(customFieldValues.fieldId, fieldId)); const valueCount = countResult[0]?.count ?? 0; // Delete definition - CASCADE handles values await db.delete(customFieldDefinitions).where(eq(customFieldDefinitions.id, fieldId)); void createAuditLog({ userId, portId, action: 'delete', entityType: 'custom_field_definition', entityId: fieldId, oldValue: { fieldName: existing.fieldName, fieldLabel: existing.fieldLabel, fieldType: existing.fieldType, entityType: existing.entityType, }, metadata: { deletedValueCount: Number(valueCount) }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return { deletedValueCount: Number(valueCount) }; } // ─── Values ─────────────────────────────────────────────────────────────────── export async function getValues(entityId: string, portId: string) { const definitions = await db.query.customFieldDefinitions.findMany({ where: eq(customFieldDefinitions.portId, portId), orderBy: (fields, { asc }) => [asc(fields.sortOrder), asc(fields.createdAt)], }); const values = await db.query.customFieldValues.findMany({ where: eq(customFieldValues.entityId, entityId), }); const valueMap = new Map(values.map((v) => [v.fieldId, v])); return definitions.map((definition) => ({ definition, value: valueMap.get(definition.id) ?? null, })); } export async function setValues( entityId: string, portId: string, userId: string, values: Array<{ fieldId: string; value: unknown }>, meta: AuditMeta, ) { if (values.length === 0) return []; // Fetch relevant definitions to validate values const fieldIds = values.map((v) => v.fieldId); const definitions = await db.query.customFieldDefinitions.findMany({ where: eq(customFieldDefinitions.portId, portId), }); const definitionMap = new Map(definitions.map((d) => [d.id, d])); // Validate each value const errors: Array<{ field: string; message: string }> = []; for (const { fieldId, value } of values) { const definition = definitionMap.get(fieldId); if (!definition) { errors.push({ field: fieldId, message: 'Custom field not found for this port' }); continue; } const error = validateCustomFieldValue(definition, value); if (error) { errors.push({ field: definition.fieldName, message: error }); } } if (errors.length > 0) { throw new ValidationError('Custom field validation failed', errors); } // Tenant scope: verify entityId actually points at a port-scoped row of // the entity type the field definitions target. Without this gate, any // authenticated user could write custom-field rows pointing at arbitrary // entityIds (or none at all) - polluting customFieldValues and creating // a join surface that could later leak data. const entityTypes = new Set( values .map((v) => definitionMap.get(v.fieldId)?.entityType) .filter((t): t is string => Boolean(t)), ); for (const entityType of entityTypes) { const { eq: drizzleEq, and: drizzleAnd } = await import('drizzle-orm'); let exists = false; if (entityType === 'client') { const { clients } = await import('@/lib/db/schema/clients'); const row = await db.query.clients.findFirst({ where: drizzleAnd(drizzleEq(clients.id, entityId), drizzleEq(clients.portId, portId)), }); exists = Boolean(row); } else if (entityType === 'interest') { const { interests } = await import('@/lib/db/schema/interests'); const row = await db.query.interests.findFirst({ where: drizzleAnd(drizzleEq(interests.id, entityId), drizzleEq(interests.portId, portId)), }); exists = Boolean(row); } else if (entityType === 'berth') { const { berths } = await import('@/lib/db/schema/berths'); const row = await db.query.berths.findFirst({ where: drizzleAnd(drizzleEq(berths.id, entityId), drizzleEq(berths.portId, portId)), }); exists = Boolean(row); } else if (entityType === 'yacht') { const { yachts } = await import('@/lib/db/schema/yachts'); const row = await db.query.yachts.findFirst({ where: drizzleAnd(drizzleEq(yachts.id, entityId), drizzleEq(yachts.portId, portId)), }); exists = Boolean(row); } else if (entityType === 'company') { const { companies } = await import('@/lib/db/schema/companies'); const row = await db.query.companies.findFirst({ where: drizzleAnd(drizzleEq(companies.id, entityId), drizzleEq(companies.portId, portId)), }); exists = Boolean(row); } else { throw new ValidationError(`Unsupported custom-field entity type: ${entityType}`); } if (!exists) { throw new ValidationError(`${entityType} not found in this port`); } } // Upsert all values const results = await Promise.all( values.map(async ({ fieldId, value }) => { const [upserted] = await db .insert(customFieldValues) .values({ fieldId, entityId, value: value as Record, updatedAt: new Date(), }) .onConflictDoUpdate({ target: [customFieldValues.fieldId, customFieldValues.entityId], set: { value: value as Record, updatedAt: new Date(), }, }) .returning(); return upserted; }), ); void createAuditLog({ userId, portId, action: 'update', entityType: 'custom_field_values', entityId, metadata: { fieldIds, updatedCount: results.length }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return results; }