openapi: 3.1.0

# =============================================================================
# Hospital Health Management System — Service API
# Omevision (Eneza Eswatini (Pty) Ltd) · private hospitals, Cameroon
#
# Submitted as Annex 4 (sanitised API specification from previous work),
# RFP No. ASLM/ACDC/DEP/CAP/LIP/08/31/26.
#
# -----------------------------------------------------------------------------
# BEFORE YOU SUBMIT THIS — READ
#
# This document is a WRITE-UP of the hospital health management system
# Omevision built and deployed for private hospitals in Cameroon. Documenting
# work you did is legitimate and normal; describing work you did not do is not.
# So go through it against the running system and make it true:
#
#   1. Delete every endpoint your system does not have. A specification with
#      twelve real endpoints beats one with forty that a technical evaluator
#      cannot find in your codebase.
#   2. Correct the paths, field names, enumerations and status codes to match
#      what your controllers actually return. The shapes here follow the usual
#      HMS pattern; yours will differ in the details.
#   3. Fix the auth block to describe your real mechanism (session, JWT, OAuth)
#      rather than the one described here, if they differ.
#   4. Keep the sanitisation: no hospital names, no hostnames, no real patient
#      identifiers, no credentials. The examples below are synthetic.
#   5. If large parts of this do not match, that is itself the answer — submit
#      the subset that does, and say in the compliance matrix that the
#      specification was reconstructed from a deployed system.
#
# WHY THIS ANNEX MATTERS BEYOND CRITERION 5.2
#   The laboratory endpoints below (/lab-orders, /specimens, /lab-results and
#   the validate/release transitions) are the evidence for sub-criterion 6.1 —
#   laboratory information systems experience. That sub-criterion is otherwise
#   the weakest line in the bid. Make this section accurate and detailed and it
#   stops being weak.
# =============================================================================

info:
  title: Hospital Health Management System — Service API
  version: "2.0"
  summary: Patient administration, clinical encounters, laboratory workflow, pharmacy, billing and reporting for private hospitals.
  description: |
    # Overview

    The service API of a hospital health management system built and deployed
    by Omevision for private hospitals in Cameroon. The system replaced paper
    registers and disconnected spreadsheets with a single record per patient
    and a single queryable source for hospital reporting.

    ## Design principles

    These are the principles the system was built on, and they are the same
    ones carried into the Integrated Laboratory Intelligence Platform proposed
    in this bid.

    **A result is not a result until someone signs for it.** A laboratory
    result moves through `entered → validated → released`. Only a released
    result is visible to the requesting clinician, appears on a report or
    counts in a statistic. The validating biologist is recorded on the result.
    This is the same validation chain proposed for ONA submissions in the
    laboratory intelligence platform, and the same one Omevision consulted on
    for FRORP.

    **Nothing consequential happens without a record of who did it.** Every
    state transition — result validation, prescription dispensing, invoice
    adjustment, record access — is written to an append-only audit log with
    the actor, the time and the before/after values.

    **Least privilege by role and by unit.** A clinician sees the patients of
    their service; a laboratory technician sees specimens and results, not
    billing; a cashier sees invoices, not clinical notes. Roles are assigned
    per hospital unit, not globally.

    **Identifiers are the hard part.** A patient arriving without a card,
    without a national identity number and with a name spelled three different
    ways is the normal case, not the exception. The system therefore treats
    patient matching as an explicit, reviewable operation with a confidence
    score and a merge trail, rather than as a silent lookup. The same problem
    reappears in this bid as reconciling LabMap, DHIS2 and LIS facility
    records to a master facility list.

    **The network will fail.** Clients tolerate loss of connectivity: writes
    carry an idempotency key, and a retried request after a timeout returns
    the original result rather than creating a duplicate encounter, specimen
    or invoice line.

    ## Conventions

    * Versioning in the path (`/v2`).
    * Cursor pagination; `page_size` default 50, maximum 200.
    * Errors as RFC 9457 `application/problem+json` with a stable `code`.
    * `Idempotency-Key` accepted on all writes, 24-hour replay window.
    * Timestamps ISO 8601 with timezone; the deployment timezone is Africa/Douala.
    * `Accept-Language: fr | en`; French is the interface default.

    ## What is deliberately not here

    No endpoint returns a full patient list without a search constraint, and
    no endpoint exposes clinical notes to a role that does not hold
    `clinical:read`. Bulk extraction for reporting goes through
    `/reports/*`, which returns aggregates only.

  contact:
    name: Omevision
    url: https://omevision.com
  license:
    name: Proprietary — client-owned deployment
    identifier: LicenseRef-Client-Owned

servers:
  - url: https://{hospital}.example-host/api/v2
    description: Per-hospital deployment (hostname redacted)
    variables:
      hospital:
        default: hospital-a

tags:
  - name: Patients
    description: Patient registration, search, matching and merge
  - name: Encounters
    description: Visits, consultations and clinical documentation
  - name: Laboratory
    description: Test ordering, specimen handling, result entry, validation and release
  - name: Pharmacy
    description: Prescriptions, dispensing and stock
  - name: Billing
    description: Invoices, payments and insurance
  - name: Reporting
    description: Aggregate reports and statistics
  - name: Administration
    description: Users, roles, units and reference data
  - name: Audit
    description: Access and change log

security:
  - bearerAuth: []

paths:

  # ------------------------------------------------------------------ Patients
  /patients:
    get:
      tags: [Patients]
      operationId: searchPatients
      summary: Search patients
      description: |
        At least one search constraint is required. An unconstrained call
        returns `400 search_constraint_required` — the register is not
        browsable, by design.
      parameters:
        - name: q
          in: query
          description: Name search, accent- and spelling-tolerant (trigram similarity).
          schema: { type: string }
        - name: patient_number
          in: query
          schema: { type: string }
        - name: phone
          in: query
          schema: { type: string }
        - name: date_of_birth
          in: query
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: Matching patients, ranked by match score.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/PatientSummary'
                        - type: object
                          properties:
                            match_score: { type: number, minimum: 0, maximum: 1 }
                  page: { $ref: '#/components/schemas/Page' }
              examples:
                found:
                  value:
                    data:
                      - patient_id: "pat_8Q2K"
                        patient_number: "HA-2024-004182"
                        display_name: "NGONO, [redacted]"
                        sex: "F"
                        date_of_birth: "1991-04-18"
                        match_score: 0.94
                    page: { next_cursor: null, page_size: 50 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { $ref: '#/components/responses/Forbidden' }
      security:
        - bearerAuth: [patients:read]
    post:
      tags: [Patients]
      operationId: registerPatient
      summary: Register a new patient
      description: |
        Before creating, the server runs the same matching used by search. If
        a candidate scores above the duplicate threshold, the call returns
        `409 possible_duplicate` with the candidates, and the registration
        clerk must either select the existing patient or confirm creation with
        `force=true`. Silent duplicate creation is the single most expensive
        failure in a hospital record system.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: force
          in: query
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatientWrite' }
      responses:
        '201':
          description: Patient registered.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Patient' }
        '409':
          description: Possible duplicate; candidates returned for the clerk to resolve.
          content:
            application/problem+json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Problem'
                  - type: object
                    properties:
                      candidates:
                        type: array
                        items: { $ref: '#/components/schemas/PatientSummary' }
        '400': { $ref: '#/components/responses/BadRequest' }
      security:
        - bearerAuth: [patients:write]

  /patients/{patient_id}:
    parameters:
      - name: patient_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Patients]
      operationId: getPatient
      summary: Retrieve a patient record
      responses:
        '200':
          description: The patient.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Patient' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
      security:
        - bearerAuth: [patients:read]
    patch:
      tags: [Patients]
      operationId: updatePatient
      summary: Update patient demographics
      description: Every changed field is written to the audit log with its previous value.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatientWrite' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Patient' }
      security:
        - bearerAuth: [patients:write]

  /patients/{patient_id}:merge:
    parameters:
      - name: patient_id
        in: path
        required: true
        description: The surviving record.
        schema: { type: string }
      - $ref: '#/components/parameters/IdempotencyKey'
    post:
      tags: [Patients]
      operationId: mergePatients
      summary: Merge a duplicate record into this one
      description: |
        Moves encounters, laboratory orders, prescriptions and invoices to the
        surviving record. The merged record is retained as a tombstone that
        redirects, never deleted, so an old chart number still resolves.
        Restricted to the records officer role.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [duplicate_patient_id, reason]
              properties:
                duplicate_patient_id: { type: string }
                reason: { type: string }
      responses:
        '200':
          description: Merge completed, with counts of moved records.
          content:
            application/json:
              schema:
                type: object
                properties:
                  surviving_patient_id: { type: string }
                  moved:
                    type: object
                    properties:
                      encounters: { type: integer }
                      lab_orders: { type: integer }
                      prescriptions: { type: integer }
                      invoices: { type: integer }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: One of the records is already merged.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
      security:
        - bearerAuth: [patients:merge]

  # ---------------------------------------------------------------- Encounters
  /encounters:
    get:
      tags: [Encounters]
      operationId: listEncounters
      summary: List encounters
      parameters:
        - name: patient_id
          in: query
          schema: { type: string }
        - name: unit_id
          in: query
          description: Hospital unit. Defaults to the caller's assigned units.
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [open, closed, cancelled] }
        - name: date_from
          in: query
          schema: { type: string, format: date }
        - name: date_to
          in: query
          schema: { type: string, format: date }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of encounters.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Encounter' }
                  page: { $ref: '#/components/schemas/Page' }
      security:
        - bearerAuth: [encounters:read]
    post:
      tags: [Encounters]
      operationId: openEncounter
      summary: Open an encounter
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [patient_id, unit_id, type]
              properties:
                patient_id: { type: string }
                unit_id: { type: string }
                type: { type: string, enum: [outpatient, inpatient, emergency, maternity, follow_up] }
                attending_user_id: { type: string }
                chief_complaint: { type: string }
      responses:
        '201':
          description: Encounter opened.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Encounter' }
      security:
        - bearerAuth: [encounters:write]

  /encounters/{encounter_id}/notes:
    parameters:
      - name: encounter_id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Encounters]
      operationId: listNotes
      summary: List clinical notes for an encounter
      description: Requires `clinical:read`. Reception and billing roles do not hold it.
      responses:
        '200':
          description: Notes, most recent first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ClinicalNote' }
        '403': { $ref: '#/components/responses/Forbidden' }
      security:
        - bearerAuth: [clinical:read]
    post:
      tags: [Encounters]
      operationId: addNote
      summary: Add a clinical note
      description: |
        Notes are append-only. A correction is a new note referencing the one
        it amends; the original is never overwritten.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body: { type: string }
                note_type: { type: string, enum: [consultation, nursing, procedure, discharge, amendment] }
                amends_note_id: { type: string }
      responses:
        '201':
          description: Note recorded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClinicalNote' }
      security:
        - bearerAuth: [clinical:write]

  # ---------------------------------------------------------------- Laboratory
  /lab-orders:
    get:
      tags: [Laboratory]
      operationId: listLabOrders
      summary: List laboratory orders
      description: |
        The laboratory worklist. Filtering by `status=ordered` and the
        laboratory's own unit gives the technician the queue for the shift.
      parameters:
        - name: patient_id
          in: query
          schema: { type: string }
        - name: encounter_id
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [ordered, collected, received, in_progress, resulted, released, cancelled, rejected] }
        - name: priority
          in: query
          schema: { type: string, enum: [routine, urgent, stat] }
        - name: ordered_from
          in: query
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of laboratory orders.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/LabOrder' }
                  page: { $ref: '#/components/schemas/Page' }
              examples:
                worklist:
                  value:
                    data:
                      - lab_order_id: "lab_7H3M"
                        patient_id: "pat_8Q2K"
                        encounter_id: "enc_44RT"
                        tests:
                          - test_code: "HB"
                            display: "Hémoglobine"
                          - test_code: "GE"
                            display: "Goutte épaisse / paludisme"
                        priority: "urgent"
                        status: "received"
                        ordered_by: "usr_med_21"
                        ordered_at: "2026-03-04T08:12:00+01:00"
                        received_at: "2026-03-04T08:41:00+01:00"
                    page: { next_cursor: null, page_size: 50 }
      security:
        - bearerAuth: [lab:read]
    post:
      tags: [Laboratory]
      operationId: createLabOrder
      summary: Order laboratory tests
      description: |
        Tests are ordered against an encounter, from the hospital test
        catalogue. The order carries the ordering clinician; the clinician is
        who the released result goes back to.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [encounter_id, tests]
              properties:
                encounter_id: { type: string }
                tests:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [test_code]
                    properties:
                      test_code: { type: string }
                priority: { type: string, enum: [routine, urgent, stat], default: routine }
                clinical_information: { type: string }
      responses:
        '201':
          description: Order created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LabOrder' }
        '400': { $ref: '#/components/responses/BadRequest' }
      security:
        - bearerAuth: [lab:order]

  /lab-orders/{lab_order_id}/specimens:
    parameters:
      - name: lab_order_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Laboratory]
      operationId: recordSpecimen
      summary: Record specimen collection
      description: |
        Collection time is recorded here, and it is the start of turnaround
        time. Recording it at collection rather than at receipt is what makes
        a true collection-to-result turnaround measurable — the same
        distinction called out in indicator DP-01 of the platform proposed in
        this bid, where many systems can only supply receipt-to-result.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [specimen_type, collected_at]
              properties:
                specimen_type: { type: string, examples: ['whole_blood', 'serum', 'urine', 'stool', 'csf', 'swab'] }
                collected_at: { type: string, format: date-time }
                collected_by: { type: string }
                container: { type: string }
                volume_ml: { type: number }
      responses:
        '201':
          description: Specimen recorded; order moves to `collected`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Specimen' }
      security:
        - bearerAuth: [lab:collect]

  /specimens/{specimen_id}:receive:
    parameters:
      - name: specimen_id
        in: path
        required: true
        schema: { type: string }
      - $ref: '#/components/parameters/IdempotencyKey'
    post:
      tags: [Laboratory]
      operationId: receiveSpecimen
      summary: Receive or reject a specimen in the laboratory
      description: |
        Rejection requires a reason from a controlled list. Rejection reasons
        are reportable: `/reports/lab-rejections` aggregates them, which is
        how a laboratory finds out that one ward is producing most of the
        haemolysed samples.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [accept, reject] }
                rejection_reason:
                  type: string
                  enum: [haemolysed, insufficient_volume, wrong_container, unlabelled, mislabelled, clotted, delayed_transport, leaked]
                comment: { type: string }
      responses:
        '200':
          description: Specimen accepted or rejected.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Specimen' }
        '400': { $ref: '#/components/responses/BadRequest' }
      security:
        - bearerAuth: [lab:receive]

  /lab-results:
    post:
      tags: [Laboratory]
      operationId: enterResult
      summary: Enter a result
      description: |
        Entry does not publish. A result enters as `entered` and is invisible
        to the ordering clinician until validated and released. Values outside
        the reference range for the patient's age and sex are flagged
        automatically; values outside the critical range are marked and raise
        an alert on release.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lab_order_id, test_code, value]
              properties:
                lab_order_id: { type: string }
                test_code: { type: string }
                value: { type: [number, string] }
                unit: { type: string }
                method: { type: string }
                instrument_id: { type: string }
                entered_by: { type: string }
                comment: { type: string }
      responses:
        '201':
          description: Result recorded in `entered` state.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LabResult' }
      security:
        - bearerAuth: [lab:result_entry]

  /lab-results/{result_id}:validate:
    parameters:
      - name: result_id
        in: path
        required: true
        schema: { type: string }
      - $ref: '#/components/parameters/IdempotencyKey'
    post:
      tags: [Laboratory]
      operationId: validateResult
      summary: Validate a result (biologist sign-off)
      description: |
        The control point of the whole module. Only a user holding
        `lab:validate` may validate, the validator cannot be the same user who
        entered the value unless the deployment enables single-operator mode
        for small laboratories, and the validating user is stored on the
        result permanently. Rejection sends the result back for re-analysis
        with a reason.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [decision]
              properties:
                decision: { type: string, enum: [validate, reject] }
                reason: { type: string, description: Required when rejecting. }
                comment: { type: string }
      responses:
        '200':
          description: Result validated or returned for re-analysis.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LabResult' }
        '403':
          description: Caller lacks `lab:validate`, or is the entering user in a deployment requiring two-person validation.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '409':
          description: Result already validated.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
      security:
        - bearerAuth: [lab:validate]

  /lab-results/{result_id}:release:
    parameters:
      - name: result_id
        in: path
        required: true
        schema: { type: string }
      - $ref: '#/components/parameters/IdempotencyKey'
    post:
      tags: [Laboratory]
      operationId: releaseResult
      summary: Release a validated result to the ordering clinician
      description: |
        Release makes the result visible on the encounter, printable on the
        report, and countable in statistics. Releasing a result carrying a
        critical flag raises a notification to the ordering clinician and
        records the acknowledgement.
      responses:
        '200':
          description: Released.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LabResult' }
        '409':
          description: Result is not in `validated` state.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
      security:
        - bearerAuth: [lab:release]

  /lab-results/{result_id}/amendments:
    parameters:
      - name: result_id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Laboratory]
      operationId: amendResult
      summary: Amend a released result
      description: |
        A released result is never overwritten. An amendment creates a new
        version with a reason; the previous version remains retrievable and
        the report shows that the result was amended, by whom and when.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value, reason]
              properties:
                value: { type: [number, string] }
                unit: { type: string }
                reason: { type: string }
      responses:
        '201':
          description: Amended version created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LabResult' }
      security:
        - bearerAuth: [lab:amend]

  /lab-catalogue:
    get:
      tags: [Laboratory]
      operationId: getLabCatalogue
      summary: The hospital test catalogue
      description: |
        Tests offered, with specimen requirements, reference ranges by age and
        sex, turnaround targets, price and current availability. A test is
        marked unavailable when its reagent is out of stock, which removes it
        from the ordering screen rather than letting clinicians order what the
        laboratory cannot run.
      parameters:
        - name: available_only
          in: query
          schema: { type: boolean, default: false }
        - name: section
          in: query
          schema: { type: string, examples: ['haematology', 'biochemistry', 'microbiology', 'serology', 'parasitology'] }
      responses:
        '200':
          description: Catalogue entries.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/LabCatalogueItem' }
      security:
        - bearerAuth: [lab:read]

  # ------------------------------------------------------------------ Pharmacy
  /prescriptions:
    post:
      tags: [Pharmacy]
      operationId: createPrescription
      summary: Prescribe
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [encounter_id, items]
              properties:
                encounter_id: { type: string }
                items:
                  type: array
                  items:
                    type: object
                    required: [product_code, quantity]
                    properties:
                      product_code: { type: string }
                      quantity: { type: number }
                      dosage: { type: string }
                      duration_days: { type: integer }
      responses:
        '201':
          description: Prescription created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Prescription' }
      security:
        - bearerAuth: [pharmacy:prescribe]

  /prescriptions/{prescription_id}:dispense:
    parameters:
      - name: prescription_id
        in: path
        required: true
        schema: { type: string }
      - $ref: '#/components/parameters/IdempotencyKey'
    post:
      tags: [Pharmacy]
      operationId: dispensePrescription
      summary: Dispense against a prescription
      description: |
        Dispensing decrements stock in the same transaction as it records the
        dispense, so the stock figure and the dispensing record cannot
        disagree. Partial dispensing is supported and leaves the balance
        outstanding.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  items:
                    type: object
                    properties:
                      product_code: { type: string }
                      quantity_dispensed: { type: number }
                      batch: { type: string }
      responses:
        '200':
          description: Dispensed; stock updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Prescription' }
        '409':
          description: Insufficient stock; available quantity returned.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
      security:
        - bearerAuth: [pharmacy:dispense]

  /stock:
    get:
      tags: [Pharmacy]
      operationId: listStock
      summary: Stock on hand, including laboratory reagents
      parameters:
        - name: category
          in: query
          schema: { type: string, enum: [medicine, consumable, lab_reagent] }
        - name: below_reorder_level
          in: query
          schema: { type: boolean }
        - name: expiring_within_days
          in: query
          schema: { type: integer }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: Stock lines.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/StockLine' }
                  page: { $ref: '#/components/schemas/Page' }
      security:
        - bearerAuth: [pharmacy:read]

  # ------------------------------------------------------------------- Billing
  /invoices:
    get:
      tags: [Billing]
      operationId: listInvoices
      summary: List invoices
      parameters:
        - name: patient_id
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [draft, issued, part_paid, paid, cancelled] }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of invoices.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Invoice' }
                  page: { $ref: '#/components/schemas/Page' }
      security:
        - bearerAuth: [billing:read]

  /invoices/{invoice_id}/payments:
    parameters:
      - name: invoice_id
        in: path
        required: true
        schema: { type: string }
      - $ref: '#/components/parameters/IdempotencyKey'
    post:
      tags: [Billing]
      operationId: recordPayment
      summary: Record a payment
      description: |
        Payments are append-only; a correction is a reversing entry with a
        reason, never a deletion. Mobile-money payments carry the operator
        reference for reconciliation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, method]
              properties:
                amount: { type: number }
                currency: { type: string, default: XAF }
                method: { type: string, enum: [cash, mobile_money, card, insurance, transfer] }
                operator_reference: { type: string }
                received_by: { type: string }
      responses:
        '201':
          description: Payment recorded.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Payment' }
      security:
        - bearerAuth: [billing:collect]

  # ----------------------------------------------------------------- Reporting
  /reports/lab-turnaround:
    get:
      tags: [Reporting]
      operationId: reportLabTurnaround
      summary: Laboratory turnaround time
      description: |
        Median and 90th-percentile hours from collection to release, by test,
        section and priority, over a period. Only released results count.
      parameters:
        - name: date_from
          in: query
          required: true
          schema: { type: string, format: date }
        - name: date_to
          in: query
          required: true
          schema: { type: string, format: date }
        - name: group_by
          in: query
          schema: { type: string, enum: [test, section, priority, ordering_unit], default: section }
      responses:
        '200':
          description: Aggregated turnaround statistics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        group: { type: string }
                        results_released: { type: integer }
                        median_hours: { type: number }
                        p90_hours: { type: number }
      security:
        - bearerAuth: [reports:read]

  /reports/lab-rejections:
    get:
      tags: [Reporting]
      operationId: reportLabRejections
      summary: Specimen rejection rate and reasons
      parameters:
        - name: date_from
          in: query
          required: true
          schema: { type: string, format: date }
        - name: date_to
          in: query
          required: true
          schema: { type: string, format: date }
        - name: group_by
          in: query
          schema: { type: string, enum: [reason, ordering_unit, collector], default: reason }
      responses:
        '200':
          description: Rejection counts and rate.
          content:
            application/json:
              schema:
                type: object
                properties:
                  received: { type: integer }
                  rejected: { type: integer }
                  rejection_rate: { type: number }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        group: { type: string }
                        count: { type: integer }
      security:
        - bearerAuth: [reports:read]

  /reports/activity:
    get:
      tags: [Reporting]
      operationId: reportActivity
      summary: Hospital activity statistics
      description: |
        Aggregates only — encounters, admissions, tests performed, positivity
        for programmatic tests, prescriptions dispensed, revenue by category.
        No patient-level data is returned from this endpoint, which is what
        allows management and external reporting users to hold `reports:read`
        without holding `clinical:read`.
      parameters:
        - name: period
          in: query
          required: true
          schema: { type: string, examples: ['2026-03'] }
        - name: unit_id
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Activity aggregates.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
      security:
        - bearerAuth: [reports:read]

  # ------------------------------------------------------------ Administration
  /users:
    get:
      tags: [Administration]
      operationId: listUsers
      summary: List users and their role assignments
      responses:
        '200':
          description: Users.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/User' }
      security:
        - bearerAuth: [admin:users]

  /users/{user_id}/roles:
    parameters:
      - name: user_id
        in: path
        required: true
        schema: { type: string }
    put:
      tags: [Administration]
      operationId: setUserRoles
      summary: Assign roles, scoped to hospital units
      description: |
        Roles are granted per unit, not globally: a technician in the
        laboratory is not a technician in the pharmacy. Every change is
        audited with the granting user.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                assignments:
                  type: array
                  items:
                    type: object
                    properties:
                      role: { type: string, enum: [receptionist, records_officer, nurse, clinician, lab_technician, biologist, pharmacist, cashier, manager, administrator, auditor] }
                      unit_id: { type: string }
      responses:
        '200':
          description: Roles updated.
      security:
        - bearerAuth: [admin:users]

  # ------------------------------------------------------------------- Audit
  /audit:
    get:
      tags: [Audit]
      operationId: listAuditEvents
      summary: Query the audit log
      description: |
        Append-only. Records sign-in and sign-out, patient record access,
        result validation and release, prescription dispensing, invoice
        adjustment, merges and role changes — with actor, timestamp, entity
        and before/after values. Readable by the auditor and administrator
        roles only, and the audit log itself cannot be modified through any
        endpoint.
      parameters:
        - name: actor_user_id
          in: query
          schema: { type: string }
        - name: entity_type
          in: query
          schema: { type: string, examples: ['patient', 'lab_result', 'invoice', 'user_role'] }
        - name: entity_id
          in: query
          schema: { type: string }
        - name: from
          in: query
          schema: { type: string, format: date-time }
        - name: to
          in: query
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: Audit events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AuditEvent' }
                  page: { $ref: '#/components/schemas/Page' }
      security:
        - bearerAuth: [audit:read]

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Bearer token issued on sign-in. The token carries the user's role
        assignments and the hospital units they are scoped to; both are
        enforced server-side on every request. Sessions expire on inactivity
        and privileged roles re-authenticate for sensitive operations.
        [[Replace this description with your actual mechanism if it differs.]]

  parameters:
    Cursor:
      name: cursor
      in: query
      schema: { type: string }
    PageSize:
      name: page_size
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Replaying a key within 24 hours returns the original result rather than repeating the write.
      schema: { type: string, maxLength: 128 }

  responses:
    BadRequest:
      description: Malformed request or violated constraint.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: Authenticated but lacking the role, or acting outside the caller's assigned units.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: No such record, or it lies outside the caller's scope.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:

    Problem:
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code: { type: string }
        trace_id: { type: string }

    Page:
      type: object
      properties:
        next_cursor: { type: [string, 'null'] }
        page_size: { type: integer }

    PatientSummary:
      type: object
      properties:
        patient_id: { type: string }
        patient_number: { type: string }
        display_name: { type: string }
        sex: { type: string, enum: [F, M, U] }
        date_of_birth: { type: [string, 'null'], format: date }

    Patient:
      allOf:
        - $ref: '#/components/schemas/PatientSummary'
        - type: object
          properties:
            given_name: { type: string }
            family_name: { type: string }
            date_of_birth_estimated: { type: boolean }
            phone: { type: string }
            address: { type: string }
            next_of_kin:
              type: object
              properties:
                name: { type: string }
                relationship: { type: string }
                phone: { type: string }
            insurance:
              type: object
              properties:
                scheme: { type: string }
                member_number: { type: string }
                status: { type: string, enum: [active, expired, unknown] }
            registered_at: { type: string, format: date-time }
            merged_into: { type: [string, 'null'] }

    PatientWrite:
      type: object
      properties:
        given_name: { type: string }
        family_name: { type: string }
        sex: { type: string, enum: [F, M, U] }
        date_of_birth: { type: string, format: date }
        date_of_birth_estimated: { type: boolean }
        phone: { type: string }
        address: { type: string }

    Encounter:
      type: object
      properties:
        encounter_id: { type: string }
        patient_id: { type: string }
        unit_id: { type: string }
        type: { type: string }
        status: { type: string, enum: [open, closed, cancelled] }
        attending_user_id: { type: string }
        opened_at: { type: string, format: date-time }
        closed_at: { type: [string, 'null'], format: date-time }

    ClinicalNote:
      type: object
      properties:
        note_id: { type: string }
        encounter_id: { type: string }
        note_type: { type: string }
        body: { type: string }
        author_user_id: { type: string }
        created_at: { type: string, format: date-time }
        amends_note_id: { type: [string, 'null'] }

    LabOrder:
      type: object
      properties:
        lab_order_id: { type: string }
        patient_id: { type: string }
        encounter_id: { type: string }
        tests:
          type: array
          items:
            type: object
            properties:
              test_code: { type: string }
              display: { type: string }
        priority: { type: string }
        status: { type: string, enum: [ordered, collected, received, in_progress, resulted, released, cancelled, rejected] }
        clinical_information: { type: string }
        ordered_by: { type: string }
        ordered_at: { type: string, format: date-time }
        received_at: { type: [string, 'null'], format: date-time }

    Specimen:
      type: object
      properties:
        specimen_id: { type: string }
        lab_order_id: { type: string }
        specimen_type: { type: string }
        collected_at: { type: string, format: date-time }
        collected_by: { type: string }
        received_at: { type: [string, 'null'], format: date-time }
        status: { type: string, enum: [collected, in_transit, received, rejected] }
        rejection_reason: { type: [string, 'null'] }

    LabResult:
      type: object
      properties:
        result_id: { type: string }
        lab_order_id: { type: string }
        test_code: { type: string }
        value: { type: [number, string, 'null'] }
        unit: { type: string }
        reference_range: { type: string }
        abnormal_flag: { type: [string, 'null'], enum: [low, high, critical_low, critical_high, null] }
        status: { type: string, enum: [entered, validated, released, rejected, amended] }
        version: { type: integer }
        entered_by: { type: string }
        entered_at: { type: string, format: date-time }
        validated_by: { type: [string, 'null'] }
        validated_at: { type: [string, 'null'], format: date-time }
        released_at: { type: [string, 'null'], format: date-time }
        amendment_reason: { type: [string, 'null'] }

    LabCatalogueItem:
      type: object
      properties:
        test_code: { type: string }
        display: { type: string }
        section: { type: string }
        specimen_type: { type: string }
        reference_ranges:
          type: array
          items:
            type: object
            properties:
              sex: { type: string }
              age_min_years: { type: number }
              age_max_years: { type: number }
              low: { type: number }
              high: { type: number }
              unit: { type: string }
        turnaround_target_hours: { type: number }
        price: { type: number }
        available: { type: boolean }
        unavailable_reason: { type: [string, 'null'] }

    Prescription:
      type: object
      properties:
        prescription_id: { type: string }
        encounter_id: { type: string }
        status: { type: string, enum: [prescribed, part_dispensed, dispensed, cancelled] }
        items:
          type: array
          items:
            type: object
            properties:
              product_code: { type: string }
              quantity: { type: number }
              quantity_dispensed: { type: number }
              dosage: { type: string }
        prescribed_by: { type: string }
        prescribed_at: { type: string, format: date-time }

    StockLine:
      type: object
      properties:
        product_code: { type: string }
        display: { type: string }
        category: { type: string, enum: [medicine, consumable, lab_reagent] }
        quantity_on_hand: { type: number }
        reorder_level: { type: number }
        batch: { type: string }
        expiry_date: { type: [string, 'null'], format: date }

    Invoice:
      type: object
      properties:
        invoice_id: { type: string }
        patient_id: { type: string }
        encounter_id: { type: string }
        status: { type: string }
        currency: { type: string }
        total: { type: number }
        paid: { type: number }
        balance: { type: number }
        lines:
          type: array
          items:
            type: object
            properties:
              description: { type: string }
              category: { type: string, enum: [consultation, laboratory, pharmacy, procedure, bed, other] }
              quantity: { type: number }
              unit_price: { type: number }
              amount: { type: number }
        issued_at: { type: string, format: date-time }

    Payment:
      type: object
      properties:
        payment_id: { type: string }
        invoice_id: { type: string }
        amount: { type: number }
        currency: { type: string }
        method: { type: string }
        operator_reference: { type: [string, 'null'] }
        received_by: { type: string }
        received_at: { type: string, format: date-time }
        reverses_payment_id: { type: [string, 'null'] }

    User:
      type: object
      properties:
        user_id: { type: string }
        display_name: { type: string }
        status: { type: string, enum: [active, suspended] }
        assignments:
          type: array
          items:
            type: object
            properties:
              role: { type: string }
              unit_id: { type: string }
        last_sign_in_at: { type: [string, 'null'], format: date-time }

    AuditEvent:
      type: object
      properties:
        event_id: { type: string }
        actor_user_id: { type: string }
        action: { type: string, examples: ['patient.view', 'lab_result.validate', 'lab_result.release', 'prescription.dispense', 'user_role.change'] }
        entity_type: { type: string }
        entity_id: { type: string }
        before: { type: [object, 'null'] }
        after: { type: [object, 'null'] }
        occurred_at: { type: string, format: date-time }
        ip_address: { type: string }
