Custom Fields
Dynamic field definitions, rule engine, formula evaluator, query filtering, and the plugin integration pattern.
The Custom Fields system allows tenants to define dynamic data attributes, complex validation rules, and computed values on any entity — without database schema migrations. Fields are defined per-tenant per-entity-type and stored as JSONB on entity rows.
Architecture
1. Shared Package (packages/custom-fields)
Platform-agnostic logic that runs on both Node.js and browser:
| Export | Description |
|---|---|
ruleEngine | Evaluates conditional when/then/else rules against a context |
customFieldDefaults | Evaluates default values (static, rule-based, computed) with multi-pass dependency resolution |
expressionEvaluator | Safe formula evaluator (no eval) for computed fields |
queryParser | Parses field__operator query string syntax into structured filters |
validateCustomFields | Validates field values against definitions with rule-based conditional constraints |
| Type definitions | CustomFieldDefinition, Rule, RuleContext, ParsedFilter, etc. |
2. API Plugin (plugins/custom-fields.ts)
A Fastify plugin that decorates every request with request.customFields, providing 4 methods:
| Method | Used In | Description |
|---|---|---|
process(entityType, body, additionalContext?) | CREATE routes | Fetches definitions, evaluates defaults, merges with user input, validates, returns final customFields |
validate(entityType, customFields, body, additionalContext?) | UPDATE routes | Validates provided custom fields (no default evaluation — existing values preserved) |
parseFilters(query, standardFields) | LIST routes | Separates custom field filters from standard query params, builds Prisma JSONB WHERE clause |
getDefinitions(entityType) | Any route | Returns field definitions for inspection |
Plugin dependencies: redis-plugin, licenses-plugin.
3. API Module (modules/custom-fields/)
CRUD for field definitions themselves. Repository, service with Redis caching, routes with RBAC.
4. Filter Utility (utils/custom-fields-filter.ts)
Converts parsed filters into Prisma-compatible customFields: { path: [...], ... } JSONB queries with AND composition.
Plugin Context
The plugin automatically builds a RuleContext from the request, available to all rules and formulas:
{
entity: { ...requestBody }, // The form data being submitted
user: { // From request.user (if authenticated)
id, name, email, role, branchId
},
organization: { // From license cache
id, slug, companyName, companyAbbreviation,
modules, accountingType, customFields
},
timestamp: new Date(), // Current time
...additionalContext // Module-provided (customer, product, etc.)
}Modules can pass additional context (e.g., a customer record, branch data) that becomes available in rules and formulas.
Module Integration Pattern
Custom fields are used in 8 modules across the codebase (access-control, organization, crm, ledger, financial-config, product-rules, reference-data, licenses). The integration follows a consistent 3-point pattern:
CREATE — request.customFields.process()
// 1. Process custom fields (evaluate defaults, validate)
const { customFields } = await request.customFields.process("Role", body);
// 2. Pass merged customFields to service
const role = await service.createRole(tenantId, { ...body, customFields });The process() method:
- Fetches active field definitions for the tenant + entity type
- Builds context from request (user, organization, entity data, timestamp)
- Evaluates defaults (static → rule-based → computed, with multi-pass for dependencies)
- Merges: user-provided values override defaults
- Validates all fields against definitions + context
- Returns
{ customFields, definitions }
UPDATE — request.customFields.validate()
// Only validate if customFields are being updated
if (body.customFields) {
await request.customFields.validate(
"Role",
body.customFields as Record<string, unknown>,
body,
);
}
const { original, updated } = await service.updateRole(tenantId, id, body);No default evaluation on update — existing values are preserved.
LIST — request.customFields.parseFilters()
// Separate custom field filters from standard query params
const { standardQuery, customFieldsWhere } = request.customFields.parseFilters(
request.query as Record<string, unknown>,
["name", "slug", "status"], // Standard entity fields
);
// Pass both to repository
const result = await service.listRoles(tenantId, standardQuery, customFieldsWhere);The filter parser uses __ (double underscore) operator syntax in query strings:
GET /api/v1/roles?name=Admin&tax_id__contains=ABC&priority__gte=5name=Admin→ standard field (exact match)tax_id__contains=ABC→ custom field filterpriority__gte=5→ custom field filter
Data Model
Table: remoteEaze_custom_field_definitions
Core Fields
| Field | Type | Description |
|---|---|---|
id | CUID | Primary key |
tenantId | String | Multi-tenant isolation key |
entityType | String | Target entity (e.g., Role, Product, Account) |
fieldKey | String | Immutable unique key (lowercase + underscores, e.g., tax_id) |
fieldLabel | String | Human-readable label (e.g., "Tax ID") |
description | String? | Helper text/tooltip for the UI |
fieldType | CustomFieldType | STRING, NUMBER, BOOLEAN, DATE, SELECT, JSON, COMPUTED |
isRequired | Boolean | Required validation (default: false) |
displayOrder | Int | UI sort order (default: 0) |
Strategy and Logic
| Field | Type | Description |
|---|---|---|
defaultStrategy | DefaultStrategy? | STATIC (default), RULE_BASED, or COMPUTED |
defaultValue | String? | Used when strategy is STATIC |
computedFormula | String? | Used when strategy is COMPUTED (e.g., entity.amount * 0.16) |
defaultRules | Json? | Array of Rule objects for RULE_BASED defaults |
Validation
| Field | Type | Description |
|---|---|---|
validation | Json? | Static constraints (min/max, regex, enum, date range) |
validationRules | Json? | Conditional constraints via Rule objects (e.g., "Required IF country=USA") |
UI and Meta
| Field | Type | Description |
|---|---|---|
uiConfig | Json? | UI metadata: { placeholder?, hidden?, readOnly?, options?: [{label, value}] } |
isActive | Boolean | Active flag (default: true) |
version | Int | Optimistic concurrency counter (default: 1, incremented on update) |
createdAt | DateTime | Creation timestamp (UTC) |
updatedAt | DateTime | Last update timestamp (UTC) |
deletedAt | DateTime? | Soft-delete timestamp |
Constraints and Indexes
@@unique([tenantId, entityType, fieldKey]) // One definition per key per entity per tenant
@@index([tenantId, entityType]) // Efficient lookup for field lists
@@map("remoteEaze_custom_field_definitions")Enums
CustomFieldType: STRING | NUMBER | BOOLEAN | DATE | COMPUTED | JSON | SELECT
Casing Convention
Prisma stores enums in UPPER_SNAKE_CASE (e.g., RULE_BASED). The API service converts to lowercase kebab-case (rule-based) in responses, and the Zod schema accepts lowercase for input.
DefaultStrategy: STATIC | RULE_BASED | COMPUTED
JSON Data Structures
Validation Rules (validation)
{
"min": 10,
"max": 1000,
"minLength": 5,
"maxLength": 50,
"pattern": "^[A-Z]+$",
"enum": ["USD", "EUR", "KES"],
"minDate": "2023-01-01",
"maxDate": "2025-12-31"
}UI Configuration (uiConfig)
{
"placeholder": "Enter amount...",
"hidden": false,
"readOnly": true,
"options": [
{ "label": "Savings", "value": "SAVINGS" },
{ "label": "Current", "value": "CURRENT" }
]
}The Rule Object
Used in defaultRules (default value logic) and validationRules (conditional validation). Rules are sorted by priority (higher runs first); first match wins.
{
"name": "High Value Transaction Check",
"priority": 10,
"when": {
"operator": "AND",
"conditions": [
{ "field": "entity.amount", "operator": "greaterThan", "value": 10000 },
{ "field": "customer.tier", "operator": "equals", "value": "GOLD" }
]
},
"then": {
"required": true,
"defaultValue": "PENDING_APPROVAL"
},
"else": {
"required": false,
"defaultValue": "AUTO_APPROVED"
}
}The then and else blocks can set:
required— override the field'sisRequiredflagdefaultValue— provide a contextual defaultvalidation— override or extend validation rules
Rule Engine
Condition Operators
| Category | Operators |
|---|---|
| Equality | equals, notEquals |
| Comparison | greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual |
| Set | in (array includes), notIn |
| String | contains, startsWith, endsWith |
| Existence | exists (not null/undefined), notExists |
Logical Operators
Condition groups support AND (all must match) and OR (any must match).
Field Path Resolution
Conditions use dot notation to access context: entity.amount, customer.country, user.role, organization.modules.
Formula Engine
Used for computedFormula on fields with defaultStrategy: "computed". Executes via new Function() with a sandboxed scope (no eval).
Available Context
entity, customer, user, organization — same context as rule engine.
Built-in Functions
| Category | Functions |
|---|---|
| Math | abs(n), round(n, decimals?), floor(n), ceil(n), min(...n), max(...n) |
| Date | year(date), month(date), day(date), today(), now() |
| String | uppercase(s), lowercase(s) |
| Type | number(v), string(v), boolean(v) |
Examples:
round(entity.amount * 0.16, 2)
entity.subtotal + entity.tax
customer.vip_status === 'Gold' ? 0.2 : 0.1Multi-Pass Evaluation
Computed fields that depend on other computed fields are resolved via multi-pass evaluation (up to 5 passes, like Excel recalculation). Non-computed defaults are evaluated first, then computed fields iterate until values stabilize.
Validation
The validator handles each field type:
| Field Type | Checks |
|---|---|
string | Type, minLength, maxLength, pattern (regex), enum |
number | Type (accepts string-to-number coercion), min, max |
boolean | Type (accepts "true"/"false" strings) |
date | Parses via UTC date utilities, minDate, maxDate |
select | Value must match one of uiConfig.options[].value |
array | Type check (must be Array) |
json | No validation (any valid JSON) |
computed | Skipped — computed fields are calculated, not validated |
Validation merges static rules (validation field) with rule-based conditional rules (validationRules). Rule-based results override static rules when matched.
Query Filter Operators
For filtering entities by custom field values in LIST endpoints:
| Operator | Syntax | Description |
|---|---|---|
eq | field=value or field__eq=value | Exact match (default if no operator) |
ne | field__ne=value | Not equal |
gt, gte | field__gt=100 | Greater than / greater than or equal |
lt, lte | field__lt=100 | Less than / less than or equal |
in | field__in=A,B,C | Value in comma-separated list |
nin | field__nin=A,B,C | Value not in list |
contains | field__contains=abc | String contains |
icontains | field__icontains=abc | Case-insensitive contains |
startswith | field__startswith=abc | String starts with |
endswith | field__endswith=abc | String ends with |
isnull | field__isnull=true | Null check |
between | field__between=10,100 | Range (min,max) |
Prisma JSONB Limitations
The in/nin operators currently only check against the first value due to Prisma JSONB limitations. icontains falls back to case-sensitive contains. Full support requires raw SQL.
API Endpoints
The custom fields definition CRUD is registered under /api/v1/custom-fields (prefix applied by parent scope). All routes require authentication and custom_field_definition entity permissions.
| Method | Path | Permission | Description |
|---|---|---|---|
POST | / | CREATE | Create a new field definition |
GET | /:entityType | READ | List active definitions for an entity type |
PATCH | /:id | UPDATE | Update a field definition (version incremented) |
DELETE | /:id | DELETE | Soft-delete a field definition |
GET | /meta/stats | READ | Cache statistics (cached entity types, key count) |
Schema Validation
Create/update schemas enforce business rules via Zod refinements:
- Computed fields require a
computedFormula - Rule-based strategy requires
defaultRulesarray with at least one rule - Select fields require
uiConfig.optionswith at least one option - Update schema makes all fields optional and prevents changing
entityTypeandfieldKey(immutable after creation)
Redis Caching
- Key pattern:
custom-fields:{tenantId}:{entityType} - TTL: 1 hour (3600 seconds)
- Read-through: Request → Redis → DB → Redis → Response
- Invalidation: Cache cleared immediately on create, update, or delete
Client Layer (PWA)
Not Yet Implemented
The PWA client layer is not yet implemented. The planned design: sync definitions from API to IndexedDB (Dexie), then use the shared package to render forms and validate inputs offline.