Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,17 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password
# IMPORT_WAIT_MS=60000


# ── Patient links: the QR on reports and bills opens the patient's page (docs/21-patient-links.md) ──────
# [optional] [secret] HMAC key for the link tokens, 32+ characters. Changing it stops every printed QR.
# PATIENT_LINK_SECRET=
# [optional] The dashboard's public page; the token is appended. Links are off unless both are set.
# PATIENT_LINK_BASE_URL=https://dashboard.example.in/r/
# [optional] How long a link works, in days.
# PATIENT_LINK_DAYS=180
# [optional] Patient page calls per IP per 10 minutes.
# PATIENT_LINK_PER_IP=60


# ── Report and invoice signatures (report-kit) ───────────────────────────────────────────────────────
# [optional] [private files] Real signature images (.png, .jpg or .svg), kept OUTSIDE the repo and mounted
# read-only. Without them, documents use the placeholder signature shipped in report-kit.
Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ Dockerfile, docker-compose.yml, deploy/ image; PostgreSQL ← PgBouncer ← N
accredited, never a logo; a partner signature only with results from its own PDF (looked up by that
PDF's sha256), never as ours; Reported by = medical signatories; imports never verify or release.
PDFs, extracted JSON and signature PNGs are patient data: never logged, never in jobs, fixtures or repos.
16. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names.
16. **Patient links** (docs/21-patient-links.md): the token is the only key and is never stored, logged or put in a job
(db-kit keeps its SHA-256 and the nonce). `patientPage` / `patientDocument` are public: rate-limited per IP, null for
any token that does not open, one booking only, released reports and issued bills only.
17. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names.

## Commands

Expand Down
9 changes: 9 additions & 0 deletions docs/04-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,15 @@ and runnable examples are in [examples/](examples/).
| mutation | `nameExternalSignature(input: ExternalSignatoryInput!): ExternalSignatory!` | `signatory:manage` | Names a partner signature; the organisation's unapplied imports that carry it are read again. |
| mutation | `rejectExternalReport(id: ID!, reason: String!): ExternalReport!` | `report:import` | |

## patientLinks

| Kind | Operation | Permission | Notes |
|---|---|---|---|
| query | `patientDocument(token: String!, kind: PatientDocumentKind!, invoiceId: ID): Document` | public | Public (no sign-in): a PDF from a patient link: one of its bills (invoiceId) or its released reports. Null when the link does not open. |
| query | `patientLink(bookingId: ID!): PatientLinkInfo!` | `booking:read`, `report:read` | The booking's patient link (made on first use). Needs booking:read and report:read on the booking's branch. |
| query | `patientPage(token: String!): PatientPage` | public | Public (no sign-in): the page behind a patient link, or null. |
| mutation | `revokePatientLinks(bookingId: ID!): Int!` | `booking:update` | Stops every link of the booking (printed QRs stop opening); the next print makes a new one. Returns how many were stopped. Needs booking:update. |

## system

| Kind | Operation | Permission | Notes |
Expand Down
7 changes: 7 additions & 0 deletions docs/08-documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ The stored invoice (paise) is mapped to invoice-kit's print shape (rupees). invo
totals; the API checks that its total equals the stored total and refuses a mismatch (`CONFLICT`), so a
printed invoice can never disagree with the books.

## The QR code

With patient links on (`PATIENT_LINK_SECRET` and `PATIENT_LINK_BASE_URL`), the QR on a printed report and on
every bill of a booking opens the patient's page for that booking ([21-patient-links.md](21-patient-links.md)).
Printing again prints the same link. Without them, the QR keeps its old summary. A bill's UPI pay QR (when the
lab sets `MGPL_UPI_ID`) is separate and unchanged.

## Ad-hoc rendering

`renderReport(request)` and `renderInvoice(request)` take the same JSON as report-kit's and invoice-kit's
Expand Down
4 changes: 4 additions & 0 deletions docs/09-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ variables already set in the real environment win over the file.
| `IMPORT_MAX_BYTES` | `15728640` (15 MiB) | Largest partner-lab PDF ([18](18-imports.md)) |
| `IMPORT_INLINE_MAX_BYTES` | `5242880` (5 MiB) | Larger partner PDFs are read by the worker when Redis is configured |
| `IMPORT_WAIT_MS` | `60000` | How long `importExternalReport` waits for the worker |
| `PATIENT_LINK_SECRET` | empty (off) | 32+ characters: HMAC key for patient link tokens ([21](21-patient-links.md)). Links are on only with `PATIENT_LINK_BASE_URL` too |
| `PATIENT_LINK_BASE_URL` | empty (off) | The public page the token is appended to, e.g. `https://dashboard.example.in/r/` (https) |
| `PATIENT_LINK_DAYS` | `180` | How long a patient link works |
| `PATIENT_LINK_PER_IP` | `60` | Patient page calls per IP per 10 minutes |
| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored. Through a trusted proxy, its `X-Request-ID` (when well-formed) becomes the request id; otherwise each request gets a UUIDv7, sent back in `x-request-id` and logged as `reqId` |
| `TLS_FINGERPRINT_HEADER` | empty (off) | Header the TLS-terminating proxy sets to the client's JA4 (or JA3) TLS fingerprint, e.g. `x-ja4`. Read only through `TRUST_PROXY` proxies (refused at startup without one); stored hashed on the session, a change audited ([06-auth-and-permissions.md](06-auth-and-permissions.md#tls-fingerprint)) |
| `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` |
Expand Down
4 changes: 3 additions & 1 deletion docs/18-imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ flowchart LR
| Step | Field | Permission | Notes |
|---|---|---|---|
| Upload | `importExternalReport(pdfBase64, fileName, bookingId, branchId)` | `report:import` in the branch | `branchId` defaults to the booking's. The same PDF again returns the same import (by SHA-256). A PDF the reader does not support → `UNSUPPORTED_REPORT`, and its file is not kept. |
| Review | `externalReport(id)`, `externalReports(branchId, bookingId, status, first, after)` | `report:import` | Tests with parameters as printed, `suggestedTestCode` (catalog-kit's top search hit), the provenance line each test will print, signatories and `warnings`. |
| Review | `externalReport(id)`, `externalReports(branchId, bookingId, status, first, after)` | `report:import` | Tests with parameters as printed, `suggestedTestCode` (once matched, the best catalog hit among the booking's tests; otherwise the top hit), the provenance line each test will print, signatories and `warnings`. |
| Find the booking | `ExternalReport.candidates(first)` | `report:import` (lists only bookings in those branches) | Patients found by the printed name (whole, then each word), their bookings of the last 90 days that are not cancelled, scored 0 to 100: name (same, in part, first name), sex, age ±1, partner tests that map to booked tests (catalog search among the booking's codes), how recent. `strong` = name, sex and age agree and a test maps. Each candidate carries its `reasons`, `patientMismatches` and per-test `mappings`. |
| Auto-match | on upload without `bookingId` | — | When exactly one strong candidate in the upload's branch stands out (no other strong one within 20 points), the import is matched to it at once (status `matched`, audited `external_report.auto_match`). Staff still check and apply; matching another booking undoes it. Nothing is applied automatically. |
| Match | `matchExternalReport(id, bookingId)` | `report:import` (both branches) | Compares the patient: name case-insensitive without titles, sex, age within ±1 year. Each difference is listed in `patientMismatches`. |
| Apply | `applyExternalReport(id, mappings, confirmPatientMismatch)` | `report:import` + `report:enter` | One mapping per partner test: `{ testIndex, testCode }` (a test in the booking). Parameters map to our catalog names within the chosen test. Values, units, ranges and flags are copied **as printed**. Ranges are never recomputed. With mismatches, `confirmPatientMismatch: true` is required (otherwise `BOOKING_PATIENT_MISMATCH`). Applying twice → `REPORT_ALREADY_APPLIED`. |
| Reject | `rejectExternalReport(id, reason)` | `report:import` | |
Expand Down
55 changes: 55 additions & 0 deletions docs/21-patient-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 21. Patient links

The QR printed on a booking's reports and bills opens a public, read-only page: that booking's bills and
released reports, and nothing else. The patient needs no account and no password; the link is the key.

```mermaid
flowchart LR
P[Report or bill printed] -->|QR = PATIENT_LINK_BASE_URL + token| Q[Patient scans]
Q --> D[Dashboard /r/token]
D -->|patientPage, patientDocument| A[api]
A -->|sha256 token, then HMAC check| L[(patient_links)]
```

## Turning it on

| Variable | Default | |
|---|---|---|
| `PATIENT_LINK_SECRET` | empty (off) | HMAC key for the tokens, 32+ characters. Keep it private; changing it stops every printed QR (the next print makes new links) |
| `PATIENT_LINK_BASE_URL` | empty (off) | The public page the token is appended to, e.g. `https://dashboard.example.in/r/`. https only (localhost may be http) |
| `PATIENT_LINK_DAYS` | `180` | How long a link works |
| `PATIENT_LINK_PER_IP` | `60` | Patient page calls per IP per 10 minutes (shared through Redis when `REDIS_URL` is set) |

With both the secret and the base URL set, every printed report (`bookingReportDocument`) and every bill of a
booking (`invoiceDocument`) carries the link in its QR, instead of the old summary QR. Without them nothing
changes.

## How a link is made and checked

- The token is `HMAC-SHA256(PATIENT_LINK_SECRET, nonce)` as base64url (43 characters); the nonce is 24 random bytes.
- db-kit stores only `sha256(token)` and the nonce ([db-kit docs/PATIENT-LINKS.md](https://github.com/mgpl-diagnostics/db-kit/blob/main/docs/PATIENT-LINKS.md)).
A copy of the database alone cannot open a link, and the api can print the same link again.
- A booking keeps one live link: printing twice prints the same QR. A link made with another secret is replaced.
- Opening looks the row up by hash, checks it is live (not expired, not revoked, booking not cancelled), then
compares the token with the HMAC of the row's nonce in constant time.
- Unknown, expired, revoked and malformed tokens all answer `null`. Every patient call counts against the
caller's IP, good token or not (`RATE_LIMITED` with `retryAfterMs`).
- Tokens and nonces are never logged and never put in jobs.

## What the patient sees

`patientPage(token)`: the lab's name, the patient's shortened name ("Ravi K. S."), the booking number and date,
the branch's name, phone and address, the issued bills (drafts and cancelled bills are left out), the released
reports (verified ones are not given out yet), and the tests still in the lab. Each call counts a view.

`patientDocument(token, kind, invoiceId)`: a PDF of one of those bills (`kind: invoice`) or of the released
reports printed together (`kind: reports`). An id from anywhere else answers `NOT_FOUND`.

## Staff

| Field | Permission | |
|---|---|---|
| `patientLink(bookingId)` | `booking:read` + `report:read` on the booking's branch | The link to send or show, made on first use |
| `revokePatientLinks(bookingId)` | `booking:update` | Stops every link of the booking at once (printed QRs stop opening); audited as `patient_link.revoke`. The next print makes a new link |

See [examples/patient-links.graphql](examples/patient-links.graphql).
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,6 @@ flowchart TD
| 18 | [Partner-lab imports](18-imports.md) | Reading partner lab PDFs, matching, applying with provenance, NABL and signature rules |
| 19 | [Releases and updates](19-updates.md) | Docker images on GHCR, hosting with Compose, the update notice, releasing |
| 20 | [Hosting on a VPS](20-vps.md) | One server with Docker Compose: production .env, first lab, HTTPS, backups, updates |
| 21 | [Patient links](21-patient-links.md) | The QR on reports and bills: a public page with one booking's bills and released reports |
| — | [examples/](examples/) | Runnable `.graphql` operations |
| — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo |
11 changes: 11 additions & 0 deletions docs/examples/imports.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ mutation ImportPartnerReport($pdf: String!, $bookingId: ID) {
}
}

query PartnerReportBookings($id: ID!) {
externalReport(id: $id) {
id status booking { id bookingNo }
candidates(first: 5) {
booking { id bookingNo patient { name uhid } }
score strong reasons patientMismatches
mappings { testIndex testCode }
}
}
}

mutation ApplyPartnerReport($id: ID!) {
applyExternalReport(id: $id, mappings: [{ testIndex: 0, testCode: "LIPR" }], confirmPatientMismatch: false) {
id testCode status
Expand Down
24 changes: 24 additions & 0 deletions docs/examples/patient-links.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Patient links (docs/21-patient-links.md). Variables: $bookingId (staff), $token (public, from the URL), $invoiceId.

query BookingPatientLink($bookingId: ID!) {
patientLink(bookingId: $bookingId) { url expiresAt }
}

mutation StopPatientLinks($bookingId: ID!) {
revokePatientLinks(bookingId: $bookingId)
}

# No sign-in: the token is the key. Null when the link does not open.
query PatientPage($token: String!) {
patientPage(token: $token) {
labName patientName bookingNo bookedAt
branch { name phone address }
invoices { id invoiceNo issuedAt totalPaise paidPaise duePaise status }
reports { id testName releasedAt }
pending expiresAt
}
}

query PatientBill($token: String!, $invoiceId: ID!) {
patientDocument(token: $token, kind: invoice, invoiceId: $invoiceId) { fileName mimeType base64 }
}
8 changes: 4 additions & 4 deletions kits.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,23 @@
},
{
"name": "db-kit",
"ref": "v2.1.1"
"ref": "v2.2.0"
},
{
"name": "report-kit",
"ref": "v2.2.0"
"ref": "v2.3.0"
},
{
"name": "ledger-kit",
"ref": "v0.1.0"
},
{
"name": "client",
"ref": "v1.10.0"
"ref": "v1.12.0"
},
{
"name": "invoice-kit",
"ref": "v2.1.0"
"ref": "v2.2.0"
},
{
"name": "finance-kit",
Expand Down
Loading
Loading