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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,15 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password
# JOB_RESULT_TTL_MS=3600000


# ── Partner-lab report imports (report-reader; docs/18-imports.md) ───────────────────────────────────
# [optional] Largest partner PDF in bytes (15 MiB). Only importExternalReport may exceed BODY_LIMIT_BYTES.
# IMPORT_MAX_BYTES=15728640
# [optional] Larger PDFs are read by the worker (with REDIS_URL) instead of in the request (5 MiB).
# IMPORT_INLINE_MAX_BYTES=5242880
# [optional] How long the request waits for the worker, in ms.
# IMPORT_WAIT_MS=60000


# ── 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
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ src/controllers/ resolvers per module: authorize → map → service
src/services/ business calls on the kits: (actor, input), ownership checks, no GraphQL
scripts/ kits.mjs (build kits from source), seed.ts, print-schema.ts, api-reference.ts, llms-full.mjs
test/run.ts end-to-end over HTTP on PostgreSQL + MongoDB, plus docs/examples validation
test/imports.ts partner-lab imports end to end (fictional PDFs drawn by test/partner-pdfs.ts)
docs/ A-to-Z docs (Mermaid), examples/*.graphql, LOCAL-AGENT-BRIEF.md
schema.graphql printed schema (generated)
kits.json kit refs for `npm run kits` (building unreleased kit branches from source)
Expand Down Expand Up @@ -60,7 +61,11 @@ Dockerfile, docker-compose.yml, deploy/ image; PostgreSQL ← PgBouncer ← N
users or sessions), and keep the db layer free of session-level PostgreSQL features (named prepared
statements, `SET` without `LOCAL`, advisory locks held across transactions, `LISTEN`, temp tables):
PgBouncer runs in transaction mode and `npm test` runs the suite through it.
15. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names.
15. **Partner-lab imports** (docs/18-imports.md): the NABL line only under tests the partner marked
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.

## Commands

Expand Down
13 changes: 13 additions & 0 deletions docs/04-api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,16 @@ and runnable examples are in [examples/](examples/).
| query | `renderReport(request: JSON!, format: DocumentFormat, variant: ReportVariant, design: ReportDesign, fileName: String): Document!` | `report:read` | Any report: request = report-kit's ReportRequest JSON (patient + tests \| profile \| report \| sample). |
| query | `reportTemplates: TemplateCatalog!` | (signed in) | |
| mutation | `startDocumentJob(input: DocumentJobInput!): Job!` | `invoice:read`, `report:read` | Renders a stored invoice or a booking's reports in the background; poll job(id) until completed or failed. |

## imports

| Kind | Operation | Permission | Notes |
|---|---|---|---|
| query | `externalReport(id: ID!): ExternalReport` | `report:import` | |
| query | `externalReports(branchId: ID, bookingId: ID, status: ExternalReportStatus, first: Int, after: String): ExternalReportConnection!` | `report:import` | |
| query | `externalSignatories(lab: ExternalLab): [ExternalSignatory!]!` | `signatory:read` | The organisation's partner signatory registry. |
| mutation | `applyExternalReport(id: ID!, mappings: [ExternalTestMappingInput!]!, confirmPatientMismatch: Boolean): [Report!]!` | `report:enter`, `report:import` | Writes the mapped tests into our reports as entered results with provenance. Staff verify as usual. |
| mutation | `importExternalReport(pdfBase64: String!, fileName: String, bookingId: ID, branchId: ID): ExternalReport!` | `report:import` | Reads a partner lab's PDF (base64, up to IMPORT_MAX_BYTES). The same PDF again returns the same import. branchId defaults to the booking's branch. |
| mutation | `matchExternalReport(id: ID!, bookingId: ID!): ExternalReport!` | `report:import` | Ties an import to a booking; patient differences become warnings. |
| 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` | |
5 changes: 5 additions & 0 deletions docs/05-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ Every error in a response carries `extensions.code`. Clients switch on the code,
| `CONFLICT` | 409 | Duplicate key, or the wrong state (e.g. release before verify, cancel a paid invoice) | Refresh and show the message |
| `PERIOD_LOCKED` | 409 | A posting date is on or before a lock date | Ask an accountant; pick another date |
| `CREDIT_LIMIT_EXCEEDED` | 409 | A B2B invoice would exceed the customer's credit limit | Collect payment or raise the limit |
| `UNSUPPORTED_REPORT` | 422 | `importExternalReport`: the PDF is not from a supported partner lab | Tell the user; enter results by hand |
| `FILE_TOO_LARGE` | 413 | A partner PDF over `IMPORT_MAX_BYTES`, or any other request over `BODY_LIMIT_BYTES` (`extensions.maxBytes`) | Ask for a smaller file |
| `BOOKING_PATIENT_MISMATCH` | 409 | `applyExternalReport`: the booking's patient differs (`extensions.mismatches`) | Show the differences; resend with `confirmPatientMismatch: true` if staff confirm |
| `REPORT_ALREADY_APPLIED` | 409 | The partner report was already applied | Open the reports it created |
| `INVALID_CREDENTIALS` | 401 | Wrong email, organisation or password (same answer for each) | "Email or password is wrong" |
| `ACCOUNT_DISABLED` | 403 | The user is disabled | Contact an administrator |
| `INVITE_PENDING` | 403 | The user has not set a password yet | Use the reset link |
Expand All @@ -38,6 +42,7 @@ flowchart LR
W -->|AuthError| A["code = AuthError.code"]
W -->|PeriodLockedError| L["PERIOD_LOCKED"]
W -->|CreditLimitError| CL["CREDIT_LIMIT_EXCEEDED"]
W -->|import errors| IM["UNSUPPORTED_REPORT / FILE_TOO_LARGE /<br/>BOOKING_PATIENT_MISMATCH / REPORT_ALREADY_APPLIED"]
W -->|NotFound / Conflict / ValidationError| D["NOT_FOUND / CONFLICT / BAD_USER_INPUT"]
W -->|report-kit HttpError 400| B["BAD_USER_INPUT"]
W -->|anything else| M["re-thrown → Yoga masks it<br/>'Unexpected error'"]
Expand Down
5 changes: 4 additions & 1 deletion docs/09-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ variables already set in the real environment win over the file.
| `CORS_ORIGINS` | `*` in development, empty in production | Comma-separated origins. `*` is refused in production |
| `GRAPHIQL` | on in development | GraphiQL at the endpoint (GET) |
| `INTROSPECTION` | on in development | Off adds `NoSchemaIntrospectionCustomRule` |
| `BODY_LIMIT_BYTES` | `2000000` | Request body limit (bank CSV imports count) |
| `BODY_LIMIT_BYTES` | `2000000` | Request body limit (bank CSV imports count). `importExternalReport` alone may send up to a base64 `IMPORT_MAX_BYTES` |
| `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 |
| `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 |
| `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
1 change: 1 addition & 0 deletions docs/16-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ flowchart LR
| Queue | Job | Started by | What the worker does | Retries |
|---|---|---|---|---|
| `mail` | `password-reset` | `requestPasswordReset` | creates the single-use token (auth-kit), sends the link through the `Mailer` | 5, exponential from 2 s; a rate-limited request is dropped |
| `imports` | `extract` · `reextract` | `importExternalReport` (PDFs over `IMPORT_INLINE_MAX_BYTES`), `nameExternalSignature` | reads a stored partner PDF with report-reader; the payload carries the file id and the actor, never the file | UNSUPPORTED_REPORT fails at once |
| `documents` | `invoice` · `booking_reports` | `startDocumentJob` | renders the stored invoice or the booking's reports (invoice-kit / report-kit) | 2; NOT_FOUND / CONFLICT / BAD_USER_INPUT fail at once |

```mermaid
Expand Down
82 changes: 82 additions & 0 deletions docs/18-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# 18 · Partner-lab report imports

Some tests are performed by partner labs (Hy Patho Lab, Clinova Pathlabs). Their PDF report can be
uploaded and read by [report-reader](https://github.com/mgpl-diagnostics/report-reader). The results
are then matched to our booking and written into our reports **as entered results with their
provenance**. Our staff verify and release them as usual. An import never verifies or releases anything.

```mermaid
flowchart LR
U["importExternalReport<br/>(PDF base64)"] --> X{"≤ IMPORT_INLINE_MAX_BYTES?"}
X -- yes --> E["report-reader in the request"]
X -- no --> J["imports job (worker)<br/>payload: file id + actor"]
J --> E
E --> S[("external_reports<br/>external_report_files<br/>external_signatures")]
S --> M["matchExternalReport<br/>(name · sex · age ±1)"]
M --> A["applyExternalReport<br/>(test mappings)"]
A --> R["our reports: status entered<br/>+ report_provenance"]
R --> V["verifyReport → releaseReport<br/>(staff, unchanged)"]
```

## The flow

| 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`. |
| 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` | |
| Name a signature | `nameExternalSignature(input)` | `signatory:manage` | Hy Patho prints no names under signatures. Staff name one once (by its `sha`). The org's imports that are still `extracted` or `matched` and carry it are read again (a `reextract` job). |
| Registry | `externalSignatories(lab)` | `signatory:read` | Data entered by staff, never code. |

Statuses: `extracted` → `matched` → `applied`, or `rejected`.

## The rules

1. **NABL belongs to the performing lab, per test.** The line "NABL accredited laboratory
(Certificate No. …)" prints only under tests the partner marked accredited (`accredited: true`). It
is worded as the partner's accreditation. No NABL or ilac logo is ever drawn. Hy Patho reports carry
no marks, so they never get the line.
2. **A partner signature only appears with results from its own PDF.** Signature PNGs are stored with
the SHA-256 of the PDF they came from. They are looked up by (organisation, that PDF's SHA-256, image
SHA), both in `ExternalSignatoryOnReport.image` and when printing. A stored signature is never picked
for another report, and never used as our own signatory.
3. **"Reported by" lists medical signatories only.** Lab managers and other staff sign the PDF but are
not listed.
4. **Our staff still verify.** Applied reports are `entered`. Printing needs `verifyReport` as before.
Edits after import are audited (`report.edit_imported`, with parameter names only). Catalog defaults
are not filled into imported results. `Report.provenance.importedResults` keeps the original.
5. **Patient data.** The PDF, the extracted JSON and the signature PNGs are stored per organisation and
never logged. Job payloads carry the stored file's id and the actor, never the file or a token. Tests
draw fictional PDFs (`test/partner-pdfs.ts`).

## Printing

`bookingReportDocument` prints each imported report's provenance under its last section:
"Performed at Clinova Pathlabs, Central Lab, City · NABL accredited laboratory (Certificate No. …) ·
Reported by Dr …". In the Navy Sidebar design, a page whose tests all come from one partner PDF prints
the partner's medical signatory and signature **instead of** ours (report-kit's default). The QC stamp
stays.

## Limits and configuration

| Variable | Default | Meaning |
|---|---|---|
| `IMPORT_MAX_BYTES` | 15 MiB | Largest PDF (`FILE_TOO_LARGE`, with `extensions.maxBytes`) |
| `IMPORT_INLINE_MAX_BYTES` | 5 MiB | Larger PDFs are read by the worker (BullMQ). Without Redis everything runs in the request |
| `IMPORT_WAIT_MS` | 60000 | How long the request waits for the worker |

The HTTP body limit is raised to fit a base64 PDF of `IMPORT_MAX_BYTES`, but **only for an operation
whose single root field is `importExternalReport`**. Every other request keeps `BODY_LIMIT_BYTES` (413,
`FILE_TOO_LARGE`). `importExternalReport` counts as a document field (at most 3 per request).

## Example

[examples/imports.graphql](examples/imports.graphql)

## Storage

db-kit 2.1.1 tables (both backends): `external_reports`, `external_report_files` (the PDF, loaded only
when reading it), `external_signatories` (cached per organisation, cleared on write),
`external_signatures` and `report_provenance`. See db-kit's `docs/EXTERNAL-REPORTS.md`.
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ flowchart TD
D --> N["14 Client"]
B --> O["16 Worker"]
L --> P["17 Scaling"]
G --> Q["18 Partner-lab imports"]
```

| # | Page | What it answers |
Expand All @@ -40,5 +41,6 @@ flowchart TD
| 15 | [Glossary](15-glossary.md) | Lab, billing and accounting terms |
| 16 | [Worker](16-worker.md) | Background jobs on Redis + BullMQ: emails, documents, running workers |
| 17 | [Scaling](17-scaling.md) | N replicas behind a proxy, PgBouncer, readiness, graceful shutdown, docker-compose |
| 18 | [Partner-lab imports](18-imports.md) | Reading partner lab PDFs, matching, applying with provenance, NABL and signature rules |
| — | [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 |
33 changes: 33 additions & 0 deletions docs/examples/imports.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Partner-lab report imports (docs/18-imports.md). Variables: $pdf (base64), $bookingId.

mutation ImportPartnerReport($pdf: String!, $bookingId: ID) {
importExternalReport(pdfBase64: $pdf, fileName: "partner.pdf", bookingId: $bookingId) {
id status lab labName patientMismatches warnings
tests {
index name accredited suggestedTestCode
parameters { name value unit range flag }
provenance { text }
}
signatories { id name medical source sha }
}
}

mutation ApplyPartnerReport($id: ID!) {
applyExternalReport(id: $id, mappings: [{ testIndex: 0, testCode: "LIPR" }], confirmPatientMismatch: false) {
id testCode status
provenance { performedBy accreditationText reportedBy { name } }
}
}

mutation NamePartnerSignature($sha: String!) {
nameExternalSignature(input: { sha: $sha, lab: hypatho, name: "Dr Example", qualification: "MD (Pathology)", role: pathologist }) {
id name signatureCount
}
}

query PartnerImports($bookingId: ID) {
externalReports(bookingId: $bookingId, first: 20) {
nodes { id status lab uploadedAt reports { id status } }
pageInfo { hasNextPage endCursor }
}
}
12 changes: 8 additions & 4 deletions kits.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,25 +13,29 @@
"name": "catalog-kit",
"ref": "v1.0.0"
},
{
"name": "report-reader",
"ref": "v0.1.0"
},
{
"name": "auth-kit",
"ref": "v1.3.0"
"ref": "v1.4.0"
},
{
"name": "db-kit",
"ref": "v2.0.0"
"ref": "v2.1.1"
},
{
"name": "report-kit",
"ref": "v2.0.0"
"ref": "v2.1.0"
},
{
"name": "ledger-kit",
"ref": "v0.1.0"
},
{
"name": "client",
"ref": "v1.2.0"
"ref": "v1.4.0"
},
{
"name": "invoice-kit",
Expand Down
Loading
Loading