Skip to main content

The Attribute Engine

This is the single most important subsystem to understand before touching Products, Batches, Arrivals, Issuing, Orders, or Reports. It's how HLMIS stays generic: instead of a named column per deployment-specific field (VVMStage, funder, hmisCode, …), an admin defines what an entity tracks, and values are stored in a JSON bag.

Why it exists

VLMIS hardcoded Rwanda's vaccine supply chain into its schema — every Product, Batch, and StockMovement carried VVM stage, funder, partner name, HMIS code whether a deployment needed them or not. The attribute engine replaces that: Rwanda's fields become the first seeded example of a generic mechanism, not the reason the mechanism doesn't exist for anyone else.

The guiding rule: ADAPTER_MODE may decide which attribute definitions are seeded and active — it must never decide what columns physically exist. A Batch table has the same real columns for Rwanda and for a laptop distributor; only the attribute-definition rows differ.

The two halves

AttributeDefinition — the schema

One row per configurable field. src/core/attribute-definitions/entities/attribute-definition.entity.ts:

FieldMeaning
entityTypeone of 10: Product, Batch, StockMovement, IssuedProducts, OrderDetails, Inventory, Issuing, Order, Consumer, Stock
actionnull = applies to every action on that entity type; a value (e.g. "Wastage") narrows it to one action — see below
keythe property name inside attributeValues (e.g. wastage_reason)
labeldisplay name; the frontend renders this, not key
dataTypetext | number | boolean | date | select
optionsstring[] for select, else null
required / displayOrder / isActiveself-explanatory; isActive: false is the soft-delete
isSystemset only by seed migrations — protects definitions that backend logic reads by exact key
uniqueenforce one value per entity type — checked by ProductsImportService only, not the single-record API paths

A DB-level unique index on (entityType, key) — not just frontend slug validation.

attributeValues: jsonb — the values

A nullable jsonb column (default {}) on each participating entity, added by its own migration (AddAttributeValuesTo<Entity>). Shape is { [key]: value }.

Action scoping

Some entities do several different things (an arrival vs. an issue vs. a wastage entry are all StockMovements). A definition with action: null applies to all of them; action: "Wastage" applies only to that one.

Endpoints declare which (entityType, action) pairs they perform with the @AttributeAction(...) decorator (src/core/attribute-definitions/attribute-action.decorator.ts). A boot-time registry collects these, and GET /api/attribute-definitions/for-endpoint / the actions endpoints let the frontend ask "what fields apply here?". assertValidAction() rejects a definition whose action isn't a registered one.

Validation on write

validateAttributeValues() (src/core/attribute-definitions/attribute-values.validator.ts) checks submitted values against the currently active definitions for that entity/action: required keys present, select values within options, correct primitive per dataType. It throws BadRequestException with a list of errors. Services call it before persisting; a global AttributeActionInterceptor is the no-op-unless-tagged plumbing that resolves the same metadata per request.

Seeding and ADAPTER_MODE

Vaccine/DHIS2 fields are seeded as isSystem: true definitions by migrations (SeedProductLegacyFieldAttributes, SeedArrivalAttributeDefinitions, SeedIssuedProductsVvmStageAttribute, …). 1789600000001-SetAttributeActivationByAdapterMode then sets each one's isActive from ADAPTER_MODE at migration time — so a generic deployment boots with VVM/HMIS/funder fields inactive (present as rows, never rendered), and a DHIS2 deployment boots with them active. Newer seed migrations check ADAPTER_MODE before inserting instead — that's the preferred pattern going forward.

Known asymmetry: deactivate() refuses for isSystem definitions, reactivate() has no matching guard — a one-way door, tracked in the backend's genericity-gaps doc.

Legacy column sync

Some named columns were backfilled into attributeValues and then dropped (see Migrations & Schema). Product's legacy columns are the exception — still read directly in a few places, so syncLegacyColumns() (src/core/products/legacy-attribute-column-sync.ts) mirrors attributeValues back into them on write via LEGACY_ATTRIBUTE_COLUMN_MAP. There's no such shim for StockMovement / Batch / OrderDetails — their old columns are simply gone.

Adding a new attribute-driven field

You usually don't write code — an admin creates the definition through POST /api/attribute-definitions and it's live. You only touch the backend when:

  • a new entity type needs attributeValues → add the column via migration, add it to ATTRIBUTE_ENTITY_TYPES
  • an endpoint needs to accept/validate values for an action it doesn't yet declare → add @AttributeAction(...), call validateAttributeValues()
  • backend logic must read a specific key → seed it as isSystem so it can't be renamed out from under the code