diff --git a/.env.example b/.env.example
index 8c07296..b36e3a3 100644
--- a/.env.example
+++ b/.env.example
@@ -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.
diff --git a/AGENTS.md b/AGENTS.md
index 0d71cec..2d782ac 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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)
@@ -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
diff --git a/docs/04-api-reference.md b/docs/04-api-reference.md
index 0044f40..34e5e2c 100644
--- a/docs/04-api-reference.md
+++ b/docs/04-api-reference.md
@@ -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` | |
diff --git a/docs/05-errors.md b/docs/05-errors.md
index 8d0fbe0..abb1cfc 100644
--- a/docs/05-errors.md
+++ b/docs/05-errors.md
@@ -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 |
@@ -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 /
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
'Unexpected error'"]
diff --git a/docs/09-configuration.md b/docs/09-configuration.md
index 8fa29a4..5d2ba27 100644
--- a/docs/09-configuration.md
+++ b/docs/09-configuration.md
@@ -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` |
diff --git a/docs/16-worker.md b/docs/16-worker.md
index 5006681..7381677 100644
--- a/docs/16-worker.md
+++ b/docs/16-worker.md
@@ -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
diff --git a/docs/18-imports.md b/docs/18-imports.md
new file mode 100644
index 0000000..6b23fbc
--- /dev/null
+++ b/docs/18-imports.md
@@ -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
(PDF base64)"] --> X{"≤ IMPORT_INLINE_MAX_BYTES?"}
+ X -- yes --> E["report-reader in the request"]
+ X -- no --> J["imports job (worker)
payload: file id + actor"]
+ J --> E
+ E --> S[("external_reports
external_report_files
external_signatures")]
+ S --> M["matchExternalReport
(name · sex · age ±1)"]
+ M --> A["applyExternalReport
(test mappings)"]
+ A --> R["our reports: status entered
+ report_provenance"]
+ R --> V["verifyReport → releaseReport
(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`.
diff --git a/docs/README.md b/docs/README.md
index ab11de9..e846dcf 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -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 |
@@ -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 |
diff --git a/docs/examples/imports.graphql b/docs/examples/imports.graphql
new file mode 100644
index 0000000..e4fa4d2
--- /dev/null
+++ b/docs/examples/imports.graphql
@@ -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 }
+ }
+}
diff --git a/kits.json b/kits.json
index 96fe5aa..020318a 100644
--- a/kits.json
+++ b/kits.json
@@ -13,17 +13,21 @@
"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",
@@ -31,7 +35,7 @@
},
{
"name": "client",
- "ref": "v1.2.0"
+ "ref": "v1.4.0"
},
{
"name": "invoice-kit",
diff --git a/llms-full.txt b/llms-full.txt
index 804f83d..b9838aa 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -1,6 +1,6 @@
# @mgpl-diagnostics/api — full documentation and source
-> Generated by scripts/llms-full.mjs. Do not edit by hand. 89 files.
+> Generated by scripts/llms-full.mjs. Do not edit by hand. 95 files.
## Contents
@@ -23,6 +23,7 @@
- docs/15-glossary.md
- docs/16-worker.md
- docs/17-scaling.md
+- docs/18-imports.md
- docs/LOCAL-AGENT-BRIEF.md
- docs/README.md
- kits.json
@@ -43,6 +44,7 @@
- src/controllers/catalog.controller.ts
- src/controllers/documents.controller.ts
- src/controllers/finance.controller.ts
+- src/controllers/imports.controller.ts
- src/controllers/lab.controller.ts
- src/controllers/organization.controller.ts
- src/graphql/context.ts
@@ -69,6 +71,7 @@
- src/schema/catalog.graphql.ts
- src/schema/documents.graphql.ts
- src/schema/finance.graphql.ts
+- src/schema/imports.graphql.ts
- src/schema/lab.graphql.ts
- src/schema/organization.graphql.ts
- src/server.ts
@@ -81,6 +84,7 @@
- src/services/document.service.ts
- src/services/export.service.ts
- src/services/gst.service.ts
+- src/services/import.service.ts
- src/services/index.ts
- src/services/job.service.ts
- src/services/organization.service.ts
@@ -92,6 +96,8 @@
- src/services/util.ts
- src/sync.ts
- src/worker.ts
+- test/imports.ts
+- test/partner-pdfs.ts
- test/run.ts
@@ -241,6 +247,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)
@@ -280,7 +287,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
@@ -852,6 +863,19 @@ and runnable examples are in [examples/](examples/).
| 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` | |
+
---
@@ -878,6 +902,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 |
@@ -897,6 +925,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 /
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
'Unexpected error'"]
@@ -1405,7 +1434,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` |
@@ -1957,6 +1989,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
@@ -2187,6 +2220,94 @@ pooler such as the provider's PgBouncer); Redis managed or as a StatefulSet; the
Deployment with `command: ["node", "dist/worker.js"]`.
+---
+
+## File: docs/18-imports.md
+
+# 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
(PDF base64)"] --> X{"≤ IMPORT_INLINE_MAX_BYTES?"}
+ X -- yes --> E["report-reader in the request"]
+ X -- no --> J["imports job (worker)
payload: file id + actor"]
+ J --> E
+ E --> S[("external_reports
external_report_files
external_signatures")]
+ S --> M["matchExternalReport
(name · sex · age ±1)"]
+ M --> A["applyExternalReport
(test mappings)"]
+ A --> R["our reports: status entered
+ report_provenance"]
+ R --> V["verifyReport → releaseReport
(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`.
+
+
---
## File: docs/LOCAL-AGENT-BRIEF.md
@@ -2424,6 +2545,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 |
@@ -2445,6 +2567,7 @@ 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 |
@@ -2469,17 +2592,21 @@ flowchart TD
"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",
@@ -2487,7 +2614,7 @@ flowchart TD
},
{
"name": "client",
- "ref": "v1.2.0"
+ "ref": "v1.4.0"
},
{
"name": "invoice-kit",
@@ -2575,14 +2702,15 @@ Key facts for agents:
},
"dependencies": {
"@graphql-tools/schema": "^10.1.5",
- "@mgpl-diagnostics/auth-kit": "^1.3.0",
+ "@mgpl-diagnostics/auth-kit": "^1.4.0",
"@mgpl-diagnostics/catalog-kit": "^1.0.0",
- "@mgpl-diagnostics/db-kit": "^2.0.0",
+ "@mgpl-diagnostics/db-kit": "^2.1.1",
"@mgpl-diagnostics/finance-kit": "^1.0.1",
"@mgpl-diagnostics/invoice-kit": "^2.0.0",
"@mgpl-diagnostics/ledger-kit": "^0.1.0",
"@mgpl-diagnostics/mail-kit": "^0.1.0",
- "@mgpl-diagnostics/report-kit": "^2.0.0",
+ "@mgpl-diagnostics/report-kit": "^2.1.0",
+ "@mgpl-diagnostics/report-reader": "^0.1.0",
"@mgpl-diagnostics/security-kit": "^0.3.0",
"bullmq": "^6.4.0",
"fastify": "^5.12.7",
@@ -2597,12 +2725,13 @@ Key facts for agents:
"sequelize": "^6.37.8"
},
"devDependencies": {
- "@mgpl-diagnostics/client": "^1.2.0",
+ "@mgpl-diagnostics/client": "^1.4.0",
"@types/node": "^22.20.5",
"embedded-postgres": "^18.4.0-beta.17",
"ioredis-mock": "^8.13.1",
"mongodb-memory-server": "^11.3.0",
"tsup": "^9.0.0",
+ "pdf-lib": "^1.17.1",
"tsx": "^4.23.15",
"typescript": "^5.9.3"
},
@@ -3248,6 +3377,215 @@ type DocumentSample {
label: String!
}
+enum ExternalLab {
+ clinova
+ hypatho
+}
+
+type ExternalParameter {
+ flag: ResultFlag
+ group: String
+ method: String
+ name: String!
+
+ """
+ As printed (the partner's range for the partner's method; never recomputed).
+ """
+ range: String
+ unit: String
+
+ """As printed."""
+ value: String!
+}
+
+type ExternalPatient {
+ ageText: String
+ ageYears: Int
+ name: String
+ patientId: String
+ sex: Sex
+ title: String
+}
+
+"""
+Who performed a test and who reported it, as it will print under the test.
+"""
+type ExternalProvenanceLabel {
+ """
+ Only when the partner marked this test accredited: the partner's accreditation, not ours.
+ """
+ accreditationText: String
+ performedAt: String
+ performedBy: String!
+
+ """Medical signatories only."""
+ reportedBy: [ExternalReportedBy!]!
+
+ """The whole line."""
+ text: String!
+}
+
+type ExternalReport {
+ appliedAt: DateTime
+ booking: Booking
+ branchId: ID!
+ byteLength: Int!
+ fileName: String
+ id: ID!
+ lab: ExternalLab!
+ labName: String!
+ patient: ExternalPatient!
+
+ """
+ Differences from the booking's patient (name, sex, age ±1). Applying needs confirmPatientMismatch when there are any.
+ """
+ patientMismatches: [String!]!
+ processedAt: String
+ rejectReason: String
+ reportedAt: String
+
+ """Our reports it was applied to."""
+ reports: [Report!]!
+ sampleIds: [String!]!
+ signatories: [ExternalSignatoryOnReport!]!
+
+ """SHA-256 of the PDF."""
+ sourceSha256: String!
+ status: ExternalReportStatus!
+ tests: [ExternalTest!]!
+ uploadedAt: DateTime!
+ uploadedBy: User
+
+ """Reader warnings and booking-check differences."""
+ warnings: [String!]!
+}
+
+type ExternalReportConnection {
+ nodes: [ExternalReport!]!
+ pageInfo: PageInfo!
+}
+
+enum ExternalReportStatus {
+ applied
+ extracted
+ matched
+ rejected
+}
+
+type ExternalReportedBy {
+ id: String!
+ name: String
+ qualification: String
+ registration: String
+ role: ExternalSignatoryRole!
+ title: String
+}
+
+"""
+A partner signatory in the organisation's registry (data entered by staff, never code).
+"""
+type ExternalSignatory {
+ createdAt: DateTime!
+ id: ID!
+ lab: ExternalLab!
+ name: String!
+ qualification: String
+ registration: String
+ role: ExternalSignatoryRole
+
+ """How many signature images are tied to this person."""
+ signatureCount: Int!
+ title: String
+}
+
+input ExternalSignatoryInput {
+ lab: ExternalLab!
+ name: String!
+ qualification: String
+ registration: String
+ role: ExternalSignatoryRole
+
+ """ExternalSignatoryOnReport.sha"""
+ sha: String!
+ title: String
+}
+
+type ExternalSignatoryOnReport {
+ id: String!
+
+ """The signature image from this PDF."""
+ image: SignatureImage
+
+ """Only medical signatories appear in Reported by."""
+ medical: Boolean!
+
+ """Null until someone names this signature (nameExternalSignature)."""
+ name: String
+ pages: [Int!]!
+ qualification: String
+ registration: String
+ role: ExternalSignatoryRole!
+
+ """SHA-256 of the signature image (pass it to nameExternalSignature)."""
+ sha: String!
+ source: ExternalSignatorySource!
+ title: String
+}
+
+enum ExternalSignatoryRole {
+ biochemist
+ consultant
+ manager
+ microbiologist
+ pathologist
+ unknown
+}
+
+"""
+How a signatory on a partner PDF was named: printed beside the signature, from our registry, or not at all.
+"""
+enum ExternalSignatorySource {
+ registry
+ text
+ unknown
+}
+
+type ExternalTest {
+ """True only when the partner marked this test as accredited."""
+ accredited: Boolean!
+ department: String
+
+ """Position in the partner report (use it in ExternalTestMappingInput)."""
+ index: Int!
+ method: String
+ name: String!
+ notes: String
+ parameters: [ExternalParameter!]!
+ provenance: ExternalProvenanceLabel!
+ reported: String
+ sampleId: String
+ sampleType: String
+
+ """Ids of the signatories who signed this test's pages."""
+ signedBy: [String!]!
+
+ """
+ Our catalog test suggested for it (top search hit), for staff to confirm.
+ """
+ suggestedTestCode: String
+}
+
+input ExternalTestMappingInput {
+ """false skips the test."""
+ include: Boolean = true
+
+ """One of the booking's test codes."""
+ testCode: String!
+
+ """ExternalTest.index"""
+ testIndex: Int!
+}
+
type FinanceConfig {
b2clThresholdPaise: Int!
bankMaxFeeBp: Int!
@@ -3576,6 +3914,11 @@ type Mutation {
addAgreement(input: AgreementInput!): PartnerAgreement!
addTaxRule(input: TaxRuleInput!): TaxRule!
amendReport(id: ID!): Report!
+
+ """
+ Writes the mapped tests into our reports as entered results with provenance. Staff verify as usual.
+ """
+ applyExternalReport(confirmPatientMismatch: Boolean = false, id: ID!, mappings: [ExternalTestMappingInput!]!): [Report!]!
cancelBooking(id: ID!): Booking!
cancelInvoice(id: ID!, reason: String!): Invoice!
cancelSettlement(id: ID!): Settlement!
@@ -3598,6 +3941,12 @@ type Mutation {
disableUser(userId: ID!): User!
enableUser(userId: ID!): User!
importBankStatement(input: BankImportInput!): BankImportResult!
+
+ """
+ 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.
+ """
+ importExternalReport(bookingId: ID, branchId: ID, fileName: String, pdfBase64: String!): ExternalReport!
issueCreditNote(input: CreditNoteInput!): CreditNoteResult!
issueCustomerInvoice(input: CustomerInvoiceInput!): Invoice!
issueInvoice(input: IssueInvoiceInput!): Invoice!
@@ -3611,6 +3960,14 @@ type Mutation {
"""Ends every other session of the signed-in user; returns how many."""
logoutOtherSessions: Int!
matchBankLine(lineId: ID!, voucherIds: [ID!]!): BankLine!
+
+ """Ties an import to a booking; patient differences become warnings."""
+ matchExternalReport(bookingId: ID!, id: ID!): ExternalReport!
+
+ """
+ Names a partner signature; the organisation's unapplied imports that carry it are read again.
+ """
+ nameExternalSignature(input: ExternalSignatoryInput!): ExternalSignatory!
paySettlement(input: PaySettlementInput!): Settlement!
"""Does nothing; lets clients check that mutations reach the server."""
@@ -3629,6 +3986,7 @@ type Mutation {
refreshSession(device: DeviceInput, refreshToken: String!): LoginResult!
refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice!
registerPatient(input: RegisterPatientInput!): Patient!
+ rejectExternalReport(id: ID!, reason: String!): ExternalReport!
rejectSample(id: ID!, reason: String!): Sample!
releaseReport(id: ID!): Report!
@@ -3729,6 +4087,11 @@ type OutstandingRow {
invoice: Invoice!
}
+type PageInfo {
+ endCursor: String
+ hasNextPage: Boolean!
+}
+
enum ParameterType {
image
numeric
@@ -3964,6 +4327,11 @@ type Query {
exportProfitAndLoss(bom: Boolean = false, branchId: ID, from: DateTime!, to: DateTime!): CsvFile!
exportSettlement(bom: Boolean = false, settlementId: ID!): CsvFile!
exportTrialBalance(asOf: DateTime, bom: Boolean = false, branchId: ID): CsvFile!
+ externalReport(id: ID!): ExternalReport
+ externalReports(after: String, bookingId: ID, branchId: ID, first: Int = 50, status: ExternalReportStatus): ExternalReportConnection!
+
+ """The organisation's partner signatory registry."""
+ externalSignatories(lab: ExternalLab): [ExternalSignatory!]!
financeConfig: FinanceConfig!
gstSummary(branchId: ID, from: DateTime!, to: DateTime!): GstSummary!
gstr1(from: DateTime!, gstin: String, to: DateTime!): Gstr1!
@@ -4091,6 +4459,9 @@ type Report {
booking: Booking!
id: ID!
patient: Patient!
+
+ """Set when the results came from a partner lab's report."""
+ provenance: ReportProvenance
releasedAt: DateTime
results: [ResultValue!]!
status: ReportStatus!
@@ -4112,6 +4483,25 @@ type ReportProfile {
tests: [String!]!
}
+"""
+Where one of our reports came from, when its results were imported from a partner PDF.
+"""
+type ReportProvenance {
+ accreditationText: String
+ appliedAt: DateTime!
+ externalReportId: ID!
+
+ """The results as imported (for comparison when staff edit them)."""
+ importedResults: [ResultValue!]!
+ lab: ExternalLab!
+ performedAt: String
+ performedBy: String!
+ reportedBy: [ExternalReportedBy!]!
+ sourceReportedAt: String
+ sourceSampleId: String
+ sourceTestName: String!
+}
+
enum ReportStatus {
amended
draft
@@ -4318,6 +4708,16 @@ input ShareRateInput {
sharePaise: Int!
}
+"""
+A signature image from a partner PDF, PNG base64. Only ever served with that PDF's own results.
+"""
+type SignatureImage {
+ base64: String!
+ height: Int!
+ mimeType: String!
+ width: Int!
+}
+
type StatementRow {
balancePaise: Int!
creditPaise: Int!
@@ -4837,6 +5237,8 @@ export interface Config {
jwt: { secrets: string[]; issuer: string; accessTtlMs: number; generated: boolean };
/** A refresh from another device: "enforce" ends the session (default), "flag" only audits, "off" ignores. */
deviceBinding: "enforce" | "flag" | "off";
+ /** Partner-lab PDF imports: largest file accepted, largest extracted in the request (larger go to the worker), how long a request waits for the worker. */
+ imports: { maxBytes: number; inlineMaxBytes: number; waitMs: number };
/** Background jobs (BullMQ on REDIS_URL): key prefix and how many jobs a worker runs at once. */
jobs: { prefix: string; concurrency: number; keepResultsMs: number };
/**
@@ -4943,6 +5345,7 @@ export function loadConfig(env: Record = process.env
generated: !secrets.length,
},
deviceBinding,
+ imports: { maxBytes: int(env.IMPORT_MAX_BYTES, 15 * 1024 * 1024), inlineMaxBytes: int(env.IMPORT_INLINE_MAX_BYTES, 5 * 1024 * 1024), waitMs: int(env.IMPORT_WAIT_MS, 60_000) },
jobs: { prefix: env.QUEUE_PREFIX || "mgpl", concurrency: Math.max(1, int(env.WORKER_CONCURRENCY, 4)), keepResultsMs: int(env.JOB_RESULT_TTL_MS, 3600_000) },
smtp: smtpUrl ? { url: smtpUrl, from: env.MAIL_FROM || "MGPL Diagnostics " } : null,
mailWebhookUrl: env.MAIL_WEBHOOK_URL || null,
@@ -5042,7 +5445,7 @@ export async function createContainer(o: ContainerOptions): Promise {
(config.redisUrl
? createBullJobQueue({ redisUrl: config.redisUrl, prefix: config.jobs.prefix, keepResultsMs: config.jobs.keepResultsMs })
: createInlineJobQueue(() => jobDeps!, { keepResultsMs: config.jobs.keepResultsMs }));
- const services = createServices({ repos: db.repos, auth, mailer, resetUrl: config.resetUrl, lab, samples: config.samples, jobs: () => jobs });
+ const services = createServices({ repos: db.repos, auth, mailer, resetUrl: config.resetUrl, lab, samples: config.samples, jobs: () => jobs, imports: config.imports });
jobDeps = { repos: db.repos, auth, services };
return {
config,
@@ -5622,6 +6025,155 @@ export const financeController = withErrorCodes({
```
+---
+
+## File: src/controllers/imports.controller.ts
+
+```ts
+import type { Booking, ExternalLabId, ExternalReport, ExternalReportStatus, ExternalSignatory, Report, ReportProvenance, ResultValue } from "@mgpl-diagnostics/db-kit";
+import { provenanceText } from "@mgpl-diagnostics/report-kit";
+import { labelFor, type ExtractedReport, type TestSection } from "@mgpl-diagnostics/report-reader";
+import type { RequestContext as Ctx } from "../graphql/context";
+import { badInput, withErrorCodes } from "../graphql/errors";
+import { actorOf, authorize, branchScope, canSee, cap } from "../graphql/guards";
+import { placeOf, printedProvenance, suggestTestCode, type SignatoryInput, type TestMapping } from "../services/import.service";
+import { flagOut } from "./lab.controller";
+
+const extractedOf = (e: ExternalReport) => e.extracted as unknown as ExtractedReport;
+const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
+
+/** Offset cursors ("o:", base64url). */
+const cursor = (n: number) => Buffer.from(`o:${n}`).toString("base64url");
+function offsetOf(after?: string | null): number {
+ if (!after) return 0;
+ const m = /^o:(\d{1,7})$/.exec(Buffer.from(after, "base64url").toString());
+ if (!m) throw badInput("after is not a cursor from this list");
+ return Number(m[1]);
+}
+
+async function importFor(ctx: Ctx, id: string, permission = "report:import") {
+ const ext = await ctx.services.imports.get(actorOf(ctx), id);
+ authorize(ctx, permission, ext.branchId);
+ return ext;
+}
+
+async function bookingFor(ctx: Ctx, id: string, permission: string): Promise {
+ const b = await ctx.services.bookings.get(actorOf(ctx), id);
+ authorize(ctx, permission, b.branchId);
+ return b;
+}
+
+/** The printed provenance line for one partner test (the same words report-kit prints). */
+function labelOf(report: ExtractedReport, test: TestSection) {
+ const l = labelFor(report, test);
+ const accreditationText = l.accreditation ? `NABL accredited laboratory${l.accreditation.certificate ? ` (Certificate No. ${l.accreditation.certificate})` : ""}` : null;
+ return {
+ performedBy: l.performedBy,
+ performedAt: placeOf(l.performedBy, l.performedAt),
+ accreditationText,
+ reportedBy: l.reportedBy,
+ text: provenanceText({ performedBy: l.performedBy, performedAt: placeOf(l.performedBy, l.performedAt) ?? undefined, ...(accreditationText ? { accreditationText } : {}), reportedBy: l.reportedBy.filter((r) => r.name).map((r) => ({ name: r.name! })) }),
+ };
+}
+
+const resultsOut = (rs: ResultValue[]) => rs.map((x) => ({ ...x, flag: flagOut(x.flag) }));
+
+/** Partner-lab imports (report-reader → db-kit), permissions report:import and signatory:read|manage per branch. */
+export const importsController = withErrorCodes({
+ Query: {
+ externalReport: (_: unknown, a: { id: string }, ctx: Ctx) => importFor(ctx, a.id, "report:import"),
+ externalReports: async (_: unknown, a: { branchId?: string | null; bookingId?: string | null; status?: ExternalReportStatus | null; first?: number | null; after?: string | null }, ctx: Ctx) => {
+ const scope = branchScope(ctx, "report:import", a.branchId);
+ if (a.bookingId) await bookingFor(ctx, a.bookingId, "report:import");
+ const limit = cap(a.first, 50);
+ const offset = offsetOf(a.after);
+ const rows = await ctx.services.imports.list(actorOf(ctx), { scope, bookingId: a.bookingId, status: a.status, limit, offset });
+ const nodes = rows.slice(0, limit);
+ const hasNextPage = rows.length > limit;
+ return { nodes, pageInfo: { hasNextPage, endCursor: hasNextPage ? cursor(offset + limit) : null } };
+ },
+ externalSignatories: (_: unknown, a: { lab?: ExternalLabId | null }, ctx: Ctx) => ctx.services.imports.signatories(authorize(ctx, "signatory:read"), a.lab),
+ },
+
+ Mutation: {
+ importExternalReport: async (_: unknown, a: { pdfBase64: string; fileName?: string | null; bookingId?: string | null; branchId?: string | null }, ctx: Ctx) => {
+ const actor = actorOf(ctx);
+ const b64 = a.pdfBase64.replace(/\s+/g, "");
+ const bytes = Math.floor((b64.length * 3) / 4) - (b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0);
+ ctx.services.imports.assertSize(bytes);
+ if (!BASE64.test(b64)) throw badInput("pdfBase64 is not base64");
+ const booking = a.bookingId ? await ctx.services.bookings.get(actor, a.bookingId) : null;
+ const branchId = booking?.branchId ?? a.branchId;
+ if (!branchId) throw badInput("branchId is required when there is no bookingId");
+ if (booking && a.branchId && a.branchId !== booking.branchId) throw badInput("The booking belongs to another branch");
+ authorize(ctx, "report:import", branchId);
+ const pdf = new Uint8Array(Buffer.from(b64, "base64"));
+ // the same PDF imported before: return it only to someone allowed to see it there
+ const before = await ctx.services.imports.existing(actor, pdf);
+ if (before) return (authorize(ctx, "report:import", before.branchId), before);
+ return ctx.services.imports.import(actor, { pdf, fileName: a.fileName?.slice(0, 200) ?? null, branchId, bookingId: booking?.id ?? null });
+ },
+ matchExternalReport: async (_: unknown, a: { id: string; bookingId: string }, ctx: Ctx) => {
+ const ext = await importFor(ctx, a.id);
+ const booking = await bookingFor(ctx, a.bookingId, "report:import");
+ return ctx.services.imports.match(actorOf(ctx), ext, booking);
+ },
+ applyExternalReport: async (_: unknown, a: { id: string; mappings: TestMapping[]; confirmPatientMismatch?: boolean | null }, ctx: Ctx) => {
+ const ext = await importFor(ctx, a.id);
+ if (!ext.bookingId) throw badInput("Match the partner report to a booking first (matchExternalReport)");
+ // applying enters results: the same permission as typing them in
+ const booking = await bookingFor(ctx, ext.bookingId, "report:enter");
+ authorize(ctx, "report:import", booking.branchId);
+ return ctx.services.imports.apply(actorOf(ctx), ext, booking, a.mappings, a.confirmPatientMismatch === true);
+ },
+ rejectExternalReport: async (_: unknown, a: { id: string; reason: string }, ctx: Ctx) => ctx.services.imports.reject(actorOf(ctx), await importFor(ctx, a.id, "report:import"), a.reason),
+ nameExternalSignature: (_: unknown, { input }: { input: SignatoryInput }, ctx: Ctx) => ctx.services.imports.nameSignature(authorize(ctx, "signatory:manage"), input),
+ },
+
+ ExternalReport: {
+ booking: (e: ExternalReport, _: unknown, ctx: Ctx) => ctx.loaders.booking.load(e.bookingId),
+ patientMismatches: (e: ExternalReport, _: unknown, ctx: Ctx) => ctx.services.imports.mismatches(e),
+ patient: (e: ExternalReport) => extractedOf(e).patient ?? {},
+ sampleIds: (e: ExternalReport) => extractedOf(e).sample?.sampleIds ?? [],
+ reportedAt: (e: ExternalReport) => extractedOf(e).dates?.reported ?? null,
+ tests: (e: ExternalReport) => {
+ const r = extractedOf(e);
+ return r.tests.map((t, index) => ({
+ ...t,
+ index,
+ accredited: t.accredited === true,
+ parameters: t.parameters.map((p) => ({ ...p, flag: flagOut(p.flag) })),
+ suggestedTestCode: suggestTestCode(t.name),
+ provenance: labelOf(r, t),
+ }));
+ },
+ signatories: (e: ExternalReport) => extractedOf(e).signatories.map((s) => ({ ...s, sha: s.signature.sha, _ext: e })),
+ reports: (e: ExternalReport, _: unknown, ctx: Ctx) => (canSee(ctx, "report:read", e.branchId) ? ctx.services.imports.reportsOf(e) : []),
+ uploadedBy: (e: ExternalReport, _: unknown, ctx: Ctx) => ctx.loaders.user.load(e.uploadedBy),
+ },
+ ExternalSignatoryOnReport: {
+ // rule: a partner signature is only served with the results of its own PDF (orgId + that PDF's sha256 + image sha)
+ image: async (s: { sha: string; _ext: ExternalReport }, _: unknown, ctx: Ctx) => {
+ const img = await ctx.services.imports.signatureImage(s._ext.orgId, s._ext.sourceSha256, s.sha);
+ return img ? { mimeType: "image/png", width: img.width, height: img.height, base64: Buffer.from(img.png).toString("base64") } : null;
+ },
+ },
+ ExternalSignatory: {
+ signatureCount: (s: ExternalSignatory) => s.sha.length,
+ },
+ ReportProvenance: {
+ accreditationText: (p: ReportProvenance) => printedProvenance(p).accreditationText ?? null,
+ reportedBy: (p: ReportProvenance) => p.reportedBy.map((r) => ({ ...r, id: r.signatoryId })),
+ importedResults: (p: ReportProvenance) => resultsOut(p.importedResults),
+ appliedAt: (p: ReportProvenance) => p.createdAt,
+ },
+ Report: {
+ provenance: (r: Report, _: unknown, ctx: Ctx) => (canSee(ctx, "report:read", r.branchId) ? ctx.loaders.provenance.load(r.id) : null),
+ },
+});
+```
+
+
---
## File: src/controllers/lab.controller.ts
@@ -5870,6 +6422,7 @@ import { ConflictError, NotFoundError, PeriodLockedError, ValidationError } from
import { CreditLimitError } from "@mgpl-diagnostics/finance-kit";
import { HttpError } from "@mgpl-diagnostics/report-kit";
import { GraphQLError } from "graphql";
+import { BookingPatientMismatchError, FileTooLargeError, ReportAlreadyAppliedError, UnsupportedReportError } from "../services/import.service";
import { ForbiddenError } from "../services/util";
/**
@@ -5881,6 +6434,8 @@ export const ERROR_CODES = [
"PERIOD_LOCKED", "CREDIT_LIMIT_EXCEEDED",
// auth-kit's own codes, passed through
"INVALID_CREDENTIALS", "ACCOUNT_DISABLED", "INVITE_PENDING", "ORG_REQUIRED", "RATE_LIMITED", "WEAK_PASSWORD", "INVALID_TOKEN",
+ // partner-lab imports (docs/18-imports.md)
+ "UNSUPPORTED_REPORT", "REPORT_ALREADY_APPLIED", "BOOKING_PATIENT_MISMATCH", "FILE_TOO_LARGE",
] as const;
export type ErrorCode = (typeof ERROR_CODES)[number];
@@ -5899,6 +6454,10 @@ export function toGraphQLError(e: unknown): unknown {
if (e instanceof GraphQLError) return e;
if (e instanceof AuthError) return apiError(e.code as ErrorCode, e.message, e.statusCode, e.details.retryAfterMs ? { retryAfterMs: e.details.retryAfterMs } : {});
if (e instanceof ForbiddenError) return apiError("FORBIDDEN", e.message, 403);
+ if (e instanceof UnsupportedReportError) return apiError("UNSUPPORTED_REPORT", e.message, 422);
+ if (e instanceof FileTooLargeError) return apiError("FILE_TOO_LARGE", e.message, 413, { maxBytes: e.maxBytes });
+ if (e instanceof ReportAlreadyAppliedError) return apiError("REPORT_ALREADY_APPLIED", e.message, 409);
+ if (e instanceof BookingPatientMismatchError) return apiError("BOOKING_PATIENT_MISMATCH", e.message, 409, { mismatches: e.mismatches });
if (e instanceof PeriodLockedError) return apiError("PERIOD_LOCKED", e.message, 409);
if (e instanceof CreditLimitError) return apiError("CREDIT_LIMIT_EXCEEDED", e.message, 409);
if (e instanceof NotFoundError) return apiError("NOT_FOUND", e.message, 404);
@@ -5977,7 +6536,7 @@ export const cap = (n: number | null | undefined, d: number) => Math.min(Math.ma
## File: src/graphql/limits.ts
```ts
-import { GraphQLError, Kind, type ASTVisitor, type FragmentDefinitionNode, type SelectionSetNode, type ValidationContext } from "graphql";
+import { GraphQLError, Kind, parse, type ASTVisitor, type FragmentDefinitionNode, type SelectionSetNode, type ValidationContext } from "graphql";
/** Rejects queries nested deeper than `max` levels (a cheap guard against abusive queries). */
export function depthLimit(max: number) {
@@ -6007,7 +6566,7 @@ export function depthLimit(max: number) {
}
/** Root fields that render a PDF / HTML document or build a CSV: CPU-heavy, so few per operation. */
-const DOCUMENT_FIELD = /^(bookingReportDocument|invoiceDocument|renderReport|renderInvoice|letterhead|export[A-Z]\w*)$/;
+const DOCUMENT_FIELD = /^(bookingReportDocument|invoiceDocument|renderReport|renderInvoice|letterhead|export[A-Z]\w*|importExternalReport)$/;
/**
* Rejects operations that select more than `maxFields` fields in all (fragments counted where they are
@@ -6051,6 +6610,32 @@ export function costLimit(maxFields: number, maxDocuments: number, maxRootFields
};
};
}
+
+/** Root fields that may send a body larger than BODY_LIMIT_BYTES (up to importBodyLimit). */
+const LARGE_BODY_FIELDS = new Set(["importExternalReport"]);
+
+/** Body size that fits a base64 PDF of `maxBytes` plus the rest of the request. */
+export const importBodyLimit = (maxBytes: number) => Math.ceil(maxBytes / 3) * 4 + 64 * 1024;
+
+/**
+ * True when a request body holds one operation whose only root field may be large (importExternalReport).
+ * Other operations keep BODY_LIMIT_BYTES, so the larger limit cannot be used to send oversized queries.
+ */
+export function allowsLargeBody(body: unknown): boolean {
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
+ const query = (body as { query?: unknown }).query;
+ if (typeof query !== "string" || query.length > 10_000) return false;
+ let doc;
+ try {
+ doc = parse(query, { noLocation: true, maxTokens: 2_000 });
+ } catch {
+ return false;
+ }
+ if (doc.definitions.length !== 1) return false;
+ const op = doc.definitions[0];
+ if (op?.kind !== Kind.OPERATION_DEFINITION || op.operation !== "mutation") return false;
+ return op.selectionSet.selections.length === 1 && op.selectionSet.selections.every((s) => s.kind === Kind.FIELD && LARGE_BODY_FIELDS.has(s.name.value));
+}
```
@@ -6059,7 +6644,7 @@ export function costLimit(maxFields: number, maxDocuments: number, maxRootFields
## File: src/graphql/loaders.ts
```ts
-import type { AccountGroup, Booking, Branch, Customer, Doctor, Invoice, Ledger, Partner, Patient, Repositories, User } from "@mgpl-diagnostics/db-kit";
+import type { AccountGroup, Booking, Branch, Customer, Doctor, Invoice, Ledger, Partner, Patient, ReportProvenance, Repositories, User } from "@mgpl-diagnostics/db-kit";
/**
* Minimal DataLoader: every load() in the same tick is batched into one getMany(), and results are
@@ -6109,6 +6694,21 @@ export class BatchLoader {
}
}
+/**
+ * Memoised lookup by a key that is not the primary id (one query per distinct key, all in the same tick),
+ * for repositories that only filter by equality.
+ */
+export class KeyLoader {
+ private cache = new Map>();
+ constructor(private readonly fetchOne: (key: string) => Promise) {}
+ load(key: string | null | undefined): Promise {
+ if (!key) return Promise.resolve(null);
+ let p = this.cache.get(key);
+ if (!p) this.cache.set(key, (p = this.fetchOne(key)));
+ return p;
+ }
+}
+
/** One set per request (created lazily by the context). */
export function createLoaders(r: Repositories) {
return {
@@ -6122,6 +6722,8 @@ export function createLoaders(r: Repositories) {
customer: new BatchLoader((ids) => r.customers.getMany(ids)),
partner: new BatchLoader((ids) => r.partners.getMany(ids)),
user: new BatchLoader((ids) => r.users.getMany(ids)),
+ /** A report's partner-lab provenance, by report id. */
+ provenance: new KeyLoader((reportId) => r.reportProvenance.findOne({ reportId } as never)),
};
}
@@ -6208,6 +6810,7 @@ import { booksController } from "../controllers/books.controller";
import { catalogController } from "../controllers/catalog.controller";
import { documentsController } from "../controllers/documents.controller";
import { financeController } from "../controllers/finance.controller";
+import { importsController } from "../controllers/imports.controller";
import { labController } from "../controllers/lab.controller";
import { organizationController } from "../controllers/organization.controller";
import { authTypeDefs } from "../schema/auth.graphql";
@@ -6217,6 +6820,7 @@ import { booksTypeDefs } from "../schema/books.graphql";
import { catalogTypeDefs } from "../schema/catalog.graphql";
import { documentsTypeDefs } from "../schema/documents.graphql";
import { financeTypeDefs } from "../schema/finance.graphql";
+import { importsTypeDefs } from "../schema/imports.graphql";
import { labTypeDefs } from "../schema/lab.graphql";
import { organizationTypeDefs } from "../schema/organization.graphql";
@@ -6237,6 +6841,7 @@ export const MODULES: GraphQLModule[] = [
{ name: "books", typeDefs: booksTypeDefs, resolvers: booksController },
{ name: "finance", typeDefs: financeTypeDefs, resolvers: financeController },
{ name: "documents", typeDefs: documentsTypeDefs, resolvers: documentsController },
+ { name: "imports", typeDefs: importsTypeDefs, resolvers: importsController },
];
/** The whole API as one executable schema. */
@@ -6270,11 +6875,11 @@ export { createBullJobQueue, createInlineJobQueue, startWorkers, webhookMailer,
```ts
import { randomUUID } from "node:crypto";
-import { Job, Queue, UnrecoverableError, Worker, type JobsOptions } from "bullmq";
+import { Job, Queue, QueueEvents, UnrecoverableError, Worker, type JobsOptions } from "bullmq";
import { Redis } from "ioredis";
import type { RenderedDocument } from "../services/document.service";
-import { processDocument, processPasswordReset, type JobDeps } from "./processors";
-import { JobFailure, QUEUES, type DocumentJob, type JobInfo, type JobQueue, type JobState, type PasswordResetJob } from "./types";
+import { processDocument, processImport, processPasswordReset, processReextract, type JobDeps } from "./processors";
+import { JobFailure, QUEUES, type DocumentJob, type ImportJob, type JobInfo, type JobQueue, type JobState, type PasswordResetJob, type ReextractJob } from "./types";
export interface BullOptions {
redisUrl: string;
@@ -6316,6 +6921,9 @@ export function createBullJobQueue(o: BullOptions): JobQueue {
const connection = connect(o.redisUrl);
const mail = new Queue(QUEUES.mail, { connection, prefix: o.prefix });
const documents = new Queue(QUEUES.documents, { connection, prefix: o.prefix });
+ const imports = new Queue(QUEUES.imports, { connection, prefix: o.prefix });
+ // completion events for imports the API waits on (opened on first use: it holds its own connection)
+ let events: QueueEvents | undefined;
return {
mode: "bullmq",
async passwordReset(job) {
@@ -6328,8 +6936,26 @@ export function createBullJobQueue(o: BullOptions): JobQueue {
const job = await Job.fromId(documents, id);
return job ? toInfo(job) : null;
},
+ async importReport(job, waitMs) {
+ if (!events) {
+ events = new QueueEvents(QUEUES.imports, { connection: connect(o.redisUrl), prefix: o.prefix });
+ await events.waitUntilReady();
+ }
+ // the result holds only an id; the job is removed once read
+ const added = await imports.add("extract", job, { ...jobOptions(o, 2), removeOnComplete: true });
+ try {
+ return (await added.waitUntilFinished(events, waitMs)) as { externalReportId: string };
+ } catch (e) {
+ const msg = (e as Error).message ?? "";
+ if (/timed out/i.test(msg)) throw new JobFailure("Reading the report is taking longer than expected; it will appear in externalReports when done", false);
+ throw new JobFailure(msg.replace(/^Error:\s*/, "") || "Job failed", false);
+ }
+ },
+ async reextract(job) {
+ await imports.add("reextract", job, { ...jobOptions(o, 3), removeOnComplete: true });
+ },
async close() {
- await Promise.all([mail.close(), documents.close()]);
+ await Promise.all([mail.close(), documents.close(), imports.close(), events?.close()]);
await connection.quit();
},
};
@@ -6347,6 +6973,11 @@ export function startWorkers(deps: JobDeps, o: BullOptions & { concurrency: numb
const workers = [
new Worker(QUEUES.mail, (job) => run(() => processPasswordReset(deps, job.data)), { connection, prefix: o.prefix, concurrency: o.concurrency }),
new Worker(QUEUES.documents, (job) => run(() => processDocument(deps, job.data)), { connection, prefix: o.prefix, concurrency: o.concurrency }),
+ new Worker(
+ QUEUES.imports,
+ (job) => run(() => (job.data.kind === "extract" ? processImport(deps, job.data) : processReextract(deps, job.data))),
+ { connection, prefix: o.prefix, concurrency: o.concurrency },
+ ),
];
for (const w of workers) w.on("failed", (job, err) => console.warn(`[worker] ${w.name} job ${job?.id} failed (attempt ${job?.attemptsMade}): ${err.message}`));
return {
@@ -6368,8 +6999,8 @@ export function startWorkers(deps: JobDeps, o: BullOptions & { concurrency: numb
export { createBullJobQueue, startWorkers, type BullOptions } from "./bullmq";
export { createInlineJobQueue } from "./inline";
export { brandFromLab, mailerFromConfig, mailKitMailer, webhookMailer } from "./mailer";
-export { processDocument, processPasswordReset, type JobDeps } from "./processors";
-export { JobFailure, QUEUES, type DocumentJob, type DocumentJobKind, type JobInfo, type JobQueue, type JobState, type PasswordResetJob } from "./types";
+export { processDocument, processImport, processPasswordReset, processReextract, type JobDeps } from "./processors";
+export { JobFailure, QUEUES, type DocumentJob, type DocumentJobKind, type ImportJob, type JobInfo, type JobQueue, type JobState, type PasswordResetJob, type ReextractJob } from "./types";
```
@@ -6379,7 +7010,7 @@ export { JobFailure, QUEUES, type DocumentJob, type DocumentJobKind, type JobInf
```ts
import { randomUUID } from "node:crypto";
-import { processDocument, processPasswordReset, type JobDeps } from "./processors";
+import { processDocument, processImport, processPasswordReset, processReextract, type JobDeps } from "./processors";
import { JobFailure, type DocumentJob, type JobInfo, type JobQueue, type PasswordResetJob } from "./types";
/**
@@ -6411,6 +7042,12 @@ export function createInlineJobQueue(deps: () => JobDeps, o: { keepResultsMs: nu
info.finishedAt = new Date();
return info;
},
+ async importReport(job) {
+ return processImport(deps(), job);
+ },
+ async reextract(job) {
+ await processReextract(deps(), job);
+ },
async get(id: string) {
prune();
return jobs.get(id) ?? null;
@@ -6496,7 +7133,8 @@ import { AuthError, type Auth } from "@mgpl-diagnostics/auth-kit";
import { ConflictError, NotFoundError, ValidationError, type Repositories } from "@mgpl-diagnostics/db-kit";
import type { Services } from "../services";
import type { RenderedDocument } from "../services/document.service";
-import { JobFailure, type DocumentJob, type PasswordResetJob } from "./types";
+import { JobFailure, type DocumentJob, type ImportJob, type PasswordResetJob, type ReextractJob } from "./types";
+import { UnsupportedReportError } from "@mgpl-diagnostics/report-reader";
/** What a job needs: the same services the API uses. */
export interface JobDeps {
@@ -6534,6 +7172,34 @@ export async function processDocument(deps: JobDeps, job: DocumentJob): Promise<
throw new JobFailure("Rendering failed", true);
}
}
+
+async function activeActor(deps: JobDeps, actor: { userId: string; orgId: string }) {
+ const user = await deps.repos.users.get(actor.userId);
+ if (!user || user.status !== "active" || user.orgId !== actor.orgId) throw new JobFailure("The user who started this job is no longer active", false);
+}
+
+/** Marks a worker failure that the API turns back into UNSUPPORTED_REPORT. */
+export const UNSUPPORTED_PREFIX = "UNSUPPORTED_REPORT: ";
+
+/** imports · extract: a large partner PDF, read off the API's request path. Never logs the file or its contents. */
+export async function processImport(deps: JobDeps, job: ImportJob): Promise<{ externalReportId: string }> {
+ await activeActor(deps, job.actor);
+ try {
+ const ext = await deps.services.imports.extractStored(job.actor, job);
+ return { externalReportId: ext.id };
+ } catch (e) {
+ if (e instanceof UnsupportedReportError) throw new JobFailure(`${UNSUPPORTED_PREFIX}${e.message}`, false);
+ if (e instanceof NotFoundError || e instanceof ConflictError || e instanceof ValidationError) throw new JobFailure(e.message, false);
+ console.error("[jobs] import job failed", (e as Error)?.name ?? "Error");
+ throw new JobFailure("Reading the report failed", true);
+ }
+}
+
+/** imports · reextract: names a newly registered signature in the organisation's unapplied imports. */
+export async function processReextract(deps: JobDeps, job: ReextractJob): Promise<{ updated: number }> {
+ await activeActor(deps, job.actor);
+ return { updated: await deps.services.imports.reextractSignature(job.actor, job.sha) };
+}
```
@@ -6547,7 +7213,7 @@ import type { Actor } from "../graphql/context";
import type { RenderedDocument, RenderOptions } from "../services/document.service";
/** Queue names (Redis keys are `::…`). */
-export const QUEUES = { mail: "mail", documents: "documents" } as const;
+export const QUEUES = { mail: "mail", documents: "documents", imports: "imports" } as const;
/** mail queue: the worker creates the reset token itself, so no raw token ever sits in Redis. */
export interface PasswordResetJob {
@@ -6568,6 +7234,27 @@ export interface DocumentJob {
options: RenderOptions;
}
+/**
+ * imports queue · extract: reads a stored partner PDF (report-reader) and creates its external report.
+ * Carries the stored file's id and the actor, never the file or a token.
+ */
+export interface ImportJob {
+ kind: "extract";
+ actor: Actor;
+ fileId: Id;
+ branchId: Id;
+ bookingId?: Id | null;
+ fileName?: string | null;
+}
+
+/** imports queue · reextract: after a partner signature is named, re-reads that organisation's unapplied imports that carry it. */
+export interface ReextractJob {
+ kind: "reextract";
+ actor: Actor;
+ /** the signature's pixel hash */
+ sha: string;
+}
+
export type JobState = "waiting" | "active" | "completed" | "failed" | "delayed";
/** A document job as the API shows it. Only the user who started it can read it. */
@@ -6592,6 +7279,10 @@ export interface JobQueue {
passwordReset(job: PasswordResetJob): Promise;
document(job: DocumentJob): Promise;
get(id: string): Promise;
+ /** Extracts a large partner PDF in the worker and waits for it (up to IMPORT_WAIT_MS); resolves with the external report's id. */
+ importReport(job: ImportJob, waitMs: number): Promise<{ externalReportId: Id }>;
+ /** Re-extraction after naming a signature, in the background (inline: at once). */
+ reextract(job: ReextractJob): Promise;
close(): Promise;
}
@@ -7522,6 +8213,209 @@ export const financeTypeDefs = /* GraphQL */ `
```
+---
+
+## File: src/schema/imports.graphql.ts
+
+```ts
+/**
+ * Partner-lab report imports (docs/18-imports.md): a partner lab's PDF is read by report-reader, matched to
+ * one of our bookings, and applied to our reports as entered results with their provenance. Our staff
+ * verify and release them as usual; an import never does.
+ */
+export const importsTypeDefs = /* GraphQL */ `
+ enum ExternalLab { hypatho clinova }
+ enum ExternalReportStatus { extracted matched applied rejected }
+ enum ExternalSignatoryRole { pathologist biochemist microbiologist consultant manager unknown }
+ "How a signatory on a partner PDF was named: printed beside the signature, from our registry, or not at all."
+ enum ExternalSignatorySource { text registry unknown }
+
+ "A signature image from a partner PDF, PNG base64. Only ever served with that PDF's own results."
+ type SignatureImage {
+ mimeType: String!
+ width: Int!
+ height: Int!
+ base64: String!
+ }
+
+ type ExternalParameter {
+ name: String!
+ "As printed."
+ value: String!
+ unit: String
+ "As printed (the partner's range for the partner's method; never recomputed)."
+ range: String
+ method: String
+ flag: ResultFlag
+ group: String
+ }
+
+ "Who performed a test and who reported it, as it will print under the test."
+ type ExternalProvenanceLabel {
+ performedBy: String!
+ performedAt: String
+ "Only when the partner marked this test accredited: the partner's accreditation, not ours."
+ accreditationText: String
+ "Medical signatories only."
+ reportedBy: [ExternalReportedBy!]!
+ "The whole line."
+ text: String!
+ }
+
+ type ExternalReportedBy { id: String!, name: String, qualification: String, registration: String, title: String, role: ExternalSignatoryRole! }
+
+ type ExternalTest {
+ "Position in the partner report (use it in ExternalTestMappingInput)."
+ index: Int!
+ name: String!
+ department: String
+ sampleType: String
+ sampleId: String
+ reported: String
+ method: String
+ "True only when the partner marked this test as accredited."
+ accredited: Boolean!
+ parameters: [ExternalParameter!]!
+ notes: String
+ "Ids of the signatories who signed this test's pages."
+ signedBy: [String!]!
+ "Our catalog test suggested for it (top search hit), for staff to confirm."
+ suggestedTestCode: String
+ provenance: ExternalProvenanceLabel!
+ }
+
+ type ExternalSignatoryOnReport {
+ id: String!
+ "Null until someone names this signature (nameExternalSignature)."
+ name: String
+ qualification: String
+ registration: String
+ title: String
+ role: ExternalSignatoryRole!
+ "Only medical signatories appear in Reported by."
+ medical: Boolean!
+ source: ExternalSignatorySource!
+ "SHA-256 of the signature image (pass it to nameExternalSignature)."
+ sha: String!
+ pages: [Int!]!
+ "The signature image from this PDF."
+ image: SignatureImage
+ }
+
+ type ExternalPatient { name: String, title: String, ageText: String, ageYears: Int, sex: Sex, patientId: String }
+
+ type ExternalReport {
+ id: ID!
+ branchId: ID!
+ booking: Booking
+ lab: ExternalLab!
+ labName: String!
+ processedAt: String
+ status: ExternalReportStatus!
+ fileName: String
+ byteLength: Int!
+ "SHA-256 of the PDF."
+ sourceSha256: String!
+ "Reader warnings and booking-check differences."
+ warnings: [String!]!
+ "Differences from the booking's patient (name, sex, age ±1). Applying needs confirmPatientMismatch when there are any."
+ patientMismatches: [String!]!
+ patient: ExternalPatient!
+ sampleIds: [String!]!
+ reportedAt: String
+ tests: [ExternalTest!]!
+ signatories: [ExternalSignatoryOnReport!]!
+ "Our reports it was applied to."
+ reports: [Report!]!
+ uploadedBy: User
+ uploadedAt: DateTime!
+ appliedAt: DateTime
+ rejectReason: String
+ }
+
+ type PageInfo { hasNextPage: Boolean!, endCursor: String }
+ type ExternalReportConnection { nodes: [ExternalReport!]!, pageInfo: PageInfo! }
+
+ "A partner signatory in the organisation's registry (data entered by staff, never code)."
+ type ExternalSignatory {
+ id: ID!
+ lab: ExternalLab!
+ name: String!
+ qualification: String
+ registration: String
+ title: String
+ role: ExternalSignatoryRole
+ "How many signature images are tied to this person."
+ signatureCount: Int!
+ createdAt: DateTime!
+ }
+
+ "Where one of our reports came from, when its results were imported from a partner PDF."
+ type ReportProvenance {
+ externalReportId: ID!
+ lab: ExternalLab!
+ performedBy: String!
+ performedAt: String
+ accreditationText: String
+ reportedBy: [ExternalReportedBy!]!
+ sourceTestName: String!
+ sourceSampleId: String
+ sourceReportedAt: String
+ "The results as imported (for comparison when staff edit them)."
+ importedResults: [ResultValue!]!
+ appliedAt: DateTime!
+ }
+
+ extend type Report {
+ "Set when the results came from a partner lab's report."
+ provenance: ReportProvenance
+ }
+
+ input ExternalTestMappingInput {
+ "ExternalTest.index"
+ testIndex: Int!
+ "One of the booking's test codes."
+ testCode: String!
+ "false skips the test."
+ include: Boolean = true
+ }
+
+ input ExternalSignatoryInput {
+ "ExternalSignatoryOnReport.sha"
+ sha: String!
+ lab: ExternalLab!
+ name: String!
+ qualification: String
+ registration: String
+ title: String
+ role: ExternalSignatoryRole
+ }
+
+ extend type Query {
+ externalReport(id: ID!): ExternalReport
+ externalReports(branchId: ID, bookingId: ID, status: ExternalReportStatus, first: Int = 50, after: String): ExternalReportConnection!
+ "The organisation's partner signatory registry."
+ externalSignatories(lab: ExternalLab): [ExternalSignatory!]!
+ }
+
+ extend type Mutation {
+ """
+ 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.
+ """
+ importExternalReport(pdfBase64: String!, fileName: String, bookingId: ID, branchId: ID): ExternalReport!
+ "Ties an import to a booking; patient differences become warnings."
+ matchExternalReport(id: ID!, bookingId: ID!): ExternalReport!
+ "Writes the mapped tests into our reports as entered results with provenance. Staff verify as usual."
+ applyExternalReport(id: ID!, mappings: [ExternalTestMappingInput!]!, confirmPatientMismatch: Boolean = false): [Report!]!
+ rejectExternalReport(id: ID!, reason: String!): ExternalReport!
+ "Names a partner signature; the organisation's unapplied imports that carry it are read again."
+ nameExternalSignature(input: ExternalSignatoryInput!): ExternalSignatory!
+ }
+`;
+```
+
+
---
## File: src/schema/lab.graphql.ts
@@ -7713,7 +8607,7 @@ import { createYoga, type Plugin } from "graphql-yoga";
import { API_CSP, fastifySecurity, type RequestSecurity } from "@mgpl-diagnostics/security-kit/server";
import type { Container } from "./container";
import { createContext, type RequestContext } from "./graphql/context";
-import { costLimit, depthLimit } from "./graphql/limits";
+import { allowsLargeBody, costLimit, depthLimit, importBodyLimit } from "./graphql/limits";
import { buildSchema } from "./graphql/schema";
import { isDraining } from "./lifecycle";
@@ -7751,7 +8645,7 @@ function rules(introspection: boolean): Plugin {
*/
export async function buildServer(container: Container): Promise {
const { config } = container;
- const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: config.bodyLimit, trustProxy: (_addr: string, hop: number) => hop < config.trustedHops });
+ const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops });
// security-kit: security headers on every response, and request.security = { ip, userAgent, … } with the
// client IP taken from X-Forwarded-For only through the configured number of proxies (TRUST_PROXY).
@@ -7784,6 +8678,14 @@ export async function buildServer(container: Container): Promise {
+ // chunked bodies carry no content-length: measure what was parsed
+ const length = Number(req.headers["content-length"]) || (req.body ? Buffer.byteLength(JSON.stringify(req.body)) : 0);
+ if (length > config.bodyLimit && !allowsLargeBody(req.body)) {
+ return reply.status(413).send({ errors: [{ message: `Request body is larger than ${config.bodyLimit} bytes`, extensions: { code: "FILE_TOO_LARGE", maxBytes: config.bodyLimit } }] });
+ }
+ },
handler: async (req, reply) => {
const response = await yoga.handleNodeRequestAndResponse(req, reply, { req, reply });
response.headers.forEach((value, key) => void reply.header(key, value));
@@ -8514,9 +9416,10 @@ import {
} from "@mgpl-diagnostics/invoice-kit";
import {
buildReport, COLS_STD, defaultLab, renderLetterheadHtml, renderLetterheadPdf, renderReportHtml, renderReportPdf, reportFromRequest, SAMPLES, templateCatalog, TEMPLATES,
- type LabProfile, type Patient as PrintPatient, type Report as PrintReport, type ReportDesign, type ReportRequest, type ReportVariant, type Section, type TestRow,
+ type LabProfile, type Patient as PrintPatient, type Report as PrintReport, type ReportDesign, type ReportRequest, type ReportTestProvenance, type ReportVariant, type Section, type TestRow,
} from "@mgpl-diagnostics/report-kit";
import type { Actor } from "../graphql/context";
+import { printedProvenance } from "./import.service";
import { mustOwn } from "./util";
export type DocumentFormat = "pdf" | "html";
@@ -8665,10 +9568,46 @@ export class DocumentService {
const printed = printPatient(patient, reportedAt, {
refDoctor: doctor?.name ?? "Self", sampleId: booking.bookingNo, sampleCollectionDate: collected, collectedAt: collected, registeredAt: booking.createdAt, reportedAt,
});
- const report: PrintReport = { patient: printed, sections: reports.flatMap((r) => this.sections(r, printed)) };
+ const sections: Section[] = [];
+ for (const r of reports) {
+ const own = this.sections(r, printed);
+ const provenance = await this.provenanceOf(actor.orgId, r);
+ // the line prints under the report's last section (report-kit wraps and paginates it)
+ if (provenance && own.length) own[own.length - 1] = { ...own[own.length - 1]!, provenance };
+ sections.push(...own);
+ }
+ const report: PrintReport = { patient: printed, sections };
return this.renderReport(report, { ...o, fileName: o.fileName ?? `${booking.bookingNo}-${o.variant}.${o.format}` });
}
+ /**
+ * The printed provenance of a report imported from a partner PDF. Partner signatures are looked up by
+ * (organisation, the sha256 of the PDF these results came from, image sha): a stored signature is never
+ * printed with results from another PDF, and never as our own signatory.
+ */
+ private async provenanceOf(orgId: Id, r: DbReport): Promise {
+ const p = await this.repos.reportProvenance.findOne({ reportId: r.id } as never);
+ if (!p || p.orgId !== orgId) return null;
+ const ext = await this.repos.externalReports.get(p.externalReportId);
+ const signatories = ext && ext.orgId === orgId && ext.sourceSha256 === p.sourceSha256
+ ? ((ext.extracted as { signatories?: { id: string; signature?: { sha?: string } }[] }).signatories ?? [])
+ : [];
+ const reportedBy = [];
+ for (const s of p.reportedBy) {
+ if (!s.name) continue;
+ const sha = signatories.find((x) => x.id === s.signatoryId)?.signature?.sha;
+ const img = sha ? await this.repos.externalSignatures.findOne({ orgId, sourceSha256: p.sourceSha256, sha } as never) : null;
+ reportedBy.push({
+ name: s.name,
+ ...(s.qualification ? { qualification: s.qualification } : {}),
+ ...(s.registration ? { registration: s.registration } : {}),
+ ...(s.title ? { title: s.title } : {}),
+ ...(img ? { signatureSrc: `data:image/png;base64,${Buffer.from(img.png).toString("base64")}` } : {}),
+ });
+ }
+ return { ...printedProvenance(p), ...(reportedBy.length ? { reportedBy } : {}) };
+ }
+
private sections(r: DbReport, patient: PrintPatient): Section[] {
const template = (TEMPLATES as Record)[r.testCode.toLowerCase()];
const flag = (f: string | undefined): TestRow["flag"] => (f === "H" ? "high" : f === "L" ? "low" : f === "A" ? "abnormal" : f === "" ? "normal" : "auto");
@@ -8847,6 +9786,467 @@ export class GstService {
```
+---
+
+## File: src/services/import.service.ts
+
+```ts
+import { createHash, randomUUID } from "node:crypto";
+import { getTestByCode, search as catalogSearch } from "@mgpl-diagnostics/catalog-kit";
+import {
+ ConflictError, NotFoundError, saveResults, ValidationError,
+ type Booking, type ExternalLabId, type ExternalReport, type ExternalSignatory, type ExternalSignatoryRole, type Id, type Patient,
+ type Report, type ReportProvenance, type Repositories, type ResultValue,
+} from "@mgpl-diagnostics/db-kit";
+import {
+ extractReport, labelFor, toPng, UnsupportedReportError,
+ type ExtractedReport, type KnownSignature, type TestSection,
+} from "@mgpl-diagnostics/report-reader";
+import type { ReportTestProvenance } from "@mgpl-diagnostics/report-kit";
+import type { Actor } from "../graphql/context";
+import { UNSUPPORTED_PREFIX } from "../jobs/processors";
+import { JobFailure, type ImportJob, type JobQueue } from "../jobs/types";
+import { mustOwn } from "./util";
+
+/** The PDF is larger than IMPORT_MAX_BYTES (FILE_TOO_LARGE). */
+export class FileTooLargeError extends Error {
+ constructor(readonly bytes: number, readonly maxBytes: number) {
+ super(`The file is ${(bytes / 1048576).toFixed(1)} MB; the limit is ${(maxBytes / 1048576).toFixed(0)} MB`);
+ this.name = "FileTooLargeError";
+ }
+}
+
+/** The external report was already applied to our reports (REPORT_ALREADY_APPLIED). */
+export class ReportAlreadyAppliedError extends ConflictError {
+ constructor() {
+ super("This partner report was already applied");
+ this.name = "ReportAlreadyAppliedError";
+ }
+}
+
+/** The booking's patient does not match the partner report's, and staff have not confirmed it (BOOKING_PATIENT_MISMATCH). */
+export class BookingPatientMismatchError extends ConflictError {
+ constructor(readonly mismatches: string[]) {
+ super(`The booking's patient does not match the partner report (${mismatches.join("; ")}). Check, then apply with confirmPatientMismatch: true`);
+ this.name = "BookingPatientMismatchError";
+ }
+}
+
+export { UnsupportedReportError };
+
+export interface ImportInput {
+ pdf: Uint8Array;
+ fileName?: string | null;
+ branchId: Id;
+ bookingId?: Id | null;
+}
+
+export interface TestMapping {
+ testIndex: number;
+ testCode: string;
+ include?: boolean | null;
+}
+
+export interface SignatoryInput {
+ sha: string;
+ lab: ExternalLabId;
+ name: string;
+ qualification?: string | null;
+ registration?: string | null;
+ title?: string | null;
+ role?: string | null;
+}
+
+export interface ImportLimits {
+ maxBytes: number;
+ inlineMaxBytes: number;
+ waitMs: number;
+}
+
+const ROLES: ExternalSignatoryRole[] = ["pathologist", "biochemist", "microbiologist", "consultant", "manager", "unknown"];
+const TITLES = /^(mr|mrs|ms|miss|master|mstr|baby|bby|b\/o|dr|smt|shri|sri|kumari|ku|kum|md|mx)\.?\s+/i;
+const MATCH_PREFIX = "Booking check: ";
+
+/** Name for comparison: lower case, no titles, letters and spaces only. */
+export function normName(name: string | null | undefined): string {
+ let n = (name ?? "").toLowerCase().replace(/[^a-z\s/]/g, " ").replace(/\s+/g, " ").trim();
+ while (TITLES.test(n)) n = n.replace(TITLES, "").trim();
+ return n.replace(/\//g, " ").replace(/\s+/g, " ").trim();
+}
+
+function ageYears(p: Patient, at: Date): number | null {
+ if (p.ageYears != null) return p.ageYears;
+ if (!p.dob) return null;
+ const dob = new Date(p.dob);
+ let y = at.getUTCFullYear() - dob.getUTCFullYear();
+ if (at.getUTCMonth() < dob.getUTCMonth() || (at.getUTCMonth() === dob.getUTCMonth() && at.getUTCDate() < dob.getUTCDate())) y--;
+ return y;
+}
+
+/**
+ * Differences between our booking's patient and the partner report's: name (case-insensitive, without
+ * titles), sex, age within ±1 year. Each is a warning that staff must confirm before applying.
+ */
+export function patientMismatches(report: ExtractedReport, patient: Patient, at = new Date()): string[] {
+ const out: string[] = [];
+ const theirs = normName(report.patient.name);
+ const ours = normName(patient.name);
+ if (theirs && ours && theirs !== ours) out.push(`patient name "${report.patient.name}" differs from the booking's "${patient.name}"`);
+ if (!theirs) out.push("the partner report prints no patient name");
+ if (report.patient.sex && patient.sex !== "other" && report.patient.sex !== patient.sex) out.push(`sex ${report.patient.sex} differs from the booking's ${patient.sex}`);
+ const ourAge = ageYears(patient, at);
+ if (report.patient.ageYears != null && ourAge != null && Math.abs(report.patient.ageYears - ourAge) > 1) out.push(`age ${report.patient.ageYears} differs from the booking's ${ourAge}`);
+ return out;
+}
+
+const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
+
+/** Our catalog parameter name for a partner parameter, within the chosen test; the partner's own name when none matches. */
+export function mapParameter(testCode: string, name: string): string {
+ const test = getTestByCode(testCode);
+ if (!test) return name;
+ const want = norm(name);
+ const named = test.parameters.filter((p): p is typeof p & { name: string } => !!p.name);
+ const exact = named.find((p) => norm(p.name) === want);
+ if (exact) return exact.name;
+ // "Haemoglobin (HB)" ~ "Hemoglobin": compare without bracketed abbreviations and common spelling variants
+ const loose = (s: string) => norm(s.replace(/\([^)]*\)/g, "")).replace(/ae/g, "e").replace(/oe/g, "e").replace(/\s+/g, "");
+ const l = loose(name);
+ const hit = named.find((p) => loose(p.name) === l);
+ return hit ? hit.name : name;
+}
+
+/** The catalog test suggested for a partner test (catalog-kit search, top hit), for staff to confirm. */
+export function suggestTestCode(name: string): string | null {
+ return catalogSearch(name, { limit: 1 })[0]?.test.code ?? null;
+}
+
+const sha256 = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
+
+/** db-kit registry rows → report-reader's KnownSignature (data, never source code). */
+export const knownSignatures = (rows: ExternalSignatory[]): KnownSignature[] =>
+ rows.map((r) => ({
+ name: r.name, sha: r.sha, dhash: r.dhash, lab: r.lab,
+ ...(r.qualification ? { qualification: r.qualification } : {}),
+ ...(r.registration ? { registration: r.registration } : {}),
+ ...(r.title ? { title: r.title } : {}),
+ ...(r.role && r.role !== "unknown" ? { role: r.role } : {}),
+ }));
+
+/** The printed provenance (report-kit) for one of our reports imported from a partner PDF. */
+/** "Clinova Pathlabs, Central Lab, City" printed after "Performed at Clinova Pathlabs": drop the repeated name. */
+export function placeOf(performedBy: string, performedAt?: string | null): string | null {
+ const at = performedAt?.trim();
+ if (!at) return null;
+ const rest = at.toLowerCase().startsWith(performedBy.toLowerCase()) ? at.slice(performedBy.length).replace(/^[\s,;:-]+/, "") : at;
+ return rest || null;
+}
+
+export function printedProvenance(p: ReportProvenance): Omit & { performedBy: string } {
+ return {
+ performedBy: p.performedBy,
+ ...(placeOf(p.performedBy, p.performedAt) ? { performedAt: placeOf(p.performedBy, p.performedAt)! } : {}),
+ ...(p.accreditation ? { accreditationText: `NABL accredited laboratory${p.accreditation.certificate ? ` (Certificate No. ${p.accreditation.certificate})` : ""}` } : {}),
+ sourceId: p.sourceSha256,
+ };
+}
+
+/**
+ * Partner-lab report imports (report-reader): upload → extract → store; match to a booking; apply to our
+ * reports with provenance; the partner signature registry. Results enter the normal flow (saveResults →
+ * verifyReport); an import never verifies or releases anything. The PDF, the extracted JSON and the
+ * signature images are patient / personal data: they are stored per organisation and never logged.
+ */
+export class ImportService {
+ constructor(
+ private readonly repos: Repositories,
+ private readonly queue: () => JobQueue,
+ private readonly limits: ImportLimits,
+ ) {}
+
+ async get(actor: Actor, id: Id): Promise {
+ return mustOwn(await this.repos.externalReports.get(id), actor.orgId, "External report", id);
+ }
+
+ /** The existing import of this exact PDF in the organisation, if any (checked before the caller authorizes the branch). */
+ async existing(actor: Actor, pdf: Uint8Array): Promise {
+ return this.repos.externalReports.findOne({ orgId: actor.orgId, sourceSha256: sha256(pdf) } as never);
+ }
+
+ assertSize(bytes: number) {
+ if (bytes > this.limits.maxBytes) throw new FileTooLargeError(bytes, this.limits.maxBytes);
+ if (bytes < 5) throw new ValidationError("The file is empty");
+ }
+
+ /**
+ * Stores the PDF and extracts it: in this request up to IMPORT_INLINE_MAX_BYTES, otherwise in the worker
+ * (the job carries the stored file's id, never the file). The same PDF uploaded again returns its row.
+ */
+ async import(actor: Actor, input: ImportInput): Promise {
+ this.assertSize(input.pdf.byteLength);
+ const sourceSha256 = sha256(input.pdf);
+ const before = await this.repos.externalReports.findOne({ orgId: actor.orgId, sourceSha256 } as never);
+ if (before) return before;
+ if (input.bookingId) {
+ const b = mustOwn(await this.repos.bookings.get(input.bookingId), actor.orgId, "Booking", input.bookingId);
+ if (b.branchId !== input.branchId) throw new ValidationError("The booking belongs to another branch");
+ }
+
+ const fileId = randomUUID();
+ try {
+ await this.repos.externalReportFiles.create({ id: fileId, orgId: actor.orgId, sourceSha256, byteLength: input.pdf.byteLength, pdf: input.pdf });
+ } catch (e) {
+ // the same PDF being imported right now by someone else
+ const row = await this.repos.externalReports.findOne({ orgId: actor.orgId, sourceSha256 } as never);
+ if (row) return row;
+ if (await this.repos.externalReportFiles.findOne({ orgId: actor.orgId, sourceSha256 } as never)) throw new ConflictError("This PDF is being imported; try again in a moment");
+ throw e;
+ }
+
+ const job: ImportJob = { kind: "extract", actor, fileId, branchId: input.branchId, bookingId: input.bookingId ?? null, fileName: input.fileName ?? null };
+ if (input.pdf.byteLength <= this.limits.inlineMaxBytes || this.queue().mode === "inline") return this.extractStored(actor, job);
+ try {
+ const { externalReportId } = await this.queue().importReport(job, this.limits.waitMs);
+ return this.get(actor, externalReportId);
+ } catch (e) {
+ if (e instanceof JobFailure && e.message.startsWith(UNSUPPORTED_PREFIX)) throw new UnsupportedReportError(e.message.slice(UNSUPPORTED_PREFIX.length));
+ if (e instanceof JobFailure) throw new ConflictError(e.message);
+ throw e;
+ }
+ }
+
+ /** Extracts a stored PDF and creates its external report (inline, or in the worker). An unsupported PDF is not kept. */
+ async extractStored(actor: Actor, job: Omit): Promise {
+ const file = mustOwn(await this.repos.externalReportFiles.get(job.fileId), actor.orgId, "File", job.fileId);
+ const done = await this.repos.externalReports.get(file.id);
+ if (done) return done; // a retried job
+ let extracted: Awaited>;
+ try {
+ extracted = await extractReport(new Uint8Array(file.pdf), { file: job.fileName ?? undefined, signatures: knownSignatures(await this.repos.externalSignatories.listForOrg(actor.orgId)) });
+ } catch (e) {
+ await this.repos.externalReportFiles.delete(file.id);
+ if (e instanceof UnsupportedReportError) throw e;
+ // a PDF the reader cannot open at all is unsupported too (never echo its contents)
+ throw new UnsupportedReportError("The PDF could not be read as a partner-lab report");
+ }
+ const { report, images } = extracted;
+ const booking = job.bookingId ? await this.repos.bookings.get(job.bookingId) : null;
+ const matchWarnings = booking ? await this.bookingWarnings(report, booking) : [];
+ const now = new Date();
+ return this.repos.transaction(async (tx) => {
+ const row = await tx.externalReports.create({
+ id: file.id, orgId: actor.orgId, branchId: job.branchId, bookingId: booking?.id ?? null,
+ lab: report.source.lab, labName: report.source.labName, processedAt: report.source.processedAt ?? null,
+ sourceSha256: report.source.sha256, fileName: job.fileName ?? null, byteLength: file.byteLength,
+ extracted: report as unknown as Record, status: booking ? "matched" : "extracted",
+ warnings: [...report.warnings, ...matchWarnings], uploadedBy: actor.userId, uploadedAt: now,
+ });
+ for (const s of report.signatories) {
+ const img = images.get(s.id);
+ if (!img || !s.signature.sha) continue;
+ await tx.externalSignatures.create({
+ orgId: actor.orgId, externalReportId: row.id, sourceSha256: report.source.sha256,
+ sha: s.signature.sha, dhash: s.signature.dhash, width: img.width, height: img.height, png: toPng(img),
+ });
+ }
+ await tx.audit.log({ orgId: actor.orgId, branchId: job.branchId, userId: actor.userId, action: "external_report.import", entity: "external_report", entityId: row.id, data: { lab: row.lab, tests: report.tests.length } });
+ return row;
+ });
+ }
+
+ private async bookingWarnings(report: ExtractedReport, booking: Booking): Promise {
+ const patient = await this.repos.patients.get(booking.patientId);
+ return patient ? patientMismatches(report, patient).map((m) => MATCH_PREFIX + m) : [`${MATCH_PREFIX}the booking's patient was not found`];
+ }
+
+ /** Ties an import to a booking (or another booking). Name, sex and age are compared; differences become warnings. */
+ async match(actor: Actor, ext: ExternalReport, booking: Booking): Promise {
+ if (ext.status === "applied") throw new ReportAlreadyAppliedError();
+ if (ext.status === "rejected") throw new ConflictError("This partner report was rejected");
+ const report = ext.extracted as unknown as ExtractedReport;
+ const warnings = [...ext.warnings.filter((w) => !w.startsWith(MATCH_PREFIX)), ...(await this.bookingWarnings(report, booking))];
+ const out = await this.repos.externalReports.update(ext.id, { bookingId: booking.id, branchId: booking.branchId, status: "matched", warnings });
+ await this.repos.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "external_report.match", entity: "external_report", entityId: ext.id, data: { bookingId: booking.id } });
+ return out!;
+ }
+
+ /** Patient differences recorded when the import was matched. */
+ mismatches(ext: ExternalReport): string[] {
+ return ext.warnings.filter((w) => w.startsWith(MATCH_PREFIX)).map((w) => w.slice(MATCH_PREFIX.length));
+ }
+
+ /**
+ * Writes the mapped partner tests into our reports (one per test code), as entered results with their
+ * provenance. Values, units, ranges and flags are copied as printed: the partner's range applies to the
+ * partner's method. Staff verify as usual; nothing is verified or released here.
+ */
+ async apply(actor: Actor, ext: ExternalReport, booking: Booking, mappings: TestMapping[], confirmPatientMismatch: boolean): Promise {
+ if (ext.status === "applied") throw new ReportAlreadyAppliedError();
+ if (ext.status === "rejected") throw new ConflictError("This partner report was rejected");
+ if (!ext.bookingId || ext.bookingId !== booking.id) throw new ValidationError("Match the partner report to a booking first (matchExternalReport)");
+ const mismatches = this.mismatches(ext);
+ if (mismatches.length && !confirmPatientMismatch) throw new BookingPatientMismatchError(mismatches);
+ const report = ext.extracted as unknown as ExtractedReport;
+ const chosen = mappings.filter((m) => m.include !== false);
+ if (!chosen.length) throw new ValidationError("Choose at least one test to apply");
+ const codes = new Set();
+ for (const m of chosen) {
+ if (!Number.isInteger(m.testIndex) || !report.tests[m.testIndex]) throw new ValidationError(`No test at index ${m.testIndex}`);
+ if (!booking.tests.some((t) => t.testCode === m.testCode)) throw new ValidationError(`Test ${m.testCode} is not in booking ${booking.bookingNo}`);
+ if (codes.has(m.testCode)) throw new ValidationError(`Test ${m.testCode} is mapped twice`);
+ codes.add(m.testCode);
+ }
+
+ return this.repos.transaction(async (tx) => {
+ const out: Report[] = [];
+ for (const m of chosen) {
+ const test = report.tests[m.testIndex]!;
+ const results = this.resultsOf(test, m.testCode);
+ if (!results.length) throw new ValidationError(`The partner test "${test.name}" has no results to apply`);
+ let ours = await tx.reports.findOne({ bookingId: booking.id, testCode: m.testCode } as never);
+ if (ours && (!["draft", "entered"].includes(ours.status) || (await tx.reportProvenance.findOne({ reportId: ours.id } as never))))
+ throw new ConflictError(`Booking ${booking.bookingNo} already has a ${m.testCode} report (${ours.status})`);
+ if (!ours) {
+ const bt = booking.tests.find((t) => t.testCode === m.testCode)!;
+ ours = await tx.reports.create({
+ orgId: booking.orgId, branchId: booking.branchId, bookingId: booking.id, patientId: booking.patientId,
+ testCode: m.testCode, testName: bt.name, results: [], status: "draft", version: 1,
+ } as never);
+ await tx.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "report.create", entity: "report", entityId: ours.id, data: { externalReportId: ext.id } });
+ }
+ const label = labelFor(report, test);
+ await tx.reportProvenance.create({
+ orgId: actor.orgId, reportId: ours.id, externalReportId: ext.id, sourceSha256: ext.sourceSha256, lab: ext.lab, testIndex: m.testIndex,
+ performedBy: label.performedBy, performedAt: label.performedAt ?? null,
+ reportedBy: label.reportedBy.map((r) => ({
+ signatoryId: r.id, name: r.name,
+ ...(r.qualification ? { qualification: r.qualification } : {}),
+ ...(r.registration ? { registration: r.registration } : {}),
+ ...(r.title ? { title: r.title } : {}),
+ role: r.role,
+ })),
+ accreditation: label.accreditation ?? null,
+ sourceTestName: test.name, sourceSampleId: test.sampleId ?? null, sourceReportedAt: test.reported ?? report.dates.reported ?? null,
+ importedResults: results, appliedBy: actor.userId,
+ });
+ // the normal flow: results entered (verification stays with our staff)
+ out.push(await saveResults(tx, ours.id, results, actor.userId));
+ }
+ await tx.externalReports.update(ext.id, { status: "applied", appliedBy: actor.userId, appliedAt: new Date() });
+ await tx.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "external_report.apply", entity: "external_report", entityId: ext.id, data: { reports: out.map((r) => r.id), confirmedMismatch: mismatches.length > 0 } });
+ return out;
+ });
+ }
+
+ /** A partner test's results as our ResultInput, copied as printed (parameter names mapped to our catalog where they match). */
+ resultsOf(test: TestSection, testCode: string): ResultValue[] {
+ return test.parameters
+ .filter((p) => p.name.trim() && p.value.trim())
+ .map((p) => ({
+ parameter: mapParameter(testCode, p.name),
+ value: p.value,
+ ...(p.unit ? { unit: p.unit } : {}),
+ ...(p.range ? { range: p.range } : {}),
+ flag: p.flag,
+ }));
+ }
+
+ /** Our reports an import was applied to. */
+ async reportsOf(ext: ExternalReport): Promise {
+ const rows = await this.repos.reportProvenance.find({ orgId: ext.orgId, externalReportId: ext.id } as never, { limit: 200 });
+ return rows.length ? this.repos.reports.getMany(rows.map((r) => r.reportId)) : [];
+ }
+
+ async reject(actor: Actor, ext: ExternalReport, reason: string): Promise {
+ if (ext.status === "applied") throw new ReportAlreadyAppliedError();
+ if (!reason.trim()) throw new ValidationError("A reason is required");
+ const out = await this.repos.externalReports.update(ext.id, { status: "rejected", rejectedBy: actor.userId, rejectReason: reason.trim() });
+ await this.repos.audit.log({ orgId: actor.orgId, branchId: ext.branchId, userId: actor.userId, action: "external_report.reject", entity: "external_report", entityId: ext.id });
+ return out!;
+ }
+
+ async list(actor: Actor, o: { scope: "all" | Id[]; bookingId?: Id | null; status?: ExternalReport["status"] | null; limit: number; offset: number }) {
+ const filter: Record = { orgId: actor.orgId, ...(o.bookingId ? { bookingId: o.bookingId } : {}), ...(o.status ? { status: o.status } : {}) };
+ const sort = { field: "uploadedAt" as const, dir: "desc" as const };
+ // one more than asked, to know whether there is a next page
+ if (o.scope === "all") return this.repos.externalReports.find(filter as never, { limit: o.limit + 1, offset: o.offset, sort });
+ const lists = await Promise.all(o.scope.map((b) => this.repos.externalReports.find({ ...filter, branchId: b } as never, { limit: o.limit + 1 + o.offset, sort })));
+ return lists.flat().sort((a, b) => +b.uploadedAt - +a.uploadedAt).slice(o.offset, o.offset + o.limit + 1);
+ }
+
+ // ---------------------------------------------------------------- signatures
+
+ /**
+ * A signature image, only from the PDF it came from: looked up by (organisation, sourceSha256, sha). The
+ * caller passes the sourceSha256 of the results it shows; a stored signature is never picked by sha alone.
+ */
+ signatureImage(orgId: Id, sourceSha256: string, sha: string) {
+ if (!sha) return Promise.resolve(null);
+ return this.repos.externalSignatures.findOne({ orgId, sourceSha256, sha } as never);
+ }
+
+ signatories(actor: Actor, lab?: ExternalLabId | null) {
+ return this.repos.externalSignatories.listForOrg(actor.orgId, lab ?? undefined);
+ }
+
+ /**
+ * Names a partner signature once (an authorised user, from what the signature shows). Adds the sha and
+ * its dhash to the registry entry of that person (the same sha, or the same lab + name + registration),
+ * then re-extracts the organisation's unapplied imports that carry it, in the background.
+ */
+ async nameSignature(actor: Actor, input: SignatoryInput): Promise {
+ const name = input.name.trim();
+ if (!name) throw new ValidationError("A name is required");
+ if (input.role && !ROLES.includes(input.role as ExternalSignatoryRole)) throw new ValidationError(`role must be one of ${ROLES.join(", ")}`);
+ const seen = await this.repos.externalSignatures.findOne({ orgId: actor.orgId, sha: input.sha } as never);
+ if (!seen) throw new NotFoundError("Signature", input.sha);
+ const fields = {
+ lab: input.lab, name,
+ qualification: input.qualification?.trim() || null, registration: input.registration?.trim() || null,
+ title: input.title?.trim() || null, role: (input.role as ExternalSignatoryRole) || null,
+ };
+ const all = await this.repos.externalSignatories.listForOrg(actor.orgId);
+ const bySha = all.find((r) => r.sha.includes(input.sha));
+ const samePerson = all.find((r) => r.lab === input.lab && normName(r.name) === normName(name) && (r.registration ?? "") === (fields.registration ?? ""));
+ const target = bySha ?? samePerson;
+ let out: ExternalSignatory;
+ if (target) {
+ out = (await this.repos.externalSignatories.update(target.id, {
+ ...fields,
+ sha: [...new Set([...target.sha, input.sha])],
+ dhash: [...new Set([...target.dhash, ...(seen.dhash ? [seen.dhash] : [])])],
+ verifiedBy: actor.userId,
+ }))!;
+ } else {
+ out = await this.repos.externalSignatories.create({ orgId: actor.orgId, ...fields, sha: [input.sha], dhash: seen.dhash ? [seen.dhash] : [], createdBy: actor.userId, verifiedBy: null });
+ }
+ await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: target ? "external_signatory.update" : "external_signatory.create", entity: "external_signatory", entityId: out.id });
+ await this.queue().reextract({ kind: "reextract", actor, sha: input.sha });
+ return out;
+ }
+
+ /** Re-reads (from the stored PDFs) the organisation's imports in status extracted or matched that carry this signature. */
+ async reextractSignature(actor: Actor, sha: string): Promise {
+ const rows = await this.repos.externalSignatures.find({ orgId: actor.orgId, sha } as never, { limit: 500 });
+ const known = knownSignatures(await this.repos.externalSignatories.listForOrg(actor.orgId));
+ let updated = 0;
+ for (const id of new Set(rows.map((r) => r.externalReportId))) {
+ const ext = await this.repos.externalReports.get(id);
+ if (!ext || ext.orgId !== actor.orgId || (ext.status !== "extracted" && ext.status !== "matched")) continue;
+ const file = await this.repos.externalReportFiles.get(ext.id);
+ if (!file) continue;
+ const { report } = await extractReport(new Uint8Array(file.pdf), { file: ext.fileName ?? undefined, signatures: known, signatureImages: false });
+ const booking = ext.bookingId ? await this.repos.bookings.get(ext.bookingId) : null;
+ const warnings = [...report.warnings, ...(booking ? await this.bookingWarnings(report, booking) : [])];
+ await this.repos.externalReports.update(ext.id, { extracted: report as unknown as Record, warnings });
+ updated++;
+ }
+ return updated;
+ }
+}
+```
+
+
---
## File: src/services/index.ts
@@ -8864,6 +10264,7 @@ import { CatalogService } from "./catalog.service";
import { DocumentService } from "./document.service";
import { ExportService } from "./export.service";
import { GstService } from "./gst.service";
+import { ImportService, type ImportLimits } from "./import.service";
import { JobService } from "./job.service";
import type { JobQueue } from "../jobs/types";
import { OrganizationService } from "./organization.service";
@@ -8882,6 +10283,8 @@ export interface ServiceDeps {
samples: boolean;
/** The job queue (BullMQ or inline); a getter because the inline queue needs the services first. */
jobs: () => JobQueue;
+ /** Partner-lab PDF limits (IMPORT_* variables). */
+ imports: ImportLimits;
}
/** Every service, one instance each. Services know nothing about GraphQL; controllers call them. */
@@ -8903,6 +10306,7 @@ export function createServices(d: ServiceDeps) {
exports: new ExportService(d.repos),
documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples }),
jobs: new JobService(d.repos, d.jobs),
+ imports: new ImportService(d.repos, d.jobs, d.imports),
};
}
@@ -9288,6 +10692,17 @@ export class ReportService {
}
async saveResults(actor: Actor, report: Report, results: ResultValue[]) {
+ const imported = await this.repos.reportProvenance.findOne({ reportId: report.id } as never);
+ if (imported) {
+ // results from a partner PDF: keep the partner's units and ranges (no catalog defaults), and audit the edit
+ // against what was imported (ReportProvenance.importedResults keeps the original)
+ const out = await saveResults(this.repos, report.id, results, actor.userId);
+ await this.repos.audit.log({
+ orgId: actor.orgId, branchId: report.branchId, userId: actor.userId, action: "report.edit_imported", entity: "report", entityId: report.id,
+ data: { externalReportId: imported.externalReportId, changed: results.filter((r) => !imported.importedResults.some((x) => x.parameter === r.parameter && x.value === r.value)).map((r) => r.parameter) },
+ });
+ return out;
+ }
const patient = await this.repos.patients.get(report.patientId);
return saveResults(this.repos, report.id, withCatalogDefaults(report.testCode, results, patient?.sex ?? "other"), actor.userId);
}
@@ -9523,6 +10938,332 @@ for (const signal of ["SIGINT", "SIGTERM"] as const) {
```
+---
+
+## File: test/imports.ts
+
+```ts
+// Partner-lab imports end to end (report-reader → db-kit → GraphQL → report-kit), on each backend.
+// The PDFs are fictional and drawn at test time (test/partner-pdfs.ts).
+import { PDFDocument, StandardFonts } from "pdf-lib";
+import type { Repositories } from "@mgpl-diagnostics/db-kit";
+import type { Services } from "../src/services";
+import { clinovaPdf, hypathoPdf } from "./partner-pdfs";
+
+type Gql = { data?: any; errors?: { message: string; extensions?: Record }[] };
+type Run = (token: string | null, query: string, variables?: Record) => Promise;
+type Ok = (cond: unknown, msg: string) => void;
+
+const code = (r: Gql) => r.errors?.[0]?.extensions?.code;
+const b64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
+
+const EXT = `id status lab labName branchId fileName warnings patientMismatches sourceSha256 booking { id }
+ patient { name ageYears sex } sampleIds
+ tests { index name accredited suggestedTestCode parameters { name value unit range flag } provenance { performedBy accreditationText reportedBy { name } text } }
+ signatories { id name role medical source sha image { mimeType width height base64 } }`;
+const IMPORT = `mutation($pdf: String!, $f: String, $b: ID, $br: ID) { importExternalReport(pdfBase64: $pdf, fileName: $f, bookingId: $b, branchId: $br) { ${EXT} } }`;
+
+export interface ImportCtx {
+ tag: string;
+ gql: Run;
+ ok: Ok;
+ owner: string;
+ otherOwner: string;
+ login: (email: string, password?: string) => Promise;
+ branchA: string;
+ branchB: string;
+ orgId: string;
+ repos: Repositories;
+ services: Services;
+ /** Sends a raw body (for the size limits). */
+ raw: (token: string, body: string) => Promise<{ status: number; json: Gql }>;
+}
+
+async function unsupportedPdf() {
+ const doc = await PDFDocument.create();
+ const font = await doc.embedFont(StandardFonts.Helvetica);
+ doc.addPage().drawText("Fictional letter, not a lab report", { x: 50, y: 700, size: 12, font });
+ return doc.save();
+}
+
+export async function importSuite(c: ImportCtx) {
+ const { tag, gql, ok, owner, otherOwner } = c;
+ const t = (s: string) => `[${tag}] imports: ${s}`;
+
+ // users: a technician in branch A (report:import, signatory:read) and a front desk (neither)
+ const pw = "Counter-Shift-7391";
+ for (const [email, role] of [["tech@apl.test", "technician"], ["desk2@apl.test", "front_desk"]] as const)
+ await gql(owner, "mutation($i: CreateUserInput!) { createUser(input: $i) { id } }", { i: { name: role, email, password: pw, roles: [{ role, branchId: c.branchA }] } });
+ const tech = await c.login("tech@apl.test", pw);
+ const desk = await c.login("desk2@apl.test", pw);
+
+ // a booking for the Clinova patient (Mr DEMO PERSON, 35, male): lipid profile + ESR
+ const patientId = (await gql(owner, "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { id } }", { i: { branchId: c.branchA, title: "Mr", name: "Demo Person", sex: "male", ageYears: 35 } })).data?.registerPatient.id;
+ const booking = (await gql(owner, "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id bookingNo } } }", { i: { branchId: c.branchA, patientId, tests: [{ testCode: "LIPR" }, { testCode: "ESR" }] } })).data?.createBooking.booking;
+ ok(!!booking?.id, t("booking for the partner patient"));
+
+ // ------------------------------------------------------------ permissions and validation
+ const clinova = await clinovaPdf();
+ ok(code(await gql(desk, IMPORT, { pdf: b64(clinova), b: booking.id })) === "FORBIDDEN", t("front desk cannot import (report:import) → FORBIDDEN"));
+ ok(code(await gql(tech, IMPORT, { pdf: b64(clinova), br: c.branchB })) === "FORBIDDEN", t("a technician of branch A cannot import into branch B"));
+ ok(code(await gql(tech, IMPORT, { pdf: b64(clinova) })) === "BAD_USER_INPUT", t("no booking and no branch → BAD_USER_INPUT"));
+ ok(code(await gql(otherOwner, IMPORT, { pdf: b64(clinova), b: booking.id })) === "NOT_FOUND", t("another organisation's booking → NOT_FOUND"));
+ const unsupported = await gql(tech, IMPORT, { pdf: b64(await unsupportedPdf()), br: c.branchA });
+ ok(code(unsupported) === "UNSUPPORTED_REPORT", t("a PDF from an unknown lab → UNSUPPORTED_REPORT"));
+ ok((await c.repos.externalReportFiles.find({ orgId: c.orgId } as never)).length === 0, t("…and its file is not kept"));
+ ok(code(await gql(tech, IMPORT, { pdf: "not base64!", br: c.branchA })) === "BAD_USER_INPUT", t("pdfBase64 that is not base64 → BAD_USER_INPUT"));
+
+ // ------------------------------------------------------------ import + re-import (Clinova: printed names, NABL on one test)
+ const imp = await gql(tech, IMPORT, { pdf: b64(clinova), f: "clinova-demo.pdf", b: booking.id });
+ const ext = imp.data?.importExternalReport;
+ ok(ext?.status === "matched" && ext.lab === "clinova" && ext.booking.id === booking.id && ext.branchId === c.branchA, t(`importExternalReport (with bookingId) → matched, ${ext?.tests.length} tests`));
+ ok(ext?.patientMismatches.length === 0 && ext.patient.name, t("same patient (title, case ignored; age ±1) → no mismatch"));
+ const lipid = ext?.tests.find((x: any) => /lipid/i.test(x.name));
+ const esr = ext?.tests.find((x: any) => /ESR|sedimentation/i.test(x.name));
+ ok(lipid?.suggestedTestCode === "LIPR" && esr?.suggestedTestCode === "ESR", t("catalog-kit suggests LIPR and ESR"));
+ ok(lipid?.accredited === true && /NABL accredited laboratory \(Certificate No\. MC-9999\)/.test(lipid.provenance.accreditationText ?? "") && lipid.provenance.text.includes("NABL"), t("NABL line on the accredited test, as the partner's accreditation"));
+ ok(esr?.accredited === false && esr.provenance.accreditationText === null && !esr.provenance.text.includes("NABL"), t("…and no NABL line on the test that is not accredited"));
+ const medical = ext?.signatories.filter((s: any) => s.medical) ?? [];
+ ok(medical.length === 1 && ext.signatories.length === 2 && lipid.provenance.reportedBy.length === 1 && lipid.provenance.reportedBy[0].name === medical[0].name, t("Reported by lists the medical signatory only (not the lab manager)"));
+ ok(ext?.signatories.every((s: any) => s.image?.mimeType === "image/png" && Buffer.from(s.image.base64, "base64").subarray(1, 4).toString() === "PNG"), t("signature images served as PNG with their own PDF"));
+ const again = await gql(tech, IMPORT, { pdf: b64(clinova), b: booking.id });
+ ok(again.data?.importExternalReport.id === ext?.id, t("the same PDF again → the same import"));
+ ok((await gql(tech, "query($b: ID!) { externalReports(bookingId: $b) { nodes { id } pageInfo { hasNextPage } } }", { b: booking.id })).data?.externalReports.nodes.length === 1, t("externalReports(bookingId) lists it once"));
+ ok(code(await gql(otherOwner, `query($id: ID!) { externalReport(id: $id) { id } }`, { id: ext?.id })) === "NOT_FOUND", t("another organisation → NOT_FOUND"));
+ ok(code(await gql(desk, `query($id: ID!) { externalReport(id: $id) { id } }`, { id: ext?.id })) === "FORBIDDEN", t("externalReport needs report:import"));
+
+ // ------------------------------------------------------------ Hy Patho: unnamed signatures, patient mismatch
+ const hy = await gql(tech, IMPORT, { pdf: b64(await hypathoPdf()), br: c.branchA });
+ const hext = hy.data?.importExternalReport;
+ ok(hext?.status === "extracted" && hext.lab === "hypatho" && hext.signatories.length >= 1 && hext.signatories.every((s: any) => s.name === null && s.source === "unknown"), t("Hy Patho import: signatures not yet named"));
+ ok(hext?.tests.every((x: any) => x.accredited === false && x.provenance.accreditationText === null), t("Hy Patho carries no marks → no NABL line"));
+ const matched = await gql(tech, "mutation($id: ID!, $b: ID!) { matchExternalReport(id: $id, bookingId: $b) { status patientMismatches warnings } }", { id: hext?.id, b: booking.id });
+ const mm = matched.data?.matchExternalReport.patientMismatches ?? [];
+ ok(mm.some((m: string) => /name/.test(m)) && mm.some((m: string) => /age 40/.test(m)), t(`matching another patient's booking → warnings (${mm.length})`));
+ const applyHy = await gql(tech, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id } }", { id: hext?.id, m: [{ testIndex: 0, testCode: "ESR" }] });
+ ok(code(applyHy) === "BOOKING_PATIENT_MISMATCH" && applyHy.errors?.[0]?.extensions?.mismatches?.length === mm.length, t("apply without confirmation → BOOKING_PATIENT_MISMATCH"));
+ ok((await gql(tech, "mutation($id: ID!) { rejectExternalReport(id: $id, reason: \"Wrong patient\") { status } }", { id: hext?.id })).data?.rejectExternalReport.status === "rejected", t("rejectExternalReport"));
+
+ // ------------------------------------------------------------ name a signature → re-extraction
+ const sha = hext?.signatories[0]?.sha;
+ const named = { sha, lab: "hypatho", name: "Dr Fictional Reader", qualification: "MD (Pathology)", registration: "XX-0001", role: "pathologist" };
+ ok(code(await gql(tech, "mutation($i: ExternalSignatoryInput!) { nameExternalSignature(input: $i) { id } }", { i: named })) === "FORBIDDEN", t("nameExternalSignature needs signatory:manage"));
+ ok(code(await gql(otherOwner, "mutation($i: ExternalSignatoryInput!) { nameExternalSignature(input: $i) { id } }", { i: named })) === "NOT_FOUND", t("…and a signature this organisation has seen"));
+ // a second, unapplied import of a Hy Patho PDF carrying that signature (new bytes, same drawing)
+ const hy2pdf = await PDFDocument.load(await hypathoPdf());
+ hy2pdf.setTitle("copy");
+ const hy2 = (await gql(tech, IMPORT, { pdf: b64(await hy2pdf.save()), br: c.branchA })).data?.importExternalReport;
+ const sig = await gql(owner, "mutation($i: ExternalSignatoryInput!) { nameExternalSignature(input: $i) { id name signatureCount } }", { i: named });
+ ok(sig.data?.nameExternalSignature.name === "Dr Fictional Reader" && sig.data.nameExternalSignature.signatureCount === 1, t("nameExternalSignature adds the registry entry"));
+ const reread = (await gql(tech, `query($id: ID!) { externalReport(id: $id) { ${EXT} } }`, { id: hy2?.id })).data?.externalReport;
+ ok(reread?.signatories.some((s: any) => s.name === "Dr Fictional Reader" && s.source === "registry" && s.medical) && reread.tests[0].provenance.reportedBy.some((r: any) => r.name === "Dr Fictional Reader"), t("unapplied imports carrying it are read again: named, Reported by"));
+ const rejected = (await gql(tech, `query($id: ID!) { externalReport(id: $id) { signatories { name } } }`, { id: hext?.id })).data?.externalReport;
+ ok(rejected?.signatories.every((s: any) => s.name === null), t("…a rejected import is left as it was"));
+ ok((await gql(tech, "{ externalSignatories(lab: hypatho) { name } }")).data?.externalSignatories.length === 1 && code(await gql(desk, "{ externalSignatories { name } }")) === "FORBIDDEN", t("externalSignatories needs signatory:read"));
+
+ // ------------------------------------------------------------ apply with provenance (status unchanged until staff verify)
+ const mapping = [{ testIndex: lipid.index, testCode: "LIPR" }, { testIndex: esr.index, testCode: "ESR" }];
+ ok(code(await gql(desk, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id } }", { id: ext.id, m: mapping })) === "FORBIDDEN", t("front desk cannot apply"));
+ ok(code(await gql(tech, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id } }", { id: ext.id, m: [{ testIndex: 0, testCode: "CBC" }] })) === "BAD_USER_INPUT", t("a test that is not in the booking → BAD_USER_INPUT"));
+ const applied = await gql(tech, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id testCode status results { parameter value unit range flag } provenance { lab performedBy accreditationText reportedBy { name } sourceTestName importedResults { value } } } }", { id: ext.id, m: mapping });
+ const reps = applied.data?.applyExternalReport ?? [];
+ const lr = reps.find((r: any) => r.testCode === "LIPR");
+ const er = reps.find((r: any) => r.testCode === "ESR");
+ ok(reps.length === 2 && reps.every((r: any) => r.status === "entered"), t("applyExternalReport → our reports, status entered (not verified)"));
+ const chol = lr?.results.find((x: any) => /cholesterol/i.test(x.parameter) && x.value === "245");
+ ok(chol?.unit === "mg/dL" && /Desirable/.test(chol.range ?? "") && er?.results[0]?.range === "<= 20", t("values, units and ranges copied as printed"));
+ ok(lr?.provenance?.lab === "clinova" && /MC-9999/.test(lr.provenance.accreditationText ?? "") && er?.provenance?.accreditationText === null && lr.provenance.reportedBy.length === 1, t("Report.provenance: lab, NABL only on the accredited test, medical signatory"));
+ ok(code(await gql(tech, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id } }", { id: ext.id, m: mapping })) === "REPORT_ALREADY_APPLIED", t("applying twice → REPORT_ALREADY_APPLIED"));
+ ok(code(await gql(tech, "query($b: ID!) { bookingReportDocument(bookingId: $b) { fileName } }", { b: booking.id })) === "CONFLICT", t("imported results do not print until verified"));
+
+ // edit after import: audited, no catalog defaults
+ await gql(owner, "mutation($r: ID!, $x: [ResultInput!]!) { saveResults(reportId: $r, results: $x) { id } }", { r: er.id, x: [{ parameter: er.results[0].parameter, value: "31", unit: "mm/Hour", range: "<= 20" }] });
+ const audit = await c.repos.audit.find({ orgId: c.orgId, action: "report.edit_imported" } as never);
+ ok(audit.length === 1 && JSON.stringify(audit[0]).includes(er.results[0].parameter) && !JSON.stringify(audit[0]).includes("\"31\""), t("editing imported results is audited (parameter names, no values)"));
+
+ for (const r of reps) await gql(owner, "mutation($r: ID!) { verifyReport(id: $r) { status } }", { r: r.id });
+ const html = (await gql(tech, "query($b: ID!) { bookingReportDocument(bookingId: $b, format: html) { html } }", { b: booking.id })).data?.bookingReportDocument.html ?? "";
+ const pdf = await gql(tech, "query($b: ID!) { bookingReportDocument(bookingId: $b) { base64 } }", { b: booking.id });
+ const printed = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
+ ok(/Performed at Clinova Pathlabs, Demo Central Lab/.test(printed) && /NABL accredited laboratory \(Certificate No\. MC-9999\)/.test(printed) && Buffer.from(pdf.data?.bookingReportDocument.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-", t("verified → prints with the per-test provenance line (HTML + PDF)"));
+ ok((printed.match(/NABL accredited/g) ?? []).length === 1 && !/
]+nabl/i.test(html), t("NABL text once (the accredited test only), never a logo"));
+ ok(/class="sb-psign"/.test(html) && /class="sb-psignimg"[^>]*src="data:image\/png;base64|src="data:image\/png;base64[^"]*"[^>]*class="sb-psignimg"/.test(html) && !/class="sb-signimg"/.test(html), t("the partner's medical signatory and signature print with its own results"));
+
+ // ------------------------------------------------------------ a signature only appears with results from its own PDF
+ const csha = ext.signatories.find((s: any) => s.medical).sha;
+ ok(!!(await c.services.imports.signatureImage(c.orgId, ext.sourceSha256, csha)) && (await c.services.imports.signatureImage(c.orgId, hext.sourceSha256, csha)) === null, t("signature lookup is keyed by its own PDF's sha256"));
+ ok((await c.services.imports.signatureImage(c.orgId, ext.sourceSha256, "")) === null, t("…never by an empty sha"));
+ // our own booking with typed-in results prints our signatory, never a stored partner signature
+ const own = (await gql(owner, "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id } } }", { i: { branchId: c.branchA, patientId, tests: [{ testCode: "ESR" }] } })).data?.createBooking.booking;
+ const ownRep = (await gql(owner, 'mutation($b: ID!) { createReport(bookingId: $b, testCode: "ESR") { id } }', { b: own.id })).data?.createReport.id;
+ await gql(owner, "mutation($r: ID!, $x: [ResultInput!]!) { saveResults(reportId: $r, results: $x) { id } }", { r: ownRep, x: [{ parameter: "Erythrocyte Sedimentation Rate (ESR)", value: "12" }] });
+ await gql(owner, "mutation($r: ID!) { verifyReport(id: $r) { id } }", { r: ownRep });
+ const ownHtml = (await gql(owner, "query($b: ID!) { bookingReportDocument(bookingId: $b, format: html) { html } }", { b: own.id })).data?.bookingReportDocument.html ?? "";
+ ok(ownHtml.includes(" ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648);
+ const a = 0.25 + rnd() * 0.2, f = 2 + rnd() * 4, ph = rnd() * 6;
+ for (let x = 4; x < w - 4; x++) {
+ const y = Math.round(h / 2 + Math.sin((x / w) * Math.PI * f + ph) * h * a + Math.sin(x / 7) * h * 0.08);
+ for (let t = -2; t <= 2; t++) {
+ const yy = y + t;
+ if (yy < 0 || yy >= h) continue;
+ const j = (yy * w + x) * 4;
+ rgba[j] = 20; rgba[j + 1] = 40; rgba[j + 2] = 140; rgba[j + 3] = 255;
+ }
+ }
+ return toPng({ width: w, height: h, rgba });
+}
+
+async function page(doc: PDFDocument, font: PDFFont, bold: PDFFont) {
+ const p = doc.addPage([W, H]);
+ const text = (x: number, yTop: number, s: string, size = 7.5, f: PDFFont = font) => p.drawText(s, { x, y: H - yTop, size, font: f });
+ return { p, text, bold };
+}
+
+async function image(doc: PDFDocument, p: PDFPage, png: Buffer, x: number, yTop: number, w: number, h: number) {
+ const img = await doc.embedPng(png);
+ p.drawImage(img, { x, y: H - yTop - h, width: w, height: h });
+}
+
+export async function hypathoPdf(): Promise {
+ const doc = await PDFDocument.create();
+ const font = await doc.embedFont(StandardFonts.Helvetica);
+ const bold = await doc.embedFont(StandardFonts.HelveticaBold);
+ const sig = squiggle(210, 130, 7);
+ const header = (text: (x: number, y: number, s: string, size?: number, f?: PDFFont) => void, n: number, of: number) => {
+ text(472, 39, "PROCESSED AT:", 8, bold);
+ text(472, 50, "HY PATHO LAB", 8, bold);
+ text(472, 61, "RAIPUR", 8, bold);
+ text(20, 148, "PT Name"); text(70, 148, ": MR. TEST PATIENT"); text(240, 148, "Age"); text(308, 148, ": 40 Y | Gender : MALE"); text(426, 148, "SAMPLE COLLECTED AT :");
+ text(20, 157, "Ref By Dr"); text(70, 157, ": SELF"); text(240, 157, "Registered on"); text(308, 157, ": 01-01-2026 09:00 AM"); text(426, 157, "DEMO COLLECTION CENTRE");
+ text(20, 166, "Booking Id"); text(70, 166, ": 100001 DEMO - 1"); text(240, 166, "Received on"); text(308, 166, ": 01-01-2026 10:00 AM");
+ text(308, 175, ": 01-01-2026 05:30 PM");
+ text(20, 178, "Barcode Id"); text(70, 178, ": BC0001"); text(240, 178, "Reported on"); text(426, 178, "SAMPLE"); text(462, 178, ": SERUM");
+ text(20, 187, "INV"); text(70, 187, ": DEMO PANEL");
+ text(298, 815, `${n} of ${of}`);
+ };
+ const th = (text: (x: number, y: number, s: string, size?: number, f?: PDFFont) => void, y: number) => {
+ text(20, y, "TEST DESCRIPTION", 8, bold); text(246, y, "RESULT", 8, bold); text(330, y, "UNITS", 8, bold); text(415, y, "BIO. REF. RANGES", 8, bold);
+ };
+
+ // page 1: a panel that continues on page 2
+ let { p, text } = await page(doc, font, bold);
+ header(text, 1, 3);
+ text(222, 216, "COMPLETE BLOOD COUNT", 9, bold);
+ th(text, 243);
+ text(20, 262, "Hemoglobin"); text(246, 262, "9.50"); text(330, 262, "g/dL"); text(415, 262, "13 - 17");
+ text(20, 271, "Method: SLS-Hemoglobin");
+ text(20, 289, "Mean Corpuscular Hemoglobin Concentration"); text(246, 289, "35.80"); text(330, 289, "g/dl"); text(415, 289, "31.5 - 34.5");
+ text(20, 300, "(MCHC)");
+ text(20, 310, "Method: Calculated");
+ text(20, 328, "DIFFERENTIAL COUNT", 7.5, bold);
+ text(20, 346, "Neutrophils"); text(246, 346, "62"); text(330, 346, "%"); text(415, 346, "40 - 80");
+ text(20, 355, "Method: Flow Cytometry");
+ text(20, 790, "Platelet Count"); text(246, 790, "120"); text(330, 790, "10^3/uL"); text(415, 790, "150 - 410");
+
+ // page 2: continuation (no title), method line carried over, end of report, signature
+ ({ p, text } = await page(doc, font, bold));
+ header(text, 2, 3);
+ th(text, 211);
+ text(20, 225, "Method: Electrical Impedance");
+ text(20, 243, "Erythrocyte Sedimentation Rate (ESR)"); text(246, 243, "34"); text(330, 243, "mm at end of Ist hr"); text(415, 243, "0 - 20");
+ text(236, 300, "~~End of report~~");
+ await image(doc, p, sig, 430, 720, 120, 74);
+
+ // page 3: a marked single test with narrative notes
+ ({ p, text } = await page(doc, font, bold));
+ header(text, 3, 3);
+ text(223, 216, "* THYROID PROFILE", 9, bold);
+ th(text, 243);
+ text(20, 262, "THYROID STIMULATING HORMONE (TSH)"); text(246, 262, "6.20"); text(330, 262, "uIU/ml"); text(415, 262, "0.35 - 5.5");
+ text(20, 271, "Method: CLIA");
+ text(25, 300, "Interpretation: values vary with time of day; repeat if clinically indicated.");
+ text(236, 340, "~~End of report~~");
+ await image(doc, p, sig, 430, 720, 120, 74);
+
+ return doc.save();
+}
+
+export async function clinovaPdf(): Promise {
+ const doc = await PDFDocument.create();
+ const font = await doc.embedFont(StandardFonts.Helvetica);
+ const bold = await doc.embedFont(StandardFonts.HelveticaBold);
+ const left = squiggle(150, 80, 11), right = squiggle(250, 68, 23);
+
+ const make = async (n: number, of: number, sampleId: string, sampleType: string, dept: string, accredited: boolean) => {
+ const { p, text } = await page(doc, font, bold);
+ text(242, 96, "LABORATORY TEST REPORT", 10, bold);
+ text(30, 117, "Patient Name"); text(94, 117, "Mr DEMO PERSON", 8, bold);
+ text(30, 131, "Age : 35 Year(s) Gender : Male");
+ text(344, 141, "Lab Code"); text(461, 141, ":"); text(470, 141, "CPL-XX-001");
+ text(30, 145, "Sample ID"); text(119, 145, ":"); text(128, 145, sampleId);
+ text(344, 156, "Sample Collection Date"); text(461, 156, ":"); text(470, 156, "2026-01-02 08:15");
+ text(30, 159, "Sample Type"); text(119, 159, ":"); text(128, 159, sampleType);
+ text(30, 171, "Patient ID"); text(119, 171, ":"); text(128, 171, "900001"); text(344, 171, "Registration Date"); text(461, 171, ":"); text(470, 171, "2026-01-02 10:00");
+ text(30, 186, "Ref. Doctor"); text(119, 186, ":"); text(344, 186, "Approved"); text(389, 186, "Date"); text(461, 186, ":"); text(470, 186, `2026-01-02 1${n}:30`);
+ text(30, 200, "Ref. Customer"); text(119, 200, ":"); text(128, 200, "DEMO COLLECTION CENTRE");
+ text(236, 220, dept, 8, bold);
+ text(32, 242, "Test Description", 8, bold); text(310, 242, "Result", 8, bold); text(372, 242, "Units", 8, bold); text(414, 242, "Biological Reference Ranges", 8, bold);
+ await image(doc, p, left, 29, 736, 116, 21);
+ await image(doc, p, right, 404, 735, 134, 22);
+ text(30, 769, "Demo Manager"); text(404, 769, "Dr Demo Pathologist, MD");
+ text(30, 784, "Manager Lab Operations"); text(404, 784, "Consultant Pathologist (XYMC1234/2020)");
+ text(30, 808, "This is an electronically authenticated report.", 5);
+ text(543, 808, `Page ${n} of ${of}`, 5);
+ if (accredited) text(30, 822, "Tests marked with NABL symbol are accredited by NABL vide Certificate No.MC-9999", 5);
+ text(30, 830, "Processed at : Clinova Pathlabs, Demo Central Lab, Demo City - 000000", 5);
+ return text;
+ };
+
+ let text = await make(1, 2, "S-001", "Serum", "CLINICAL BIOCHEMISTRY", true);
+ text(39, 262, "LIPID PROFILE");
+ text(39, 277, "Cholesterol - Total"); text(310, 277, "245"); text(369, 277, "mg/dL"); text(426, 277, "<200 - Desirable");
+ text(39, 285, "(Method: CHOD-PAP)", 6);
+ text(426, 289, "200-239 - Borderline risk");
+ text(426, 300, ">240 - High risk");
+ text(39, 311, "Cholesterol - HDL"); text(310, 311, "35"); text(369, 311, "mg/dL"); text(426, 311, "40 - 60");
+ text(39, 320, "(Method: Enzymatic Colorimetric)", 6);
+ text(33, 360, "HDL cholesterol below 40 mg/dL is a cardiovascular risk factor. Correlate clinically with other findings.");
+
+ text = await make(2, 2, "S-002", "WB EDTA", "HEMATOLOGY", false);
+ text(39, 260, "Erythrocyte Sedimentation Rate (ESR)"); text(310, 260, "30"); text(369, 260, "mm/Hour"); text(426, 260, "<= 20");
+ text(39, 269, "(Method: Westergren)", 6);
+ text(248, 500, "--End of Report--");
+
+ return doc.save();
+}
+```
+
+
---
## File: test/run.ts
@@ -9553,6 +11294,9 @@ import type { Mailer } from "../src/services/auth.service";
import { createMailer, memoryTransport } from "@mgpl-diagnostics/mail-kit";
import { MGPL_LAB } from "@mgpl-diagnostics/report-kit";
import { brandFromLab, mailerFromConfig, mailKitMailer } from "../src/jobs";
+import { importSuite } from "./imports";
+import { clinovaPdf } from "./partner-pdfs";
+import { PDFDocument } from "pdf-lib";
let passed = 0;
let failed = 0;
@@ -9786,6 +11530,13 @@ async function suite(db: Db, tag: string) {
ok(code(await gql(otherAgain, "mutation($id: ID!) { startDocumentJob(input: { kind: invoice, invoiceId: $id }) { id } }", { id: invoiceId })) === "NOT_FOUND", `[${tag}] startDocumentJob on another organisation's invoice → NOT_FOUND`);
ok(code(await gql(owner2, "mutation { startDocumentJob(input: { kind: booking_reports }) { id } }")) === "BAD_USER_INPUT", `[${tag}] startDocumentJob without bookingId → BAD_USER_INPUT`);
+ // ---------------------------------------------------------------- partner-lab imports (test/imports.ts)
+ const raw = async (token: string, body: string) => {
+ const res = await app.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, payload: body });
+ return { status: res.statusCode, json: res.json() as Gql };
+ };
+ await importSuite({ tag, gql, ok, owner: owner2, otherOwner: otherAgain, login, branchA: a.branch.id, branchB: branchB.id, orgId: a.org.id, repos: container.db.repos, services: container.services, raw });
+
// ---------------------------------------------------------------- server guards
ok(code(await gql(null, "{ __schema { types { name } } }")) === "GRAPHQL_VALIDATION_FAILED", `[${tag}] introspection disabled when INTROSPECTION=0`);
const deep = `{ invoices ${"{ booking { invoice ".repeat(7)}{ id }${" } }".repeat(7)} }`;
@@ -9917,7 +11668,8 @@ async function workerSuite(pgUrl: string) {
}
const mails: { to: string; link: string }[] = [];
const mailer: Mailer = { passwordReset: async (to, link) => void mails.push({ to: to.email, link }) };
- const config = loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: pgUrl, REDIS_URL: url, QUEUE_PREFIX: `mgpl-test-${process.pid}` });
+ // IMPORT_INLINE_MAX_BYTES=1: every partner PDF is read by the worker
+ const config = loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: pgUrl, REDIS_URL: url, QUEUE_PREFIX: `mgpl-test-${process.pid}`, IMPORT_INLINE_MAX_BYTES: "1" });
const api = await createContainer({ config, mailer });
const app = await buildServer(api);
const worker = startWorkers(api.jobDeps, { redisUrl: url, prefix: config.jobs.prefix, keepResultsMs: config.jobs.keepResultsMs, concurrency: 2 });
@@ -9955,6 +11707,23 @@ async function workerSuite(pgUrl: string) {
ok(done?.document.html.includes(" ((await r2.type(k)) === "hash" ? JSON.stringify(await r2.hgetall(k)) : "")))).join("\n");
+ ok(jobKeys.length > 0 && !dump.includes(pdfBase64.slice(100, 160)) && !dump.includes(refreshed.accessToken), `[worker] import jobs hold ids, not the PDF or a token (${jobKeys.length} keys checked)`);
+ await r2.quit();
} finally {
await worker.close();
await app.close();
diff --git a/package-lock.json b/package-lock.json
index 2c34e22..74b096b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,14 +10,15 @@
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@graphql-tools/schema": "^10.1.5",
- "@mgpl-diagnostics/auth-kit": "^1.3.0",
+ "@mgpl-diagnostics/auth-kit": "^1.4.0",
"@mgpl-diagnostics/catalog-kit": "^1.0.0",
- "@mgpl-diagnostics/db-kit": "^2.0.0",
+ "@mgpl-diagnostics/db-kit": "^2.1.1",
"@mgpl-diagnostics/finance-kit": "^1.0.1",
"@mgpl-diagnostics/invoice-kit": "^2.0.0",
"@mgpl-diagnostics/ledger-kit": "^0.1.0",
"@mgpl-diagnostics/mail-kit": "^0.1.0",
- "@mgpl-diagnostics/report-kit": "^2.0.0",
+ "@mgpl-diagnostics/report-kit": "^2.1.0",
+ "@mgpl-diagnostics/report-reader": "^0.1.0",
"@mgpl-diagnostics/security-kit": "^0.3.0",
"bullmq": "^6.4.0",
"fastify": "^5.12.7",
@@ -32,11 +33,12 @@
"sequelize": "^6.37.8"
},
"devDependencies": {
- "@mgpl-diagnostics/client": "^1.2.0",
+ "@mgpl-diagnostics/client": "^1.4.0",
"@types/node": "^22.20.5",
"embedded-postgres": "^18.4.0-beta.17",
"ioredis-mock": "^8.13.1",
"mongodb-memory-server": "^11.3.0",
+ "pdf-lib": "^1.17.1",
"tsup": "^9.0.0",
"tsx": "^4.23.15",
"typescript": "^5.9.3"
@@ -1023,7 +1025,7 @@
}
},
"node_modules/@mgpl-diagnostics/auth-kit": {
- "version": "1.3.0",
+ "version": "1.4.0",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=20"
@@ -1042,9 +1044,7 @@
}
},
"node_modules/@mgpl-diagnostics/client": {
- "version": "1.2.0",
- "resolved": "https://npm.pkg.github.com/download/@mgpl-diagnostics/client/1.2.0/39a698b327d2bdc6bbb2a370358706364fe56f1c",
- "integrity": "sha512-12S1r4U5/t57DB6Sj75xFrZ0XV130Tlmg/htoaoBKaD/lSWHwLyuHv4gOGWL6bK73sTr1/8CVn924osGJY9t5A==",
+ "version": "1.4.0",
"dev": true,
"license": "SEE LICENSE IN LICENSE",
"engines": {
@@ -1055,9 +1055,7 @@
}
},
"node_modules/@mgpl-diagnostics/db-kit": {
- "version": "2.0.0",
- "resolved": "https://npm.pkg.github.com/download/@mgpl-diagnostics/db-kit/2.0.0/c2acc5eef0efbf8b692b51813bcaa6e67ac3aae2",
- "integrity": "sha512-Q3o4dQwUFHFgKCeT39TLkygFNvWJ1qq50g7vcdCWm+5Wbi/zhkt8Olc5s6WhVnmDIBcIlWVFpE01+wX1/cXVOg==",
+ "version": "2.1.1",
"license": "SEE LICENSE IN LICENSE",
"engines": {
"node": ">=20"
@@ -1126,9 +1124,7 @@
}
},
"node_modules/@mgpl-diagnostics/report-kit": {
- "version": "2.0.0",
- "resolved": "https://npm.pkg.github.com/download/@mgpl-diagnostics/report-kit/2.0.0/a3b66c081969d78a482f3daf54d3da56e560a439",
- "integrity": "sha512-GhiJdgelZHBgH7MV0LXD3hxloHufqc8RAs2gLSk/igRbOIzlebURClo0Kv88GazzwUsqeE/TjtFGLeO6HqCUXw==",
+ "version": "2.1.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@react-pdf/renderer": "^4.9.0",
@@ -1148,6 +1144,19 @@
"react-dom": ">=18"
}
},
+ "node_modules/@mgpl-diagnostics/report-reader": {
+ "version": "0.1.0",
+ "license": "SEE LICENSE IN LICENSE",
+ "dependencies": {
+ "pdfjs-dist": "6.4.299"
+ },
+ "bin": {
+ "report-reader": "dist/cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@mgpl-diagnostics/security-kit": {
"version": "0.3.0",
"license": "SEE LICENSE IN LICENSE",
@@ -1250,6 +1259,268 @@
"win32"
]
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.1.0.tgz",
+ "integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==",
+ "optional": true,
+ "optionalDependencies": {
+ "@napi-rs/canvas-darwin-x64": "1.1.0",
+ "@napi-rs/canvas-darwin-arm64": "1.1.0",
+ "@napi-rs/canvas-android-arm64": "1.1.0",
+ "@napi-rs/canvas-linux-x64-gnu": "1.1.0",
+ "@napi-rs/canvas-linux-x64-musl": "1.1.0",
+ "@napi-rs/canvas-win32-x64-msvc": "1.1.0",
+ "@napi-rs/canvas-linux-arm64-gnu": "1.1.0",
+ "@napi-rs/canvas-linux-arm64-musl": "1.1.0",
+ "@napi-rs/canvas-win32-arm64-msvc": "1.1.0",
+ "@napi-rs/canvas-linux-riscv64-gnu": "1.1.0",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0"
+ },
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz",
+ "integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==",
+ "optional": true,
+ "cpu": [
+ "arm64"
+ ],
+ "os": [
+ "android"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz",
+ "integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==",
+ "optional": true,
+ "cpu": [
+ "arm64"
+ ],
+ "os": [
+ "darwin"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz",
+ "integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==",
+ "optional": true,
+ "cpu": [
+ "x64"
+ ],
+ "os": [
+ "darwin"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz",
+ "integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==",
+ "optional": true,
+ "cpu": [
+ "arm"
+ ],
+ "os": [
+ "linux"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz",
+ "integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==",
+ "optional": true,
+ "cpu": [
+ "arm64"
+ ],
+ "os": [
+ "linux"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz",
+ "integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==",
+ "optional": true,
+ "cpu": [
+ "arm64"
+ ],
+ "os": [
+ "linux"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz",
+ "integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==",
+ "optional": true,
+ "cpu": [
+ "riscv64"
+ ],
+ "os": [
+ "linux"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz",
+ "integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==",
+ "optional": true,
+ "cpu": [
+ "x64"
+ ],
+ "os": [
+ "linux"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz",
+ "integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==",
+ "optional": true,
+ "cpu": [
+ "x64"
+ ],
+ "os": [
+ "linux"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz",
+ "integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==",
+ "optional": true,
+ "cpu": [
+ "arm64"
+ ],
+ "os": [
+ "win32"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz",
+ "integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==",
+ "optional": true,
+ "cpu": [
+ "x64"
+ ],
+ "os": [
+ "win32"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Brooooooklyn",
+ "type": "github"
+ }
+ },
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1294,6 +1565,40 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@pdf-lib/standard-fonts": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
+ "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
+ "dev": true,
+ "dependencies": {
+ "pako": "^1.0.6"
+ },
+ "license": "MIT"
+ },
+ "node_modules/@pdf-lib/standard-fonts/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true,
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/@pdf-lib/upng": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
+ "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
+ "dev": true,
+ "dependencies": {
+ "pako": "^1.0.10"
+ },
+ "license": "MIT"
+ },
+ "node_modules/@pdf-lib/upng/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true,
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -4089,6 +4394,45 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pdf-lib": {
+ "version": "1.17.1",
+ "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
+ "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
+ "dev": true,
+ "dependencies": {
+ "@pdf-lib/standard-fonts": "^1.0.0",
+ "@pdf-lib/upng": "^1.0.1",
+ "pako": "^1.0.11",
+ "tslib": "^1.11.1"
+ },
+ "license": "MIT"
+ },
+ "node_modules/pdf-lib/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true,
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/pdf-lib/node_modules/tslib": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/pdfjs-dist": {
+ "version": "6.4.299",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
+ "integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^1.0.10"
+ },
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=22.13.0 || >=24"
+ }
+ },
"node_modules/pdfkit": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz",
diff --git a/package.json b/package.json
index 5a3610e..7780dcf 100644
--- a/package.json
+++ b/package.json
@@ -24,14 +24,15 @@
},
"dependencies": {
"@graphql-tools/schema": "^10.1.5",
- "@mgpl-diagnostics/auth-kit": "^1.3.0",
+ "@mgpl-diagnostics/auth-kit": "^1.4.0",
"@mgpl-diagnostics/catalog-kit": "^1.0.0",
- "@mgpl-diagnostics/db-kit": "^2.0.0",
+ "@mgpl-diagnostics/db-kit": "^2.1.1",
"@mgpl-diagnostics/finance-kit": "^1.0.1",
"@mgpl-diagnostics/invoice-kit": "^2.0.0",
"@mgpl-diagnostics/ledger-kit": "^0.1.0",
"@mgpl-diagnostics/mail-kit": "^0.1.0",
- "@mgpl-diagnostics/report-kit": "^2.0.0",
+ "@mgpl-diagnostics/report-kit": "^2.1.0",
+ "@mgpl-diagnostics/report-reader": "^0.1.0",
"@mgpl-diagnostics/security-kit": "^0.3.0",
"bullmq": "^6.4.0",
"fastify": "^5.12.7",
@@ -46,12 +47,13 @@
"sequelize": "^6.37.8"
},
"devDependencies": {
- "@mgpl-diagnostics/client": "^1.2.0",
+ "@mgpl-diagnostics/client": "^1.4.0",
"@types/node": "^22.20.5",
"embedded-postgres": "^18.4.0-beta.17",
"ioredis-mock": "^8.13.1",
"mongodb-memory-server": "^11.3.0",
"tsup": "^9.0.0",
+ "pdf-lib": "^1.17.1",
"tsx": "^4.23.15",
"typescript": "^5.9.3"
},
diff --git a/schema.graphql b/schema.graphql
index 157adc3..f7f043f 100644
--- a/schema.graphql
+++ b/schema.graphql
@@ -628,6 +628,215 @@ type DocumentSample {
label: String!
}
+enum ExternalLab {
+ clinova
+ hypatho
+}
+
+type ExternalParameter {
+ flag: ResultFlag
+ group: String
+ method: String
+ name: String!
+
+ """
+ As printed (the partner's range for the partner's method; never recomputed).
+ """
+ range: String
+ unit: String
+
+ """As printed."""
+ value: String!
+}
+
+type ExternalPatient {
+ ageText: String
+ ageYears: Int
+ name: String
+ patientId: String
+ sex: Sex
+ title: String
+}
+
+"""
+Who performed a test and who reported it, as it will print under the test.
+"""
+type ExternalProvenanceLabel {
+ """
+ Only when the partner marked this test accredited: the partner's accreditation, not ours.
+ """
+ accreditationText: String
+ performedAt: String
+ performedBy: String!
+
+ """Medical signatories only."""
+ reportedBy: [ExternalReportedBy!]!
+
+ """The whole line."""
+ text: String!
+}
+
+type ExternalReport {
+ appliedAt: DateTime
+ booking: Booking
+ branchId: ID!
+ byteLength: Int!
+ fileName: String
+ id: ID!
+ lab: ExternalLab!
+ labName: String!
+ patient: ExternalPatient!
+
+ """
+ Differences from the booking's patient (name, sex, age ±1). Applying needs confirmPatientMismatch when there are any.
+ """
+ patientMismatches: [String!]!
+ processedAt: String
+ rejectReason: String
+ reportedAt: String
+
+ """Our reports it was applied to."""
+ reports: [Report!]!
+ sampleIds: [String!]!
+ signatories: [ExternalSignatoryOnReport!]!
+
+ """SHA-256 of the PDF."""
+ sourceSha256: String!
+ status: ExternalReportStatus!
+ tests: [ExternalTest!]!
+ uploadedAt: DateTime!
+ uploadedBy: User
+
+ """Reader warnings and booking-check differences."""
+ warnings: [String!]!
+}
+
+type ExternalReportConnection {
+ nodes: [ExternalReport!]!
+ pageInfo: PageInfo!
+}
+
+enum ExternalReportStatus {
+ applied
+ extracted
+ matched
+ rejected
+}
+
+type ExternalReportedBy {
+ id: String!
+ name: String
+ qualification: String
+ registration: String
+ role: ExternalSignatoryRole!
+ title: String
+}
+
+"""
+A partner signatory in the organisation's registry (data entered by staff, never code).
+"""
+type ExternalSignatory {
+ createdAt: DateTime!
+ id: ID!
+ lab: ExternalLab!
+ name: String!
+ qualification: String
+ registration: String
+ role: ExternalSignatoryRole
+
+ """How many signature images are tied to this person."""
+ signatureCount: Int!
+ title: String
+}
+
+input ExternalSignatoryInput {
+ lab: ExternalLab!
+ name: String!
+ qualification: String
+ registration: String
+ role: ExternalSignatoryRole
+
+ """ExternalSignatoryOnReport.sha"""
+ sha: String!
+ title: String
+}
+
+type ExternalSignatoryOnReport {
+ id: String!
+
+ """The signature image from this PDF."""
+ image: SignatureImage
+
+ """Only medical signatories appear in Reported by."""
+ medical: Boolean!
+
+ """Null until someone names this signature (nameExternalSignature)."""
+ name: String
+ pages: [Int!]!
+ qualification: String
+ registration: String
+ role: ExternalSignatoryRole!
+
+ """SHA-256 of the signature image (pass it to nameExternalSignature)."""
+ sha: String!
+ source: ExternalSignatorySource!
+ title: String
+}
+
+enum ExternalSignatoryRole {
+ biochemist
+ consultant
+ manager
+ microbiologist
+ pathologist
+ unknown
+}
+
+"""
+How a signatory on a partner PDF was named: printed beside the signature, from our registry, or not at all.
+"""
+enum ExternalSignatorySource {
+ registry
+ text
+ unknown
+}
+
+type ExternalTest {
+ """True only when the partner marked this test as accredited."""
+ accredited: Boolean!
+ department: String
+
+ """Position in the partner report (use it in ExternalTestMappingInput)."""
+ index: Int!
+ method: String
+ name: String!
+ notes: String
+ parameters: [ExternalParameter!]!
+ provenance: ExternalProvenanceLabel!
+ reported: String
+ sampleId: String
+ sampleType: String
+
+ """Ids of the signatories who signed this test's pages."""
+ signedBy: [String!]!
+
+ """
+ Our catalog test suggested for it (top search hit), for staff to confirm.
+ """
+ suggestedTestCode: String
+}
+
+input ExternalTestMappingInput {
+ """false skips the test."""
+ include: Boolean = true
+
+ """One of the booking's test codes."""
+ testCode: String!
+
+ """ExternalTest.index"""
+ testIndex: Int!
+}
+
type FinanceConfig {
b2clThresholdPaise: Int!
bankMaxFeeBp: Int!
@@ -956,6 +1165,11 @@ type Mutation {
addAgreement(input: AgreementInput!): PartnerAgreement!
addTaxRule(input: TaxRuleInput!): TaxRule!
amendReport(id: ID!): Report!
+
+ """
+ Writes the mapped tests into our reports as entered results with provenance. Staff verify as usual.
+ """
+ applyExternalReport(confirmPatientMismatch: Boolean = false, id: ID!, mappings: [ExternalTestMappingInput!]!): [Report!]!
cancelBooking(id: ID!): Booking!
cancelInvoice(id: ID!, reason: String!): Invoice!
cancelSettlement(id: ID!): Settlement!
@@ -978,6 +1192,12 @@ type Mutation {
disableUser(userId: ID!): User!
enableUser(userId: ID!): User!
importBankStatement(input: BankImportInput!): BankImportResult!
+
+ """
+ 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.
+ """
+ importExternalReport(bookingId: ID, branchId: ID, fileName: String, pdfBase64: String!): ExternalReport!
issueCreditNote(input: CreditNoteInput!): CreditNoteResult!
issueCustomerInvoice(input: CustomerInvoiceInput!): Invoice!
issueInvoice(input: IssueInvoiceInput!): Invoice!
@@ -991,6 +1211,14 @@ type Mutation {
"""Ends every other session of the signed-in user; returns how many."""
logoutOtherSessions: Int!
matchBankLine(lineId: ID!, voucherIds: [ID!]!): BankLine!
+
+ """Ties an import to a booking; patient differences become warnings."""
+ matchExternalReport(bookingId: ID!, id: ID!): ExternalReport!
+
+ """
+ Names a partner signature; the organisation's unapplied imports that carry it are read again.
+ """
+ nameExternalSignature(input: ExternalSignatoryInput!): ExternalSignatory!
paySettlement(input: PaySettlementInput!): Settlement!
"""Does nothing; lets clients check that mutations reach the server."""
@@ -1009,6 +1237,7 @@ type Mutation {
refreshSession(device: DeviceInput, refreshToken: String!): LoginResult!
refundPayment(invoiceId: ID!, payment: PaymentInput!): Invoice!
registerPatient(input: RegisterPatientInput!): Patient!
+ rejectExternalReport(id: ID!, reason: String!): ExternalReport!
rejectSample(id: ID!, reason: String!): Sample!
releaseReport(id: ID!): Report!
@@ -1109,6 +1338,11 @@ type OutstandingRow {
invoice: Invoice!
}
+type PageInfo {
+ endCursor: String
+ hasNextPage: Boolean!
+}
+
enum ParameterType {
image
numeric
@@ -1344,6 +1578,11 @@ type Query {
exportProfitAndLoss(bom: Boolean = false, branchId: ID, from: DateTime!, to: DateTime!): CsvFile!
exportSettlement(bom: Boolean = false, settlementId: ID!): CsvFile!
exportTrialBalance(asOf: DateTime, bom: Boolean = false, branchId: ID): CsvFile!
+ externalReport(id: ID!): ExternalReport
+ externalReports(after: String, bookingId: ID, branchId: ID, first: Int = 50, status: ExternalReportStatus): ExternalReportConnection!
+
+ """The organisation's partner signatory registry."""
+ externalSignatories(lab: ExternalLab): [ExternalSignatory!]!
financeConfig: FinanceConfig!
gstSummary(branchId: ID, from: DateTime!, to: DateTime!): GstSummary!
gstr1(from: DateTime!, gstin: String, to: DateTime!): Gstr1!
@@ -1471,6 +1710,9 @@ type Report {
booking: Booking!
id: ID!
patient: Patient!
+
+ """Set when the results came from a partner lab's report."""
+ provenance: ReportProvenance
releasedAt: DateTime
results: [ResultValue!]!
status: ReportStatus!
@@ -1492,6 +1734,25 @@ type ReportProfile {
tests: [String!]!
}
+"""
+Where one of our reports came from, when its results were imported from a partner PDF.
+"""
+type ReportProvenance {
+ accreditationText: String
+ appliedAt: DateTime!
+ externalReportId: ID!
+
+ """The results as imported (for comparison when staff edit them)."""
+ importedResults: [ResultValue!]!
+ lab: ExternalLab!
+ performedAt: String
+ performedBy: String!
+ reportedBy: [ExternalReportedBy!]!
+ sourceReportedAt: String
+ sourceSampleId: String
+ sourceTestName: String!
+}
+
enum ReportStatus {
amended
draft
@@ -1698,6 +1959,16 @@ input ShareRateInput {
sharePaise: Int!
}
+"""
+A signature image from a partner PDF, PNG base64. Only ever served with that PDF's own results.
+"""
+type SignatureImage {
+ base64: String!
+ height: Int!
+ mimeType: String!
+ width: Int!
+}
+
type StatementRow {
balancePaise: Int!
creditPaise: Int!
diff --git a/src/config/env.ts b/src/config/env.ts
index 1dddd62..05ff139 100644
--- a/src/config/env.ts
+++ b/src/config/env.ts
@@ -48,6 +48,8 @@ export interface Config {
jwt: { secrets: string[]; issuer: string; accessTtlMs: number; generated: boolean };
/** A refresh from another device: "enforce" ends the session (default), "flag" only audits, "off" ignores. */
deviceBinding: "enforce" | "flag" | "off";
+ /** Partner-lab PDF imports: largest file accepted, largest extracted in the request (larger go to the worker), how long a request waits for the worker. */
+ imports: { maxBytes: number; inlineMaxBytes: number; waitMs: number };
/** Background jobs (BullMQ on REDIS_URL): key prefix and how many jobs a worker runs at once. */
jobs: { prefix: string; concurrency: number; keepResultsMs: number };
/**
@@ -154,6 +156,7 @@ export function loadConfig(env: Record = process.env
generated: !secrets.length,
},
deviceBinding,
+ imports: { maxBytes: int(env.IMPORT_MAX_BYTES, 15 * 1024 * 1024), inlineMaxBytes: int(env.IMPORT_INLINE_MAX_BYTES, 5 * 1024 * 1024), waitMs: int(env.IMPORT_WAIT_MS, 60_000) },
jobs: { prefix: env.QUEUE_PREFIX || "mgpl", concurrency: Math.max(1, int(env.WORKER_CONCURRENCY, 4)), keepResultsMs: int(env.JOB_RESULT_TTL_MS, 3600_000) },
smtp: smtpUrl ? { url: smtpUrl, from: env.MAIL_FROM || "MGPL Diagnostics " } : null,
mailWebhookUrl: env.MAIL_WEBHOOK_URL || null,
diff --git a/src/container.ts b/src/container.ts
index bf13e26..53704d0 100644
--- a/src/container.ts
+++ b/src/container.ts
@@ -83,7 +83,7 @@ export async function createContainer(o: ContainerOptions): Promise {
(config.redisUrl
? createBullJobQueue({ redisUrl: config.redisUrl, prefix: config.jobs.prefix, keepResultsMs: config.jobs.keepResultsMs })
: createInlineJobQueue(() => jobDeps!, { keepResultsMs: config.jobs.keepResultsMs }));
- const services = createServices({ repos: db.repos, auth, mailer, resetUrl: config.resetUrl, lab, samples: config.samples, jobs: () => jobs });
+ const services = createServices({ repos: db.repos, auth, mailer, resetUrl: config.resetUrl, lab, samples: config.samples, jobs: () => jobs, imports: config.imports });
jobDeps = { repos: db.repos, auth, services };
return {
config,
diff --git a/src/controllers/imports.controller.ts b/src/controllers/imports.controller.ts
new file mode 100644
index 0000000..633c2cb
--- /dev/null
+++ b/src/controllers/imports.controller.ts
@@ -0,0 +1,142 @@
+import type { Booking, ExternalLabId, ExternalReport, ExternalReportStatus, ExternalSignatory, Report, ReportProvenance, ResultValue } from "@mgpl-diagnostics/db-kit";
+import { provenanceText } from "@mgpl-diagnostics/report-kit";
+import { labelFor, type ExtractedReport, type TestSection } from "@mgpl-diagnostics/report-reader";
+import type { RequestContext as Ctx } from "../graphql/context";
+import { badInput, withErrorCodes } from "../graphql/errors";
+import { actorOf, authorize, branchScope, canSee, cap } from "../graphql/guards";
+import { placeOf, printedProvenance, suggestTestCode, type SignatoryInput, type TestMapping } from "../services/import.service";
+import { flagOut } from "./lab.controller";
+
+const extractedOf = (e: ExternalReport) => e.extracted as unknown as ExtractedReport;
+const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
+
+/** Offset cursors ("o:", base64url). */
+const cursor = (n: number) => Buffer.from(`o:${n}`).toString("base64url");
+function offsetOf(after?: string | null): number {
+ if (!after) return 0;
+ const m = /^o:(\d{1,7})$/.exec(Buffer.from(after, "base64url").toString());
+ if (!m) throw badInput("after is not a cursor from this list");
+ return Number(m[1]);
+}
+
+async function importFor(ctx: Ctx, id: string, permission = "report:import") {
+ const ext = await ctx.services.imports.get(actorOf(ctx), id);
+ authorize(ctx, permission, ext.branchId);
+ return ext;
+}
+
+async function bookingFor(ctx: Ctx, id: string, permission: string): Promise {
+ const b = await ctx.services.bookings.get(actorOf(ctx), id);
+ authorize(ctx, permission, b.branchId);
+ return b;
+}
+
+/** The printed provenance line for one partner test (the same words report-kit prints). */
+function labelOf(report: ExtractedReport, test: TestSection) {
+ const l = labelFor(report, test);
+ const accreditationText = l.accreditation ? `NABL accredited laboratory${l.accreditation.certificate ? ` (Certificate No. ${l.accreditation.certificate})` : ""}` : null;
+ return {
+ performedBy: l.performedBy,
+ performedAt: placeOf(l.performedBy, l.performedAt),
+ accreditationText,
+ reportedBy: l.reportedBy,
+ text: provenanceText({ performedBy: l.performedBy, performedAt: placeOf(l.performedBy, l.performedAt) ?? undefined, ...(accreditationText ? { accreditationText } : {}), reportedBy: l.reportedBy.filter((r) => r.name).map((r) => ({ name: r.name! })) }),
+ };
+}
+
+const resultsOut = (rs: ResultValue[]) => rs.map((x) => ({ ...x, flag: flagOut(x.flag) }));
+
+/** Partner-lab imports (report-reader → db-kit), permissions report:import and signatory:read|manage per branch. */
+export const importsController = withErrorCodes({
+ Query: {
+ externalReport: (_: unknown, a: { id: string }, ctx: Ctx) => importFor(ctx, a.id, "report:import"),
+ externalReports: async (_: unknown, a: { branchId?: string | null; bookingId?: string | null; status?: ExternalReportStatus | null; first?: number | null; after?: string | null }, ctx: Ctx) => {
+ const scope = branchScope(ctx, "report:import", a.branchId);
+ if (a.bookingId) await bookingFor(ctx, a.bookingId, "report:import");
+ const limit = cap(a.first, 50);
+ const offset = offsetOf(a.after);
+ const rows = await ctx.services.imports.list(actorOf(ctx), { scope, bookingId: a.bookingId, status: a.status, limit, offset });
+ const nodes = rows.slice(0, limit);
+ const hasNextPage = rows.length > limit;
+ return { nodes, pageInfo: { hasNextPage, endCursor: hasNextPage ? cursor(offset + limit) : null } };
+ },
+ externalSignatories: (_: unknown, a: { lab?: ExternalLabId | null }, ctx: Ctx) => ctx.services.imports.signatories(authorize(ctx, "signatory:read"), a.lab),
+ },
+
+ Mutation: {
+ importExternalReport: async (_: unknown, a: { pdfBase64: string; fileName?: string | null; bookingId?: string | null; branchId?: string | null }, ctx: Ctx) => {
+ const actor = actorOf(ctx);
+ const b64 = a.pdfBase64.replace(/\s+/g, "");
+ const bytes = Math.floor((b64.length * 3) / 4) - (b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0);
+ ctx.services.imports.assertSize(bytes);
+ if (!BASE64.test(b64)) throw badInput("pdfBase64 is not base64");
+ const booking = a.bookingId ? await ctx.services.bookings.get(actor, a.bookingId) : null;
+ const branchId = booking?.branchId ?? a.branchId;
+ if (!branchId) throw badInput("branchId is required when there is no bookingId");
+ if (booking && a.branchId && a.branchId !== booking.branchId) throw badInput("The booking belongs to another branch");
+ authorize(ctx, "report:import", branchId);
+ const pdf = new Uint8Array(Buffer.from(b64, "base64"));
+ // the same PDF imported before: return it only to someone allowed to see it there
+ const before = await ctx.services.imports.existing(actor, pdf);
+ if (before) return (authorize(ctx, "report:import", before.branchId), before);
+ return ctx.services.imports.import(actor, { pdf, fileName: a.fileName?.slice(0, 200) ?? null, branchId, bookingId: booking?.id ?? null });
+ },
+ matchExternalReport: async (_: unknown, a: { id: string; bookingId: string }, ctx: Ctx) => {
+ const ext = await importFor(ctx, a.id);
+ const booking = await bookingFor(ctx, a.bookingId, "report:import");
+ return ctx.services.imports.match(actorOf(ctx), ext, booking);
+ },
+ applyExternalReport: async (_: unknown, a: { id: string; mappings: TestMapping[]; confirmPatientMismatch?: boolean | null }, ctx: Ctx) => {
+ const ext = await importFor(ctx, a.id);
+ if (!ext.bookingId) throw badInput("Match the partner report to a booking first (matchExternalReport)");
+ // applying enters results: the same permission as typing them in
+ const booking = await bookingFor(ctx, ext.bookingId, "report:enter");
+ authorize(ctx, "report:import", booking.branchId);
+ return ctx.services.imports.apply(actorOf(ctx), ext, booking, a.mappings, a.confirmPatientMismatch === true);
+ },
+ rejectExternalReport: async (_: unknown, a: { id: string; reason: string }, ctx: Ctx) => ctx.services.imports.reject(actorOf(ctx), await importFor(ctx, a.id, "report:import"), a.reason),
+ nameExternalSignature: (_: unknown, { input }: { input: SignatoryInput }, ctx: Ctx) => ctx.services.imports.nameSignature(authorize(ctx, "signatory:manage"), input),
+ },
+
+ ExternalReport: {
+ booking: (e: ExternalReport, _: unknown, ctx: Ctx) => ctx.loaders.booking.load(e.bookingId),
+ patientMismatches: (e: ExternalReport, _: unknown, ctx: Ctx) => ctx.services.imports.mismatches(e),
+ patient: (e: ExternalReport) => extractedOf(e).patient ?? {},
+ sampleIds: (e: ExternalReport) => extractedOf(e).sample?.sampleIds ?? [],
+ reportedAt: (e: ExternalReport) => extractedOf(e).dates?.reported ?? null,
+ tests: (e: ExternalReport) => {
+ const r = extractedOf(e);
+ return r.tests.map((t, index) => ({
+ ...t,
+ index,
+ accredited: t.accredited === true,
+ parameters: t.parameters.map((p) => ({ ...p, flag: flagOut(p.flag) })),
+ suggestedTestCode: suggestTestCode(t.name),
+ provenance: labelOf(r, t),
+ }));
+ },
+ signatories: (e: ExternalReport) => extractedOf(e).signatories.map((s) => ({ ...s, sha: s.signature.sha, _ext: e })),
+ reports: (e: ExternalReport, _: unknown, ctx: Ctx) => (canSee(ctx, "report:read", e.branchId) ? ctx.services.imports.reportsOf(e) : []),
+ uploadedBy: (e: ExternalReport, _: unknown, ctx: Ctx) => ctx.loaders.user.load(e.uploadedBy),
+ },
+ ExternalSignatoryOnReport: {
+ // rule: a partner signature is only served with the results of its own PDF (orgId + that PDF's sha256 + image sha)
+ image: async (s: { sha: string; _ext: ExternalReport }, _: unknown, ctx: Ctx) => {
+ const img = await ctx.services.imports.signatureImage(s._ext.orgId, s._ext.sourceSha256, s.sha);
+ return img ? { mimeType: "image/png", width: img.width, height: img.height, base64: Buffer.from(img.png).toString("base64") } : null;
+ },
+ },
+ ExternalSignatory: {
+ signatureCount: (s: ExternalSignatory) => s.sha.length,
+ },
+ ReportProvenance: {
+ accreditationText: (p: ReportProvenance) => printedProvenance(p).accreditationText ?? null,
+ reportedBy: (p: ReportProvenance) => p.reportedBy.map((r) => ({ ...r, id: r.signatoryId })),
+ importedResults: (p: ReportProvenance) => resultsOut(p.importedResults),
+ appliedAt: (p: ReportProvenance) => p.createdAt,
+ },
+ Report: {
+ provenance: (r: Report, _: unknown, ctx: Ctx) => (canSee(ctx, "report:read", r.branchId) ? ctx.loaders.provenance.load(r.id) : null),
+ },
+});
+
diff --git a/src/graphql/errors.ts b/src/graphql/errors.ts
index c80354a..98b1c5d 100644
--- a/src/graphql/errors.ts
+++ b/src/graphql/errors.ts
@@ -3,6 +3,7 @@ import { ConflictError, NotFoundError, PeriodLockedError, ValidationError } from
import { CreditLimitError } from "@mgpl-diagnostics/finance-kit";
import { HttpError } from "@mgpl-diagnostics/report-kit";
import { GraphQLError } from "graphql";
+import { BookingPatientMismatchError, FileTooLargeError, ReportAlreadyAppliedError, UnsupportedReportError } from "../services/import.service";
import { ForbiddenError } from "../services/util";
/**
@@ -14,6 +15,8 @@ export const ERROR_CODES = [
"PERIOD_LOCKED", "CREDIT_LIMIT_EXCEEDED",
// auth-kit's own codes, passed through
"INVALID_CREDENTIALS", "ACCOUNT_DISABLED", "INVITE_PENDING", "ORG_REQUIRED", "RATE_LIMITED", "WEAK_PASSWORD", "INVALID_TOKEN",
+ // partner-lab imports (docs/18-imports.md)
+ "UNSUPPORTED_REPORT", "REPORT_ALREADY_APPLIED", "BOOKING_PATIENT_MISMATCH", "FILE_TOO_LARGE",
] as const;
export type ErrorCode = (typeof ERROR_CODES)[number];
@@ -32,6 +35,10 @@ export function toGraphQLError(e: unknown): unknown {
if (e instanceof GraphQLError) return e;
if (e instanceof AuthError) return apiError(e.code as ErrorCode, e.message, e.statusCode, e.details.retryAfterMs ? { retryAfterMs: e.details.retryAfterMs } : {});
if (e instanceof ForbiddenError) return apiError("FORBIDDEN", e.message, 403);
+ if (e instanceof UnsupportedReportError) return apiError("UNSUPPORTED_REPORT", e.message, 422);
+ if (e instanceof FileTooLargeError) return apiError("FILE_TOO_LARGE", e.message, 413, { maxBytes: e.maxBytes });
+ if (e instanceof ReportAlreadyAppliedError) return apiError("REPORT_ALREADY_APPLIED", e.message, 409);
+ if (e instanceof BookingPatientMismatchError) return apiError("BOOKING_PATIENT_MISMATCH", e.message, 409, { mismatches: e.mismatches });
if (e instanceof PeriodLockedError) return apiError("PERIOD_LOCKED", e.message, 409);
if (e instanceof CreditLimitError) return apiError("CREDIT_LIMIT_EXCEEDED", e.message, 409);
if (e instanceof NotFoundError) return apiError("NOT_FOUND", e.message, 404);
diff --git a/src/graphql/limits.ts b/src/graphql/limits.ts
index 2eb877a..b48beb4 100644
--- a/src/graphql/limits.ts
+++ b/src/graphql/limits.ts
@@ -1,4 +1,4 @@
-import { GraphQLError, Kind, type ASTVisitor, type FragmentDefinitionNode, type SelectionSetNode, type ValidationContext } from "graphql";
+import { GraphQLError, Kind, parse, type ASTVisitor, type FragmentDefinitionNode, type SelectionSetNode, type ValidationContext } from "graphql";
/** Rejects queries nested deeper than `max` levels (a cheap guard against abusive queries). */
export function depthLimit(max: number) {
@@ -28,7 +28,7 @@ export function depthLimit(max: number) {
}
/** Root fields that render a PDF / HTML document or build a CSV: CPU-heavy, so few per operation. */
-const DOCUMENT_FIELD = /^(bookingReportDocument|invoiceDocument|renderReport|renderInvoice|letterhead|export[A-Z]\w*)$/;
+const DOCUMENT_FIELD = /^(bookingReportDocument|invoiceDocument|renderReport|renderInvoice|letterhead|export[A-Z]\w*|importExternalReport)$/;
/**
* Rejects operations that select more than `maxFields` fields in all (fragments counted where they are
@@ -72,3 +72,29 @@ export function costLimit(maxFields: number, maxDocuments: number, maxRootFields
};
};
}
+
+/** Root fields that may send a body larger than BODY_LIMIT_BYTES (up to importBodyLimit). */
+const LARGE_BODY_FIELDS = new Set(["importExternalReport"]);
+
+/** Body size that fits a base64 PDF of `maxBytes` plus the rest of the request. */
+export const importBodyLimit = (maxBytes: number) => Math.ceil(maxBytes / 3) * 4 + 64 * 1024;
+
+/**
+ * True when a request body holds one operation whose only root field may be large (importExternalReport).
+ * Other operations keep BODY_LIMIT_BYTES, so the larger limit cannot be used to send oversized queries.
+ */
+export function allowsLargeBody(body: unknown): boolean {
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
+ const query = (body as { query?: unknown }).query;
+ if (typeof query !== "string" || query.length > 10_000) return false;
+ let doc;
+ try {
+ doc = parse(query, { noLocation: true, maxTokens: 2_000 });
+ } catch {
+ return false;
+ }
+ if (doc.definitions.length !== 1) return false;
+ const op = doc.definitions[0];
+ if (op?.kind !== Kind.OPERATION_DEFINITION || op.operation !== "mutation") return false;
+ return op.selectionSet.selections.length === 1 && op.selectionSet.selections.every((s) => s.kind === Kind.FIELD && LARGE_BODY_FIELDS.has(s.name.value));
+}
diff --git a/src/graphql/loaders.ts b/src/graphql/loaders.ts
index c18ccd3..30ff2b8 100644
--- a/src/graphql/loaders.ts
+++ b/src/graphql/loaders.ts
@@ -1,4 +1,4 @@
-import type { AccountGroup, Booking, Branch, Customer, Doctor, Invoice, Ledger, Partner, Patient, Repositories, User } from "@mgpl-diagnostics/db-kit";
+import type { AccountGroup, Booking, Branch, Customer, Doctor, Invoice, Ledger, Partner, Patient, ReportProvenance, Repositories, User } from "@mgpl-diagnostics/db-kit";
/**
* Minimal DataLoader: every load() in the same tick is batched into one getMany(), and results are
@@ -48,6 +48,21 @@ export class BatchLoader {
}
}
+/**
+ * Memoised lookup by a key that is not the primary id (one query per distinct key, all in the same tick),
+ * for repositories that only filter by equality.
+ */
+export class KeyLoader {
+ private cache = new Map>();
+ constructor(private readonly fetchOne: (key: string) => Promise) {}
+ load(key: string | null | undefined): Promise {
+ if (!key) return Promise.resolve(null);
+ let p = this.cache.get(key);
+ if (!p) this.cache.set(key, (p = this.fetchOne(key)));
+ return p;
+ }
+}
+
/** One set per request (created lazily by the context). */
export function createLoaders(r: Repositories) {
return {
@@ -61,6 +76,8 @@ export function createLoaders(r: Repositories) {
customer: new BatchLoader((ids) => r.customers.getMany(ids)),
partner: new BatchLoader((ids) => r.partners.getMany(ids)),
user: new BatchLoader((ids) => r.users.getMany(ids)),
+ /** A report's partner-lab provenance, by report id. */
+ provenance: new KeyLoader((reportId) => r.reportProvenance.findOne({ reportId } as never)),
};
}
diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts
index cac6fb7..4608a1e 100644
--- a/src/graphql/schema.ts
+++ b/src/graphql/schema.ts
@@ -7,6 +7,7 @@ import { booksController } from "../controllers/books.controller";
import { catalogController } from "../controllers/catalog.controller";
import { documentsController } from "../controllers/documents.controller";
import { financeController } from "../controllers/finance.controller";
+import { importsController } from "../controllers/imports.controller";
import { labController } from "../controllers/lab.controller";
import { organizationController } from "../controllers/organization.controller";
import { authTypeDefs } from "../schema/auth.graphql";
@@ -16,6 +17,7 @@ import { booksTypeDefs } from "../schema/books.graphql";
import { catalogTypeDefs } from "../schema/catalog.graphql";
import { documentsTypeDefs } from "../schema/documents.graphql";
import { financeTypeDefs } from "../schema/finance.graphql";
+import { importsTypeDefs } from "../schema/imports.graphql";
import { labTypeDefs } from "../schema/lab.graphql";
import { organizationTypeDefs } from "../schema/organization.graphql";
@@ -36,6 +38,7 @@ export const MODULES: GraphQLModule[] = [
{ name: "books", typeDefs: booksTypeDefs, resolvers: booksController },
{ name: "finance", typeDefs: financeTypeDefs, resolvers: financeController },
{ name: "documents", typeDefs: documentsTypeDefs, resolvers: documentsController },
+ { name: "imports", typeDefs: importsTypeDefs, resolvers: importsController },
];
/** The whole API as one executable schema. */
diff --git a/src/jobs/bullmq.ts b/src/jobs/bullmq.ts
index 8e0b776..4f2d5a6 100644
--- a/src/jobs/bullmq.ts
+++ b/src/jobs/bullmq.ts
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
-import { Job, Queue, UnrecoverableError, Worker, type JobsOptions } from "bullmq";
+import { Job, Queue, QueueEvents, UnrecoverableError, Worker, type JobsOptions } from "bullmq";
import { Redis } from "ioredis";
import type { RenderedDocument } from "../services/document.service";
-import { processDocument, processPasswordReset, type JobDeps } from "./processors";
-import { JobFailure, QUEUES, type DocumentJob, type JobInfo, type JobQueue, type JobState, type PasswordResetJob } from "./types";
+import { processDocument, processImport, processPasswordReset, processReextract, type JobDeps } from "./processors";
+import { JobFailure, QUEUES, type DocumentJob, type ImportJob, type JobInfo, type JobQueue, type JobState, type PasswordResetJob, type ReextractJob } from "./types";
export interface BullOptions {
redisUrl: string;
@@ -45,6 +45,9 @@ export function createBullJobQueue(o: BullOptions): JobQueue {
const connection = connect(o.redisUrl);
const mail = new Queue(QUEUES.mail, { connection, prefix: o.prefix });
const documents = new Queue(QUEUES.documents, { connection, prefix: o.prefix });
+ const imports = new Queue(QUEUES.imports, { connection, prefix: o.prefix });
+ // completion events for imports the API waits on (opened on first use: it holds its own connection)
+ let events: QueueEvents | undefined;
return {
mode: "bullmq",
async passwordReset(job) {
@@ -57,8 +60,26 @@ export function createBullJobQueue(o: BullOptions): JobQueue {
const job = await Job.fromId(documents, id);
return job ? toInfo(job) : null;
},
+ async importReport(job, waitMs) {
+ if (!events) {
+ events = new QueueEvents(QUEUES.imports, { connection: connect(o.redisUrl), prefix: o.prefix });
+ await events.waitUntilReady();
+ }
+ // the result holds only an id; the job is removed once read
+ const added = await imports.add("extract", job, { ...jobOptions(o, 2), removeOnComplete: true });
+ try {
+ return (await added.waitUntilFinished(events, waitMs)) as { externalReportId: string };
+ } catch (e) {
+ const msg = (e as Error).message ?? "";
+ if (/timed out/i.test(msg)) throw new JobFailure("Reading the report is taking longer than expected; it will appear in externalReports when done", false);
+ throw new JobFailure(msg.replace(/^Error:\s*/, "") || "Job failed", false);
+ }
+ },
+ async reextract(job) {
+ await imports.add("reextract", job, { ...jobOptions(o, 3), removeOnComplete: true });
+ },
async close() {
- await Promise.all([mail.close(), documents.close()]);
+ await Promise.all([mail.close(), documents.close(), imports.close(), events?.close()]);
await connection.quit();
},
};
@@ -76,6 +97,11 @@ export function startWorkers(deps: JobDeps, o: BullOptions & { concurrency: numb
const workers = [
new Worker(QUEUES.mail, (job) => run(() => processPasswordReset(deps, job.data)), { connection, prefix: o.prefix, concurrency: o.concurrency }),
new Worker(QUEUES.documents, (job) => run(() => processDocument(deps, job.data)), { connection, prefix: o.prefix, concurrency: o.concurrency }),
+ new Worker(
+ QUEUES.imports,
+ (job) => run(() => (job.data.kind === "extract" ? processImport(deps, job.data) : processReextract(deps, job.data))),
+ { connection, prefix: o.prefix, concurrency: o.concurrency },
+ ),
];
for (const w of workers) w.on("failed", (job, err) => console.warn(`[worker] ${w.name} job ${job?.id} failed (attempt ${job?.attemptsMade}): ${err.message}`));
return {
diff --git a/src/jobs/index.ts b/src/jobs/index.ts
index 8903bdd..f3b8119 100644
--- a/src/jobs/index.ts
+++ b/src/jobs/index.ts
@@ -1,5 +1,5 @@
export { createBullJobQueue, startWorkers, type BullOptions } from "./bullmq";
export { createInlineJobQueue } from "./inline";
export { brandFromLab, mailerFromConfig, mailKitMailer, webhookMailer } from "./mailer";
-export { processDocument, processPasswordReset, type JobDeps } from "./processors";
-export { JobFailure, QUEUES, type DocumentJob, type DocumentJobKind, type JobInfo, type JobQueue, type JobState, type PasswordResetJob } from "./types";
+export { processDocument, processImport, processPasswordReset, processReextract, type JobDeps } from "./processors";
+export { JobFailure, QUEUES, type DocumentJob, type DocumentJobKind, type ImportJob, type JobInfo, type JobQueue, type JobState, type PasswordResetJob, type ReextractJob } from "./types";
diff --git a/src/jobs/inline.ts b/src/jobs/inline.ts
index 4795863..8075803 100644
--- a/src/jobs/inline.ts
+++ b/src/jobs/inline.ts
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
-import { processDocument, processPasswordReset, type JobDeps } from "./processors";
+import { processDocument, processImport, processPasswordReset, processReextract, type JobDeps } from "./processors";
import { JobFailure, type DocumentJob, type JobInfo, type JobQueue, type PasswordResetJob } from "./types";
/**
@@ -31,6 +31,12 @@ export function createInlineJobQueue(deps: () => JobDeps, o: { keepResultsMs: nu
info.finishedAt = new Date();
return info;
},
+ async importReport(job) {
+ return processImport(deps(), job);
+ },
+ async reextract(job) {
+ await processReextract(deps(), job);
+ },
async get(id: string) {
prune();
return jobs.get(id) ?? null;
diff --git a/src/jobs/processors.ts b/src/jobs/processors.ts
index 18301db..a8818b3 100644
--- a/src/jobs/processors.ts
+++ b/src/jobs/processors.ts
@@ -2,7 +2,8 @@ import { AuthError, type Auth } from "@mgpl-diagnostics/auth-kit";
import { ConflictError, NotFoundError, ValidationError, type Repositories } from "@mgpl-diagnostics/db-kit";
import type { Services } from "../services";
import type { RenderedDocument } from "../services/document.service";
-import { JobFailure, type DocumentJob, type PasswordResetJob } from "./types";
+import { JobFailure, type DocumentJob, type ImportJob, type PasswordResetJob, type ReextractJob } from "./types";
+import { UnsupportedReportError } from "@mgpl-diagnostics/report-reader";
/** What a job needs: the same services the API uses. */
export interface JobDeps {
@@ -40,3 +41,31 @@ export async function processDocument(deps: JobDeps, job: DocumentJob): Promise<
throw new JobFailure("Rendering failed", true);
}
}
+
+async function activeActor(deps: JobDeps, actor: { userId: string; orgId: string }) {
+ const user = await deps.repos.users.get(actor.userId);
+ if (!user || user.status !== "active" || user.orgId !== actor.orgId) throw new JobFailure("The user who started this job is no longer active", false);
+}
+
+/** Marks a worker failure that the API turns back into UNSUPPORTED_REPORT. */
+export const UNSUPPORTED_PREFIX = "UNSUPPORTED_REPORT: ";
+
+/** imports · extract: a large partner PDF, read off the API's request path. Never logs the file or its contents. */
+export async function processImport(deps: JobDeps, job: ImportJob): Promise<{ externalReportId: string }> {
+ await activeActor(deps, job.actor);
+ try {
+ const ext = await deps.services.imports.extractStored(job.actor, job);
+ return { externalReportId: ext.id };
+ } catch (e) {
+ if (e instanceof UnsupportedReportError) throw new JobFailure(`${UNSUPPORTED_PREFIX}${e.message}`, false);
+ if (e instanceof NotFoundError || e instanceof ConflictError || e instanceof ValidationError) throw new JobFailure(e.message, false);
+ console.error("[jobs] import job failed", (e as Error)?.name ?? "Error");
+ throw new JobFailure("Reading the report failed", true);
+ }
+}
+
+/** imports · reextract: names a newly registered signature in the organisation's unapplied imports. */
+export async function processReextract(deps: JobDeps, job: ReextractJob): Promise<{ updated: number }> {
+ await activeActor(deps, job.actor);
+ return { updated: await deps.services.imports.reextractSignature(job.actor, job.sha) };
+}
diff --git a/src/jobs/types.ts b/src/jobs/types.ts
index 698bb0d..ebffa5e 100644
--- a/src/jobs/types.ts
+++ b/src/jobs/types.ts
@@ -3,7 +3,7 @@ import type { Actor } from "../graphql/context";
import type { RenderedDocument, RenderOptions } from "../services/document.service";
/** Queue names (Redis keys are `::…`). */
-export const QUEUES = { mail: "mail", documents: "documents" } as const;
+export const QUEUES = { mail: "mail", documents: "documents", imports: "imports" } as const;
/** mail queue: the worker creates the reset token itself, so no raw token ever sits in Redis. */
export interface PasswordResetJob {
@@ -24,6 +24,27 @@ export interface DocumentJob {
options: RenderOptions;
}
+/**
+ * imports queue · extract: reads a stored partner PDF (report-reader) and creates its external report.
+ * Carries the stored file's id and the actor, never the file or a token.
+ */
+export interface ImportJob {
+ kind: "extract";
+ actor: Actor;
+ fileId: Id;
+ branchId: Id;
+ bookingId?: Id | null;
+ fileName?: string | null;
+}
+
+/** imports queue · reextract: after a partner signature is named, re-reads that organisation's unapplied imports that carry it. */
+export interface ReextractJob {
+ kind: "reextract";
+ actor: Actor;
+ /** the signature's pixel hash */
+ sha: string;
+}
+
export type JobState = "waiting" | "active" | "completed" | "failed" | "delayed";
/** A document job as the API shows it. Only the user who started it can read it. */
@@ -48,6 +69,10 @@ export interface JobQueue {
passwordReset(job: PasswordResetJob): Promise;
document(job: DocumentJob): Promise;
get(id: string): Promise;
+ /** Extracts a large partner PDF in the worker and waits for it (up to IMPORT_WAIT_MS); resolves with the external report's id. */
+ importReport(job: ImportJob, waitMs: number): Promise<{ externalReportId: Id }>;
+ /** Re-extraction after naming a signature, in the background (inline: at once). */
+ reextract(job: ReextractJob): Promise;
close(): Promise;
}
diff --git a/src/schema/imports.graphql.ts b/src/schema/imports.graphql.ts
new file mode 100644
index 0000000..167d7fe
--- /dev/null
+++ b/src/schema/imports.graphql.ts
@@ -0,0 +1,195 @@
+/**
+ * Partner-lab report imports (docs/18-imports.md): a partner lab's PDF is read by report-reader, matched to
+ * one of our bookings, and applied to our reports as entered results with their provenance. Our staff
+ * verify and release them as usual; an import never does.
+ */
+export const importsTypeDefs = /* GraphQL */ `
+ enum ExternalLab { hypatho clinova }
+ enum ExternalReportStatus { extracted matched applied rejected }
+ enum ExternalSignatoryRole { pathologist biochemist microbiologist consultant manager unknown }
+ "How a signatory on a partner PDF was named: printed beside the signature, from our registry, or not at all."
+ enum ExternalSignatorySource { text registry unknown }
+
+ "A signature image from a partner PDF, PNG base64. Only ever served with that PDF's own results."
+ type SignatureImage {
+ mimeType: String!
+ width: Int!
+ height: Int!
+ base64: String!
+ }
+
+ type ExternalParameter {
+ name: String!
+ "As printed."
+ value: String!
+ unit: String
+ "As printed (the partner's range for the partner's method; never recomputed)."
+ range: String
+ method: String
+ flag: ResultFlag
+ group: String
+ }
+
+ "Who performed a test and who reported it, as it will print under the test."
+ type ExternalProvenanceLabel {
+ performedBy: String!
+ performedAt: String
+ "Only when the partner marked this test accredited: the partner's accreditation, not ours."
+ accreditationText: String
+ "Medical signatories only."
+ reportedBy: [ExternalReportedBy!]!
+ "The whole line."
+ text: String!
+ }
+
+ type ExternalReportedBy { id: String!, name: String, qualification: String, registration: String, title: String, role: ExternalSignatoryRole! }
+
+ type ExternalTest {
+ "Position in the partner report (use it in ExternalTestMappingInput)."
+ index: Int!
+ name: String!
+ department: String
+ sampleType: String
+ sampleId: String
+ reported: String
+ method: String
+ "True only when the partner marked this test as accredited."
+ accredited: Boolean!
+ parameters: [ExternalParameter!]!
+ notes: String
+ "Ids of the signatories who signed this test's pages."
+ signedBy: [String!]!
+ "Our catalog test suggested for it (top search hit), for staff to confirm."
+ suggestedTestCode: String
+ provenance: ExternalProvenanceLabel!
+ }
+
+ type ExternalSignatoryOnReport {
+ id: String!
+ "Null until someone names this signature (nameExternalSignature)."
+ name: String
+ qualification: String
+ registration: String
+ title: String
+ role: ExternalSignatoryRole!
+ "Only medical signatories appear in Reported by."
+ medical: Boolean!
+ source: ExternalSignatorySource!
+ "SHA-256 of the signature image (pass it to nameExternalSignature)."
+ sha: String!
+ pages: [Int!]!
+ "The signature image from this PDF."
+ image: SignatureImage
+ }
+
+ type ExternalPatient { name: String, title: String, ageText: String, ageYears: Int, sex: Sex, patientId: String }
+
+ type ExternalReport {
+ id: ID!
+ branchId: ID!
+ booking: Booking
+ lab: ExternalLab!
+ labName: String!
+ processedAt: String
+ status: ExternalReportStatus!
+ fileName: String
+ byteLength: Int!
+ "SHA-256 of the PDF."
+ sourceSha256: String!
+ "Reader warnings and booking-check differences."
+ warnings: [String!]!
+ "Differences from the booking's patient (name, sex, age ±1). Applying needs confirmPatientMismatch when there are any."
+ patientMismatches: [String!]!
+ patient: ExternalPatient!
+ sampleIds: [String!]!
+ reportedAt: String
+ tests: [ExternalTest!]!
+ signatories: [ExternalSignatoryOnReport!]!
+ "Our reports it was applied to."
+ reports: [Report!]!
+ uploadedBy: User
+ uploadedAt: DateTime!
+ appliedAt: DateTime
+ rejectReason: String
+ }
+
+ type PageInfo { hasNextPage: Boolean!, endCursor: String }
+ type ExternalReportConnection { nodes: [ExternalReport!]!, pageInfo: PageInfo! }
+
+ "A partner signatory in the organisation's registry (data entered by staff, never code)."
+ type ExternalSignatory {
+ id: ID!
+ lab: ExternalLab!
+ name: String!
+ qualification: String
+ registration: String
+ title: String
+ role: ExternalSignatoryRole
+ "How many signature images are tied to this person."
+ signatureCount: Int!
+ createdAt: DateTime!
+ }
+
+ "Where one of our reports came from, when its results were imported from a partner PDF."
+ type ReportProvenance {
+ externalReportId: ID!
+ lab: ExternalLab!
+ performedBy: String!
+ performedAt: String
+ accreditationText: String
+ reportedBy: [ExternalReportedBy!]!
+ sourceTestName: String!
+ sourceSampleId: String
+ sourceReportedAt: String
+ "The results as imported (for comparison when staff edit them)."
+ importedResults: [ResultValue!]!
+ appliedAt: DateTime!
+ }
+
+ extend type Report {
+ "Set when the results came from a partner lab's report."
+ provenance: ReportProvenance
+ }
+
+ input ExternalTestMappingInput {
+ "ExternalTest.index"
+ testIndex: Int!
+ "One of the booking's test codes."
+ testCode: String!
+ "false skips the test."
+ include: Boolean = true
+ }
+
+ input ExternalSignatoryInput {
+ "ExternalSignatoryOnReport.sha"
+ sha: String!
+ lab: ExternalLab!
+ name: String!
+ qualification: String
+ registration: String
+ title: String
+ role: ExternalSignatoryRole
+ }
+
+ extend type Query {
+ externalReport(id: ID!): ExternalReport
+ externalReports(branchId: ID, bookingId: ID, status: ExternalReportStatus, first: Int = 50, after: String): ExternalReportConnection!
+ "The organisation's partner signatory registry."
+ externalSignatories(lab: ExternalLab): [ExternalSignatory!]!
+ }
+
+ extend type Mutation {
+ """
+ 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.
+ """
+ importExternalReport(pdfBase64: String!, fileName: String, bookingId: ID, branchId: ID): ExternalReport!
+ "Ties an import to a booking; patient differences become warnings."
+ matchExternalReport(id: ID!, bookingId: ID!): ExternalReport!
+ "Writes the mapped tests into our reports as entered results with provenance. Staff verify as usual."
+ applyExternalReport(id: ID!, mappings: [ExternalTestMappingInput!]!, confirmPatientMismatch: Boolean = false): [Report!]!
+ rejectExternalReport(id: ID!, reason: String!): ExternalReport!
+ "Names a partner signature; the organisation's unapplied imports that carry it are read again."
+ nameExternalSignature(input: ExternalSignatoryInput!): ExternalSignatory!
+ }
+`;
diff --git a/src/server.ts b/src/server.ts
index 4574347..7796ca2 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -4,7 +4,7 @@ import { createYoga, type Plugin } from "graphql-yoga";
import { API_CSP, fastifySecurity, type RequestSecurity } from "@mgpl-diagnostics/security-kit/server";
import type { Container } from "./container";
import { createContext, type RequestContext } from "./graphql/context";
-import { costLimit, depthLimit } from "./graphql/limits";
+import { allowsLargeBody, costLimit, depthLimit, importBodyLimit } from "./graphql/limits";
import { buildSchema } from "./graphql/schema";
import { isDraining } from "./lifecycle";
@@ -42,7 +42,7 @@ function rules(introspection: boolean): Plugin {
*/
export async function buildServer(container: Container): Promise {
const { config } = container;
- const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: config.bodyLimit, trustProxy: (_addr: string, hop: number) => hop < config.trustedHops });
+ const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops });
// security-kit: security headers on every response, and request.security = { ip, userAgent, … } with the
// client IP taken from X-Forwarded-For only through the configured number of proxies (TRUST_PROXY).
@@ -75,6 +75,14 @@ export async function buildServer(container: Container): Promise {
+ // chunked bodies carry no content-length: measure what was parsed
+ const length = Number(req.headers["content-length"]) || (req.body ? Buffer.byteLength(JSON.stringify(req.body)) : 0);
+ if (length > config.bodyLimit && !allowsLargeBody(req.body)) {
+ return reply.status(413).send({ errors: [{ message: `Request body is larger than ${config.bodyLimit} bytes`, extensions: { code: "FILE_TOO_LARGE", maxBytes: config.bodyLimit } }] });
+ }
+ },
handler: async (req, reply) => {
const response = await yoga.handleNodeRequestAndResponse(req, reply, { req, reply });
response.headers.forEach((value, key) => void reply.header(key, value));
diff --git a/src/services/document.service.ts b/src/services/document.service.ts
index 602e91e..e615994 100644
--- a/src/services/document.service.ts
+++ b/src/services/document.service.ts
@@ -9,9 +9,10 @@ import {
} from "@mgpl-diagnostics/invoice-kit";
import {
buildReport, COLS_STD, defaultLab, renderLetterheadHtml, renderLetterheadPdf, renderReportHtml, renderReportPdf, reportFromRequest, SAMPLES, templateCatalog, TEMPLATES,
- type LabProfile, type Patient as PrintPatient, type Report as PrintReport, type ReportDesign, type ReportRequest, type ReportVariant, type Section, type TestRow,
+ type LabProfile, type Patient as PrintPatient, type Report as PrintReport, type ReportDesign, type ReportRequest, type ReportTestProvenance, type ReportVariant, type Section, type TestRow,
} from "@mgpl-diagnostics/report-kit";
import type { Actor } from "../graphql/context";
+import { printedProvenance } from "./import.service";
import { mustOwn } from "./util";
export type DocumentFormat = "pdf" | "html";
@@ -160,10 +161,46 @@ export class DocumentService {
const printed = printPatient(patient, reportedAt, {
refDoctor: doctor?.name ?? "Self", sampleId: booking.bookingNo, sampleCollectionDate: collected, collectedAt: collected, registeredAt: booking.createdAt, reportedAt,
});
- const report: PrintReport = { patient: printed, sections: reports.flatMap((r) => this.sections(r, printed)) };
+ const sections: Section[] = [];
+ for (const r of reports) {
+ const own = this.sections(r, printed);
+ const provenance = await this.provenanceOf(actor.orgId, r);
+ // the line prints under the report's last section (report-kit wraps and paginates it)
+ if (provenance && own.length) own[own.length - 1] = { ...own[own.length - 1]!, provenance };
+ sections.push(...own);
+ }
+ const report: PrintReport = { patient: printed, sections };
return this.renderReport(report, { ...o, fileName: o.fileName ?? `${booking.bookingNo}-${o.variant}.${o.format}` });
}
+ /**
+ * The printed provenance of a report imported from a partner PDF. Partner signatures are looked up by
+ * (organisation, the sha256 of the PDF these results came from, image sha): a stored signature is never
+ * printed with results from another PDF, and never as our own signatory.
+ */
+ private async provenanceOf(orgId: Id, r: DbReport): Promise {
+ const p = await this.repos.reportProvenance.findOne({ reportId: r.id } as never);
+ if (!p || p.orgId !== orgId) return null;
+ const ext = await this.repos.externalReports.get(p.externalReportId);
+ const signatories = ext && ext.orgId === orgId && ext.sourceSha256 === p.sourceSha256
+ ? ((ext.extracted as { signatories?: { id: string; signature?: { sha?: string } }[] }).signatories ?? [])
+ : [];
+ const reportedBy = [];
+ for (const s of p.reportedBy) {
+ if (!s.name) continue;
+ const sha = signatories.find((x) => x.id === s.signatoryId)?.signature?.sha;
+ const img = sha ? await this.repos.externalSignatures.findOne({ orgId, sourceSha256: p.sourceSha256, sha } as never) : null;
+ reportedBy.push({
+ name: s.name,
+ ...(s.qualification ? { qualification: s.qualification } : {}),
+ ...(s.registration ? { registration: s.registration } : {}),
+ ...(s.title ? { title: s.title } : {}),
+ ...(img ? { signatureSrc: `data:image/png;base64,${Buffer.from(img.png).toString("base64")}` } : {}),
+ });
+ }
+ return { ...printedProvenance(p), ...(reportedBy.length ? { reportedBy } : {}) };
+ }
+
private sections(r: DbReport, patient: PrintPatient): Section[] {
const template = (TEMPLATES as Record)[r.testCode.toLowerCase()];
const flag = (f: string | undefined): TestRow["flag"] => (f === "H" ? "high" : f === "L" ? "low" : f === "A" ? "abnormal" : f === "" ? "normal" : "auto");
diff --git a/src/services/import.service.ts b/src/services/import.service.ts
new file mode 100644
index 0000000..a8a4893
--- /dev/null
+++ b/src/services/import.service.ts
@@ -0,0 +1,453 @@
+import { createHash, randomUUID } from "node:crypto";
+import { getTestByCode, search as catalogSearch } from "@mgpl-diagnostics/catalog-kit";
+import {
+ ConflictError, NotFoundError, saveResults, ValidationError,
+ type Booking, type ExternalLabId, type ExternalReport, type ExternalSignatory, type ExternalSignatoryRole, type Id, type Patient,
+ type Report, type ReportProvenance, type Repositories, type ResultValue,
+} from "@mgpl-diagnostics/db-kit";
+import {
+ extractReport, labelFor, toPng, UnsupportedReportError,
+ type ExtractedReport, type KnownSignature, type TestSection,
+} from "@mgpl-diagnostics/report-reader";
+import type { ReportTestProvenance } from "@mgpl-diagnostics/report-kit";
+import type { Actor } from "../graphql/context";
+import { UNSUPPORTED_PREFIX } from "../jobs/processors";
+import { JobFailure, type ImportJob, type JobQueue } from "../jobs/types";
+import { mustOwn } from "./util";
+
+/** The PDF is larger than IMPORT_MAX_BYTES (FILE_TOO_LARGE). */
+export class FileTooLargeError extends Error {
+ constructor(readonly bytes: number, readonly maxBytes: number) {
+ super(`The file is ${(bytes / 1048576).toFixed(1)} MB; the limit is ${(maxBytes / 1048576).toFixed(0)} MB`);
+ this.name = "FileTooLargeError";
+ }
+}
+
+/** The external report was already applied to our reports (REPORT_ALREADY_APPLIED). */
+export class ReportAlreadyAppliedError extends ConflictError {
+ constructor() {
+ super("This partner report was already applied");
+ this.name = "ReportAlreadyAppliedError";
+ }
+}
+
+/** The booking's patient does not match the partner report's, and staff have not confirmed it (BOOKING_PATIENT_MISMATCH). */
+export class BookingPatientMismatchError extends ConflictError {
+ constructor(readonly mismatches: string[]) {
+ super(`The booking's patient does not match the partner report (${mismatches.join("; ")}). Check, then apply with confirmPatientMismatch: true`);
+ this.name = "BookingPatientMismatchError";
+ }
+}
+
+export { UnsupportedReportError };
+
+export interface ImportInput {
+ pdf: Uint8Array;
+ fileName?: string | null;
+ branchId: Id;
+ bookingId?: Id | null;
+}
+
+export interface TestMapping {
+ testIndex: number;
+ testCode: string;
+ include?: boolean | null;
+}
+
+export interface SignatoryInput {
+ sha: string;
+ lab: ExternalLabId;
+ name: string;
+ qualification?: string | null;
+ registration?: string | null;
+ title?: string | null;
+ role?: string | null;
+}
+
+export interface ImportLimits {
+ maxBytes: number;
+ inlineMaxBytes: number;
+ waitMs: number;
+}
+
+const ROLES: ExternalSignatoryRole[] = ["pathologist", "biochemist", "microbiologist", "consultant", "manager", "unknown"];
+const TITLES = /^(mr|mrs|ms|miss|master|mstr|baby|bby|b\/o|dr|smt|shri|sri|kumari|ku|kum|md|mx)\.?\s+/i;
+const MATCH_PREFIX = "Booking check: ";
+
+/** Name for comparison: lower case, no titles, letters and spaces only. */
+export function normName(name: string | null | undefined): string {
+ let n = (name ?? "").toLowerCase().replace(/[^a-z\s/]/g, " ").replace(/\s+/g, " ").trim();
+ while (TITLES.test(n)) n = n.replace(TITLES, "").trim();
+ return n.replace(/\//g, " ").replace(/\s+/g, " ").trim();
+}
+
+function ageYears(p: Patient, at: Date): number | null {
+ if (p.ageYears != null) return p.ageYears;
+ if (!p.dob) return null;
+ const dob = new Date(p.dob);
+ let y = at.getUTCFullYear() - dob.getUTCFullYear();
+ if (at.getUTCMonth() < dob.getUTCMonth() || (at.getUTCMonth() === dob.getUTCMonth() && at.getUTCDate() < dob.getUTCDate())) y--;
+ return y;
+}
+
+/**
+ * Differences between our booking's patient and the partner report's: name (case-insensitive, without
+ * titles), sex, age within ±1 year. Each is a warning that staff must confirm before applying.
+ */
+export function patientMismatches(report: ExtractedReport, patient: Patient, at = new Date()): string[] {
+ const out: string[] = [];
+ const theirs = normName(report.patient.name);
+ const ours = normName(patient.name);
+ if (theirs && ours && theirs !== ours) out.push(`patient name "${report.patient.name}" differs from the booking's "${patient.name}"`);
+ if (!theirs) out.push("the partner report prints no patient name");
+ if (report.patient.sex && patient.sex !== "other" && report.patient.sex !== patient.sex) out.push(`sex ${report.patient.sex} differs from the booking's ${patient.sex}`);
+ const ourAge = ageYears(patient, at);
+ if (report.patient.ageYears != null && ourAge != null && Math.abs(report.patient.ageYears - ourAge) > 1) out.push(`age ${report.patient.ageYears} differs from the booking's ${ourAge}`);
+ return out;
+}
+
+const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
+
+/** Our catalog parameter name for a partner parameter, within the chosen test; the partner's own name when none matches. */
+export function mapParameter(testCode: string, name: string): string {
+ const test = getTestByCode(testCode);
+ if (!test) return name;
+ const want = norm(name);
+ const named = test.parameters.filter((p): p is typeof p & { name: string } => !!p.name);
+ const exact = named.find((p) => norm(p.name) === want);
+ if (exact) return exact.name;
+ // "Haemoglobin (HB)" ~ "Hemoglobin": compare without bracketed abbreviations and common spelling variants
+ const loose = (s: string) => norm(s.replace(/\([^)]*\)/g, "")).replace(/ae/g, "e").replace(/oe/g, "e").replace(/\s+/g, "");
+ const l = loose(name);
+ const hit = named.find((p) => loose(p.name) === l);
+ return hit ? hit.name : name;
+}
+
+/** The catalog test suggested for a partner test (catalog-kit search, top hit), for staff to confirm. */
+export function suggestTestCode(name: string): string | null {
+ return catalogSearch(name, { limit: 1 })[0]?.test.code ?? null;
+}
+
+const sha256 = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
+
+/** db-kit registry rows → report-reader's KnownSignature (data, never source code). */
+export const knownSignatures = (rows: ExternalSignatory[]): KnownSignature[] =>
+ rows.map((r) => ({
+ name: r.name, sha: r.sha, dhash: r.dhash, lab: r.lab,
+ ...(r.qualification ? { qualification: r.qualification } : {}),
+ ...(r.registration ? { registration: r.registration } : {}),
+ ...(r.title ? { title: r.title } : {}),
+ ...(r.role && r.role !== "unknown" ? { role: r.role } : {}),
+ }));
+
+/** The printed provenance (report-kit) for one of our reports imported from a partner PDF. */
+/** "Clinova Pathlabs, Central Lab, City" printed after "Performed at Clinova Pathlabs": drop the repeated name. */
+export function placeOf(performedBy: string, performedAt?: string | null): string | null {
+ const at = performedAt?.trim();
+ if (!at) return null;
+ const rest = at.toLowerCase().startsWith(performedBy.toLowerCase()) ? at.slice(performedBy.length).replace(/^[\s,;:-]+/, "") : at;
+ return rest || null;
+}
+
+export function printedProvenance(p: ReportProvenance): Omit & { performedBy: string } {
+ return {
+ performedBy: p.performedBy,
+ ...(placeOf(p.performedBy, p.performedAt) ? { performedAt: placeOf(p.performedBy, p.performedAt)! } : {}),
+ ...(p.accreditation ? { accreditationText: `NABL accredited laboratory${p.accreditation.certificate ? ` (Certificate No. ${p.accreditation.certificate})` : ""}` } : {}),
+ sourceId: p.sourceSha256,
+ };
+}
+
+/**
+ * Partner-lab report imports (report-reader): upload → extract → store; match to a booking; apply to our
+ * reports with provenance; the partner signature registry. Results enter the normal flow (saveResults →
+ * verifyReport); an import never verifies or releases anything. The PDF, the extracted JSON and the
+ * signature images are patient / personal data: they are stored per organisation and never logged.
+ */
+export class ImportService {
+ constructor(
+ private readonly repos: Repositories,
+ private readonly queue: () => JobQueue,
+ private readonly limits: ImportLimits,
+ ) {}
+
+ async get(actor: Actor, id: Id): Promise {
+ return mustOwn(await this.repos.externalReports.get(id), actor.orgId, "External report", id);
+ }
+
+ /** The existing import of this exact PDF in the organisation, if any (checked before the caller authorizes the branch). */
+ async existing(actor: Actor, pdf: Uint8Array): Promise {
+ return this.repos.externalReports.findOne({ orgId: actor.orgId, sourceSha256: sha256(pdf) } as never);
+ }
+
+ assertSize(bytes: number) {
+ if (bytes > this.limits.maxBytes) throw new FileTooLargeError(bytes, this.limits.maxBytes);
+ if (bytes < 5) throw new ValidationError("The file is empty");
+ }
+
+ /**
+ * Stores the PDF and extracts it: in this request up to IMPORT_INLINE_MAX_BYTES, otherwise in the worker
+ * (the job carries the stored file's id, never the file). The same PDF uploaded again returns its row.
+ */
+ async import(actor: Actor, input: ImportInput): Promise {
+ this.assertSize(input.pdf.byteLength);
+ const sourceSha256 = sha256(input.pdf);
+ const before = await this.repos.externalReports.findOne({ orgId: actor.orgId, sourceSha256 } as never);
+ if (before) return before;
+ if (input.bookingId) {
+ const b = mustOwn(await this.repos.bookings.get(input.bookingId), actor.orgId, "Booking", input.bookingId);
+ if (b.branchId !== input.branchId) throw new ValidationError("The booking belongs to another branch");
+ }
+
+ const fileId = randomUUID();
+ try {
+ await this.repos.externalReportFiles.create({ id: fileId, orgId: actor.orgId, sourceSha256, byteLength: input.pdf.byteLength, pdf: input.pdf });
+ } catch (e) {
+ // the same PDF being imported right now by someone else
+ const row = await this.repos.externalReports.findOne({ orgId: actor.orgId, sourceSha256 } as never);
+ if (row) return row;
+ if (await this.repos.externalReportFiles.findOne({ orgId: actor.orgId, sourceSha256 } as never)) throw new ConflictError("This PDF is being imported; try again in a moment");
+ throw e;
+ }
+
+ const job: ImportJob = { kind: "extract", actor, fileId, branchId: input.branchId, bookingId: input.bookingId ?? null, fileName: input.fileName ?? null };
+ if (input.pdf.byteLength <= this.limits.inlineMaxBytes || this.queue().mode === "inline") return this.extractStored(actor, job);
+ try {
+ const { externalReportId } = await this.queue().importReport(job, this.limits.waitMs);
+ return this.get(actor, externalReportId);
+ } catch (e) {
+ if (e instanceof JobFailure && e.message.startsWith(UNSUPPORTED_PREFIX)) throw new UnsupportedReportError(e.message.slice(UNSUPPORTED_PREFIX.length));
+ if (e instanceof JobFailure) throw new ConflictError(e.message);
+ throw e;
+ }
+ }
+
+ /** Extracts a stored PDF and creates its external report (inline, or in the worker). An unsupported PDF is not kept. */
+ async extractStored(actor: Actor, job: Omit): Promise {
+ const file = mustOwn(await this.repos.externalReportFiles.get(job.fileId), actor.orgId, "File", job.fileId);
+ const done = await this.repos.externalReports.get(file.id);
+ if (done) return done; // a retried job
+ let extracted: Awaited>;
+ try {
+ extracted = await extractReport(new Uint8Array(file.pdf), { file: job.fileName ?? undefined, signatures: knownSignatures(await this.repos.externalSignatories.listForOrg(actor.orgId)) });
+ } catch (e) {
+ await this.repos.externalReportFiles.delete(file.id);
+ if (e instanceof UnsupportedReportError) throw e;
+ // a PDF the reader cannot open at all is unsupported too (never echo its contents)
+ throw new UnsupportedReportError("The PDF could not be read as a partner-lab report");
+ }
+ const { report, images } = extracted;
+ const booking = job.bookingId ? await this.repos.bookings.get(job.bookingId) : null;
+ const matchWarnings = booking ? await this.bookingWarnings(report, booking) : [];
+ const now = new Date();
+ return this.repos.transaction(async (tx) => {
+ const row = await tx.externalReports.create({
+ id: file.id, orgId: actor.orgId, branchId: job.branchId, bookingId: booking?.id ?? null,
+ lab: report.source.lab, labName: report.source.labName, processedAt: report.source.processedAt ?? null,
+ sourceSha256: report.source.sha256, fileName: job.fileName ?? null, byteLength: file.byteLength,
+ extracted: report as unknown as Record, status: booking ? "matched" : "extracted",
+ warnings: [...report.warnings, ...matchWarnings], uploadedBy: actor.userId, uploadedAt: now,
+ });
+ for (const s of report.signatories) {
+ const img = images.get(s.id);
+ if (!img || !s.signature.sha) continue;
+ await tx.externalSignatures.create({
+ orgId: actor.orgId, externalReportId: row.id, sourceSha256: report.source.sha256,
+ sha: s.signature.sha, dhash: s.signature.dhash, width: img.width, height: img.height, png: toPng(img),
+ });
+ }
+ await tx.audit.log({ orgId: actor.orgId, branchId: job.branchId, userId: actor.userId, action: "external_report.import", entity: "external_report", entityId: row.id, data: { lab: row.lab, tests: report.tests.length } });
+ return row;
+ });
+ }
+
+ private async bookingWarnings(report: ExtractedReport, booking: Booking): Promise {
+ const patient = await this.repos.patients.get(booking.patientId);
+ return patient ? patientMismatches(report, patient).map((m) => MATCH_PREFIX + m) : [`${MATCH_PREFIX}the booking's patient was not found`];
+ }
+
+ /** Ties an import to a booking (or another booking). Name, sex and age are compared; differences become warnings. */
+ async match(actor: Actor, ext: ExternalReport, booking: Booking): Promise {
+ if (ext.status === "applied") throw new ReportAlreadyAppliedError();
+ if (ext.status === "rejected") throw new ConflictError("This partner report was rejected");
+ const report = ext.extracted as unknown as ExtractedReport;
+ const warnings = [...ext.warnings.filter((w) => !w.startsWith(MATCH_PREFIX)), ...(await this.bookingWarnings(report, booking))];
+ const out = await this.repos.externalReports.update(ext.id, { bookingId: booking.id, branchId: booking.branchId, status: "matched", warnings });
+ await this.repos.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "external_report.match", entity: "external_report", entityId: ext.id, data: { bookingId: booking.id } });
+ return out!;
+ }
+
+ /** Patient differences recorded when the import was matched. */
+ mismatches(ext: ExternalReport): string[] {
+ return ext.warnings.filter((w) => w.startsWith(MATCH_PREFIX)).map((w) => w.slice(MATCH_PREFIX.length));
+ }
+
+ /**
+ * Writes the mapped partner tests into our reports (one per test code), as entered results with their
+ * provenance. Values, units, ranges and flags are copied as printed: the partner's range applies to the
+ * partner's method. Staff verify as usual; nothing is verified or released here.
+ */
+ async apply(actor: Actor, ext: ExternalReport, booking: Booking, mappings: TestMapping[], confirmPatientMismatch: boolean): Promise {
+ if (ext.status === "applied") throw new ReportAlreadyAppliedError();
+ if (ext.status === "rejected") throw new ConflictError("This partner report was rejected");
+ if (!ext.bookingId || ext.bookingId !== booking.id) throw new ValidationError("Match the partner report to a booking first (matchExternalReport)");
+ const mismatches = this.mismatches(ext);
+ if (mismatches.length && !confirmPatientMismatch) throw new BookingPatientMismatchError(mismatches);
+ const report = ext.extracted as unknown as ExtractedReport;
+ const chosen = mappings.filter((m) => m.include !== false);
+ if (!chosen.length) throw new ValidationError("Choose at least one test to apply");
+ const codes = new Set();
+ for (const m of chosen) {
+ if (!Number.isInteger(m.testIndex) || !report.tests[m.testIndex]) throw new ValidationError(`No test at index ${m.testIndex}`);
+ if (!booking.tests.some((t) => t.testCode === m.testCode)) throw new ValidationError(`Test ${m.testCode} is not in booking ${booking.bookingNo}`);
+ if (codes.has(m.testCode)) throw new ValidationError(`Test ${m.testCode} is mapped twice`);
+ codes.add(m.testCode);
+ }
+
+ return this.repos.transaction(async (tx) => {
+ const out: Report[] = [];
+ for (const m of chosen) {
+ const test = report.tests[m.testIndex]!;
+ const results = this.resultsOf(test, m.testCode);
+ if (!results.length) throw new ValidationError(`The partner test "${test.name}" has no results to apply`);
+ let ours = await tx.reports.findOne({ bookingId: booking.id, testCode: m.testCode } as never);
+ if (ours && (!["draft", "entered"].includes(ours.status) || (await tx.reportProvenance.findOne({ reportId: ours.id } as never))))
+ throw new ConflictError(`Booking ${booking.bookingNo} already has a ${m.testCode} report (${ours.status})`);
+ if (!ours) {
+ const bt = booking.tests.find((t) => t.testCode === m.testCode)!;
+ ours = await tx.reports.create({
+ orgId: booking.orgId, branchId: booking.branchId, bookingId: booking.id, patientId: booking.patientId,
+ testCode: m.testCode, testName: bt.name, results: [], status: "draft", version: 1,
+ } as never);
+ await tx.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "report.create", entity: "report", entityId: ours.id, data: { externalReportId: ext.id } });
+ }
+ const label = labelFor(report, test);
+ await tx.reportProvenance.create({
+ orgId: actor.orgId, reportId: ours.id, externalReportId: ext.id, sourceSha256: ext.sourceSha256, lab: ext.lab, testIndex: m.testIndex,
+ performedBy: label.performedBy, performedAt: label.performedAt ?? null,
+ reportedBy: label.reportedBy.map((r) => ({
+ signatoryId: r.id, name: r.name,
+ ...(r.qualification ? { qualification: r.qualification } : {}),
+ ...(r.registration ? { registration: r.registration } : {}),
+ ...(r.title ? { title: r.title } : {}),
+ role: r.role,
+ })),
+ accreditation: label.accreditation ?? null,
+ sourceTestName: test.name, sourceSampleId: test.sampleId ?? null, sourceReportedAt: test.reported ?? report.dates.reported ?? null,
+ importedResults: results, appliedBy: actor.userId,
+ });
+ // the normal flow: results entered (verification stays with our staff)
+ out.push(await saveResults(tx, ours.id, results, actor.userId));
+ }
+ await tx.externalReports.update(ext.id, { status: "applied", appliedBy: actor.userId, appliedAt: new Date() });
+ await tx.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "external_report.apply", entity: "external_report", entityId: ext.id, data: { reports: out.map((r) => r.id), confirmedMismatch: mismatches.length > 0 } });
+ return out;
+ });
+ }
+
+ /** A partner test's results as our ResultInput, copied as printed (parameter names mapped to our catalog where they match). */
+ resultsOf(test: TestSection, testCode: string): ResultValue[] {
+ return test.parameters
+ .filter((p) => p.name.trim() && p.value.trim())
+ .map((p) => ({
+ parameter: mapParameter(testCode, p.name),
+ value: p.value,
+ ...(p.unit ? { unit: p.unit } : {}),
+ ...(p.range ? { range: p.range } : {}),
+ flag: p.flag,
+ }));
+ }
+
+ /** Our reports an import was applied to. */
+ async reportsOf(ext: ExternalReport): Promise {
+ const rows = await this.repos.reportProvenance.find({ orgId: ext.orgId, externalReportId: ext.id } as never, { limit: 200 });
+ return rows.length ? this.repos.reports.getMany(rows.map((r) => r.reportId)) : [];
+ }
+
+ async reject(actor: Actor, ext: ExternalReport, reason: string): Promise {
+ if (ext.status === "applied") throw new ReportAlreadyAppliedError();
+ if (!reason.trim()) throw new ValidationError("A reason is required");
+ const out = await this.repos.externalReports.update(ext.id, { status: "rejected", rejectedBy: actor.userId, rejectReason: reason.trim() });
+ await this.repos.audit.log({ orgId: actor.orgId, branchId: ext.branchId, userId: actor.userId, action: "external_report.reject", entity: "external_report", entityId: ext.id });
+ return out!;
+ }
+
+ async list(actor: Actor, o: { scope: "all" | Id[]; bookingId?: Id | null; status?: ExternalReport["status"] | null; limit: number; offset: number }) {
+ const filter: Record = { orgId: actor.orgId, ...(o.bookingId ? { bookingId: o.bookingId } : {}), ...(o.status ? { status: o.status } : {}) };
+ const sort = { field: "uploadedAt" as const, dir: "desc" as const };
+ // one more than asked, to know whether there is a next page
+ if (o.scope === "all") return this.repos.externalReports.find(filter as never, { limit: o.limit + 1, offset: o.offset, sort });
+ const lists = await Promise.all(o.scope.map((b) => this.repos.externalReports.find({ ...filter, branchId: b } as never, { limit: o.limit + 1 + o.offset, sort })));
+ return lists.flat().sort((a, b) => +b.uploadedAt - +a.uploadedAt).slice(o.offset, o.offset + o.limit + 1);
+ }
+
+ // ---------------------------------------------------------------- signatures
+
+ /**
+ * A signature image, only from the PDF it came from: looked up by (organisation, sourceSha256, sha). The
+ * caller passes the sourceSha256 of the results it shows; a stored signature is never picked by sha alone.
+ */
+ signatureImage(orgId: Id, sourceSha256: string, sha: string) {
+ if (!sha) return Promise.resolve(null);
+ return this.repos.externalSignatures.findOne({ orgId, sourceSha256, sha } as never);
+ }
+
+ signatories(actor: Actor, lab?: ExternalLabId | null) {
+ return this.repos.externalSignatories.listForOrg(actor.orgId, lab ?? undefined);
+ }
+
+ /**
+ * Names a partner signature once (an authorised user, from what the signature shows). Adds the sha and
+ * its dhash to the registry entry of that person (the same sha, or the same lab + name + registration),
+ * then re-extracts the organisation's unapplied imports that carry it, in the background.
+ */
+ async nameSignature(actor: Actor, input: SignatoryInput): Promise {
+ const name = input.name.trim();
+ if (!name) throw new ValidationError("A name is required");
+ if (input.role && !ROLES.includes(input.role as ExternalSignatoryRole)) throw new ValidationError(`role must be one of ${ROLES.join(", ")}`);
+ const seen = await this.repos.externalSignatures.findOne({ orgId: actor.orgId, sha: input.sha } as never);
+ if (!seen) throw new NotFoundError("Signature", input.sha);
+ const fields = {
+ lab: input.lab, name,
+ qualification: input.qualification?.trim() || null, registration: input.registration?.trim() || null,
+ title: input.title?.trim() || null, role: (input.role as ExternalSignatoryRole) || null,
+ };
+ const all = await this.repos.externalSignatories.listForOrg(actor.orgId);
+ const bySha = all.find((r) => r.sha.includes(input.sha));
+ const samePerson = all.find((r) => r.lab === input.lab && normName(r.name) === normName(name) && (r.registration ?? "") === (fields.registration ?? ""));
+ const target = bySha ?? samePerson;
+ let out: ExternalSignatory;
+ if (target) {
+ out = (await this.repos.externalSignatories.update(target.id, {
+ ...fields,
+ sha: [...new Set([...target.sha, input.sha])],
+ dhash: [...new Set([...target.dhash, ...(seen.dhash ? [seen.dhash] : [])])],
+ verifiedBy: actor.userId,
+ }))!;
+ } else {
+ out = await this.repos.externalSignatories.create({ orgId: actor.orgId, ...fields, sha: [input.sha], dhash: seen.dhash ? [seen.dhash] : [], createdBy: actor.userId, verifiedBy: null });
+ }
+ await this.repos.audit.log({ orgId: actor.orgId, userId: actor.userId, action: target ? "external_signatory.update" : "external_signatory.create", entity: "external_signatory", entityId: out.id });
+ await this.queue().reextract({ kind: "reextract", actor, sha: input.sha });
+ return out;
+ }
+
+ /** Re-reads (from the stored PDFs) the organisation's imports in status extracted or matched that carry this signature. */
+ async reextractSignature(actor: Actor, sha: string): Promise {
+ const rows = await this.repos.externalSignatures.find({ orgId: actor.orgId, sha } as never, { limit: 500 });
+ const known = knownSignatures(await this.repos.externalSignatories.listForOrg(actor.orgId));
+ let updated = 0;
+ for (const id of new Set(rows.map((r) => r.externalReportId))) {
+ const ext = await this.repos.externalReports.get(id);
+ if (!ext || ext.orgId !== actor.orgId || (ext.status !== "extracted" && ext.status !== "matched")) continue;
+ const file = await this.repos.externalReportFiles.get(ext.id);
+ if (!file) continue;
+ const { report } = await extractReport(new Uint8Array(file.pdf), { file: ext.fileName ?? undefined, signatures: known, signatureImages: false });
+ const booking = ext.bookingId ? await this.repos.bookings.get(ext.bookingId) : null;
+ const warnings = [...report.warnings, ...(booking ? await this.bookingWarnings(report, booking) : [])];
+ await this.repos.externalReports.update(ext.id, { extracted: report as unknown as Record, warnings });
+ updated++;
+ }
+ return updated;
+ }
+}
diff --git a/src/services/index.ts b/src/services/index.ts
index e4e87ac..3f7b36d 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -10,6 +10,7 @@ import { CatalogService } from "./catalog.service";
import { DocumentService } from "./document.service";
import { ExportService } from "./export.service";
import { GstService } from "./gst.service";
+import { ImportService, type ImportLimits } from "./import.service";
import { JobService } from "./job.service";
import type { JobQueue } from "../jobs/types";
import { OrganizationService } from "./organization.service";
@@ -28,6 +29,8 @@ export interface ServiceDeps {
samples: boolean;
/** The job queue (BullMQ or inline); a getter because the inline queue needs the services first. */
jobs: () => JobQueue;
+ /** Partner-lab PDF limits (IMPORT_* variables). */
+ imports: ImportLimits;
}
/** Every service, one instance each. Services know nothing about GraphQL; controllers call them. */
@@ -49,6 +52,7 @@ export function createServices(d: ServiceDeps) {
exports: new ExportService(d.repos),
documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples }),
jobs: new JobService(d.repos, d.jobs),
+ imports: new ImportService(d.repos, d.jobs, d.imports),
};
}
diff --git a/src/services/report.service.ts b/src/services/report.service.ts
index e0d30e6..26e2fb7 100644
--- a/src/services/report.service.ts
+++ b/src/services/report.service.ts
@@ -54,6 +54,17 @@ export class ReportService {
}
async saveResults(actor: Actor, report: Report, results: ResultValue[]) {
+ const imported = await this.repos.reportProvenance.findOne({ reportId: report.id } as never);
+ if (imported) {
+ // results from a partner PDF: keep the partner's units and ranges (no catalog defaults), and audit the edit
+ // against what was imported (ReportProvenance.importedResults keeps the original)
+ const out = await saveResults(this.repos, report.id, results, actor.userId);
+ await this.repos.audit.log({
+ orgId: actor.orgId, branchId: report.branchId, userId: actor.userId, action: "report.edit_imported", entity: "report", entityId: report.id,
+ data: { externalReportId: imported.externalReportId, changed: results.filter((r) => !imported.importedResults.some((x) => x.parameter === r.parameter && x.value === r.value)).map((r) => r.parameter) },
+ });
+ return out;
+ }
const patient = await this.repos.patients.get(report.patientId);
return saveResults(this.repos, report.id, withCatalogDefaults(report.testCode, results, patient?.sex ?? "other"), actor.userId);
}
diff --git a/test/imports.ts b/test/imports.ts
new file mode 100644
index 0000000..3fa7f39
--- /dev/null
+++ b/test/imports.ts
@@ -0,0 +1,164 @@
+// Partner-lab imports end to end (report-reader → db-kit → GraphQL → report-kit), on each backend.
+// The PDFs are fictional and drawn at test time (test/partner-pdfs.ts).
+import { PDFDocument, StandardFonts } from "pdf-lib";
+import type { Repositories } from "@mgpl-diagnostics/db-kit";
+import type { Services } from "../src/services";
+import { clinovaPdf, hypathoPdf } from "./partner-pdfs";
+
+type Gql = { data?: any; errors?: { message: string; extensions?: Record }[] };
+type Run = (token: string | null, query: string, variables?: Record) => Promise;
+type Ok = (cond: unknown, msg: string) => void;
+
+const code = (r: Gql) => r.errors?.[0]?.extensions?.code;
+const b64 = (b: Uint8Array) => Buffer.from(b).toString("base64");
+
+const EXT = `id status lab labName branchId fileName warnings patientMismatches sourceSha256 booking { id }
+ patient { name ageYears sex } sampleIds
+ tests { index name accredited suggestedTestCode parameters { name value unit range flag } provenance { performedBy accreditationText reportedBy { name } text } }
+ signatories { id name role medical source sha image { mimeType width height base64 } }`;
+const IMPORT = `mutation($pdf: String!, $f: String, $b: ID, $br: ID) { importExternalReport(pdfBase64: $pdf, fileName: $f, bookingId: $b, branchId: $br) { ${EXT} } }`;
+
+export interface ImportCtx {
+ tag: string;
+ gql: Run;
+ ok: Ok;
+ owner: string;
+ otherOwner: string;
+ login: (email: string, password?: string) => Promise;
+ branchA: string;
+ branchB: string;
+ orgId: string;
+ repos: Repositories;
+ services: Services;
+ /** Sends a raw body (for the size limits). */
+ raw: (token: string, body: string) => Promise<{ status: number; json: Gql }>;
+}
+
+async function unsupportedPdf() {
+ const doc = await PDFDocument.create();
+ const font = await doc.embedFont(StandardFonts.Helvetica);
+ doc.addPage().drawText("Fictional letter, not a lab report", { x: 50, y: 700, size: 12, font });
+ return doc.save();
+}
+
+export async function importSuite(c: ImportCtx) {
+ const { tag, gql, ok, owner, otherOwner } = c;
+ const t = (s: string) => `[${tag}] imports: ${s}`;
+
+ // users: a technician in branch A (report:import, signatory:read) and a front desk (neither)
+ const pw = "Counter-Shift-7391";
+ for (const [email, role] of [["tech@apl.test", "technician"], ["desk2@apl.test", "front_desk"]] as const)
+ await gql(owner, "mutation($i: CreateUserInput!) { createUser(input: $i) { id } }", { i: { name: role, email, password: pw, roles: [{ role, branchId: c.branchA }] } });
+ const tech = await c.login("tech@apl.test", pw);
+ const desk = await c.login("desk2@apl.test", pw);
+
+ // a booking for the Clinova patient (Mr DEMO PERSON, 35, male): lipid profile + ESR
+ const patientId = (await gql(owner, "mutation($i: RegisterPatientInput!) { registerPatient(input: $i) { id } }", { i: { branchId: c.branchA, title: "Mr", name: "Demo Person", sex: "male", ageYears: 35 } })).data?.registerPatient.id;
+ const booking = (await gql(owner, "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id bookingNo } } }", { i: { branchId: c.branchA, patientId, tests: [{ testCode: "LIPR" }, { testCode: "ESR" }] } })).data?.createBooking.booking;
+ ok(!!booking?.id, t("booking for the partner patient"));
+
+ // ------------------------------------------------------------ permissions and validation
+ const clinova = await clinovaPdf();
+ ok(code(await gql(desk, IMPORT, { pdf: b64(clinova), b: booking.id })) === "FORBIDDEN", t("front desk cannot import (report:import) → FORBIDDEN"));
+ ok(code(await gql(tech, IMPORT, { pdf: b64(clinova), br: c.branchB })) === "FORBIDDEN", t("a technician of branch A cannot import into branch B"));
+ ok(code(await gql(tech, IMPORT, { pdf: b64(clinova) })) === "BAD_USER_INPUT", t("no booking and no branch → BAD_USER_INPUT"));
+ ok(code(await gql(otherOwner, IMPORT, { pdf: b64(clinova), b: booking.id })) === "NOT_FOUND", t("another organisation's booking → NOT_FOUND"));
+ const unsupported = await gql(tech, IMPORT, { pdf: b64(await unsupportedPdf()), br: c.branchA });
+ ok(code(unsupported) === "UNSUPPORTED_REPORT", t("a PDF from an unknown lab → UNSUPPORTED_REPORT"));
+ ok((await c.repos.externalReportFiles.find({ orgId: c.orgId } as never)).length === 0, t("…and its file is not kept"));
+ ok(code(await gql(tech, IMPORT, { pdf: "not base64!", br: c.branchA })) === "BAD_USER_INPUT", t("pdfBase64 that is not base64 → BAD_USER_INPUT"));
+
+ // ------------------------------------------------------------ import + re-import (Clinova: printed names, NABL on one test)
+ const imp = await gql(tech, IMPORT, { pdf: b64(clinova), f: "clinova-demo.pdf", b: booking.id });
+ const ext = imp.data?.importExternalReport;
+ ok(ext?.status === "matched" && ext.lab === "clinova" && ext.booking.id === booking.id && ext.branchId === c.branchA, t(`importExternalReport (with bookingId) → matched, ${ext?.tests.length} tests`));
+ ok(ext?.patientMismatches.length === 0 && ext.patient.name, t("same patient (title, case ignored; age ±1) → no mismatch"));
+ const lipid = ext?.tests.find((x: any) => /lipid/i.test(x.name));
+ const esr = ext?.tests.find((x: any) => /ESR|sedimentation/i.test(x.name));
+ ok(lipid?.suggestedTestCode === "LIPR" && esr?.suggestedTestCode === "ESR", t("catalog-kit suggests LIPR and ESR"));
+ ok(lipid?.accredited === true && /NABL accredited laboratory \(Certificate No\. MC-9999\)/.test(lipid.provenance.accreditationText ?? "") && lipid.provenance.text.includes("NABL"), t("NABL line on the accredited test, as the partner's accreditation"));
+ ok(esr?.accredited === false && esr.provenance.accreditationText === null && !esr.provenance.text.includes("NABL"), t("…and no NABL line on the test that is not accredited"));
+ const medical = ext?.signatories.filter((s: any) => s.medical) ?? [];
+ ok(medical.length === 1 && ext.signatories.length === 2 && lipid.provenance.reportedBy.length === 1 && lipid.provenance.reportedBy[0].name === medical[0].name, t("Reported by lists the medical signatory only (not the lab manager)"));
+ ok(ext?.signatories.every((s: any) => s.image?.mimeType === "image/png" && Buffer.from(s.image.base64, "base64").subarray(1, 4).toString() === "PNG"), t("signature images served as PNG with their own PDF"));
+ const again = await gql(tech, IMPORT, { pdf: b64(clinova), b: booking.id });
+ ok(again.data?.importExternalReport.id === ext?.id, t("the same PDF again → the same import"));
+ ok((await gql(tech, "query($b: ID!) { externalReports(bookingId: $b) { nodes { id } pageInfo { hasNextPage } } }", { b: booking.id })).data?.externalReports.nodes.length === 1, t("externalReports(bookingId) lists it once"));
+ ok(code(await gql(otherOwner, `query($id: ID!) { externalReport(id: $id) { id } }`, { id: ext?.id })) === "NOT_FOUND", t("another organisation → NOT_FOUND"));
+ ok(code(await gql(desk, `query($id: ID!) { externalReport(id: $id) { id } }`, { id: ext?.id })) === "FORBIDDEN", t("externalReport needs report:import"));
+
+ // ------------------------------------------------------------ Hy Patho: unnamed signatures, patient mismatch
+ const hy = await gql(tech, IMPORT, { pdf: b64(await hypathoPdf()), br: c.branchA });
+ const hext = hy.data?.importExternalReport;
+ ok(hext?.status === "extracted" && hext.lab === "hypatho" && hext.signatories.length >= 1 && hext.signatories.every((s: any) => s.name === null && s.source === "unknown"), t("Hy Patho import: signatures not yet named"));
+ ok(hext?.tests.every((x: any) => x.accredited === false && x.provenance.accreditationText === null), t("Hy Patho carries no marks → no NABL line"));
+ const matched = await gql(tech, "mutation($id: ID!, $b: ID!) { matchExternalReport(id: $id, bookingId: $b) { status patientMismatches warnings } }", { id: hext?.id, b: booking.id });
+ const mm = matched.data?.matchExternalReport.patientMismatches ?? [];
+ ok(mm.some((m: string) => /name/.test(m)) && mm.some((m: string) => /age 40/.test(m)), t(`matching another patient's booking → warnings (${mm.length})`));
+ const applyHy = await gql(tech, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id } }", { id: hext?.id, m: [{ testIndex: 0, testCode: "ESR" }] });
+ ok(code(applyHy) === "BOOKING_PATIENT_MISMATCH" && applyHy.errors?.[0]?.extensions?.mismatches?.length === mm.length, t("apply without confirmation → BOOKING_PATIENT_MISMATCH"));
+ ok((await gql(tech, "mutation($id: ID!) { rejectExternalReport(id: $id, reason: \"Wrong patient\") { status } }", { id: hext?.id })).data?.rejectExternalReport.status === "rejected", t("rejectExternalReport"));
+
+ // ------------------------------------------------------------ name a signature → re-extraction
+ const sha = hext?.signatories[0]?.sha;
+ const named = { sha, lab: "hypatho", name: "Dr Fictional Reader", qualification: "MD (Pathology)", registration: "XX-0001", role: "pathologist" };
+ ok(code(await gql(tech, "mutation($i: ExternalSignatoryInput!) { nameExternalSignature(input: $i) { id } }", { i: named })) === "FORBIDDEN", t("nameExternalSignature needs signatory:manage"));
+ ok(code(await gql(otherOwner, "mutation($i: ExternalSignatoryInput!) { nameExternalSignature(input: $i) { id } }", { i: named })) === "NOT_FOUND", t("…and a signature this organisation has seen"));
+ // a second, unapplied import of a Hy Patho PDF carrying that signature (new bytes, same drawing)
+ const hy2pdf = await PDFDocument.load(await hypathoPdf());
+ hy2pdf.setTitle("copy");
+ const hy2 = (await gql(tech, IMPORT, { pdf: b64(await hy2pdf.save()), br: c.branchA })).data?.importExternalReport;
+ const sig = await gql(owner, "mutation($i: ExternalSignatoryInput!) { nameExternalSignature(input: $i) { id name signatureCount } }", { i: named });
+ ok(sig.data?.nameExternalSignature.name === "Dr Fictional Reader" && sig.data.nameExternalSignature.signatureCount === 1, t("nameExternalSignature adds the registry entry"));
+ const reread = (await gql(tech, `query($id: ID!) { externalReport(id: $id) { ${EXT} } }`, { id: hy2?.id })).data?.externalReport;
+ ok(reread?.signatories.some((s: any) => s.name === "Dr Fictional Reader" && s.source === "registry" && s.medical) && reread.tests[0].provenance.reportedBy.some((r: any) => r.name === "Dr Fictional Reader"), t("unapplied imports carrying it are read again: named, Reported by"));
+ const rejected = (await gql(tech, `query($id: ID!) { externalReport(id: $id) { signatories { name } } }`, { id: hext?.id })).data?.externalReport;
+ ok(rejected?.signatories.every((s: any) => s.name === null), t("…a rejected import is left as it was"));
+ ok((await gql(tech, "{ externalSignatories(lab: hypatho) { name } }")).data?.externalSignatories.length === 1 && code(await gql(desk, "{ externalSignatories { name } }")) === "FORBIDDEN", t("externalSignatories needs signatory:read"));
+
+ // ------------------------------------------------------------ apply with provenance (status unchanged until staff verify)
+ const mapping = [{ testIndex: lipid.index, testCode: "LIPR" }, { testIndex: esr.index, testCode: "ESR" }];
+ ok(code(await gql(desk, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id } }", { id: ext.id, m: mapping })) === "FORBIDDEN", t("front desk cannot apply"));
+ ok(code(await gql(tech, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id } }", { id: ext.id, m: [{ testIndex: 0, testCode: "CBC" }] })) === "BAD_USER_INPUT", t("a test that is not in the booking → BAD_USER_INPUT"));
+ const applied = await gql(tech, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id testCode status results { parameter value unit range flag } provenance { lab performedBy accreditationText reportedBy { name } sourceTestName importedResults { value } } } }", { id: ext.id, m: mapping });
+ const reps = applied.data?.applyExternalReport ?? [];
+ const lr = reps.find((r: any) => r.testCode === "LIPR");
+ const er = reps.find((r: any) => r.testCode === "ESR");
+ ok(reps.length === 2 && reps.every((r: any) => r.status === "entered"), t("applyExternalReport → our reports, status entered (not verified)"));
+ const chol = lr?.results.find((x: any) => /cholesterol/i.test(x.parameter) && x.value === "245");
+ ok(chol?.unit === "mg/dL" && /Desirable/.test(chol.range ?? "") && er?.results[0]?.range === "<= 20", t("values, units and ranges copied as printed"));
+ ok(lr?.provenance?.lab === "clinova" && /MC-9999/.test(lr.provenance.accreditationText ?? "") && er?.provenance?.accreditationText === null && lr.provenance.reportedBy.length === 1, t("Report.provenance: lab, NABL only on the accredited test, medical signatory"));
+ ok(code(await gql(tech, "mutation($id: ID!, $m: [ExternalTestMappingInput!]!) { applyExternalReport(id: $id, mappings: $m) { id } }", { id: ext.id, m: mapping })) === "REPORT_ALREADY_APPLIED", t("applying twice → REPORT_ALREADY_APPLIED"));
+ ok(code(await gql(tech, "query($b: ID!) { bookingReportDocument(bookingId: $b) { fileName } }", { b: booking.id })) === "CONFLICT", t("imported results do not print until verified"));
+
+ // edit after import: audited, no catalog defaults
+ await gql(owner, "mutation($r: ID!, $x: [ResultInput!]!) { saveResults(reportId: $r, results: $x) { id } }", { r: er.id, x: [{ parameter: er.results[0].parameter, value: "31", unit: "mm/Hour", range: "<= 20" }] });
+ const audit = await c.repos.audit.find({ orgId: c.orgId, action: "report.edit_imported" } as never);
+ ok(audit.length === 1 && JSON.stringify(audit[0]).includes(er.results[0].parameter) && !JSON.stringify(audit[0]).includes("\"31\""), t("editing imported results is audited (parameter names, no values)"));
+
+ for (const r of reps) await gql(owner, "mutation($r: ID!) { verifyReport(id: $r) { status } }", { r: r.id });
+ const html = (await gql(tech, "query($b: ID!) { bookingReportDocument(bookingId: $b, format: html) { html } }", { b: booking.id })).data?.bookingReportDocument.html ?? "";
+ const pdf = await gql(tech, "query($b: ID!) { bookingReportDocument(bookingId: $b) { base64 } }", { b: booking.id });
+ const printed = html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ");
+ ok(/Performed at Clinova Pathlabs, Demo Central Lab/.test(printed) && /NABL accredited laboratory \(Certificate No\. MC-9999\)/.test(printed) && Buffer.from(pdf.data?.bookingReportDocument.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-", t("verified → prints with the per-test provenance line (HTML + PDF)"));
+ ok((printed.match(/NABL accredited/g) ?? []).length === 1 && !/
]+nabl/i.test(html), t("NABL text once (the accredited test only), never a logo"));
+ ok(/class="sb-psign"/.test(html) && /class="sb-psignimg"[^>]*src="data:image\/png;base64|src="data:image\/png;base64[^"]*"[^>]*class="sb-psignimg"/.test(html) && !/class="sb-signimg"/.test(html), t("the partner's medical signatory and signature print with its own results"));
+
+ // ------------------------------------------------------------ a signature only appears with results from its own PDF
+ const csha = ext.signatories.find((s: any) => s.medical).sha;
+ ok(!!(await c.services.imports.signatureImage(c.orgId, ext.sourceSha256, csha)) && (await c.services.imports.signatureImage(c.orgId, hext.sourceSha256, csha)) === null, t("signature lookup is keyed by its own PDF's sha256"));
+ ok((await c.services.imports.signatureImage(c.orgId, ext.sourceSha256, "")) === null, t("…never by an empty sha"));
+ // our own booking with typed-in results prints our signatory, never a stored partner signature
+ const own = (await gql(owner, "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id } } }", { i: { branchId: c.branchA, patientId, tests: [{ testCode: "ESR" }] } })).data?.createBooking.booking;
+ const ownRep = (await gql(owner, 'mutation($b: ID!) { createReport(bookingId: $b, testCode: "ESR") { id } }', { b: own.id })).data?.createReport.id;
+ await gql(owner, "mutation($r: ID!, $x: [ResultInput!]!) { saveResults(reportId: $r, results: $x) { id } }", { r: ownRep, x: [{ parameter: "Erythrocyte Sedimentation Rate (ESR)", value: "12" }] });
+ await gql(owner, "mutation($r: ID!) { verifyReport(id: $r) { id } }", { r: ownRep });
+ const ownHtml = (await gql(owner, "query($b: ID!) { bookingReportDocument(bookingId: $b, format: html) { html } }", { b: own.id })).data?.bookingReportDocument.html ?? "";
+ ok(ownHtml.includes(" ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648);
+ const a = 0.25 + rnd() * 0.2, f = 2 + rnd() * 4, ph = rnd() * 6;
+ for (let x = 4; x < w - 4; x++) {
+ const y = Math.round(h / 2 + Math.sin((x / w) * Math.PI * f + ph) * h * a + Math.sin(x / 7) * h * 0.08);
+ for (let t = -2; t <= 2; t++) {
+ const yy = y + t;
+ if (yy < 0 || yy >= h) continue;
+ const j = (yy * w + x) * 4;
+ rgba[j] = 20; rgba[j + 1] = 40; rgba[j + 2] = 140; rgba[j + 3] = 255;
+ }
+ }
+ return toPng({ width: w, height: h, rgba });
+}
+
+async function page(doc: PDFDocument, font: PDFFont, bold: PDFFont) {
+ const p = doc.addPage([W, H]);
+ const text = (x: number, yTop: number, s: string, size = 7.5, f: PDFFont = font) => p.drawText(s, { x, y: H - yTop, size, font: f });
+ return { p, text, bold };
+}
+
+async function image(doc: PDFDocument, p: PDFPage, png: Buffer, x: number, yTop: number, w: number, h: number) {
+ const img = await doc.embedPng(png);
+ p.drawImage(img, { x, y: H - yTop - h, width: w, height: h });
+}
+
+export async function hypathoPdf(): Promise {
+ const doc = await PDFDocument.create();
+ const font = await doc.embedFont(StandardFonts.Helvetica);
+ const bold = await doc.embedFont(StandardFonts.HelveticaBold);
+ const sig = squiggle(210, 130, 7);
+ const header = (text: (x: number, y: number, s: string, size?: number, f?: PDFFont) => void, n: number, of: number) => {
+ text(472, 39, "PROCESSED AT:", 8, bold);
+ text(472, 50, "HY PATHO LAB", 8, bold);
+ text(472, 61, "RAIPUR", 8, bold);
+ text(20, 148, "PT Name"); text(70, 148, ": MR. TEST PATIENT"); text(240, 148, "Age"); text(308, 148, ": 40 Y | Gender : MALE"); text(426, 148, "SAMPLE COLLECTED AT :");
+ text(20, 157, "Ref By Dr"); text(70, 157, ": SELF"); text(240, 157, "Registered on"); text(308, 157, ": 01-01-2026 09:00 AM"); text(426, 157, "DEMO COLLECTION CENTRE");
+ text(20, 166, "Booking Id"); text(70, 166, ": 100001 DEMO - 1"); text(240, 166, "Received on"); text(308, 166, ": 01-01-2026 10:00 AM");
+ text(308, 175, ": 01-01-2026 05:30 PM");
+ text(20, 178, "Barcode Id"); text(70, 178, ": BC0001"); text(240, 178, "Reported on"); text(426, 178, "SAMPLE"); text(462, 178, ": SERUM");
+ text(20, 187, "INV"); text(70, 187, ": DEMO PANEL");
+ text(298, 815, `${n} of ${of}`);
+ };
+ const th = (text: (x: number, y: number, s: string, size?: number, f?: PDFFont) => void, y: number) => {
+ text(20, y, "TEST DESCRIPTION", 8, bold); text(246, y, "RESULT", 8, bold); text(330, y, "UNITS", 8, bold); text(415, y, "BIO. REF. RANGES", 8, bold);
+ };
+
+ // page 1: a panel that continues on page 2
+ let { p, text } = await page(doc, font, bold);
+ header(text, 1, 3);
+ text(222, 216, "COMPLETE BLOOD COUNT", 9, bold);
+ th(text, 243);
+ text(20, 262, "Hemoglobin"); text(246, 262, "9.50"); text(330, 262, "g/dL"); text(415, 262, "13 - 17");
+ text(20, 271, "Method: SLS-Hemoglobin");
+ text(20, 289, "Mean Corpuscular Hemoglobin Concentration"); text(246, 289, "35.80"); text(330, 289, "g/dl"); text(415, 289, "31.5 - 34.5");
+ text(20, 300, "(MCHC)");
+ text(20, 310, "Method: Calculated");
+ text(20, 328, "DIFFERENTIAL COUNT", 7.5, bold);
+ text(20, 346, "Neutrophils"); text(246, 346, "62"); text(330, 346, "%"); text(415, 346, "40 - 80");
+ text(20, 355, "Method: Flow Cytometry");
+ text(20, 790, "Platelet Count"); text(246, 790, "120"); text(330, 790, "10^3/uL"); text(415, 790, "150 - 410");
+
+ // page 2: continuation (no title), method line carried over, end of report, signature
+ ({ p, text } = await page(doc, font, bold));
+ header(text, 2, 3);
+ th(text, 211);
+ text(20, 225, "Method: Electrical Impedance");
+ text(20, 243, "Erythrocyte Sedimentation Rate (ESR)"); text(246, 243, "34"); text(330, 243, "mm at end of Ist hr"); text(415, 243, "0 - 20");
+ text(236, 300, "~~End of report~~");
+ await image(doc, p, sig, 430, 720, 120, 74);
+
+ // page 3: a marked single test with narrative notes
+ ({ p, text } = await page(doc, font, bold));
+ header(text, 3, 3);
+ text(223, 216, "* THYROID PROFILE", 9, bold);
+ th(text, 243);
+ text(20, 262, "THYROID STIMULATING HORMONE (TSH)"); text(246, 262, "6.20"); text(330, 262, "uIU/ml"); text(415, 262, "0.35 - 5.5");
+ text(20, 271, "Method: CLIA");
+ text(25, 300, "Interpretation: values vary with time of day; repeat if clinically indicated.");
+ text(236, 340, "~~End of report~~");
+ await image(doc, p, sig, 430, 720, 120, 74);
+
+ return doc.save();
+}
+
+export async function clinovaPdf(): Promise {
+ const doc = await PDFDocument.create();
+ const font = await doc.embedFont(StandardFonts.Helvetica);
+ const bold = await doc.embedFont(StandardFonts.HelveticaBold);
+ const left = squiggle(150, 80, 11), right = squiggle(250, 68, 23);
+
+ const make = async (n: number, of: number, sampleId: string, sampleType: string, dept: string, accredited: boolean) => {
+ const { p, text } = await page(doc, font, bold);
+ text(242, 96, "LABORATORY TEST REPORT", 10, bold);
+ text(30, 117, "Patient Name"); text(94, 117, "Mr DEMO PERSON", 8, bold);
+ text(30, 131, "Age : 35 Year(s) Gender : Male");
+ text(344, 141, "Lab Code"); text(461, 141, ":"); text(470, 141, "CPL-XX-001");
+ text(30, 145, "Sample ID"); text(119, 145, ":"); text(128, 145, sampleId);
+ text(344, 156, "Sample Collection Date"); text(461, 156, ":"); text(470, 156, "2026-01-02 08:15");
+ text(30, 159, "Sample Type"); text(119, 159, ":"); text(128, 159, sampleType);
+ text(30, 171, "Patient ID"); text(119, 171, ":"); text(128, 171, "900001"); text(344, 171, "Registration Date"); text(461, 171, ":"); text(470, 171, "2026-01-02 10:00");
+ text(30, 186, "Ref. Doctor"); text(119, 186, ":"); text(344, 186, "Approved"); text(389, 186, "Date"); text(461, 186, ":"); text(470, 186, `2026-01-02 1${n}:30`);
+ text(30, 200, "Ref. Customer"); text(119, 200, ":"); text(128, 200, "DEMO COLLECTION CENTRE");
+ text(236, 220, dept, 8, bold);
+ text(32, 242, "Test Description", 8, bold); text(310, 242, "Result", 8, bold); text(372, 242, "Units", 8, bold); text(414, 242, "Biological Reference Ranges", 8, bold);
+ await image(doc, p, left, 29, 736, 116, 21);
+ await image(doc, p, right, 404, 735, 134, 22);
+ text(30, 769, "Demo Manager"); text(404, 769, "Dr Demo Pathologist, MD");
+ text(30, 784, "Manager Lab Operations"); text(404, 784, "Consultant Pathologist (XYMC1234/2020)");
+ text(30, 808, "This is an electronically authenticated report.", 5);
+ text(543, 808, `Page ${n} of ${of}`, 5);
+ if (accredited) text(30, 822, "Tests marked with NABL symbol are accredited by NABL vide Certificate No.MC-9999", 5);
+ text(30, 830, "Processed at : Clinova Pathlabs, Demo Central Lab, Demo City - 000000", 5);
+ return text;
+ };
+
+ let text = await make(1, 2, "S-001", "Serum", "CLINICAL BIOCHEMISTRY", true);
+ text(39, 262, "LIPID PROFILE");
+ text(39, 277, "Cholesterol - Total"); text(310, 277, "245"); text(369, 277, "mg/dL"); text(426, 277, "<200 - Desirable");
+ text(39, 285, "(Method: CHOD-PAP)", 6);
+ text(426, 289, "200-239 - Borderline risk");
+ text(426, 300, ">240 - High risk");
+ text(39, 311, "Cholesterol - HDL"); text(310, 311, "35"); text(369, 311, "mg/dL"); text(426, 311, "40 - 60");
+ text(39, 320, "(Method: Enzymatic Colorimetric)", 6);
+ text(33, 360, "HDL cholesterol below 40 mg/dL is a cardiovascular risk factor. Correlate clinically with other findings.");
+
+ text = await make(2, 2, "S-002", "WB EDTA", "HEMATOLOGY", false);
+ text(39, 260, "Erythrocyte Sedimentation Rate (ESR)"); text(310, 260, "30"); text(369, 260, "mm/Hour"); text(426, 260, "<= 20");
+ text(39, 269, "(Method: Westergren)", 6);
+ text(248, 500, "--End of Report--");
+
+ return doc.save();
+}
diff --git a/test/run.ts b/test/run.ts
index 66ddf45..aaa8b56 100644
--- a/test/run.ts
+++ b/test/run.ts
@@ -23,6 +23,9 @@ import type { Mailer } from "../src/services/auth.service";
import { createMailer, memoryTransport } from "@mgpl-diagnostics/mail-kit";
import { MGPL_LAB } from "@mgpl-diagnostics/report-kit";
import { brandFromLab, mailerFromConfig, mailKitMailer } from "../src/jobs";
+import { importSuite } from "./imports";
+import { clinovaPdf } from "./partner-pdfs";
+import { PDFDocument } from "pdf-lib";
let passed = 0;
let failed = 0;
@@ -256,6 +259,13 @@ async function suite(db: Db, tag: string) {
ok(code(await gql(otherAgain, "mutation($id: ID!) { startDocumentJob(input: { kind: invoice, invoiceId: $id }) { id } }", { id: invoiceId })) === "NOT_FOUND", `[${tag}] startDocumentJob on another organisation's invoice → NOT_FOUND`);
ok(code(await gql(owner2, "mutation { startDocumentJob(input: { kind: booking_reports }) { id } }")) === "BAD_USER_INPUT", `[${tag}] startDocumentJob without bookingId → BAD_USER_INPUT`);
+ // ---------------------------------------------------------------- partner-lab imports (test/imports.ts)
+ const raw = async (token: string, body: string) => {
+ const res = await app.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, payload: body });
+ return { status: res.statusCode, json: res.json() as Gql };
+ };
+ await importSuite({ tag, gql, ok, owner: owner2, otherOwner: otherAgain, login, branchA: a.branch.id, branchB: branchB.id, orgId: a.org.id, repos: container.db.repos, services: container.services, raw });
+
// ---------------------------------------------------------------- server guards
ok(code(await gql(null, "{ __schema { types { name } } }")) === "GRAPHQL_VALIDATION_FAILED", `[${tag}] introspection disabled when INTROSPECTION=0`);
const deep = `{ invoices ${"{ booking { invoice ".repeat(7)}{ id }${" } }".repeat(7)} }`;
@@ -387,7 +397,8 @@ async function workerSuite(pgUrl: string) {
}
const mails: { to: string; link: string }[] = [];
const mailer: Mailer = { passwordReset: async (to, link) => void mails.push({ to: to.email, link }) };
- const config = loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: pgUrl, REDIS_URL: url, QUEUE_PREFIX: `mgpl-test-${process.pid}` });
+ // IMPORT_INLINE_MAX_BYTES=1: every partner PDF is read by the worker
+ const config = loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: pgUrl, REDIS_URL: url, QUEUE_PREFIX: `mgpl-test-${process.pid}`, IMPORT_INLINE_MAX_BYTES: "1" });
const api = await createContainer({ config, mailer });
const app = await buildServer(api);
const worker = startWorkers(api.jobDeps, { redisUrl: url, prefix: config.jobs.prefix, keepResultsMs: config.jobs.keepResultsMs, concurrency: 2 });
@@ -425,6 +436,23 @@ async function workerSuite(pgUrl: string) {
ok(done?.document.html.includes(" ((await r2.type(k)) === "hash" ? JSON.stringify(await r2.hgetall(k)) : "")))).join("\n");
+ ok(jobKeys.length > 0 && !dump.includes(pdfBase64.slice(100, 160)) && !dump.includes(refreshed.accessToken), `[worker] import jobs hold ids, not the PDF or a token (${jobKeys.length} keys checked)`);
+ await r2.quit();
} finally {
await worker.close();
await app.close();