medOS ultra

Blood Infection Register Lock Rules

Three lock states for the blood-infection register, LIS data flow, and view-vs-table write whitelist.

3 min read diagramsUpdated 2026-05-02docs/architecture/blood-infection-register-lock-rules.md

Page: /blood-bank/blood-infection-register Module: BloodInfectionRegisterModule.tsx

Why locks exist

Q/D (Qualified / Disqualified) verdicts must not be recorded against a bag with no screening data. The lock prevents staff from clicking through Q/D checkboxes on an empty row and creating a verdict the lab never produced.

Three lock states

State Condition Lock icon Q/D inputs
Locked (default) No screening data, not manually unlocked gray closed read-only
Auto-unlocked At least one panel result present green open editable
Manually unlocked User clicked the lock to override orange open editable

Auto-unlock signal

hasScreeningData(row) returns true if any of these are filled:

  • nat_result, hiv_result, hbv_result, hcv_result, syphilis_result, antibody_screen — individual TTI panel results
  • lis_received_at — set when LIS feed delivers results
  • source === 'lis' — provenance flag for LIS-imported rows

A value counts as “filled” if it’s not null, not empty string, and not 'not_done'.

Data flow

/blood-bank/blood-intake
  → LIS import or manual entry
  → blood_lis_results.{nat_result | hiv_result | ...}
  → blood_infection_register view picks up the result
  → row auto-unlocks on next render

When the lab tech enters or imports the first panel result for a bag at intake, the corresponding row in the infection register auto-unlocks. They can then qualify/disqualify per panel without an explicit unlock click.

Manual unlock — when to use

For bags where:

  • Results were recorded outside the system and need to be back-entered
  • A correction is needed on a row whose *_result columns were cleared
  • You’re testing the screening UI before LIS is wired up

Manual unlock state persists in localStorage (medos.bloodInfectionRegister.unlockedBags.v1).

Common pitfalls

  • Don’t auto-unlock just because a row is a split child (PRC/FFP/etc.). A child bag with no screening data should stay locked — same rule applies to parents and children.
  • Don’t rely on nat_qualified/sero_qualified/antibody_qualified to detect “has data.” Those are the outputs of this page, not the inputs. Use the raw *_result columns (and lis_received_at) which come from upstream lab/intake.
  • Don’t skip the .maybeSingle() discipline in upsertBloodInfectionScreening. The view’s id is the blood_separation_entries.id when no LIS row exists yet — UPDATE blood_lis_results WHERE id = X returns 0 rows in that case, and .single() throws “Cannot coerce the result to a single JSON object.”
  • Don’t spread the row directly into the UPDATE/INSERT payload. The BloodInfectionScreeningRow shape mirrors the view, which joins blood_separation_entries (bag_type / bag_status / volume / etc.) onto the lab results. Writing those view-only fields to the underlying blood_lis_results table raises PGRST204 (“Could not find the ‘X’ column in the schema cache”). The service uses an explicit column whitelist (BLOOD_LIS_RESULTS_COLUMNS) — keep new lab-result fields in sync with that list, and never bypass it.
  • Service: upsertBloodInfectionScreening — writes go to blood_lis_results, never the view. Uses the BLOOD_LIS_RESULTS_COLUMNS whitelist + pickLisResultsPayload helper.
  • Callers (must all funnel through the service, never write directly):
    • BloodInfectionRegisterModule.tsx — Q/D edits + bulk apply
    • BloodIntakeSpreadsheet.tsx — LIS import + manual screening entry
    • BloodWorkflowDialogs.tsx — pre-crossmatch screening dialog
  • Visual grouping rules for sibling component bags (PRC + FFP + …): same module, search for parentBagKey and parentBagGrouping.
Ask Anything