Facility — How it works
Overview
The facility engine models where care happens: facilities and their internal structure (departments,
locations, rooms), the services a facility offers, and per-facility configuration. Facilities carry
logical geography ids so they roll up through the metadata hierarchy for geographic access
scoping (GeographyScopeResolver).
Data model & ownership
| Table | Purpose |
|---|---|
facility | A care location; tenant-scoped; logical geography ids. |
department | A unit within a facility. |
unit / unit_leader / department_lead | Care units under a department; leaders keyed by personId (M17-004A). |
location / room | Physical sub-structure. |
facility_service | Services the facility offers. |
facility_configuration | Per-facility settings. |
Key rules & invariants
- A facility's geography ids reference the metadata hierarchy; inherited
geographic scopes cover facilities via
GeographyScopeResolver. - Departments/locations/rooms belong to their facility; access scopes can target facility or department.
statuson facility / department / unit / facility_service isLifecycleStatus(TERM-008D). Those four columns were freeVARCHAR(30)withDEFAULT_STATUS = "ACTIVE"redeclared in each write service, so miscased or invented values persisted. The wire type is the shared enum (ACTIVE/SUSPENDED— the vocabulary the APIs and tests already used). Jackson rejects an unrecognised value with 400; migrationfacility/012normalises case/whitespace then CHECK-constrains, and HALTs on dirty data rather than guessing. Null on create still defaults toACTIVE.
One demonstration facility at t=0 (SEED-108)
A freshly migrated database ships one FMoH demonstration clinic (DEMO-CLINIC)
with the same default structure codes as SEED-100 — department GEN, unit GOPD, care location CR1,
and the unit_care_location join — all in facility/015, not via FacilityStructureSeeder.
That seeder runs only on the service create path; Liquibase-inserted facilities never pass through it.
⚠ Not the national register. The 40k-row FMoH list stays opt-in under context: ng-mfl
(SEED-109). This row is generic demonstration data on the FMoH operating tenant (the Platform demo
tenant from tenant/002 is retired in tenant/006) so a new deployment has somewhere to stand
before anybody creates a real facility. It is not marked undeletable.
Lookups in the changeset resolve facility_type, care_location_type and a Nigerian ward division
by code, never by hard-coded type ids. SeedDriftGateTest counts exactly one facility and one of
each structure row on a scratch database before the provisioning test adds another.
Creating a facility seeds its structure (SEED-100)
Every API-created facility gets the same default department (GEN), unit (GOPD), care location
(CR1) and unit_care_location mapping — in the same transaction as the insert. A facility with none
of these cannot have a patient placed anywhere in it.
⚠ SEED-100 is provisioning on the service path; SEED-108 covers t=0. All four tables carry
facility_id NOT NULL. Before SEED-108, a migrated database had no facility unless somebody opted
into e2e-seed or ng-mfl; a Liquibase seed could not attach structure rows without a parent facility
and would fail silently.
⚠ The care-location type is resolved by code (CONSULTATION_ROOM), never by a hard-coded id: the
types are seeded with gen_random_uuid(), so a constant would resolve to nothing on every deployment
but the one it was written on.
⚠ If the care-location types are missing, the seeder logs and skips the location rather than failing the facility. Their absence is a migration problem, and aborting would present a half-migrated platform as "the facility screen is broken".
Minimal on purpose — a floor, not a model of anybody's hospital. Rename or replace it. Idempotent on "does this facility have a department", so deleting the default does not bring it back.
⚠ A facility listing is therefore no longer empty on creation. Two tests asserted $[0].code on
a listing — "the one I just created is first" — which was only true while facilities started empty.
…and a check-in journey that actually starts (SEED-101)
Creating a facility also adopts the outpatient starter workflow and maps the default OPD visit
type to it, so the facility can start a visit on day one.
⚠ This is the defect the seeding rule exists for. M28-004 shipped fully wired with
workflow_visit_type_mapping empty, so no check-in journey ever started — feature complete,
screen rendering, nothing happening.
Three steps, each demanded by an existing rule:
- ⚠ Copy the starter. Platform starters are templates;
createMappingrefuses to map a visit type to one and tells you to copy it first (M28-007). - ⚠ Publish the copy. A copy arrives as an unpublished draft, and
createMappingrefuses an unpublished definition — so the failure lands at the write rather than at the desk on somebody's first check-in. - Map the visit type to it.
⚠ Each step names the new facility explicitly. The ordinary copyDefinition and createMapping
take scope from the signed session — correct for every ordinary caller — but while a facility is
being created the session's current facility is a different one. They gained explicit-scope
siblings (copyDefinitionInto, createMappingIn) that the session-based methods now delegate to, so
there is still one implementation of the rules.
⚠ None of this can fail the facility. Every failure is logged and abandoned: a facility without a journey is one somebody configures by hand, which is where every deployment is today; a facility that could not be created because a starter was missing would present a seeding gap as a broken screen.
The OPD visit type itself ships via Liquibase (clinical/029) under the Global tenant — possible
only because visit_type.facility_id is nullable, unlike the four tables above.
The Nigerian facility register (SEED-109)
The Federal Ministry of Health's Master Facility List — 40,463 facilities and 82,100 identifiers — ships in the changelog as a context-gated seed.
⚠ It is off by default. facility/014 carries context: ng-mfl, which is not in the default
LIQUIBASE_CONTEXTS, the same pattern e2e-seed uses. The platform is global and this is one
country's register, so a deployment opts in:
LIQUIBASE_CONTEXTS=ng-mfl
Everything the register needs is already seeded by Liquibase before it runs: the FMoH tenant at
the fixed id 00000000-0000-4000-8000-000000000003, the Nigerian division tree (10,870 rows),
and the facility classification lookups. The CSV carries tenant_id, country_id, division_id and
division_path as resolved literals, so nothing is derived at load time.
⚠ The CSVs carry codes, not ids — facility_type_code, facility_identifier_type_code — because
those ids are generated per deployment. A loadData pointed straight at facility fails on a column
that does not exist. The load goes through two UNLOGGED staging tables and then the same two merge
statements the psql loader uses, lifted verbatim so the two cannot disagree.
Cost, measured: ~72 seconds for the 40,463 facilities, ~3.5 minutes for all 122,563 rows. That is what the gate exists to keep off deployments that cannot use the register.
⚠ Preconditions HALT rather than warn: without the tenant, the country and the division tree, the register would attach to nothing or to the wrong country.
This supersedes the manual scripts/mfl-import/load_nigeria_mfl.sql step, which only ran if somebody
remembered to run it from the right working directory. That script is kept until the gated
changeset has been proven on a real deployment.
API
See the API Reference. Endpoint groups under /api/v1/facility: facilities,
departments, units (+ leaders / department leads), locations, rooms, services, configuration.
FacilityUnitClient exposes unit/leader reads for the workforce plug-in.
Configuration & feature flags
None.
Related features
- Metadata (geography), Access control (facility/ department scopes), Module (per-facility module activation), Queue (facility-scoped queues), Workforce (rosters on units).