Remote Eaze
Features

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:

ExportDescription
ruleEngineEvaluates conditional when/then/else rules against a context
customFieldDefaultsEvaluates default values (static, rule-based, computed) with multi-pass dependency resolution
expressionEvaluatorSafe formula evaluator (no eval) for computed fields
queryParserParses field__operator query string syntax into structured filters
validateCustomFieldsValidates field values against definitions with rule-based conditional constraints
Type definitionsCustomFieldDefinition, Rule, RuleContext, ParsedFilter, etc.

2. API Plugin (plugins/custom-fields.ts)

A Fastify plugin that decorates every request with request.customFields, providing 4 methods:

MethodUsed InDescription
process(entityType, body, additionalContext?)CREATE routesFetches definitions, evaluates defaults, merges with user input, validates, returns final customFields
validate(entityType, customFields, body, additionalContext?)UPDATE routesValidates provided custom fields (no default evaluation — existing values preserved)
parseFilters(query, standardFields)LIST routesSeparates custom field filters from standard query params, builds Prisma JSONB WHERE clause
getDefinitions(entityType)Any routeReturns 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:

  1. Fetches active field definitions for the tenant + entity type
  2. Builds context from request (user, organization, entity data, timestamp)
  3. Evaluates defaults (static → rule-based → computed, with multi-pass for dependencies)
  4. Merges: user-provided values override defaults
  5. Validates all fields against definitions + context
  6. 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=5
  • name=Admin → standard field (exact match)
  • tax_id__contains=ABC → custom field filter
  • priority__gte=5 → custom field filter

Data Model

Table: remoteEaze_custom_field_definitions

Core Fields

FieldTypeDescription
idCUIDPrimary key
tenantIdStringMulti-tenant isolation key
entityTypeStringTarget entity (e.g., Role, Product, Account)
fieldKeyStringImmutable unique key (lowercase + underscores, e.g., tax_id)
fieldLabelStringHuman-readable label (e.g., "Tax ID")
descriptionString?Helper text/tooltip for the UI
fieldTypeCustomFieldTypeSTRING, NUMBER, BOOLEAN, DATE, SELECT, JSON, COMPUTED
isRequiredBooleanRequired validation (default: false)
displayOrderIntUI sort order (default: 0)

Strategy and Logic

FieldTypeDescription
defaultStrategyDefaultStrategy?STATIC (default), RULE_BASED, or COMPUTED
defaultValueString?Used when strategy is STATIC
computedFormulaString?Used when strategy is COMPUTED (e.g., entity.amount * 0.16)
defaultRulesJson?Array of Rule objects for RULE_BASED defaults

Validation

FieldTypeDescription
validationJson?Static constraints (min/max, regex, enum, date range)
validationRulesJson?Conditional constraints via Rule objects (e.g., "Required IF country=USA")

UI and Meta

FieldTypeDescription
uiConfigJson?UI metadata: { placeholder?, hidden?, readOnly?, options?: [{label, value}] }
isActiveBooleanActive flag (default: true)
versionIntOptimistic concurrency counter (default: 1, incremented on update)
createdAtDateTimeCreation timestamp (UTC)
updatedAtDateTimeLast update timestamp (UTC)
deletedAtDateTime?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's isRequired flag
  • defaultValue — provide a contextual default
  • validation — override or extend validation rules

Rule Engine

Condition Operators

CategoryOperators
Equalityequals, notEquals
ComparisongreaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual
Setin (array includes), notIn
Stringcontains, startsWith, endsWith
Existenceexists (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

CategoryFunctions
Mathabs(n), round(n, decimals?), floor(n), ceil(n), min(...n), max(...n)
Dateyear(date), month(date), day(date), today(), now()
Stringuppercase(s), lowercase(s)
Typenumber(v), string(v), boolean(v)

Examples:

round(entity.amount * 0.16, 2)
entity.subtotal + entity.tax
customer.vip_status === 'Gold' ? 0.2 : 0.1

Multi-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 TypeChecks
stringType, minLength, maxLength, pattern (regex), enum
numberType (accepts string-to-number coercion), min, max
booleanType (accepts "true"/"false" strings)
dateParses via UTC date utilities, minDate, maxDate
selectValue must match one of uiConfig.options[].value
arrayType check (must be Array)
jsonNo validation (any valid JSON)
computedSkipped — 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:

OperatorSyntaxDescription
eqfield=value or field__eq=valueExact match (default if no operator)
nefield__ne=valueNot equal
gt, gtefield__gt=100Greater than / greater than or equal
lt, ltefield__lt=100Less than / less than or equal
infield__in=A,B,CValue in comma-separated list
ninfield__nin=A,B,CValue not in list
containsfield__contains=abcString contains
icontainsfield__icontains=abcCase-insensitive contains
startswithfield__startswith=abcString starts with
endswithfield__endswith=abcString ends with
isnullfield__isnull=trueNull check
betweenfield__between=10,100Range (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.

MethodPathPermissionDescription
POST/CREATECreate a new field definition
GET/:entityTypeREADList active definitions for an entity type
PATCH/:idUPDATEUpdate a field definition (version incremented)
DELETE/:idDELETESoft-delete a field definition
GET/meta/statsREADCache 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 defaultRules array with at least one rule
  • Select fields require uiConfig.options with at least one option
  • Update schema makes all fields optional and prevents changing entityType and fieldKey (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.

On this page