S
supero.docs
Documentation/Reference apps/How the Medora hospital patient portal keeps a lab flag from patients

How the Medora hospital patient portal keeps a lab flag from patients

Medora is an open-source hospital patient portal for a three-site network, with fields hidden per role. The source is on GitHub and a live demo is running.

  • apps
  • healthcare
  • multi-tenant
  • field-level-access
  • patient-portal

Overview

Each hospital is a tenant, a walled-off set of records, in this open-source hospital patient portal, and the patient role reads its own records minus the parts written for the doctor.
LDL cholesterol, 112 mg/dL, reference range under 100. That is the lab result seeded for Maria Alvarez, the demo patient at Mercy General in Medora. The row has one more attribute, flag, seeded as high. A request made with Maria's login gets the first three back.
The fourth belongs to her clinician.

Two patient portal rules that drop fields

Seven of the 19 apps in the supero-apps repo use hidden_fields somewhere. Medora, whose code sits under apps/healthcare/medora, is the only one that uses it on two entities. Both rules sit in the patient's policy in setup.py:
python
PolicyRule(entity="encounter", can_read=True,
           filter_field="owner_username", filter_match="$user.name",
           hidden_fields=["assessment", "plan"]),
python
PolicyRule(entity="lab_result", can_read=True,
           filter_field="owner_username", filter_match="$user.name",
           hidden_fields=["flag"]),
An Encounter is the visit note a doctor records after a completed appointment. Most of it is fit for the patient: the chief complaint, blood pressure and heart rate, a follow-up interval. Two fields are not. assessment and plan are a clinician's notes to other clinicians, and when a tenant_user reads their own encounter those two are absent from the response body, removed server-side. So is flag on a lab result. A comment above the rules puts the design in one line: tenants isolate rows, hidden_fields isolate fields. The same comment says where to check it, which is the API and not a screen: curl it with the patient's own valid token and the field is "simply absent from the JSON." If you are building a patient portal, that pair of sentences is most of your access model.
The patient role has eight rules in all. Two grant plain reads of the public catalogue. The other six carry the owner_username filter, policy-based row scoping that limits a patient to rows stamped with their own login, and four of those six (encounter, prescription, lab_result, invoice) grant can_read and nothing else. Patients can create and update exactly two things: their profile and their appointments. Nothing else is writable.
Medora models the shape of that access control. It holds no certification. Keep real patient data out of it.

A hospital is a tenant here

config.py declares four tenants. default-tenant is the network admin's home and holds no clinical data. The other three are sites: Mercy General Hospital, Lakeside Family Clinic, Summit Children's Center.
Seven users are seeded across them. One is the network administrator. Three are site staff, each a tenant_admin inside their own site only. Three are patients, one per site. The seed function in setup.py then loops over the sites and gives each one six departments, a share of the network's ten doctors and twelve patients, and a short clinical history for its first patient: three appointments in three different states plus one row each of encounter, prescription, lab result, invoice. Those are the seed's counts, and the hosted demo has gathered more rows since.
Nothing in the patient-facing code filters by site. A patient signed in at Lakeside asks for appointment and gets Lakeside rows that are theirs. For the network admin, ui/app.js has a SiteSwitcher component that reads the tenant list and calls client.setTenantOverride. It renders only when the generated config marks the app as multi-tenant. A clone gets that from .env.example, which sets SUPERO_IS_MULTI_TENANT=true and the noun "Site" for the label.
BrightSmile, also in this repo, makes the opposite call and keeps four dental clinics as rows in one tenant.

Appointments and invoices inherit their lifecycle

Two schemas in schemas.py do not define their own state field. Appointment declares "extends": "appointment:base_appointment" and Invoice declares "extends": "payment:base_payment". The file's header comment lists what each base brings. An appointment must carry a status with a start and end time, and it moves from requested to confirmed to completed, or out to cancelled or no-show. An invoice must carry a status, an amount, a currency.
The staff console renders appointments as a board with three lanes. Confirming one calls client.transactional.appointment.schedule(a.uuid) and then runs the app's single workflow, appointment_confirmed. That workflow opens with a step of type parallel: the email and the SMS go out side by side. Its last step stamps workflow_status and processed_at on the appointment, and the patient's own list shows "Confirmation sent" once the stamp is there.
The eight schemas hold twelve references between them. Appointment alone points at three (Doctor, Department, Patient) and also copies their display names onto itself. The comment says why: so that the board and the confirmation workflow render with zero joins.

Starting the network locally

bash
git clone https://github.com/supero-platform/supero-apps.git
cd supero-apps/apps/healthcare/medora
cp .env.example .env      # set SUPERO_DOMAIN (any free name) + SUPERO_PASSWORD
./run.sh
.env.example says no account is needed and registration is open. It asks for a free domain name and a password. The first run claims that domain for you. Expect the app running in about two minutes, on port 5712. The sign-in screen lists the demo logins.
The files under apps/healthcare/medora are MIT. Supero itself is not open source. It is a hosted service.
Try the patient login at medora.supero.live before you clone. The front end is one file of 879 lines. Whether your network has two sites or twenty comes down to the length of the tenants list in config.py and the seed data that follows it. Build yours at supero.dev.