From b4b72d562a2359e696c83ddecbad3353738c8a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 12:21:14 +0000 Subject: [PATCH 1/3] feat: import partner-lab reports (report-reader) importExternalReport / externalReport(s) / matchExternalReport / applyExternalReport / rejectExternalReport / externalSignatories / nameExternalSignature, and Report.provenance. - report-reader extracts in the request up to IMPORT_INLINE_MAX_BYTES, else in the worker (imports queue; the payload is the stored file's id and the actor). IMPORT_MAX_BYTES caps the PDF (15 MiB); only an importExternalReport-only operation may exceed BODY_LIMIT_BYTES. - Booking match: name without titles, sex, age +-1; differences need confirmPatientMismatch. - Apply: results as printed into our reports (status entered) with report_provenance; staff verify. Edits after import are audited; catalog defaults are not filled in. - Printing: per-test provenance line; NABL only on tests the partner marked accredited; partner signature only from the same PDF (orgId + sourceSha256 + sha), instead of ours on single-source pages. - Errors: UNSUPPORTED_REPORT, FILE_TOO_LARGE, BOOKING_PATIENT_MISMATCH, REPORT_ALREADY_APPLIED. - Tests on fictional PDFs drawn at test time; docs/18-imports.md; schema, reference, llms regenerated. - Kits: auth-kit 1.4.0, db-kit 2.1.0, report-kit 2.1.0, report-reader 0.1.0, client 1.4.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohmYiwjQLWmN2duiZcA --- .env.example | 9 + AGENTS.md | 7 +- docs/04-api-reference.md | 13 + docs/05-errors.md | 5 + docs/09-configuration.md | 5 +- docs/16-worker.md | 1 + docs/18-imports.md | 82 ++ docs/README.md | 2 + docs/examples/imports.graphql | 33 + kits.json | 12 +- llms-full.txt | 1827 ++++++++++++++++++++++++- package.json | 10 +- schema.graphql | 271 ++++ src/config/env.ts | 3 + src/container.ts | 2 +- src/controllers/imports.controller.ts | 142 ++ src/graphql/errors.ts | 7 + src/graphql/limits.ts | 30 +- src/graphql/loaders.ts | 19 +- src/graphql/schema.ts | 3 + src/jobs/bullmq.ts | 34 +- src/jobs/index.ts | 4 +- src/jobs/inline.ts | 8 +- src/jobs/processors.ts | 31 +- src/jobs/types.ts | 27 +- src/schema/imports.graphql.ts | 195 +++ src/server.ts | 12 +- src/services/document.service.ts | 41 +- src/services/import.service.ts | 453 ++++++ src/services/index.ts | 4 + src/services/report.service.ts | 11 + test/imports.ts | 164 +++ test/partner-pdfs.ts | 146 ++ test/run.ts | 30 +- 34 files changed, 3586 insertions(+), 57 deletions(-) create mode 100644 docs/18-imports.md create mode 100644 docs/examples/imports.graphql create mode 100644 src/controllers/imports.controller.ts create mode 100644 src/schema/imports.graphql.ts create mode 100644 src/services/import.service.ts create mode 100644 test/imports.ts create mode 100644 test/partner-pdfs.ts 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..8f22475 --- /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.0 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..5893d60 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.0" }, { "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..b6ecd96 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.0 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.0" }, { "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.0", "@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.json b/package.json index 5a3610e..dfec713 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.0", "@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(); From 7228bb3ea2bcce2b9208f33bb4416839a7f0c817 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 12:43:18 +0000 Subject: [PATCH 2/3] build(deps): lockfile for auth-kit 1.4.0, db-kit 2.1.0, report-kit 2.1.0, report-reader 0.1.0, client 1.4.0, pdf-lib Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohmYiwjQLWmN2duiZcA --- package-lock.json | 372 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 358 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2c34e22..4cb3b6b 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.0", "@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.0", "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", From 3a96707b47d8435560ae963384a887dc101570f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 07:38:59 +0000 Subject: [PATCH 3/3] build(deps): db-kit 2.1.1 (external_report_files without a foreign key) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohmYiwjQLWmN2duiZcA --- docs/18-imports.md | 2 +- kits.json | 2 +- llms-full.txt | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/18-imports.md b/docs/18-imports.md index 8f22475..6b23fbc 100644 --- a/docs/18-imports.md +++ b/docs/18-imports.md @@ -77,6 +77,6 @@ whose single root field is `importExternalReport`**. Every other request keeps ` ## Storage -db-kit 2.1.0 tables (both backends): `external_reports`, `external_report_files` (the PDF, loaded only +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/kits.json b/kits.json index 5893d60..020318a 100644 --- a/kits.json +++ b/kits.json @@ -23,7 +23,7 @@ }, { "name": "db-kit", - "ref": "v2.1.0" + "ref": "v2.1.1" }, { "name": "report-kit", diff --git a/llms-full.txt b/llms-full.txt index b6ecd96..b9838aa 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2303,7 +2303,7 @@ whose single root field is `importExternalReport`**. Every other request keeps ` ## Storage -db-kit 2.1.0 tables (both backends): `external_reports`, `external_report_files` (the PDF, loaded only +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`. @@ -2602,7 +2602,7 @@ flowchart TD }, { "name": "db-kit", - "ref": "v2.1.0" + "ref": "v2.1.1" }, { "name": "report-kit", @@ -2704,7 +2704,7 @@ Key facts for agents: "@graphql-tools/schema": "^10.1.5", "@mgpl-diagnostics/auth-kit": "^1.4.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", - "@mgpl-diagnostics/db-kit": "^2.1.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", diff --git a/package-lock.json b/package-lock.json index 4cb3b6b..74b096b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@graphql-tools/schema": "^10.1.5", "@mgpl-diagnostics/auth-kit": "^1.4.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", - "@mgpl-diagnostics/db-kit": "^2.1.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", @@ -1055,7 +1055,7 @@ } }, "node_modules/@mgpl-diagnostics/db-kit": { - "version": "2.1.0", + "version": "2.1.1", "license": "SEE LICENSE IN LICENSE", "engines": { "node": ">=20" diff --git a/package.json b/package.json index dfec713..7780dcf 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@graphql-tools/schema": "^10.1.5", "@mgpl-diagnostics/auth-kit": "^1.4.0", "@mgpl-diagnostics/catalog-kit": "^1.0.0", - "@mgpl-diagnostics/db-kit": "^2.1.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",