Skip to main content

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

TablePurpose
facilityA care location; tenant-scoped; logical geography ids.
departmentA unit within a facility.
unit / unit_leader / department_leadCare units under a department; leaders keyed by personId (M17-004A).
location / roomPhysical sub-structure.
facility_serviceServices the facility offers.
facility_configurationPer-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.
  • status on facility / department / unit / facility_service is LifecycleStatus (TERM-008D). Those four columns were free VARCHAR(30) with DEFAULT_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; migration facility/012 normalises case/whitespace then CHECK-constrains, and HALTs on dirty data rather than guessing. Null on create still defaults to ACTIVE.

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:

  1. Copy the starter. Platform starters are templates; createMapping refuses to map a visit type to one and tells you to copy it first (M28-007).
  2. Publish the copy. A copy arrives as an unpublished draft, and createMapping refuses an unpublished definition — so the failure lands at the write rather than at the desk on somebody's first check-in.
  3. 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 idsfacility_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.