Corteksa

Architecture

CRM — Architecture

The CRM is a dynamic object system: objects and fields are data, and each object gets its own physical table. A facade coordinates typed validation, persistence, and domain events so every write is consistent regardless of who made it.

The dynamic schema

  • An object row (objects) owns a dedicated table (object.table_name), built and altered at runtime by the object infrastructure (objects/infrastructure/: SchemaBuilder, SchemaGenerator, TypeMapper, DynamicQueryBuilder).
  • A field row (fields) maps to a column. Its type (one of 17) selects a field-type handler at runtime.
  • A record is a row in that table, with values keyed by field slug.

Because the schema is data, adding a field is a row insert plus an ALTER TABLE — never a code change.

Request flow (write)

App ──REST──▶ DataService (facade) ──▶ Mutation service ──▶ dedicated table
                    │                        │
                    │                        └──▶ RecordEventsService.emit(data.*)
                    └──▶ DynamicValidationService ──▶ FieldTypeRegistry (per-type)

POST/PUT /object/data/:objectSlug runs field validation, persists to the object's table in a transaction, then emits a data.* event. Listeners (audit changelog, real-time gateway, workflow engine, webhook fanout) pick it up asynchronously. See Events.

Write locks

Some fields' values belong to the platform rather than to whoever is posting. After validation and before the write, every mutation path drops values the caller isn't entitled to set (records/mutations/helpers/write-lock.util.ts):

LockApplies toWho may still write it
Computedcalculation, serial_numberNobody — the recalculation engine owns them
API-onlyany field with api_only = trueOnly an api origin (an API-key / OAuth caller)
Static systemsource_name, session_name, provider_idTrusted system origins (automation, system, api); never a person

The origin is asserted server-side by write-origin.util.ts — from the caller's own tag (ai, automation) or, failing that, from the API credential the auth guard stamped on the request. It is never read from the request body, so a payload cannot claim to be an API write. An explicit tag always wins, so an AI tool running inside an API-key request stays ai and does not inherit that credential's authority.

The locks are silent: the rest of the payload saves normally, matching the long-standing behaviour for computed fields. They apply on create, update, and bulk-update — bulk writes straight to the table without the validation pipeline, so it calls the same helpers explicitly.

A client renders the lock from api_only on the field, which every field- carrying response exposes: GET /object/field/:objectSlug, GET /object/field/active/:objectSlug, and the fields[] array embedded in record list / detail / create / update responses. Each of those reads must select the column — FieldResponseDto coerces a missing one to false so the wire shape stays stable, which means an omitted column reads as "not locked" rather than as "unknown".

Workflow actions write via raw SQL and so enforce the same policy through a separate predicate, workflow/constants/field-writability.ts. It backs both the writable flag the fields endpoint exposes (what the builder UI disables) and the executor's server-side guard (what stops a client that ignores it) — a workflow may not write a platform-owned type, nor an api_only field, since an automation is not the integration that owns it.

Import runs its own variant: an upload is never an API write, chat-linkage identifiers are dropped, but a deliberately mapped Source column is honoured.

source is also auto-stamped so it is never blank — manual on a record a person created, import on an imported row with no Source column mapped, and the provider value on messaging/lead-ads auto-create. manual and import are permanent entries in every Source field's option catalog (shared/seed-data/source-field-options.ts); an options edit may rename them but not remove them.

Building blocks

LayerWhereJob
Facaderecords/services/data.service.tsThe one interface controllers call for records
Query servicesrecords/queries/ (list, search, lookup, single, facets)Read paths, pagination, filtering
Mutation servicesrecords/mutations/ (create, update, delete, bulk)Write paths + event emit
Validationfields/services/validation/DynamicValidationService + per-type validators
Field-type registry_shared/field-types/Auto-discovered FieldType → handler map (validate + serialize)
Object infrastructureobjects/infrastructure/Build/alter the dynamic tables
Access filterrecords/queries/helpers/access-filter.builder.tsRow-level A/G/M/D scoping
Realtimerecords/realtime/ (DataEventsGateway)Push live record changes to the UI

Dependency direction

Controllers → DataService (facade) → Query / Mutation services → dedicated table
                                   → DynamicValidationService → FieldTypeRegistry
                                   → RecordEventsService → EventEmitter → listeners

Controllers never touch a repository directly — they call the facade, which delegates. New read/write concerns get a new query/mutation service, not more controller logic.

Design principles

  • Slug-first — every inbound/outbound reference is a slug; numeric ids stay internal. See the Data Model.
  • Field-type registry — per-type behaviour (validation, serialization) is auto-discovered via @HandlesFieldType(...), not a hand-wired switch. A new FieldType without a handler fails fast at boot.
  • Facade over services — one DataService entry point keeps circular deps out and gives every caller the same validation + events.
  • Events, not inline side-effects — audit, realtime, workflow, and webhooks all hang off data.* events, so a mutation path doesn't know its consumers.
  • Response DTOs via @Exclude/@Expose + plainToInstance; pagination is always top-level.

The module is organized by domain subfolder (records/, fields/, objects/, relations/, views/, comments/, export-import/, shared/), each owning its own controllers, services, and DTOs.

On this page