Custom Fields
Custom fields in CRMBuilder are stored as rows in a definitions table, not as schema columns. This is critical to understand before building any CF-prefixed template.
Storage model
Custom fields use a two-table EAV (Entity-Attribute-Value) pattern:
| Table | Purpose |
|---|---|
custom_field_definitions |
Field metadata (name, type, target object) |
custom_field_values |
Per-record field values |
This means adding a custom field requires two steps:
- Insert a row into
custom_field_definitions - Values are written to
custom_field_valuesas records are updated
Never add columns to customers, deals, or companies for
custom fields — always use this pattern.
custom_field_definitions schema
SELECT id, id_company, object_type, field_name, field_label,
field_type, options, required, sort_order, created_at
FROM custom_field_definitions
WHERE id_company = :id_company
ORDER BY object_type, sort_order;
| Column | Type | Notes |
|---|---|---|
object_type |
varchar | contact, deal, or company |
field_name |
varchar | Snake_case internal identifier |
field_label |
varchar | Human-readable label shown in UI |
field_type |
varchar | See type enum below |
options |
jsonb | For select and multiselect types only |
required |
boolean | Whether field is required on save |
sort_order |
int | Display order within object |
field_type enum
| Value | Input rendered |
|---|---|
text |
Single-line text input |
textarea |
Multi-line text |
number |
Numeric input |
date |
Date picker |
select |
Single-option dropdown |
multiselect |
Multi-option checkbox group |
checkbox |
Boolean toggle |
url |
URL input with validation |
Creating a custom field
INSERT INTO custom_field_definitions
(id_company, object_type, field_name, field_label,
field_type, options, required, sort_order)
VALUES
(:id_company, 'contact', 'lead_temperature', 'Lead Temperature',
'select', '["Hot","Warm","Cold"]'::jsonb, false, 1)
RETURNING id;
For select and multiselect, options must be a JSON array of
strings. For all other types, set options to null.
custom_field_values schema
SELECT id, id_definition, id_record, id_company, value
FROM custom_field_values
WHERE id_company = :id_company
AND id_record = :contact_id
AND id_definition = :definition_id;
| Column | Type | Notes |
|---|---|---|
id_definition |
int | FK → custom_field_definitions.id |
id_record |
int | ID of the contact, deal, or company |
id_company |
int | Tenant scope |
value |
text | Always stored as text regardless of field_type |
Writing a value (upsert pattern)
INSERT INTO custom_field_values
(id_definition, id_record, id_company, value)
VALUES (:id_definition, :id_record, :id_company, :value)
ON CONFLICT (id_definition, id_record)
DO UPDATE SET value = EXCLUDED.value;
Always use upsert — never plain INSERT — to handle re-saves.
Reading all custom field values for a record
SELECT
cfd.field_label,
cfd.field_type,
cfv.value
FROM custom_field_definitions cfd
LEFT JOIN custom_field_values cfv
ON cfv.id_definition = cfd.id
AND cfv.id_record = :record_id
WHERE cfd.id_company = :id_company
AND cfd.object_type = 'contact'
ORDER BY cfd.sort_order;
Checking for field name conflicts
Before inserting a new field definition, check for duplicates:
SELECT id FROM custom_field_definitions
WHERE id_company = :id_company
AND object_type = :object_type
AND field_name = :field_name;
-- If rows returned, field already exists — update or skip