From 1178afe3c830db94636eb57dc315c44c63506cc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:28:09 +0000 Subject: [PATCH 1/2] feat(imports): find the booking a partner report belongs to; auto-match on upload (0.1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ExternalReport.candidates(first): bookings of patients found by the printed name, scored by name, sex, age ±1, partner tests that map to booked tests, and recency; reasons, mismatches, mappings - An upload without a booking is matched on its own when exactly one strong candidate stands out (audited external_report.auto_match); staff still review and apply - suggestedTestCode prefers the matched booking's tests - Lockfile: client 1.10.0 (published from client v1.10.0; hash filled on the next install) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohjC7uBfVPopxyZBtS1 --- docs/18-imports.md | 4 +- docs/examples/imports.graphql | 11 ++ llms-full.txt | 223 ++++++++++++++++++++++++-- package-lock.json | 6 +- package.json | 2 +- schema.graphql | 40 ++++- src/controllers/imports.controller.ts | 8 +- src/schema/imports.graphql.ts | 24 ++- src/services/import.service.ts | 132 ++++++++++++++- test/imports.ts | 13 ++ 10 files changed, 437 insertions(+), 26 deletions(-) diff --git a/docs/18-imports.md b/docs/18-imports.md index 6b23fbc..70d84c7 100644 --- a/docs/18-imports.md +++ b/docs/18-imports.md @@ -23,7 +23,9 @@ flowchart LR | Step | Field | Permission | Notes | |---|---|---|---| | Upload | `importExternalReport(pdfBase64, fileName, bookingId, branchId)` | `report:import` in the branch | `branchId` defaults to the booking's. The same PDF again returns the same import (by SHA-256). A PDF the reader does not support → `UNSUPPORTED_REPORT`, and its file is not kept. | -| Review | `externalReport(id)`, `externalReports(branchId, bookingId, status, first, after)` | `report:import` | Tests with parameters as printed, `suggestedTestCode` (catalog-kit's top search hit), the provenance line each test will print, signatories and `warnings`. | +| Review | `externalReport(id)`, `externalReports(branchId, bookingId, status, first, after)` | `report:import` | Tests with parameters as printed, `suggestedTestCode` (once matched, the best catalog hit among the booking's tests; otherwise the top hit), the provenance line each test will print, signatories and `warnings`. | +| Find the booking | `ExternalReport.candidates(first)` | `report:import` (lists only bookings in those branches) | Patients found by the printed name (whole, then each word), their bookings of the last 90 days that are not cancelled, scored 0 to 100: name (same, in part, first name), sex, age ±1, partner tests that map to booked tests (catalog search among the booking's codes), how recent. `strong` = name, sex and age agree and a test maps. Each candidate carries its `reasons`, `patientMismatches` and per-test `mappings`. | +| Auto-match | on upload without `bookingId` | — | When exactly one strong candidate in the upload's branch stands out (no other strong one within 20 points), the import is matched to it at once (status `matched`, audited `external_report.auto_match`). Staff still check and apply; matching another booking undoes it. Nothing is applied automatically. | | Match | `matchExternalReport(id, bookingId)` | `report:import` (both branches) | Compares the patient: name case-insensitive without titles, sex, age within ±1 year. Each difference is listed in `patientMismatches`. | | Apply | `applyExternalReport(id, mappings, confirmPatientMismatch)` | `report:import` + `report:enter` | One mapping per partner test: `{ testIndex, testCode }` (a test in the booking). Parameters map to our catalog names within the chosen test. Values, units, ranges and flags are copied **as printed**. Ranges are never recomputed. With mismatches, `confirmPatientMismatch: true` is required (otherwise `BOOKING_PATIENT_MISMATCH`). Applying twice → `REPORT_ALREADY_APPLIED`. | | Reject | `rejectExternalReport(id, reason)` | `report:import` | | diff --git a/docs/examples/imports.graphql b/docs/examples/imports.graphql index e4fa4d2..8febafa 100644 --- a/docs/examples/imports.graphql +++ b/docs/examples/imports.graphql @@ -12,6 +12,17 @@ mutation ImportPartnerReport($pdf: String!, $bookingId: ID) { } } +query PartnerReportBookings($id: ID!) { + externalReport(id: $id) { + id status booking { id bookingNo } + candidates(first: 5) { + booking { id bookingNo patient { name uhid } } + score strong reasons patientMismatches + mappings { testIndex testCode } + } + } +} + mutation ApplyPartnerReport($id: ID!) { applyExternalReport(id: $id, mappings: [{ testIndex: 0, testCode: "LIPR" }], confirmPatientMismatch: false) { id testCode status diff --git a/llms-full.txt b/llms-full.txt index 9a56f4f..e3d8afb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2381,7 +2381,9 @@ flowchart LR | Step | Field | Permission | Notes | |---|---|---|---| | Upload | `importExternalReport(pdfBase64, fileName, bookingId, branchId)` | `report:import` in the branch | `branchId` defaults to the booking's. The same PDF again returns the same import (by SHA-256). A PDF the reader does not support → `UNSUPPORTED_REPORT`, and its file is not kept. | -| Review | `externalReport(id)`, `externalReports(branchId, bookingId, status, first, after)` | `report:import` | Tests with parameters as printed, `suggestedTestCode` (catalog-kit's top search hit), the provenance line each test will print, signatories and `warnings`. | +| Review | `externalReport(id)`, `externalReports(branchId, bookingId, status, first, after)` | `report:import` | Tests with parameters as printed, `suggestedTestCode` (once matched, the best catalog hit among the booking's tests; otherwise the top hit), the provenance line each test will print, signatories and `warnings`. | +| Find the booking | `ExternalReport.candidates(first)` | `report:import` (lists only bookings in those branches) | Patients found by the printed name (whole, then each word), their bookings of the last 90 days that are not cancelled, scored 0 to 100: name (same, in part, first name), sex, age ±1, partner tests that map to booked tests (catalog search among the booking's codes), how recent. `strong` = name, sex and age agree and a test maps. Each candidate carries its `reasons`, `patientMismatches` and per-test `mappings`. | +| Auto-match | on upload without `bookingId` | — | When exactly one strong candidate in the upload's branch stands out (no other strong one within 20 points), the import is matched to it at once (status `matched`, audited `external_report.auto_match`). Staff still check and apply; matching another booking undoes it. Nothing is applied automatically. | | Match | `matchExternalReport(id, bookingId)` | `report:import` (both branches) | Compares the patient: name case-insensitive without titles, sex, age within ±1 year. Each difference is listed in `patientMismatches`. | | Apply | `applyExternalReport(id, mappings, confirmPatientMismatch)` | `report:import` + `report:enter` | One mapping per partner test: `{ testIndex, testCode }` (a test in the booking). Parameters map to our catalog names within the chosen test. Values, units, ranges and flags are copied **as printed**. Ranges are never recomputed. With mismatches, `confirmPatientMismatch: true` is required (otherwise `BOOKING_PATIENT_MISMATCH`). Applying twice → `REPORT_ALREADY_APPLIED`. | | Reject | `rejectExternalReport(id, reason)` | `report:import` | | @@ -3096,7 +3098,7 @@ Key facts for agents: ```json { "name": "@mgpl-diagnostics/api", - "version": "0.1.4", + "version": "0.1.5", "private": true, "description": "MGPL Diagnostics backend: one GraphQL endpoint (GraphQL Yoga on Fastify) over the kits (auth, db, catalog, report, invoice, finance, tally) used as internal libraries, plus a BullMQ worker on Redis.", "type": "module", @@ -3906,6 +3908,13 @@ type ExternalReport { booking: Booking branchId: ID! byteLength: Int! + + """ + Bookings it may belong to, best first, in the branches where you may import. An upload with no + bookingId is matched on its own when exactly one strong candidate stands out (status matched, + audited as external_report.auto_match); staff still review and apply. + """ + candidates(first: Int = 5): [ExternalReportCandidate!]! fileName: String id: ID! lab: ExternalLab! @@ -3936,6 +3945,32 @@ type ExternalReport { warnings: [String!]! } +""" +A booking a partner report may belong to (found by the printed patient name), with why. +""" +type ExternalReportCandidate { + booking: Booking! + + """For each partner test, the booked test it maps to (null: none).""" + mappings: [ExternalTestSuggestion!]! + + """ + Differences from this booking's patient, as matchExternalReport would record them. + """ + patientMismatches: [String!]! + reasons: [String!]! + + """ + 0 to 100: name, sex, age ±1, partner tests that map to the booked tests, how recent. + """ + score: Int! + + """ + Name, sex and age agree and at least one partner test maps to a booked test. + """ + strong: Boolean! +} + type ExternalReportConnection { nodes: [ExternalReport!]! pageInfo: PageInfo! @@ -4046,7 +4081,7 @@ type ExternalTest { signedBy: [String!]! """ - Our catalog test suggested for it (top search hit), for staff to confirm. + Our catalog test suggested for it, for staff to confirm: once matched, the best hit among the booking's tests; otherwise the top search hit. """ suggestedTestCode: String } @@ -4062,6 +4097,11 @@ input ExternalTestMappingInput { testIndex: Int! } +type ExternalTestSuggestion { + testCode: String + testIndex: Int! +} + type FinanceConfig { b2clThresholdPaise: Int! bankMaxFeeBp: Int! @@ -6930,14 +6970,18 @@ export const importsController = withErrorCodes({ patient: (e: ExternalReport) => extractedOf(e).patient ?? {}, sampleIds: (e: ExternalReport) => extractedOf(e).sample?.sampleIds ?? [], reportedAt: (e: ExternalReport) => extractedOf(e).dates?.reported ?? null, - tests: (e: ExternalReport) => { + candidates: (e: ExternalReport, a: { first?: number | null }, ctx: Ctx) => + e.status === "applied" || e.status === "rejected" ? [] : ctx.services.imports.candidates(actorOf(ctx), e, branchScope(ctx, "report:import"), Math.min(cap(a.first, 5), 10)), + tests: async (e: ExternalReport, _: unknown, ctx: Ctx) => { const r = extractedOf(e); + const booking = e.bookingId ? await ctx.loaders.booking.load(e.bookingId) : null; + const codes = booking?.tests.map((t) => t.testCode); 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), + suggestedTestCode: suggestTestCode(t.name, codes), provenance: labelOf(r, t), })); }, @@ -9336,7 +9380,7 @@ export const importsTypeDefs = /* GraphQL */ ` 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." + "Our catalog test suggested for it, for staff to confirm: once matched, the best hit among the booking's tests; otherwise the top search hit." suggestedTestCode: String provenance: ExternalProvenanceLabel! } @@ -9388,8 +9432,30 @@ export const importsTypeDefs = /* GraphQL */ ` uploadedAt: DateTime! appliedAt: DateTime rejectReason: String + """ + Bookings it may belong to, best first, in the branches where you may import. An upload with no + bookingId is matched on its own when exactly one strong candidate stands out (status matched, + audited as external_report.auto_match); staff still review and apply. + """ + candidates(first: Int = 5): [ExternalReportCandidate!]! } + "A booking a partner report may belong to (found by the printed patient name), with why." + type ExternalReportCandidate { + booking: Booking! + "0 to 100: name, sex, age ±1, partner tests that map to the booked tests, how recent." + score: Int! + "Name, sex and age agree and at least one partner test maps to a booked test." + strong: Boolean! + reasons: [String!]! + "Differences from this booking's patient, as matchExternalReport would record them." + patientMismatches: [String!]! + "For each partner test, the booked test it maps to (null: none)." + mappings: [ExternalTestSuggestion!]! + } + + type ExternalTestSuggestion { testIndex: Int!, testCode: String } + type PageInfo { hasNextPage: Boolean!, endCursor: String } type ExternalReportConnection { nodes: [ExternalReport!]!, pageInfo: PageInfo! } @@ -11396,11 +11462,89 @@ export function mapParameter(testCode: string, name: string): string { 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 { +/** + * The catalog test suggested for a partner test, for staff to confirm. With the booking's test codes, the + * best search hit among them ("LIVER FUNCTION TEST" → the booking's LFT); otherwise the top hit. + */ +export function suggestTestCode(name: string, bookingCodes?: readonly string[]): string | null { + if (bookingCodes?.length) { + const hit = catalogSearch(name, { limit: 10 }).find((h) => h.test.code && bookingCodes.includes(h.test.code)); + if (hit) return hit.test.code!; + } return catalogSearch(name, { limit: 1 })[0]?.test.code ?? null; } +/** A booking that may be the one a partner report belongs to, with why. */ +export interface BookingCandidate { + booking: Booking; + /** 0 to 100 */ + score: number; + reasons: string[]; + patientMismatches: string[]; + /** For each partner test (by index), the booking's test it maps to, if any. */ + mappings: { testIndex: number; testCode: string | null }[]; + /** Name, sex and age agree and at least one test maps: safe to suggest first. */ + strong: boolean; +} + +/** How alike two names are, without titles: 50 same, 35 one contains the other's words, 15 same first name, else 0. */ +export function nameScore(a: string | null | undefined, b: string | null | undefined): number { + const x = normName(a), y = normName(b); + if (!x || !y) return 0; + if (x === y) return 50; + const xs = x.split(" "), ys = y.split(" "); + const within = (p: string[], q: string[]) => p.length > 1 && p.every((w) => q.includes(w)); + if (within(xs, ys) || within(ys, xs)) return 35; + if (xs[0] === ys[0] && xs[0]!.length > 2) return 15; + return 0; +} + +/** + * Scores one booking against a partner report: name, sex, age (±1), the partner tests that map to the + * booked tests, and how recent the booking is. Pure, so it is tested without a database. + */ +export function scoreCandidate(report: ExtractedReport, booking: Booking, patient: Patient, at = new Date()): BookingCandidate { + const reasons: string[] = []; + let score = 0; + const ns = nameScore(report.patient.name, patient.name); + score += ns; + if (ns === 50) reasons.push("same name"); + else if (ns === 35) reasons.push("name matches in part"); + else if (ns === 15) reasons.push("same first name"); + let agree = ns >= 35; + if (report.patient.sex && patient.sex !== "other") { + if (report.patient.sex === patient.sex) (score += 10), reasons.push("same sex"); + else (score -= 30), (agree = false); + } + const ours = ageYears(patient, at); + if (report.patient.ageYears != null && ours != null) { + if (Math.abs(report.patient.ageYears - ours) <= 1) (score += 10), reasons.push(`age ${ours}`); + else (score -= 20), (agree = false); + } + const codes = booking.tests.map((t) => t.testCode); + const mappings = report.tests.map((t, testIndex) => { + const c = suggestTestCode(t.name, codes); + return { testIndex, testCode: c && codes.includes(c) ? c : null }; + }); + const mapped = [...new Set(mappings.map((m) => m.testCode).filter((c): c is string => !!c))]; + if (mapped.length) { + score += Math.min(30, 15 * mapped.length); + reasons.push(`booked ${mapped.join(", ")}`); + } else score -= 10; + const days = (at.getTime() - new Date(booking.createdAt).getTime()) / 86_400_000; + if (days <= 7) (score += 10), reasons.push(days < 1 ? "booked today" : `booked ${Math.round(days)} day${Math.round(days) === 1 ? "" : "s"} ago`); + else if (days <= 30) score += 5; + if (booking.status === "completed") score -= 15; + return { + booking, score: Math.max(0, Math.min(100, score)), reasons, mappings, + patientMismatches: patientMismatches(report, patient, at), + strong: agree && mapped.length > 0, + }; +} + +/** Bookings an import can be tied to: open ones (not cancelled), newest first. */ +const OPEN = new Set(["booked", "sample_collected", "in_process", "completed"]); + const sha256 = (b: Uint8Array) => createHash("sha256").update(b).digest("hex"); /** db-kit registry rows → report-reader's KnownSignature (data, never source code). */ @@ -11513,7 +11657,7 @@ export class ImportService { 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 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, @@ -11532,6 +11676,50 @@ export class ImportService { 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; }); + if (booking) return row; + // no booking given: tie it to the one booking it clearly belongs to, if there is exactly one + const found = await this.autoMatchFor(actor, row); + return found ? this.match(actor, row, found, true) : row; + } + + /** + * Bookings this partner report may belong to, best first: patients found by the printed name (whole, + * then each word of 3+ letters), their recent bookings in `scope`, scored by name, sex, age and the + * booked tests (scoreCandidate). Candidates with nothing in common are left out. + */ + async candidates(actor: Actor, ext: ExternalReport, scope: "all" | Id[], limit = 5): Promise { + const report = ext.extracted as unknown as ExtractedReport; + const name = normName(report.patient.name); + if (!name) return []; + const patients = new Map(); + for (const q of [name, ...name.split(" ").filter((w) => w.length >= 3)]) { + for (const p of await this.repos.patients.search(actor.orgId, q, 20)) if (p.orgId === actor.orgId) patients.set(p.id, p); + if (patients.size >= 40) break; + } + const since = Date.now() - 90 * 86_400_000; + const out: BookingCandidate[] = []; + for (const p of patients.values()) { + if (!nameScore(report.patient.name, p.name)) continue; + const bookings = await this.repos.bookings.find({ orgId: actor.orgId, patientId: p.id } as never, { sort: { field: "createdAt", dir: "desc" }, limit: 5 }); + for (const b of bookings) { + if (!OPEN.has(b.status) || new Date(b.createdAt).getTime() < since) continue; + if (scope !== "all" && !scope.includes(b.branchId)) continue; + const c = scoreCandidate(report, b, p); + if (c.score > 0) out.push(c); + } + } + return out.sort((a, b) => Number(b.strong) - Number(a.strong) || b.score - a.score).slice(0, limit); + } + + /** + * The one booking an upload clearly belongs to, or null: a strong candidate in the upload's branch with + * no other strong candidate within 20 points. Staff still review and apply; matching is reversible. + */ + async autoMatchFor(actor: Actor, ext: ExternalReport): Promise { + const [top, next] = await this.candidates(actor, ext, [ext.branchId], 2); + if (!top?.strong) return null; + if (next?.strong && next.score > top.score - 20) return null; + return top.booking; } private async bookingWarnings(report: ExtractedReport, booking: Booking): Promise { @@ -11540,13 +11728,13 @@ export class ImportService { } /** 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 { + async match(actor: Actor, ext: ExternalReport, booking: Booking, auto = false): 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 } }); + await this.repos.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: auto ? "external_report.auto_match" : "external_report.match", entity: "external_report", entityId: ext.id, data: { bookingId: booking.id } }); return out!; } @@ -12733,6 +12921,19 @@ export async function importSuite(c: ImportCtx) { 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")); + + // ------------------------------------------------------------ no booking given: candidates and auto-match + const copyPdf = await PDFDocument.load(clinova); + copyPdf.setTitle("copy"); + const auto = (await gql(tech, IMPORT, { pdf: b64(await copyPdf.save()), br: c.branchA })).data?.importExternalReport; + ok(auto?.status === "matched" && auto.booking?.id === booking.id && auto.patientMismatches.length === 0, t("upload with no booking → matched on its own to the one booking it fits")); + ok((await c.repos.audit.find({ orgId: c.orgId, action: "external_report.auto_match" } as never)).length === 1, t("…audited as external_report.auto_match")); + const cands = (await gql(tech, `query($id: ID!) { externalReport(id: $id) { candidates { booking { id } score strong reasons patientMismatches mappings { testIndex testCode } } tests { index suggestedTestCode } } }`, { id: auto?.id })).data?.externalReport; + const top = cands?.candidates[0]; + ok(top?.booking.id === booking.id && top.strong && top.score >= 70 && top.reasons.includes("same name") && top.mappings.some((m: any) => m.testCode === "LIPR") && top.mappings.some((m: any) => m.testCode === "ESR"), t(`candidates: the booking first, strong, score ${top?.score}, tests mapped to LIPR and ESR`)); + ok(cands?.tests.every((x: any) => ["LIPR", "ESR"].includes(x.suggestedTestCode)), t("matched → suggested codes come from the booking's tests")); + ok((await gql(otherOwner, `query($id: ID!) { externalReport(id: $id) { candidates { score } } }`, { id: auto?.id })).data?.externalReport == null, t("another organisation sees no candidates")); + await gql(tech, "mutation($id: ID!) { rejectExternalReport(id: $id, reason: \"Duplicate\") { status } }", { id: auto?.id }); 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")); diff --git a/package-lock.json b/package-lock.json index d876cf5..25c57c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@mgpl-diagnostics/api", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { @@ -1019,9 +1019,7 @@ } }, "node_modules/@mgpl-diagnostics/client": { - "version": "1.9.0", - "resolved": "https://npm.pkg.github.com/download/@mgpl-diagnostics/client/1.9.0/185a20d1c229a379803e8d09f21b1f68f780cd84", - "integrity": "sha512-ZuLFb6ih6f7i7+1Zy8FMUk9S2UN+Tn6k09YGR/wklPGL+AfC2jros3YgGuUSLibjfkLj5MIh2ulIZJVy3PDCJA==", + "version": "1.10.0", "dev": true, "license": "SEE LICENSE IN LICENSE", "engines": { diff --git a/package.json b/package.json index 2106d0a..4802571 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mgpl-diagnostics/api", - "version": "0.1.4", + "version": "0.1.5", "private": true, "description": "MGPL Diagnostics backend: one GraphQL endpoint (GraphQL Yoga on Fastify) over the kits (auth, db, catalog, report, invoice, finance, tally) used as internal libraries, plus a BullMQ worker on Redis.", "type": "module", diff --git a/schema.graphql b/schema.graphql index 0eab822..3d5131e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -738,6 +738,13 @@ type ExternalReport { booking: Booking branchId: ID! byteLength: Int! + + """ + Bookings it may belong to, best first, in the branches where you may import. An upload with no + bookingId is matched on its own when exactly one strong candidate stands out (status matched, + audited as external_report.auto_match); staff still review and apply. + """ + candidates(first: Int = 5): [ExternalReportCandidate!]! fileName: String id: ID! lab: ExternalLab! @@ -768,6 +775,32 @@ type ExternalReport { warnings: [String!]! } +""" +A booking a partner report may belong to (found by the printed patient name), with why. +""" +type ExternalReportCandidate { + booking: Booking! + + """For each partner test, the booked test it maps to (null: none).""" + mappings: [ExternalTestSuggestion!]! + + """ + Differences from this booking's patient, as matchExternalReport would record them. + """ + patientMismatches: [String!]! + reasons: [String!]! + + """ + 0 to 100: name, sex, age ±1, partner tests that map to the booked tests, how recent. + """ + score: Int! + + """ + Name, sex and age agree and at least one partner test maps to a booked test. + """ + strong: Boolean! +} + type ExternalReportConnection { nodes: [ExternalReport!]! pageInfo: PageInfo! @@ -878,7 +911,7 @@ type ExternalTest { signedBy: [String!]! """ - Our catalog test suggested for it (top search hit), for staff to confirm. + Our catalog test suggested for it, for staff to confirm: once matched, the best hit among the booking's tests; otherwise the top search hit. """ suggestedTestCode: String } @@ -894,6 +927,11 @@ input ExternalTestMappingInput { testIndex: Int! } +type ExternalTestSuggestion { + testCode: String + testIndex: Int! +} + type FinanceConfig { b2clThresholdPaise: Int! bankMaxFeeBp: Int! diff --git a/src/controllers/imports.controller.ts b/src/controllers/imports.controller.ts index 633c2cb..ae03cc2 100644 --- a/src/controllers/imports.controller.ts +++ b/src/controllers/imports.controller.ts @@ -104,14 +104,18 @@ export const importsController = withErrorCodes({ patient: (e: ExternalReport) => extractedOf(e).patient ?? {}, sampleIds: (e: ExternalReport) => extractedOf(e).sample?.sampleIds ?? [], reportedAt: (e: ExternalReport) => extractedOf(e).dates?.reported ?? null, - tests: (e: ExternalReport) => { + candidates: (e: ExternalReport, a: { first?: number | null }, ctx: Ctx) => + e.status === "applied" || e.status === "rejected" ? [] : ctx.services.imports.candidates(actorOf(ctx), e, branchScope(ctx, "report:import"), Math.min(cap(a.first, 5), 10)), + tests: async (e: ExternalReport, _: unknown, ctx: Ctx) => { const r = extractedOf(e); + const booking = e.bookingId ? await ctx.loaders.booking.load(e.bookingId) : null; + const codes = booking?.tests.map((t) => t.testCode); 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), + suggestedTestCode: suggestTestCode(t.name, codes), provenance: labelOf(r, t), })); }, diff --git a/src/schema/imports.graphql.ts b/src/schema/imports.graphql.ts index 167d7fe..31b671d 100644 --- a/src/schema/imports.graphql.ts +++ b/src/schema/imports.graphql.ts @@ -59,7 +59,7 @@ export const importsTypeDefs = /* GraphQL */ ` 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." + "Our catalog test suggested for it, for staff to confirm: once matched, the best hit among the booking's tests; otherwise the top search hit." suggestedTestCode: String provenance: ExternalProvenanceLabel! } @@ -111,8 +111,30 @@ export const importsTypeDefs = /* GraphQL */ ` uploadedAt: DateTime! appliedAt: DateTime rejectReason: String + """ + Bookings it may belong to, best first, in the branches where you may import. An upload with no + bookingId is matched on its own when exactly one strong candidate stands out (status matched, + audited as external_report.auto_match); staff still review and apply. + """ + candidates(first: Int = 5): [ExternalReportCandidate!]! + } + + "A booking a partner report may belong to (found by the printed patient name), with why." + type ExternalReportCandidate { + booking: Booking! + "0 to 100: name, sex, age ±1, partner tests that map to the booked tests, how recent." + score: Int! + "Name, sex and age agree and at least one partner test maps to a booked test." + strong: Boolean! + reasons: [String!]! + "Differences from this booking's patient, as matchExternalReport would record them." + patientMismatches: [String!]! + "For each partner test, the booked test it maps to (null: none)." + mappings: [ExternalTestSuggestion!]! } + type ExternalTestSuggestion { testIndex: Int!, testCode: String } + type PageInfo { hasNextPage: Boolean!, endCursor: String } type ExternalReportConnection { nodes: [ExternalReport!]!, pageInfo: PageInfo! } diff --git a/src/services/import.service.ts b/src/services/import.service.ts index a8a4893..3fcfc30 100644 --- a/src/services/import.service.ts +++ b/src/services/import.service.ts @@ -123,11 +123,89 @@ export function mapParameter(testCode: string, name: string): string { 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 { +/** + * The catalog test suggested for a partner test, for staff to confirm. With the booking's test codes, the + * best search hit among them ("LIVER FUNCTION TEST" → the booking's LFT); otherwise the top hit. + */ +export function suggestTestCode(name: string, bookingCodes?: readonly string[]): string | null { + if (bookingCodes?.length) { + const hit = catalogSearch(name, { limit: 10 }).find((h) => h.test.code && bookingCodes.includes(h.test.code)); + if (hit) return hit.test.code!; + } return catalogSearch(name, { limit: 1 })[0]?.test.code ?? null; } +/** A booking that may be the one a partner report belongs to, with why. */ +export interface BookingCandidate { + booking: Booking; + /** 0 to 100 */ + score: number; + reasons: string[]; + patientMismatches: string[]; + /** For each partner test (by index), the booking's test it maps to, if any. */ + mappings: { testIndex: number; testCode: string | null }[]; + /** Name, sex and age agree and at least one test maps: safe to suggest first. */ + strong: boolean; +} + +/** How alike two names are, without titles: 50 same, 35 one contains the other's words, 15 same first name, else 0. */ +export function nameScore(a: string | null | undefined, b: string | null | undefined): number { + const x = normName(a), y = normName(b); + if (!x || !y) return 0; + if (x === y) return 50; + const xs = x.split(" "), ys = y.split(" "); + const within = (p: string[], q: string[]) => p.length > 1 && p.every((w) => q.includes(w)); + if (within(xs, ys) || within(ys, xs)) return 35; + if (xs[0] === ys[0] && xs[0]!.length > 2) return 15; + return 0; +} + +/** + * Scores one booking against a partner report: name, sex, age (±1), the partner tests that map to the + * booked tests, and how recent the booking is. Pure, so it is tested without a database. + */ +export function scoreCandidate(report: ExtractedReport, booking: Booking, patient: Patient, at = new Date()): BookingCandidate { + const reasons: string[] = []; + let score = 0; + const ns = nameScore(report.patient.name, patient.name); + score += ns; + if (ns === 50) reasons.push("same name"); + else if (ns === 35) reasons.push("name matches in part"); + else if (ns === 15) reasons.push("same first name"); + let agree = ns >= 35; + if (report.patient.sex && patient.sex !== "other") { + if (report.patient.sex === patient.sex) (score += 10), reasons.push("same sex"); + else (score -= 30), (agree = false); + } + const ours = ageYears(patient, at); + if (report.patient.ageYears != null && ours != null) { + if (Math.abs(report.patient.ageYears - ours) <= 1) (score += 10), reasons.push(`age ${ours}`); + else (score -= 20), (agree = false); + } + const codes = booking.tests.map((t) => t.testCode); + const mappings = report.tests.map((t, testIndex) => { + const c = suggestTestCode(t.name, codes); + return { testIndex, testCode: c && codes.includes(c) ? c : null }; + }); + const mapped = [...new Set(mappings.map((m) => m.testCode).filter((c): c is string => !!c))]; + if (mapped.length) { + score += Math.min(30, 15 * mapped.length); + reasons.push(`booked ${mapped.join(", ")}`); + } else score -= 10; + const days = (at.getTime() - new Date(booking.createdAt).getTime()) / 86_400_000; + if (days <= 7) (score += 10), reasons.push(days < 1 ? "booked today" : `booked ${Math.round(days)} day${Math.round(days) === 1 ? "" : "s"} ago`); + else if (days <= 30) score += 5; + if (booking.status === "completed") score -= 15; + return { + booking, score: Math.max(0, Math.min(100, score)), reasons, mappings, + patientMismatches: patientMismatches(report, patient, at), + strong: agree && mapped.length > 0, + }; +} + +/** Bookings an import can be tied to: open ones (not cancelled), newest first. */ +const OPEN = new Set(["booked", "sample_collected", "in_process", "completed"]); + const sha256 = (b: Uint8Array) => createHash("sha256").update(b).digest("hex"); /** db-kit registry rows → report-reader's KnownSignature (data, never source code). */ @@ -240,7 +318,7 @@ export class ImportService { 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 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, @@ -259,6 +337,50 @@ export class ImportService { 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; }); + if (booking) return row; + // no booking given: tie it to the one booking it clearly belongs to, if there is exactly one + const found = await this.autoMatchFor(actor, row); + return found ? this.match(actor, row, found, true) : row; + } + + /** + * Bookings this partner report may belong to, best first: patients found by the printed name (whole, + * then each word of 3+ letters), their recent bookings in `scope`, scored by name, sex, age and the + * booked tests (scoreCandidate). Candidates with nothing in common are left out. + */ + async candidates(actor: Actor, ext: ExternalReport, scope: "all" | Id[], limit = 5): Promise { + const report = ext.extracted as unknown as ExtractedReport; + const name = normName(report.patient.name); + if (!name) return []; + const patients = new Map(); + for (const q of [name, ...name.split(" ").filter((w) => w.length >= 3)]) { + for (const p of await this.repos.patients.search(actor.orgId, q, 20)) if (p.orgId === actor.orgId) patients.set(p.id, p); + if (patients.size >= 40) break; + } + const since = Date.now() - 90 * 86_400_000; + const out: BookingCandidate[] = []; + for (const p of patients.values()) { + if (!nameScore(report.patient.name, p.name)) continue; + const bookings = await this.repos.bookings.find({ orgId: actor.orgId, patientId: p.id } as never, { sort: { field: "createdAt", dir: "desc" }, limit: 5 }); + for (const b of bookings) { + if (!OPEN.has(b.status) || new Date(b.createdAt).getTime() < since) continue; + if (scope !== "all" && !scope.includes(b.branchId)) continue; + const c = scoreCandidate(report, b, p); + if (c.score > 0) out.push(c); + } + } + return out.sort((a, b) => Number(b.strong) - Number(a.strong) || b.score - a.score).slice(0, limit); + } + + /** + * The one booking an upload clearly belongs to, or null: a strong candidate in the upload's branch with + * no other strong candidate within 20 points. Staff still review and apply; matching is reversible. + */ + async autoMatchFor(actor: Actor, ext: ExternalReport): Promise { + const [top, next] = await this.candidates(actor, ext, [ext.branchId], 2); + if (!top?.strong) return null; + if (next?.strong && next.score > top.score - 20) return null; + return top.booking; } private async bookingWarnings(report: ExtractedReport, booking: Booking): Promise { @@ -267,13 +389,13 @@ export class ImportService { } /** 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 { + async match(actor: Actor, ext: ExternalReport, booking: Booking, auto = false): 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 } }); + await this.repos.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: auto ? "external_report.auto_match" : "external_report.match", entity: "external_report", entityId: ext.id, data: { bookingId: booking.id } }); return out!; } diff --git a/test/imports.ts b/test/imports.ts index 3fa7f39..2d25d71 100644 --- a/test/imports.ts +++ b/test/imports.ts @@ -84,6 +84,19 @@ export async function importSuite(c: ImportCtx) { 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")); + + // ------------------------------------------------------------ no booking given: candidates and auto-match + const copyPdf = await PDFDocument.load(clinova); + copyPdf.setTitle("copy"); + const auto = (await gql(tech, IMPORT, { pdf: b64(await copyPdf.save()), br: c.branchA })).data?.importExternalReport; + ok(auto?.status === "matched" && auto.booking?.id === booking.id && auto.patientMismatches.length === 0, t("upload with no booking → matched on its own to the one booking it fits")); + ok((await c.repos.audit.find({ orgId: c.orgId, action: "external_report.auto_match" } as never)).length === 1, t("…audited as external_report.auto_match")); + const cands = (await gql(tech, `query($id: ID!) { externalReport(id: $id) { candidates { booking { id } score strong reasons patientMismatches mappings { testIndex testCode } } tests { index suggestedTestCode } } }`, { id: auto?.id })).data?.externalReport; + const top = cands?.candidates[0]; + ok(top?.booking.id === booking.id && top.strong && top.score >= 70 && top.reasons.includes("same name") && top.mappings.some((m: any) => m.testCode === "LIPR") && top.mappings.some((m: any) => m.testCode === "ESR"), t(`candidates: the booking first, strong, score ${top?.score}, tests mapped to LIPR and ESR`)); + ok(cands?.tests.every((x: any) => ["LIPR", "ESR"].includes(x.suggestedTestCode)), t("matched → suggested codes come from the booking's tests")); + ok((await gql(otherOwner, `query($id: ID!) { externalReport(id: $id) { candidates { score } } }`, { id: auto?.id })).data?.externalReport == null, t("another organisation sees no candidates")); + await gql(tech, "mutation($id: ID!) { rejectExternalReport(id: $id, reason: \"Duplicate\") { status } }", { id: auto?.id }); 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")); From 2ea0b8677d326c6cc4e7f6e4b62eca93b21f0a34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:48:13 +0000 Subject: [PATCH 2/2] feat: patient links, the QR on reports and bills opens the patient's page - patientPage(token) and patientDocument(token, kind, invoiceId): public, rate-limited per IP (PATIENT_LINK_PER_IP), null for any token that does not open; one booking's issued bills and released reports only, the patient's name shortened. - patientLink(bookingId) (booking:read + report:read) and revokePatientLinks(bookingId) (booking:update, audited). - Tokens are HMAC(PATIENT_LINK_SECRET, nonce); db-kit stores only the SHA-256 and the nonce. - Printed reports and a booking's bills carry the link in their QR when PATIENT_LINK_SECRET and PATIENT_LINK_BASE_URL are set. - Kits: db-kit ^2.2.0, report-kit ^2.3.0, invoice-kit ^2.2.0, client ^1.12.0. - docs/21-patient-links.md, configuration, example, schema, reference and llms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohjC7uBfVPopxyZBtS1 --- .env.example | 11 + AGENTS.md | 5 +- docs/04-api-reference.md | 9 + docs/08-documents.md | 7 + docs/09-configuration.md | 4 + docs/21-patient-links.md | 55 ++ docs/README.md | 1 + docs/examples/patient-links.graphql | 24 + kits.json | 8 +- llms-full.txt | 603 +++++++++++++++++++- package-lock.json | 22 +- package.json | 8 +- schema.graphql | 72 +++ src/config/env.ts | 26 + src/container.ts | 8 +- src/controllers/patient-links.controller.ts | 37 ++ src/graphql/errors.ts | 2 + src/graphql/limits.ts | 2 +- src/graphql/schema.ts | 3 + src/schema/patient-links.graphql.ts | 66 +++ src/services/document.service.ts | 42 +- src/services/index.ts | 11 +- src/services/patient-link.service.ts | 165 ++++++ test/run.ts | 36 +- 24 files changed, 1173 insertions(+), 54 deletions(-) create mode 100644 docs/21-patient-links.md create mode 100644 docs/examples/patient-links.graphql create mode 100644 src/controllers/patient-links.controller.ts create mode 100644 src/schema/patient-links.graphql.ts create mode 100644 src/services/patient-link.service.ts diff --git a/.env.example b/.env.example index b03714f..8402e2d 100644 --- a/.env.example +++ b/.env.example @@ -151,6 +151,17 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password # IMPORT_WAIT_MS=60000 +# ── Patient links: the QR on reports and bills opens the patient's page (docs/21-patient-links.md) ────── +# [optional] [secret] HMAC key for the link tokens, 32+ characters. Changing it stops every printed QR. +# PATIENT_LINK_SECRET= +# [optional] The dashboard's public page; the token is appended. Links are off unless both are set. +# PATIENT_LINK_BASE_URL=https://dashboard.example.in/r/ +# [optional] How long a link works, in days. +# PATIENT_LINK_DAYS=180 +# [optional] Patient page calls per IP per 10 minutes. +# PATIENT_LINK_PER_IP=60 + + # ── Report and invoice signatures (report-kit) ─────────────────────────────────────────────────────── # [optional] [private files] Real signature images (.png, .jpg or .svg), kept OUTSIDE the repo and mounted # read-only. Without them, documents use the placeholder signature shipped in report-kit. diff --git a/AGENTS.md b/AGENTS.md index 9804bdc..d1997ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,10 @@ Dockerfile, docker-compose.yml, deploy/ image; PostgreSQL ← PgBouncer ← N accredited, never a logo; a partner signature only with results from its own PDF (looked up by that PDF's sha256), never as ours; Reported by = medical signatories; imports never verify or release. PDFs, extracted JSON and signature PNGs are patient data: never logged, never in jobs, fixtures or repos. -16. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names. +16. **Patient links** (docs/21-patient-links.md): the token is the only key and is never stored, logged or put in a job + (db-kit keeps its SHA-256 and the nonce). `patientPage` / `patientDocument` are public: rate-limited per IP, null for + any token that does not open, one booking only, released reports and issued bills only. +17. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names. ## Commands diff --git a/docs/04-api-reference.md b/docs/04-api-reference.md index cba6ee6..db9aae6 100644 --- a/docs/04-api-reference.md +++ b/docs/04-api-reference.md @@ -210,6 +210,15 @@ and runnable examples are in [examples/](examples/). | mutation | `nameExternalSignature(input: ExternalSignatoryInput!): ExternalSignatory!` | `signatory:manage` | Names a partner signature; the organisation's unapplied imports that carry it are read again. | | mutation | `rejectExternalReport(id: ID!, reason: String!): ExternalReport!` | `report:import` | | +## patientLinks + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `patientDocument(token: String!, kind: PatientDocumentKind!, invoiceId: ID): Document` | public | Public (no sign-in): a PDF from a patient link: one of its bills (invoiceId) or its released reports. Null when the link does not open. | +| query | `patientLink(bookingId: ID!): PatientLinkInfo!` | `booking:read`, `report:read` | The booking's patient link (made on first use). Needs booking:read and report:read on the booking's branch. | +| query | `patientPage(token: String!): PatientPage` | public | Public (no sign-in): the page behind a patient link, or null. | +| mutation | `revokePatientLinks(bookingId: ID!): Int!` | `booking:update` | Stops every link of the booking (printed QRs stop opening); the next print makes a new one. Returns how many were stopped. Needs booking:update. | + ## system | Kind | Operation | Permission | Notes | diff --git a/docs/08-documents.md b/docs/08-documents.md index 9ffb57b..a6ce722 100644 --- a/docs/08-documents.md +++ b/docs/08-documents.md @@ -59,6 +59,13 @@ The stored invoice (paise) is mapped to invoice-kit's print shape (rupees). invo totals; the API checks that its total equals the stored total and refuses a mismatch (`CONFLICT`), so a printed invoice can never disagree with the books. +## The QR code + +With patient links on (`PATIENT_LINK_SECRET` and `PATIENT_LINK_BASE_URL`), the QR on a printed report and on +every bill of a booking opens the patient's page for that booking ([21-patient-links.md](21-patient-links.md)). +Printing again prints the same link. Without them, the QR keeps its old summary. A bill's UPI pay QR (when the +lab sets `MGPL_UPI_ID`) is separate and unchanged. + ## Ad-hoc rendering `renderReport(request)` and `renderInvoice(request)` take the same JSON as report-kit's and invoice-kit's diff --git a/docs/09-configuration.md b/docs/09-configuration.md index 792cbb2..e1e9db9 100644 --- a/docs/09-configuration.md +++ b/docs/09-configuration.md @@ -33,6 +33,10 @@ variables already set in the real environment win over the file. | `IMPORT_MAX_BYTES` | `15728640` (15 MiB) | Largest partner-lab PDF ([18](18-imports.md)) | | `IMPORT_INLINE_MAX_BYTES` | `5242880` (5 MiB) | Larger partner PDFs are read by the worker when Redis is configured | | `IMPORT_WAIT_MS` | `60000` | How long `importExternalReport` waits for the worker | +| `PATIENT_LINK_SECRET` | empty (off) | 32+ characters: HMAC key for patient link tokens ([21](21-patient-links.md)). Links are on only with `PATIENT_LINK_BASE_URL` too | +| `PATIENT_LINK_BASE_URL` | empty (off) | The public page the token is appended to, e.g. `https://dashboard.example.in/r/` (https) | +| `PATIENT_LINK_DAYS` | `180` | How long a patient link works | +| `PATIENT_LINK_PER_IP` | `60` | Patient page calls per IP per 10 minutes | | `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored. Through a trusted proxy, its `X-Request-ID` (when well-formed) becomes the request id; otherwise each request gets a UUIDv7, sent back in `x-request-id` and logged as `reqId` | | `TLS_FINGERPRINT_HEADER` | empty (off) | Header the TLS-terminating proxy sets to the client's JA4 (or JA3) TLS fingerprint, e.g. `x-ja4`. Read only through `TRUST_PROXY` proxies (refused at startup without one); stored hashed on the session, a change audited ([06-auth-and-permissions.md](06-auth-and-permissions.md#tls-fingerprint)) | | `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` | diff --git a/docs/21-patient-links.md b/docs/21-patient-links.md new file mode 100644 index 0000000..9cb34f6 --- /dev/null +++ b/docs/21-patient-links.md @@ -0,0 +1,55 @@ +# 21. Patient links + +The QR printed on a booking's reports and bills opens a public, read-only page: that booking's bills and +released reports, and nothing else. The patient needs no account and no password; the link is the key. + +```mermaid +flowchart LR + P[Report or bill printed] -->|QR = PATIENT_LINK_BASE_URL + token| Q[Patient scans] + Q --> D[Dashboard /r/token] + D -->|patientPage, patientDocument| A[api] + A -->|sha256 token, then HMAC check| L[(patient_links)] +``` + +## Turning it on + +| Variable | Default | | +|---|---|---| +| `PATIENT_LINK_SECRET` | empty (off) | HMAC key for the tokens, 32+ characters. Keep it private; changing it stops every printed QR (the next print makes new links) | +| `PATIENT_LINK_BASE_URL` | empty (off) | The public page the token is appended to, e.g. `https://dashboard.example.in/r/`. https only (localhost may be http) | +| `PATIENT_LINK_DAYS` | `180` | How long a link works | +| `PATIENT_LINK_PER_IP` | `60` | Patient page calls per IP per 10 minutes (shared through Redis when `REDIS_URL` is set) | + +With both the secret and the base URL set, every printed report (`bookingReportDocument`) and every bill of a +booking (`invoiceDocument`) carries the link in its QR, instead of the old summary QR. Without them nothing +changes. + +## How a link is made and checked + +- The token is `HMAC-SHA256(PATIENT_LINK_SECRET, nonce)` as base64url (43 characters); the nonce is 24 random bytes. +- db-kit stores only `sha256(token)` and the nonce ([db-kit docs/PATIENT-LINKS.md](https://github.com/mgpl-diagnostics/db-kit/blob/main/docs/PATIENT-LINKS.md)). + A copy of the database alone cannot open a link, and the api can print the same link again. +- A booking keeps one live link: printing twice prints the same QR. A link made with another secret is replaced. +- Opening looks the row up by hash, checks it is live (not expired, not revoked, booking not cancelled), then + compares the token with the HMAC of the row's nonce in constant time. +- Unknown, expired, revoked and malformed tokens all answer `null`. Every patient call counts against the + caller's IP, good token or not (`RATE_LIMITED` with `retryAfterMs`). +- Tokens and nonces are never logged and never put in jobs. + +## What the patient sees + +`patientPage(token)`: the lab's name, the patient's shortened name ("Ravi K. S."), the booking number and date, +the branch's name, phone and address, the issued bills (drafts and cancelled bills are left out), the released +reports (verified ones are not given out yet), and the tests still in the lab. Each call counts a view. + +`patientDocument(token, kind, invoiceId)`: a PDF of one of those bills (`kind: invoice`) or of the released +reports printed together (`kind: reports`). An id from anywhere else answers `NOT_FOUND`. + +## Staff + +| Field | Permission | | +|---|---|---| +| `patientLink(bookingId)` | `booking:read` + `report:read` on the booking's branch | The link to send or show, made on first use | +| `revokePatientLinks(bookingId)` | `booking:update` | Stops every link of the booking at once (printed QRs stop opening); audited as `patient_link.revoke`. The next print makes a new link | + +See [examples/patient-links.graphql](examples/patient-links.graphql). diff --git a/docs/README.md b/docs/README.md index 681a214..8ac17ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -46,5 +46,6 @@ flowchart TD | 18 | [Partner-lab imports](18-imports.md) | Reading partner lab PDFs, matching, applying with provenance, NABL and signature rules | | 19 | [Releases and updates](19-updates.md) | Docker images on GHCR, hosting with Compose, the update notice, releasing | | 20 | [Hosting on a VPS](20-vps.md) | One server with Docker Compose: production .env, first lab, HTTPS, backups, updates | +| 21 | [Patient links](21-patient-links.md) | The QR on reports and bills: a public page with one booking's bills and released reports | | — | [examples/](examples/) | Runnable `.graphql` operations | | — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo | diff --git a/docs/examples/patient-links.graphql b/docs/examples/patient-links.graphql new file mode 100644 index 0000000..48655e1 --- /dev/null +++ b/docs/examples/patient-links.graphql @@ -0,0 +1,24 @@ +# Patient links (docs/21-patient-links.md). Variables: $bookingId (staff), $token (public, from the URL), $invoiceId. + +query BookingPatientLink($bookingId: ID!) { + patientLink(bookingId: $bookingId) { url expiresAt } +} + +mutation StopPatientLinks($bookingId: ID!) { + revokePatientLinks(bookingId: $bookingId) +} + +# No sign-in: the token is the key. Null when the link does not open. +query PatientPage($token: String!) { + patientPage(token: $token) { + labName patientName bookingNo bookedAt + branch { name phone address } + invoices { id invoiceNo issuedAt totalPaise paidPaise duePaise status } + reports { id testName releasedAt } + pending expiresAt + } +} + +query PatientBill($token: String!, $invoiceId: ID!) { + patientDocument(token: $token, kind: invoice, invoiceId: $invoiceId) { fileName mimeType base64 } +} diff --git a/kits.json b/kits.json index d9a2119..887b007 100644 --- a/kits.json +++ b/kits.json @@ -23,11 +23,11 @@ }, { "name": "db-kit", - "ref": "v2.1.1" + "ref": "v2.2.0" }, { "name": "report-kit", - "ref": "v2.2.0" + "ref": "v2.3.0" }, { "name": "ledger-kit", @@ -35,11 +35,11 @@ }, { "name": "client", - "ref": "v1.10.0" + "ref": "v1.12.0" }, { "name": "invoice-kit", - "ref": "v2.1.0" + "ref": "v2.2.0" }, { "name": "finance-kit", diff --git a/llms-full.txt b/llms-full.txt index e3d8afb..d586317 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. 104 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 108 files. ## Contents @@ -26,6 +26,7 @@ - docs/18-imports.md - docs/19-updates.md - docs/20-vps.md +- docs/21-patient-links.md - docs/LOCAL-AGENT-BRIEF.md - docs/README.md - kits.json @@ -50,6 +51,7 @@ - src/controllers/imports.controller.ts - src/controllers/lab.controller.ts - src/controllers/organization.controller.ts +- src/controllers/patient-links.controller.ts - src/controllers/system.controller.ts - src/graphql/context.ts - src/graphql/cookies.ts @@ -80,6 +82,7 @@ - src/schema/imports.graphql.ts - src/schema/lab.graphql.ts - src/schema/organization.graphql.ts +- src/schema/patient-links.graphql.ts - src/schema/system.graphql.ts - src/server.ts - src/services/auth.service.ts @@ -95,6 +98,7 @@ - src/services/index.ts - src/services/job.service.ts - src/services/organization.service.ts +- src/services/patient-link.service.ts - src/services/patient.service.ts - src/services/period.service.ts - src/services/receivables.service.ts @@ -302,7 +306,10 @@ Dockerfile, docker-compose.yml, deploy/ image; PostgreSQL ← PgBouncer ← N accredited, never a logo; a partner signature only with results from its own PDF (looked up by that PDF's sha256), never as ours; Reported by = medical signatories; imports never verify or release. PDFs, extracted JSON and signature PNGs are patient data: never logged, never in jobs, fixtures or repos. -16. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names. +16. **Patient links** (docs/21-patient-links.md): the token is the only key and is never stored, logged or put in a job + (db-kit keeps its SHA-256 and the nonce). `patientPage` / `patientDocument` are public: rate-limited per IP, null for + any token that does not open, one booking only, released reports and issued bills only. +17. Never commit patient data, signatures, secrets or `.env` files. Test fixtures use fictional names. ## Commands @@ -891,6 +898,15 @@ and runnable examples are in [examples/](examples/). | mutation | `nameExternalSignature(input: ExternalSignatoryInput!): ExternalSignatory!` | `signatory:manage` | Names a partner signature; the organisation's unapplied imports that carry it are read again. | | mutation | `rejectExternalReport(id: ID!, reason: String!): ExternalReport!` | `report:import` | | +## patientLinks + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `patientDocument(token: String!, kind: PatientDocumentKind!, invoiceId: ID): Document` | public | Public (no sign-in): a PDF from a patient link: one of its bills (invoiceId) or its released reports. Null when the link does not open. | +| query | `patientLink(bookingId: ID!): PatientLinkInfo!` | `booking:read`, `report:read` | The booking's patient link (made on first use). Needs booking:read and report:read on the booking's branch. | +| query | `patientPage(token: String!): PatientPage` | public | Public (no sign-in): the page behind a patient link, or null. | +| mutation | `revokePatientLinks(bookingId: ID!): Int!` | `booking:update` | Stops every link of the booking (printed QRs stop opening); the next print makes a new one. Returns how many were stopped. Needs booking:update. | + ## system | Kind | Operation | Permission | Notes | @@ -1474,6 +1490,13 @@ The stored invoice (paise) is mapped to invoice-kit's print shape (rupees). invo totals; the API checks that its total equals the stored total and refuses a mismatch (`CONFLICT`), so a printed invoice can never disagree with the books. +## The QR code + +With patient links on (`PATIENT_LINK_SECRET` and `PATIENT_LINK_BASE_URL`), the QR on a printed report and on +every bill of a booking opens the patient's page for that booking ([21-patient-links.md](21-patient-links.md)). +Printing again prints the same link. Without them, the QR keeps its old summary. A bill's UPI pay QR (when the +lab sets `MGPL_UPI_ID`) is separate and unchanged. + ## Ad-hoc rendering `renderReport(request)` and `renderInvoice(request)` take the same JSON as report-kit's and invoice-kit's @@ -1540,6 +1563,10 @@ variables already set in the real environment win over the file. | `IMPORT_MAX_BYTES` | `15728640` (15 MiB) | Largest partner-lab PDF ([18](18-imports.md)) | | `IMPORT_INLINE_MAX_BYTES` | `5242880` (5 MiB) | Larger partner PDFs are read by the worker when Redis is configured | | `IMPORT_WAIT_MS` | `60000` | How long `importExternalReport` waits for the worker | +| `PATIENT_LINK_SECRET` | empty (off) | 32+ characters: HMAC key for patient link tokens ([21](21-patient-links.md)). Links are on only with `PATIENT_LINK_BASE_URL` too | +| `PATIENT_LINK_BASE_URL` | empty (off) | The public page the token is appended to, e.g. `https://dashboard.example.in/r/` (https) | +| `PATIENT_LINK_DAYS` | `180` | How long a patient link works | +| `PATIENT_LINK_PER_IP` | `60` | Patient page calls per IP per 10 minutes | | `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored. Through a trusted proxy, its `X-Request-ID` (when well-formed) becomes the request id; otherwise each request gets a UUIDv7, sent back in `x-request-id` and logged as `reqId` | | `TLS_FINGERPRINT_HEADER` | empty (off) | Header the TLS-terminating proxy sets to the client's JA4 (or JA3) TLS fingerprint, e.g. `x-ja4`. Read only through `TRUST_PROXY` proxies (refused at startup without one); stored hashed on the session, a change audited ([06-auth-and-permissions.md](06-auth-and-permissions.md#tls-fingerprint)) | | `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` | @@ -2724,6 +2751,67 @@ docker compose up -d --scale api=3 # more api replicas | ☐ | Versions pinned in `.env` | +--- + +## File: docs/21-patient-links.md + +# 21. Patient links + +The QR printed on a booking's reports and bills opens a public, read-only page: that booking's bills and +released reports, and nothing else. The patient needs no account and no password; the link is the key. + +```mermaid +flowchart LR + P[Report or bill printed] -->|QR = PATIENT_LINK_BASE_URL + token| Q[Patient scans] + Q --> D[Dashboard /r/token] + D -->|patientPage, patientDocument| A[api] + A -->|sha256 token, then HMAC check| L[(patient_links)] +``` + +## Turning it on + +| Variable | Default | | +|---|---|---| +| `PATIENT_LINK_SECRET` | empty (off) | HMAC key for the tokens, 32+ characters. Keep it private; changing it stops every printed QR (the next print makes new links) | +| `PATIENT_LINK_BASE_URL` | empty (off) | The public page the token is appended to, e.g. `https://dashboard.example.in/r/`. https only (localhost may be http) | +| `PATIENT_LINK_DAYS` | `180` | How long a link works | +| `PATIENT_LINK_PER_IP` | `60` | Patient page calls per IP per 10 minutes (shared through Redis when `REDIS_URL` is set) | + +With both the secret and the base URL set, every printed report (`bookingReportDocument`) and every bill of a +booking (`invoiceDocument`) carries the link in its QR, instead of the old summary QR. Without them nothing +changes. + +## How a link is made and checked + +- The token is `HMAC-SHA256(PATIENT_LINK_SECRET, nonce)` as base64url (43 characters); the nonce is 24 random bytes. +- db-kit stores only `sha256(token)` and the nonce ([db-kit docs/PATIENT-LINKS.md](https://github.com/mgpl-diagnostics/db-kit/blob/main/docs/PATIENT-LINKS.md)). + A copy of the database alone cannot open a link, and the api can print the same link again. +- A booking keeps one live link: printing twice prints the same QR. A link made with another secret is replaced. +- Opening looks the row up by hash, checks it is live (not expired, not revoked, booking not cancelled), then + compares the token with the HMAC of the row's nonce in constant time. +- Unknown, expired, revoked and malformed tokens all answer `null`. Every patient call counts against the + caller's IP, good token or not (`RATE_LIMITED` with `retryAfterMs`). +- Tokens and nonces are never logged and never put in jobs. + +## What the patient sees + +`patientPage(token)`: the lab's name, the patient's shortened name ("Ravi K. S."), the booking number and date, +the branch's name, phone and address, the issued bills (drafts and cancelled bills are left out), the released +reports (verified ones are not given out yet), and the tests still in the lab. Each call counts a view. + +`patientDocument(token, kind, invoiceId)`: a PDF of one of those bills (`kind: invoice`) or of the released +reports printed together (`kind: reports`). An id from anywhere else answers `NOT_FOUND`. + +## Staff + +| Field | Permission | | +|---|---|---| +| `patientLink(bookingId)` | `booking:read` + `report:read` on the booking's branch | The link to send or show, made on first use | +| `revokePatientLinks(bookingId)` | `booking:update` | Stops every link of the booking at once (printed QRs stop opening); audited as `patient_link.revoke`. The next print makes a new link | + +See [examples/patient-links.graphql](examples/patient-links.graphql). + + --- ## File: docs/LOCAL-AGENT-BRIEF.md @@ -2988,6 +3076,7 @@ flowchart TD | 18 | [Partner-lab imports](18-imports.md) | Reading partner lab PDFs, matching, applying with provenance, NABL and signature rules | | 19 | [Releases and updates](19-updates.md) | Docker images on GHCR, hosting with Compose, the update notice, releasing | | 20 | [Hosting on a VPS](20-vps.md) | One server with Docker Compose: production .env, first lab, HTTPS, backups, updates | +| 21 | [Patient links](21-patient-links.md) | The QR on reports and bills: a public page with one booking's bills and released reports | | — | [examples/](examples/) | Runnable `.graphql` operations | | — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo | @@ -3022,11 +3111,11 @@ flowchart TD }, { "name": "db-kit", - "ref": "v2.1.1" + "ref": "v2.2.0" }, { "name": "report-kit", - "ref": "v2.2.0" + "ref": "v2.3.0" }, { "name": "ledger-kit", @@ -3034,11 +3123,11 @@ flowchart TD }, { "name": "client", - "ref": "v1.10.0" + "ref": "v1.12.0" }, { "name": "invoice-kit", - "ref": "v2.1.0" + "ref": "v2.2.0" }, { "name": "finance-kit", @@ -3125,12 +3214,12 @@ Key facts for agents: "@graphql-tools/schema": "^11.0.8", "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.2.0", - "@mgpl-diagnostics/db-kit": "^2.1.1", + "@mgpl-diagnostics/db-kit": "^2.2.0", "@mgpl-diagnostics/finance-kit": "^1.0.1", - "@mgpl-diagnostics/invoice-kit": "^2.1.0", + "@mgpl-diagnostics/invoice-kit": "^2.2.0", "@mgpl-diagnostics/ledger-kit": "^0.1.0", "@mgpl-diagnostics/mail-kit": "^0.1.0", - "@mgpl-diagnostics/report-kit": "^2.2.0", + "@mgpl-diagnostics/report-kit": "^2.3.0", "@mgpl-diagnostics/report-reader": "^0.1.0", "@mgpl-diagnostics/security-kit": "^0.3.0", "bullmq": "^6.7.1", @@ -3146,7 +3235,7 @@ Key facts for agents: "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.10.0", + "@mgpl-diagnostics/client": "^1.12.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1", @@ -4524,6 +4613,11 @@ type Mutation { """ requestPasswordReset(email: String!, orgCode: String): Boolean! resetPassword(newPassword: String!, token: String!): Boolean! + + """ + Stops every link of the booking (printed QRs stop opening); the next print makes a new one. Returns how many were stopped. Needs booking:update. + """ + revokePatientLinks(bookingId: ID!): Int! revokeSession(id: ID!): Boolean! saveResults(reportId: ID!, results: [ResultInput!]!): Report! @@ -4753,6 +4847,60 @@ type Patient { uhid: String! } +enum PatientDocumentKind { + invoice + reports +} + +"""A booking's patient link, for staff to send or show.""" +type PatientLinkInfo { + expiresAt: DateTime! + url: String! +} + +""" +What a patient sees from their link: one booking, nothing else. The name is shortened ("Ravi K. S."). +""" +type PatientPage { + bookedAt: DateTime! + bookingNo: String! + branch: PatientPageBranch! + expiresAt: DateTime! + + """Issued bills of the booking (drafts and cancelled bills are left out).""" + invoices: [PatientPageInvoice!]! + labName: String! + patientName: String! + + """Tests still in the lab, by name.""" + pending: [String!]! + + """Released reports (verified ones are not given out yet).""" + reports: [PatientPageReport!]! +} + +type PatientPageBranch { + address: String + name: String! + phone: String +} + +type PatientPageInvoice { + duePaise: Int! + id: ID! + invoiceNo: String! + issuedAt: DateTime! + paidPaise: Int! + status: String! + totalPaise: Int! +} + +type PatientPageReport { + id: ID! + releasedAt: DateTime + testName: String! +} + input PaySettlementInput { date: DateTime mode: PaymentMode! @@ -4925,6 +5073,19 @@ type Query { partners(branchId: ID): [Partner!]! patient(id: ID!): Patient + """ + Public (no sign-in): a PDF from a patient link: one of its bills (invoiceId) or its released reports. Null when the link does not open. + """ + patientDocument(invoiceId: ID, kind: PatientDocumentKind!, token: String!): Document + + """ + The booking's patient link (made on first use). Needs booking:read and report:read on the booking's branch. + """ + patientLink(bookingId: ID!): PatientLinkInfo! + + """Public (no sign-in): the page behind a patient link, or null.""" + patientPage(token: String!): PatientPage + """Name, phone or UHID.""" patients(limit: Int = 20, search: String!): [Patient!]! @@ -6000,6 +6161,13 @@ export interface Config { 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 }; + /** + * Patient links: the QR on reports and bills opens a read-only page with that booking's bill and released + * reports. Off unless both `PATIENT_LINK_SECRET` (32+ characters, HMAC key for the tokens) and + * `PATIENT_LINK_BASE_URL` (the dashboard's public page, e.g. https://dashboard.example.com/r/) are set. + * `ttlDays` (PATIENT_LINK_DAYS) is how long a link works; `perIp` caps patient page calls per IP per 10 minutes. + */ + patientLinks: { secret: string | null; baseUrl: string | null; ttlDays: number; perIp: 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 }; /** @@ -6149,6 +6317,7 @@ export function loadConfig(env: Record = process.env generated: !secrets.length, }, deviceBinding, + patientLinks: patientLinksConfig(env), 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, @@ -6157,6 +6326,24 @@ export function loadConfig(env: Record = process.env updates: updatesConfig(env, mode), }; } + +function patientLinksConfig(env: Record): Config["patientLinks"] { + const secret = env.PATIENT_LINK_SECRET?.trim() || null; + if (secret && secret.length < 32) throw new Error("PATIENT_LINK_SECRET needs at least 32 characters"); + let baseUrl = env.PATIENT_LINK_BASE_URL?.trim() || null; + if (baseUrl) { + let u: URL; + try { + u = new URL(baseUrl); + } catch { + throw new Error("PATIENT_LINK_BASE_URL is not a URL"); + } + if (u.protocol !== "https:" && u.hostname !== "localhost" && u.hostname !== "127.0.0.1") throw new Error("PATIENT_LINK_BASE_URL must be https"); + if (u.search || u.hash) throw new Error("PATIENT_LINK_BASE_URL must not have a query or a fragment"); + if (!baseUrl.endsWith("/")) baseUrl += "/"; + } + return { secret, baseUrl, ttlDays: Math.max(1, int(env.PATIENT_LINK_DAYS, 180)), perIp: Math.max(1, int(env.PATIENT_LINK_PER_IP, 60)) }; +} ``` @@ -6244,6 +6431,12 @@ export async function createContainer(o: ContainerOptions): Promise { }); } + // patient page calls per IP, shared by every replica through Redis + const window = { limit: config.patientLinks.perIp, windowMs: 10 * 60_000 }; + const patientLinkLimiter = db.redis + ? (await import("@mgpl-diagnostics/auth-kit/redis")).redisRateLimiter(db.redis, { ...window, prefix: "mgpl:patient-link:rl:" }) + : memoryRateLimiter(window); + const lab = o.lab ?? defaultLab(); const mailer = o.mailer ?? mailerFromConfig(config, lab); let jobDeps: JobDeps | undefined; @@ -6252,7 +6445,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, imports: config.imports, updates: config.updates, fetch: o.fetch }); + const services = createServices({ repos: db.repos, auth, mailer, resetUrl: config.resetUrl, lab, samples: config.samples, jobs: () => jobs, imports: config.imports, patientLinks: config.patientLinks, patientLinkLimiter, updates: config.updates, fetch: o.fetch }); jobDeps = { repos: db.repos, auth, services }; return { config, @@ -7184,6 +7377,51 @@ export const organizationController = withErrorCodes({ ``` +--- + +## File: src/controllers/patient-links.controller.ts + +```ts +import type { RequestContext as Ctx } from "../graphql/context"; +import { badInput, withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize } from "../graphql/guards"; + +async function bookingFor(ctx: Ctx, id: string, ...permissions: string[]) { + const b = await ctx.services.bookings.get(actorOf(ctx), id); + for (const p of permissions) authorize(ctx, p, b.branchId); + return b; +} + +/** Patient links: two public fields (rate-limited per IP, null for any token that does not open) and two staff fields. */ +export const patientLinksController = withErrorCodes({ + Query: { + patientPage: async (_: unknown, a: { token: string }, ctx: Ctx) => { + await ctx.services.patientLinks.throttle(ctx.ip); + const v = await ctx.services.patientLinks.view(a.token); + if (!v) return null; + return { + labName: v.labName, patientName: v.patientName, bookingNo: v.booking.bookingNo, bookedAt: v.booking.createdAt, branch: v.branch, + invoices: v.invoices, reports: v.reports, pending: v.pending, expiresAt: v.link.expiresAt, + }; + }, + patientDocument: async (_: unknown, a: { token: string; kind: "invoice" | "reports"; invoiceId?: string | null }, ctx: Ctx) => { + await ctx.services.patientLinks.throttle(ctx.ip); + if (a.kind === "invoice" && !a.invoiceId) throw badInput("invoiceId is required for kind invoice"); + const opened = await ctx.services.patientLinks.open(a.token); + if (!opened) return null; + return a.kind === "invoice" ? ctx.services.documents.patientInvoice(opened.link, a.invoiceId!) : ctx.services.documents.patientReports(opened.link); + }, + patientLink: async (_: unknown, a: { bookingId: string }, ctx: Ctx) => + ctx.services.patientLinks.forBooking(actorOf(ctx), await bookingFor(ctx, a.bookingId, "booking:read", "report:read")), + }, + Mutation: { + revokePatientLinks: async (_: unknown, a: { bookingId: string }, ctx: Ctx) => + ctx.services.patientLinks.revoke(actorOf(ctx), await bookingFor(ctx, a.bookingId, "booking:update")), + }, +}); +``` + + --- ## File: src/controllers/system.controller.ts @@ -7435,6 +7673,7 @@ import { HttpError } from "@mgpl-diagnostics/report-kit"; import { GraphQLError } from "graphql"; import { BookingPatientMismatchError, FileTooLargeError, ReportAlreadyAppliedError, UnsupportedReportError } from "../services/import.service"; import { BarcodeTakenError } from "../services/booking.service"; +import { PatientLinkRateLimitError } from "../services/patient-link.service"; import { ForbiddenError } from "../services/util"; /** @@ -7474,6 +7713,7 @@ export function toGraphQLError(e: unknown): unknown { if (e.details.organizations?.length) extra.organizations = e.details.organizations.map((o) => ({ code: o.code, name: o.name })); return apiError(e.code as ErrorCode, e.message, e.statusCode, extra); } + if (e instanceof PatientLinkRateLimitError) return apiError("RATE_LIMITED", e.message, 429, { retryAfterMs: e.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 }); @@ -7588,7 +7828,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*|importExternalReport)$/; +const DOCUMENT_FIELD = /^(bookingReportDocument|invoiceDocument|renderReport|renderInvoice|letterhead|patientDocument|export[A-Z]\w*|importExternalReport)$/; /** * Rejects operations that select more than `maxFields` fields in all (fragments counted where they are @@ -7835,6 +8075,7 @@ 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 { patientLinksController } from "../controllers/patient-links.controller"; import { systemController } from "../controllers/system.controller"; import { authTypeDefs } from "../schema/auth.graphql"; import { baseTypeDefs } from "../schema/base.graphql"; @@ -7846,6 +8087,7 @@ import { financeTypeDefs } from "../schema/finance.graphql"; import { importsTypeDefs } from "../schema/imports.graphql"; import { labTypeDefs } from "../schema/lab.graphql"; import { organizationTypeDefs } from "../schema/organization.graphql"; +import { patientLinksTypeDefs } from "../schema/patient-links.graphql"; import { systemTypeDefs } from "../schema/system.graphql"; /** One domain = one SDL file in src/schema + one controller in src/controllers. */ @@ -7866,6 +8108,7 @@ export const MODULES: GraphQLModule[] = [ { name: "finance", typeDefs: financeTypeDefs, resolvers: financeController }, { name: "documents", typeDefs: documentsTypeDefs, resolvers: documentsController }, { name: "imports", typeDefs: importsTypeDefs, resolvers: importsController }, + { name: "patientLinks", typeDefs: patientLinksTypeDefs, resolvers: patientLinksController }, { name: "system", typeDefs: systemTypeDefs, resolvers: systemController }, ]; @@ -9774,6 +10017,80 @@ export const organizationTypeDefs = /* GraphQL */ ` ``` +--- + +## File: src/schema/patient-links.graphql.ts + +```ts +/** + * Patient links (docs/19-patient-links.md): the QR printed on a booking's reports and bills opens a public, + * read-only page with that booking's bill and released reports. The token in the link is the only key; + * unknown, expired, revoked and malformed tokens all answer null. Every patient call is rate-limited per IP. + */ +export const patientLinksTypeDefs = /* GraphQL */ ` + "A booking's patient link, for staff to send or show." + type PatientLinkInfo { + url: String! + expiresAt: DateTime! + } + + type PatientPageBranch { + name: String! + phone: String + address: String + } + + type PatientPageInvoice { + id: ID! + invoiceNo: String! + issuedAt: DateTime! + totalPaise: Int! + paidPaise: Int! + duePaise: Int! + status: String! + } + + type PatientPageReport { + id: ID! + testName: String! + releasedAt: DateTime + } + + "What a patient sees from their link: one booking, nothing else. The name is shortened (\\"Ravi K. S.\\")." + type PatientPage { + labName: String! + patientName: String! + bookingNo: String! + bookedAt: DateTime! + branch: PatientPageBranch! + "Issued bills of the booking (drafts and cancelled bills are left out)." + invoices: [PatientPageInvoice!]! + "Released reports (verified ones are not given out yet)." + reports: [PatientPageReport!]! + "Tests still in the lab, by name." + pending: [String!]! + expiresAt: DateTime! + } + + enum PatientDocumentKind { invoice reports } + + extend type Query { + "Public (no sign-in): the page behind a patient link, or null." + patientPage(token: String!): PatientPage + "Public (no sign-in): a PDF from a patient link: one of its bills (invoiceId) or its released reports. Null when the link does not open." + patientDocument(token: String!, kind: PatientDocumentKind!, invoiceId: ID): Document + "The booking's patient link (made on first use). Needs booking:read and report:read on the booking's branch." + patientLink(bookingId: ID!): PatientLinkInfo! + } + + extend type Mutation { + "Stops every link of the booking (printed QRs stop opening); the next print makes a new one. Returns how many were stopped. Needs booking:update." + revokePatientLinks(bookingId: ID!): Int! + } +`; +``` + + --- ## File: src/schema/system.graphql.ts @@ -10936,7 +11253,7 @@ export class CatalogService { import { getTestByCode, isNarrative } from "@mgpl-diagnostics/catalog-kit"; import { ConflictError, NotFoundError, ValidationError, - type Id, type Invoice as DbInvoice, type Patient as DbPatient, type PaymentMode, type Report as DbReport, type Repositories, + type Booking as DbBooking, type Id, type Invoice as DbInvoice, type PatientLink, type Patient as DbPatient, type PaymentMode, type Report as DbReport, type Repositories, } from "@mgpl-diagnostics/db-kit"; import { computeInvoice, INVOICE_SAMPLES, invoiceFromRequest, invoiceTotalsJson, PRICE_LIST, renderInvoiceHtml, renderInvoicePdf, @@ -10947,7 +11264,11 @@ import { type LabProfile, type Patient as PrintPatient, type Report as PrintReport, type ReportDesign, type ReportRequest, type ReportTestProvenance, type ReportVariant, type Row, type Section, type TestRow, } from "@mgpl-diagnostics/report-kit"; import type { Actor } from "../graphql/context"; + +/** Who prints: the organisation, and the user when there is one (a patient's own copy has none). */ +type Owner = Pick & { userId?: Id | null }; import { printedProvenance } from "./import.service"; +import type { PatientLinkService } from "./patient-link.service"; import { mustOwn, properName } from "./util"; export type DocumentFormat = "pdf" | "html"; @@ -10997,10 +11318,18 @@ export function printPatient(p: DbPatient, at: Date, extra: Partial { + if (!this.links?.enabled || !booking) return undefined; + return (await this.links.linkFor(orgId, booking, createdBy))?.url; } // ---------------------------------------------------------------- catalogs @@ -11044,7 +11373,7 @@ export class DocumentService { // ---------------------------------------------------------------- from stored data /** The printed form of a stored invoice (with its payments). */ - async storedInvoice(actor: Actor, invoice: DbInvoice): Promise { + async storedInvoice(actor: Owner, invoice: DbInvoice): Promise { const patient = mustOwn(await this.repos.patients.get(invoice.patientId), actor.orgId, "Patient", invoice.patientId); const payments = await this.repos.payments.find({ invoiceId: invoice.id }, { sort: { field: "receivedAt" } }); const at = invoice.issuedAt ?? invoice.createdAt; @@ -11059,7 +11388,10 @@ export class DocumentService { })); // refunds print as their own rows in the payments table (invoice-kit ≥ 2.1), not as a note const notes = [invoice.status === "cancelled" ? `CANCELLED: ${invoice.cancelReason ?? ""}`.trim() : ""].filter(Boolean); + // the QR opens the patient's page (this booking's bill and reports) when patient links are on + const qrValue = invoice.bookingId && invoice.status !== "cancelled" ? await this.linkOf(actor.orgId, await this.repos.bookings.get(invoice.bookingId), actor.userId ?? null) : undefined; return { + ...(qrValue ? { qrValue, qrLabel: "Scan for your reports and bill" } : {}), invoiceNo: invoice.invoiceNo, date: at, patient: printPatient(patient, at, sampleId ? { sampleId } : {}), @@ -11073,7 +11405,7 @@ export class DocumentService { }; } - async renderStoredInvoice(actor: Actor, invoice: DbInvoice, o: RenderOptions) { + async renderStoredInvoice(actor: Owner, invoice: DbInvoice, o: RenderOptions) { const printed = await this.storedInvoice(actor, invoice); // the printed total must equal the stored one (both are integer-paise maths with the same rules) const t = computeInvoice(printed); @@ -11081,15 +11413,29 @@ export class DocumentService { return this.renderInvoice(printed, { ...o, fileName: o.fileName ?? `${invoice.invoiceNo.replaceAll("/", "-")}.${o.format}` }); } + /** The patient's copy of one of the link's bills (issued or paid, never a draft or cancelled one). */ + async patientInvoice(link: PatientLink, invoiceId: Id) { + const inv = await this.repos.invoices.get(invoiceId); + if (!inv || inv.orgId !== link.orgId || inv.bookingId !== link.bookingId || inv.status === "draft" || inv.status === "cancelled") throw new NotFoundError("Invoice", invoiceId); + return this.renderStoredInvoice({ orgId: link.orgId, userId: null }, inv, { format: "pdf", variant: "header", design: "sidebar" }); + } + + /** The patient's copy of the link's released reports, printed together. */ + async patientReports(link: PatientLink) { + return this.renderBookingReports({ orgId: link.orgId, userId: null }, link.bookingId, null, { format: "pdf", variant: "header", design: "sidebar", releasedOnly: true }); + } + /** * A booking's reports as one printed report. Only verified or released reports print. A report * whose test code is a report-kit template uses that template (results keyed by its field keys); * any other test prints as a standard Test | Result | Unit | Range table. */ - async renderBookingReports(actor: Actor, bookingId: Id, reportIds: Id[] | null, o: RenderOptions) { + async renderBookingReports(actor: Owner, bookingId: Id, reportIds: Id[] | null, o: RenderOptions & { releasedOnly?: boolean }) { const booking = mustOwn(await this.repos.bookings.get(bookingId), actor.orgId, "Booking", bookingId); let reports = await this.repos.reports.find({ bookingId }); if (reportIds?.length) reports = reports.filter((r) => reportIds.includes(r.id)); + // the patient's page gives out released reports only + if (o.releasedOnly) reports = reports.filter((r) => r.status === "released"); if (!reports.length) throw new NotFoundError("Report", reportIds?.join(",") ?? bookingId); const unready = reports.find((r) => r.status !== "verified" && r.status !== "released"); if (unready) throw new ConflictError(`Report ${unready.testName} is ${unready.status}; verify it before printing`); @@ -11110,7 +11456,8 @@ export class DocumentService { if (provenance && own.length) own[own.length - 1] = { ...own[own.length - 1]!, provenance }; sections.push(...own); } - const report: PrintReport = { patient: printed, sections }; + const qrValue = await this.linkOf(actor.orgId, booking, actor.userId ?? null); + const report: PrintReport = { patient: printed, sections, ...(qrValue ? { qrValue } : {}) }; return this.renderReport(report, { ...o, fileName: o.fileName ?? `${booking.bookingNo}-${o.variant}.${o.format}` }); } @@ -11920,7 +12267,7 @@ export class ImportService { ## File: src/services/index.ts ```ts -import type { Auth } from "@mgpl-diagnostics/auth-kit"; +import type { Auth, RateLimiter } from "@mgpl-diagnostics/auth-kit"; import type { Repositories } from "@mgpl-diagnostics/db-kit"; import type { LabProfile } from "@mgpl-diagnostics/report-kit"; import { AuthService, type Mailer } from "./auth.service"; @@ -11936,6 +12283,7 @@ import { ImportService, type ImportLimits } from "./import.service"; import { JobService } from "./job.service"; import type { JobQueue } from "../jobs/types"; import { OrganizationService } from "./organization.service"; +import { PatientLinkService, type PatientLinkConfig } from "./patient-link.service"; import { PatientService } from "./patient.service"; import { PeriodService } from "./period.service"; import { ReceivablesService } from "./receivables.service"; @@ -11955,6 +12303,10 @@ export interface ServiceDeps { jobs: () => JobQueue; /** Partner-lab PDF limits (IMPORT_* variables). */ imports: ImportLimits; + /** Patient links (PATIENT_LINK_*); off when omitted. */ + patientLinks?: PatientLinkConfig; + /** Limits patient page calls per IP (Redis when REDIS_URL is set, so every replica shares it). */ + patientLinkLimiter?: RateLimiter; /** Update notices (UPDATE_* settings); off when omitted. */ updates?: Config["updates"]; /** The HTTP client for the release feed (tests). */ @@ -11968,6 +12320,7 @@ const NO_UPDATES: Config["updates"] = { /** Every service, one instance each. Services know nothing about GraphQL; controllers call them. */ export function createServices(d: ServiceDeps) { + const patientLinks = new PatientLinkService(d.repos, d.patientLinks ?? { secret: null, baseUrl: null, ttlDays: 180 }, d.patientLinkLimiter ?? null); return { auth: new AuthService(d.repos, d.auth, { mailer: d.mailer, resetUrl: d.resetUrl, jobs: d.jobs }), organization: new OrganizationService(d.repos), @@ -11983,7 +12336,8 @@ export function createServices(d: ServiceDeps) { cashBank: new CashBankService(d.repos), periods: new PeriodService(d.repos), exports: new ExportService(d.repos), - documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples }), + documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples, links: patientLinks }), + patientLinks, jobs: new JobService(d.repos, d.jobs), imports: new ImportService(d.repos, d.jobs, d.imports), system: new SystemService(d.updates ?? NO_UPDATES, d.fetch), @@ -12123,6 +12477,179 @@ export class OrganizationService { ``` +--- + +## File: src/services/patient-link.service.ts + +```ts +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { ConflictError, type Booking, type Id, type PatientLink, type Repositories } from "@mgpl-diagnostics/db-kit"; +import type { RateLimiter } from "@mgpl-diagnostics/auth-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +export interface PatientLinkConfig { + /** HMAC secret (32+ characters). Without it, links are off and documents print their old QR. */ + secret: string | null; + /** The public page, e.g. "https://dashboard.example.com/r/" (the token is appended). */ + baseUrl: string | null; + /** How long a link works. */ + ttlDays: number; +} + +/** What a patient sees from their link: one booking's bill and released reports. Nothing else. */ +export interface PatientView { + link: PatientLink; + booking: Booking; + patientName: string; + labName: string; + branch: { name: string; phone: string | null; address: string | null }; + invoices: { id: Id; invoiceNo: string; issuedAt: Date; totalPaise: number; paidPaise: number; duePaise: number; status: string }[]; + /** Released reports only (verified ones are not yet given out). */ + reports: { id: Id; testName: string; releasedAt: Date | null }[]; + /** Tests still in the lab, by name. */ + pending: string[]; +} + +/** Too many patient page calls from one address (RATE_LIMITED). */ +export class PatientLinkRateLimitError extends Error { + constructor(readonly retryAfterMs: number) { + super("Too many requests. Try again in a few minutes."); + this.name = "PatientLinkRateLimitError"; + } +} + +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); +const TOKEN = /^[A-Za-z0-9_-]{43}$/; + +/** "Ravi Kumar Sahu" → "Ravi K. S.": enough for the patient to recognise, little for anyone else. */ +export function shortName(name: string): string { + const [first, ...rest] = name.trim().split(/\s+/); + return [first, ...rest.map((w) => `${w[0]!.toUpperCase()}.`)].join(" "); +} + +/** + * Patient links: the QR on a report or a bill opens a read-only page with that booking's bill and released + * reports. The token (32 random-looking bytes, base64url) is HMAC(secret, nonce); only its SHA-256 and the + * nonce are stored, so a database copy alone cannot open any link, and the server can print the same link + * again. Links expire and can be revoked; a revoked booking gets a new link on its next print. + */ +export class PatientLinkService { + constructor(private readonly repos: Repositories, private readonly config: PatientLinkConfig, private readonly limiter: RateLimiter | null = null) {} + + /** Counts one patient page call from `ip` (every call, good token or not); throws when over the limit. */ + async throttle(ip: string | undefined): Promise { + if (!this.limiter) return; + const r = await this.limiter.hit(`patient-link:${ip ?? "unknown"}`); + if (!r.allowed) throw new PatientLinkRateLimitError(r.retryAfterMs); + } + + get enabled(): boolean { + return !!this.config.secret && !!this.config.baseUrl; + } + + private token(nonce: string): string { + return createHmac("sha256", this.config.secret!).update(nonce).digest("base64url"); + } + + private url(token: string): string { + return `${this.config.baseUrl}${token}`; + } + + private live(l: PatientLink, now = new Date()): boolean { + return !l.revokedAt && new Date(l.expiresAt).getTime() > now.getTime(); + } + + /** The booking's current link (created on first use), or null when links are off. */ + async linkFor(orgId: Id, booking: Booking, createdBy: Id | null = null): Promise<{ url: string; expiresAt: Date } | null> { + if (!this.enabled || booking.orgId !== orgId || booking.status === "cancelled") return null; + const existing = (await this.repos.patientLinks.find({ orgId, bookingId: booking.id } as never, { sort: { field: "createdAt", dir: "desc" }, limit: 5 })).find((l) => this.live(l)); + if (existing) { + const token = this.token(existing.nonce); + // a link made with another secret (rotated) no longer opens: make a new one + if (sha256(token) === existing.tokenHash) return { url: this.url(token), expiresAt: existing.expiresAt }; + } + const nonce = randomBytes(24).toString("base64url"); + const token = this.token(nonce); + const expiresAt = new Date(Date.now() + this.config.ttlDays * 86_400_000); + await this.repos.patientLinks.create({ + orgId, branchId: booking.branchId, bookingId: booking.id, patientId: booking.patientId, tokenHash: sha256(token), nonce, expiresAt, createdBy, viewCount: 0, + }); + return { url: this.url(token), expiresAt }; + } + + /** Staff: the link for a booking (to send or show), creating it when needed. */ + async forBooking(actor: Actor, booking: Booking) { + if (!this.enabled) throw new ConflictError("Patient links are off: set PATIENT_LINK_SECRET and PATIENT_LINK_BASE_URL"); + mustOwn(booking, actor.orgId, "Booking", booking.id); + if (booking.status === "cancelled") throw new ConflictError("The booking is cancelled"); + return (await this.linkFor(actor.orgId, booking, actor.userId))!; + } + + /** Staff: stops every link of the booking at once (printed QRs stop opening). The next print makes a new one. */ + async revoke(actor: Actor, booking: Booking): Promise { + mustOwn(booking, actor.orgId, "Booking", booking.id); + const links = (await this.repos.patientLinks.find({ orgId: actor.orgId, bookingId: booking.id } as never)).filter((l) => !l.revokedAt); + const now = new Date(); + for (const l of links) await this.repos.patientLinks.update(l.id, { revokedAt: now, revokedBy: actor.userId }); + if (links.length) await this.repos.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "patient_link.revoke", entity: "booking", entityId: booking.id, data: { links: links.length } }); + return links.length; + } + + /** The live link for a token, or null (unknown, expired, revoked and malformed all look the same). */ + async resolve(token: string): Promise { + if (!this.enabled || !TOKEN.test(token)) return null; + const hash = sha256(token); + const link = await this.repos.patientLinks.findOne({ tokenHash: hash } as never); + if (!link || !this.live(link)) return null; + // constant-time check that the token is the one derived from this row (a copied hash alone never opens it) + const want = Buffer.from(this.token(link.nonce)); + const got = Buffer.from(token); + if (want.length !== got.length || !timingSafeEqual(want, got)) return null; + return link; + } + + /** The live link and its booking for a token, or null (also when the booking was cancelled). */ + async open(token: string): Promise<{ link: PatientLink; booking: Booking } | null> { + const link = await this.resolve(token); + if (!link) return null; + const booking = await this.repos.bookings.get(link.bookingId); + if (!booking || booking.orgId !== link.orgId || booking.id !== link.bookingId || booking.status === "cancelled") return null; + return { link, booking }; + } + + /** What the patient's page shows, or null. Counts the view. */ + async view(token: string): Promise { + const opened = await this.open(token); + if (!opened) return null; + const { link, booking } = opened; + const [patient, branch, org, invoices, reports] = await Promise.all([ + this.repos.patients.get(booking.patientId), + this.repos.branches.get(booking.branchId), + this.repos.orgs.get(booking.orgId), + this.repos.invoices.find({ orgId: booking.orgId, bookingId: booking.id } as never, { sort: { field: "createdAt", dir: "asc" } }), + this.repos.reports.find({ bookingId: booking.id }), + ]); + await this.repos.patientLinks.update(link.id, { viewCount: (link.viewCount ?? 0) + 1, lastViewedAt: new Date() }); + const released = reports.filter((r) => r.status === "released"); + const done = new Set(released.map((r) => r.testCode)); + return { + link, + booking, + patientName: shortName(patient?.name ?? "Patient"), + labName: org?.name ?? "", + branch: { name: branch?.name ?? "", phone: branch?.phone ?? null, address: [branch?.address?.line1, branch?.address?.city].filter(Boolean).join(", ") || null }, + invoices: invoices.filter((i) => i.status !== "cancelled" && i.status !== "draft").map((i) => ({ + id: i.id, invoiceNo: i.invoiceNo, issuedAt: i.issuedAt ?? i.createdAt, totalPaise: i.totalPaise, paidPaise: i.paidPaise, duePaise: i.duePaise, status: i.status, + })), + reports: released.map((r) => ({ id: r.id, testName: r.testName, releasedAt: r.releasedAt ?? null })), + pending: booking.tests.filter((t) => !done.has(t.testCode)).map((t) => t.name), + }; + } +} +``` + + --- ## File: src/services/patient.service.ts @@ -13224,7 +13751,7 @@ async function suite(db: Db, tag: string) { const repos: Repositories = { ...db.repos, patients: { ...db.repos.patients, getMany: (ids) => { patientBatches++; return db.repos.patients.getMany(ids); } } }; 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", MGPL_SAMPLES: "1", INTROSPECTION: "0", DATABASE_URL: "unused" }) }; + const config = { ...loadConfig({ NODE_ENV: "test", MGPL_SAMPLES: "1", INTROSPECTION: "0", DATABASE_URL: "unused", PATIENT_LINK_SECRET: "test-patient-link-secret-0123456789abcdef", PATIENT_LINK_BASE_URL: "https://lab.example.in/r", PATIENT_LINK_PER_IP: "40" }) }; const container = await createContainer({ config, db: { ...db, repos }, mailer }); const app = await buildServer(container); @@ -13324,6 +13851,40 @@ async function suite(db: Db, tag: string) { ok(tb.data?.trialBalance.differencePaise === 0 && tb.data.trialBalance.totalDebitPaise > 0 && tb.data.outstandingReceivables.totalDuePaise === 0, `[${tag}] trial balance balances (ledger-kit); nothing outstanding`); const invDoc = await gql(owner, "query($id: ID!) { invoiceDocument(invoiceId: $id) { base64 fileName } h: invoiceDocument(invoiceId: $id, format: html) { html } }", { id: invoiceId }); ok(Buffer.from(invDoc.data?.invoiceDocument.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-" && invDoc.data.h.html.includes(inv.data.issueInvoice.invoiceNo), `[${tag}] invoiceDocument: invoice-kit PDF + HTML of the stored invoice (totals match)`); + // ---------------------------------------------------------------- patient links (the QR on reports and bills) + { + const b = booking.booking.id; + const L = "query($b: ID!) { patientLink(bookingId: $b) { url expiresAt } }"; + const link = (await gql(owner, L, { b })).data?.patientLink; + const token = String(link?.url ?? "").replace("https://lab.example.in/r/", ""); + ok(/^[A-Za-z0-9_-]{43}$/.test(token) && new Date(link.expiresAt).getTime() > Date.now() + 170 * 86_400_000, `[${tag}] patientLink: an https URL under PATIENT_LINK_BASE_URL with a 43-character token, 180 days`); + ok((await gql(owner, L, { b })).data?.patientLink.url === link.url, `[${tag}] …the same link again (printing twice prints the same QR)`); + const rows = await db.rawRepos.patientLinks.find({ bookingId: b } as never); + ok(rows.length === 1 && rows[0]!.tokenHash !== token && !JSON.stringify(rows).includes(token), `[${tag}] …stored as a hash and a nonce, never the token`); + ok(code(await gql(otherOwner, L, { b })) === "NOT_FOUND" && code(await gql(null, L, { b })) === "UNAUTHENTICATED", `[${tag}] patientLink: another organisation → NOT_FOUND, no sign-in → UNAUTHENTICATED`); + const P = "query($t: String!) { patientPage(token: $t) { labName patientName bookingNo branch { name } invoices { id invoiceNo totalPaise duePaise status } reports { id testName } pending expiresAt } }"; + const page = (await gql(null, P, { t: token })).data?.patientPage; + ok(page?.patientName === "Asha P." && page.labName === "API Lab" && page.branch.name === "Branch A" && page.reports.length === 1 && page.pending.length === 0, `[${tag}] patientPage without sign-in: shortened name, lab, branch, the released report`); + ok(page?.invoices.length === 1 && page.invoices[0].invoiceNo === inv.data.issueInvoice.invoiceNo && page.invoices[0].duePaise === 0, `[${tag}] …and the bill, paid`); + const bad = await gql(null, "query($a: String!, $b: String!, $c: String!) { a: patientPage(token: $a) { bookingNo } b: patientPage(token: $b) { bookingNo } c: patientPage(token: $c) { bookingNo } }", { a: "x".repeat(43), b: token.slice(0, 42), c: `${token}a` }); + ok(bad.data && bad.data.a === null && bad.data.b === null && bad.data.c === null && !bad.errors, `[${tag}] unknown, short and long tokens → null (all look the same)`); + const D = "query($t: String!, $k: PatientDocumentKind!, $i: ID) { patientDocument(token: $t, kind: $k, invoiceId: $i) { mimeType base64 } }"; + const rep = (await gql(null, D, { t: token, k: "reports" })).data?.patientDocument; + const bill = (await gql(null, D, { t: token, k: "invoice", i: invoiceId })).data?.patientDocument; + ok(Buffer.from(rep?.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-" && Buffer.from(bill?.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-", `[${tag}] patientDocument: the released reports and the bill as PDFs`); + ok(code(await gql(null, D, { t: token, k: "invoice", i: page.reports[0].id })) === "NOT_FOUND", `[${tag}] …a bill of another booking (or any other id) → NOT_FOUND`); + const views = (await db.rawRepos.patientLinks.find({ bookingId: b } as never))[0]!; + ok(views.viewCount === 1 && !!views.lastViewedAt, `[${tag}] the page counts its views`); + ok(code(await gql(desk, "mutation($b: ID!) { revokePatientLinks(bookingId: $b) }", { b })) === undefined, `[${tag}] revokePatientLinks with booking:update (front desk)`); + ok((await gql(null, P, { t: token })).data?.patientPage === null, `[${tag}] …the revoked link no longer opens`); + const next = (await gql(owner, L, { b })).data?.patientLink.url; + ok(!!next && next !== link.url && (await gql(null, P, { t: next.split("/").pop() })).data?.patientPage?.bookingNo === page.bookingNo, `[${tag}] …the next print makes a new link that opens`); + const audit = await db.rawRepos.audit.find({ action: "patient_link.revoke" } as never); + ok(audit.length >= 1, `[${tag}] the revoke is audited`); + let limited: string | undefined; + for (let i = 0; i < 40 && !limited; i++) limited = code(await gql(null, P, { t: "y".repeat(43) })); + ok(limited === "RATE_LIMITED", `[${tag}] patient pages are rate-limited per IP → RATE_LIMITED`); + } // ---------------------------------------------------------------- tube labels, calculated values, methods, refunds on paper { const lft = (await gql(owner, "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id } samples { id barcode } } }", { i: { branchId: a.branch.id, patientId, tests: [{ testCode: "LIFT" }] } })).data?.createBooking; diff --git a/package-lock.json b/package-lock.json index 25c57c0..0d484a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,12 +12,12 @@ "@graphql-tools/schema": "^11.0.8", "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.2.0", - "@mgpl-diagnostics/db-kit": "^2.1.1", + "@mgpl-diagnostics/db-kit": "^2.2.0", "@mgpl-diagnostics/finance-kit": "^1.0.1", - "@mgpl-diagnostics/invoice-kit": "^2.1.0", + "@mgpl-diagnostics/invoice-kit": "^2.2.0", "@mgpl-diagnostics/ledger-kit": "^0.1.0", "@mgpl-diagnostics/mail-kit": "^0.1.0", - "@mgpl-diagnostics/report-kit": "^2.2.0", + "@mgpl-diagnostics/report-kit": "^2.3.0", "@mgpl-diagnostics/report-reader": "^0.1.0", "@mgpl-diagnostics/security-kit": "^0.3.0", "bullmq": "^6.7.1", @@ -33,7 +33,7 @@ "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.10.0", + "@mgpl-diagnostics/client": "^1.12.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1", @@ -1019,7 +1019,7 @@ } }, "node_modules/@mgpl-diagnostics/client": { - "version": "1.10.0", + "version": "1.12.0", "dev": true, "license": "SEE LICENSE IN LICENSE", "engines": { @@ -1030,7 +1030,7 @@ } }, "node_modules/@mgpl-diagnostics/db-kit": { - "version": "2.1.1", + "version": "2.2.0", "license": "SEE LICENSE IN LICENSE", "engines": { "node": ">=20" @@ -1058,9 +1058,7 @@ } }, "node_modules/@mgpl-diagnostics/invoice-kit": { - "version": "2.1.0", - "resolved": "https://npm.pkg.github.com/download/@mgpl-diagnostics/invoice-kit/2.1.0/b076345c53531dfc4332ef302991d1cfc58cdb1b", - "integrity": "sha512-7Jh6YpVwx97o1eAdMEqVpHCh4YMcvbp3mT4YqRG+j8p0XWh23qkEufvXvavbSK0hrn4GHB727B5Eiib9qEPqJQ==", + "version": "2.2.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@react-pdf/renderer": "^4.9.0", @@ -1071,7 +1069,7 @@ "node": ">=20" }, "peerDependencies": { - "@mgpl-diagnostics/report-kit": "^2.0.0", + "@mgpl-diagnostics/report-kit": "^2.3.0", "fastify": ">=4", "react": ">=18", "react-dom": ">=18" @@ -1099,9 +1097,7 @@ } }, "node_modules/@mgpl-diagnostics/report-kit": { - "version": "2.2.1", - "resolved": "https://npm.pkg.github.com/download/@mgpl-diagnostics/report-kit/2.2.1/7ef3e20f3b11ced9847ba4e1f10e0a6dbd3f0cc5", - "integrity": "sha512-lSrbjUm5i/ibL5tZBRVcthS246YDrjkJQzQSKHTBz8uPK99VjZZZS+pPUfC1fsAdVK2C5xomFd2adPHdlQUGMg==", + "version": "2.3.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@react-pdf/renderer": "^4.9.0", diff --git a/package.json b/package.json index 4802571..4f224e7 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,12 @@ "@graphql-tools/schema": "^11.0.8", "@mgpl-diagnostics/auth-kit": "^1.5.0", "@mgpl-diagnostics/catalog-kit": "^1.2.0", - "@mgpl-diagnostics/db-kit": "^2.1.1", + "@mgpl-diagnostics/db-kit": "^2.2.0", "@mgpl-diagnostics/finance-kit": "^1.0.1", - "@mgpl-diagnostics/invoice-kit": "^2.1.0", + "@mgpl-diagnostics/invoice-kit": "^2.2.0", "@mgpl-diagnostics/ledger-kit": "^0.1.0", "@mgpl-diagnostics/mail-kit": "^0.1.0", - "@mgpl-diagnostics/report-kit": "^2.2.0", + "@mgpl-diagnostics/report-kit": "^2.3.0", "@mgpl-diagnostics/report-reader": "^0.1.0", "@mgpl-diagnostics/security-kit": "^0.3.0", "bullmq": "^6.7.1", @@ -48,7 +48,7 @@ "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.10.0", + "@mgpl-diagnostics/client": "^1.12.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1", diff --git a/schema.graphql b/schema.graphql index 3d5131e..cdd3072 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1354,6 +1354,11 @@ type Mutation { """ requestPasswordReset(email: String!, orgCode: String): Boolean! resetPassword(newPassword: String!, token: String!): Boolean! + + """ + Stops every link of the booking (printed QRs stop opening); the next print makes a new one. Returns how many were stopped. Needs booking:update. + """ + revokePatientLinks(bookingId: ID!): Int! revokeSession(id: ID!): Boolean! saveResults(reportId: ID!, results: [ResultInput!]!): Report! @@ -1583,6 +1588,60 @@ type Patient { uhid: String! } +enum PatientDocumentKind { + invoice + reports +} + +"""A booking's patient link, for staff to send or show.""" +type PatientLinkInfo { + expiresAt: DateTime! + url: String! +} + +""" +What a patient sees from their link: one booking, nothing else. The name is shortened ("Ravi K. S."). +""" +type PatientPage { + bookedAt: DateTime! + bookingNo: String! + branch: PatientPageBranch! + expiresAt: DateTime! + + """Issued bills of the booking (drafts and cancelled bills are left out).""" + invoices: [PatientPageInvoice!]! + labName: String! + patientName: String! + + """Tests still in the lab, by name.""" + pending: [String!]! + + """Released reports (verified ones are not given out yet).""" + reports: [PatientPageReport!]! +} + +type PatientPageBranch { + address: String + name: String! + phone: String +} + +type PatientPageInvoice { + duePaise: Int! + id: ID! + invoiceNo: String! + issuedAt: DateTime! + paidPaise: Int! + status: String! + totalPaise: Int! +} + +type PatientPageReport { + id: ID! + releasedAt: DateTime + testName: String! +} + input PaySettlementInput { date: DateTime mode: PaymentMode! @@ -1755,6 +1814,19 @@ type Query { partners(branchId: ID): [Partner!]! patient(id: ID!): Patient + """ + Public (no sign-in): a PDF from a patient link: one of its bills (invoiceId) or its released reports. Null when the link does not open. + """ + patientDocument(invoiceId: ID, kind: PatientDocumentKind!, token: String!): Document + + """ + The booking's patient link (made on first use). Needs booking:read and report:read on the booking's branch. + """ + patientLink(bookingId: ID!): PatientLinkInfo! + + """Public (no sign-in): the page behind a patient link, or null.""" + patientPage(token: String!): PatientPage + """Name, phone or UHID.""" patients(limit: Int = 20, search: String!): [Patient!]! diff --git a/src/config/env.ts b/src/config/env.ts index a6fda73..753194f 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -53,6 +53,13 @@ export interface Config { 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 }; + /** + * Patient links: the QR on reports and bills opens a read-only page with that booking's bill and released + * reports. Off unless both `PATIENT_LINK_SECRET` (32+ characters, HMAC key for the tokens) and + * `PATIENT_LINK_BASE_URL` (the dashboard's public page, e.g. https://dashboard.example.com/r/) are set. + * `ttlDays` (PATIENT_LINK_DAYS) is how long a link works; `perIp` caps patient page calls per IP per 10 minutes. + */ + patientLinks: { secret: string | null; baseUrl: string | null; ttlDays: number; perIp: 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 }; /** @@ -202,6 +209,7 @@ export function loadConfig(env: Record = process.env generated: !secrets.length, }, deviceBinding, + patientLinks: patientLinksConfig(env), 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, @@ -210,3 +218,21 @@ export function loadConfig(env: Record = process.env updates: updatesConfig(env, mode), }; } + +function patientLinksConfig(env: Record): Config["patientLinks"] { + const secret = env.PATIENT_LINK_SECRET?.trim() || null; + if (secret && secret.length < 32) throw new Error("PATIENT_LINK_SECRET needs at least 32 characters"); + let baseUrl = env.PATIENT_LINK_BASE_URL?.trim() || null; + if (baseUrl) { + let u: URL; + try { + u = new URL(baseUrl); + } catch { + throw new Error("PATIENT_LINK_BASE_URL is not a URL"); + } + if (u.protocol !== "https:" && u.hostname !== "localhost" && u.hostname !== "127.0.0.1") throw new Error("PATIENT_LINK_BASE_URL must be https"); + if (u.search || u.hash) throw new Error("PATIENT_LINK_BASE_URL must not have a query or a fragment"); + if (!baseUrl.endsWith("/")) baseUrl += "/"; + } + return { secret, baseUrl, ttlDays: Math.max(1, int(env.PATIENT_LINK_DAYS, 180)), perIp: Math.max(1, int(env.PATIENT_LINK_PER_IP, 60)) }; +} diff --git a/src/container.ts b/src/container.ts index dfe600b..5d192ff 100644 --- a/src/container.ts +++ b/src/container.ts @@ -77,6 +77,12 @@ export async function createContainer(o: ContainerOptions): Promise { }); } + // patient page calls per IP, shared by every replica through Redis + const window = { limit: config.patientLinks.perIp, windowMs: 10 * 60_000 }; + const patientLinkLimiter = db.redis + ? (await import("@mgpl-diagnostics/auth-kit/redis")).redisRateLimiter(db.redis, { ...window, prefix: "mgpl:patient-link:rl:" }) + : memoryRateLimiter(window); + const lab = o.lab ?? defaultLab(); const mailer = o.mailer ?? mailerFromConfig(config, lab); let jobDeps: JobDeps | undefined; @@ -85,7 +91,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, imports: config.imports, updates: config.updates, fetch: o.fetch }); + const services = createServices({ repos: db.repos, auth, mailer, resetUrl: config.resetUrl, lab, samples: config.samples, jobs: () => jobs, imports: config.imports, patientLinks: config.patientLinks, patientLinkLimiter, updates: config.updates, fetch: o.fetch }); jobDeps = { repos: db.repos, auth, services }; return { config, diff --git a/src/controllers/patient-links.controller.ts b/src/controllers/patient-links.controller.ts new file mode 100644 index 0000000..c31f3c7 --- /dev/null +++ b/src/controllers/patient-links.controller.ts @@ -0,0 +1,37 @@ +import type { RequestContext as Ctx } from "../graphql/context"; +import { badInput, withErrorCodes } from "../graphql/errors"; +import { actorOf, authorize } from "../graphql/guards"; + +async function bookingFor(ctx: Ctx, id: string, ...permissions: string[]) { + const b = await ctx.services.bookings.get(actorOf(ctx), id); + for (const p of permissions) authorize(ctx, p, b.branchId); + return b; +} + +/** Patient links: two public fields (rate-limited per IP, null for any token that does not open) and two staff fields. */ +export const patientLinksController = withErrorCodes({ + Query: { + patientPage: async (_: unknown, a: { token: string }, ctx: Ctx) => { + await ctx.services.patientLinks.throttle(ctx.ip); + const v = await ctx.services.patientLinks.view(a.token); + if (!v) return null; + return { + labName: v.labName, patientName: v.patientName, bookingNo: v.booking.bookingNo, bookedAt: v.booking.createdAt, branch: v.branch, + invoices: v.invoices, reports: v.reports, pending: v.pending, expiresAt: v.link.expiresAt, + }; + }, + patientDocument: async (_: unknown, a: { token: string; kind: "invoice" | "reports"; invoiceId?: string | null }, ctx: Ctx) => { + await ctx.services.patientLinks.throttle(ctx.ip); + if (a.kind === "invoice" && !a.invoiceId) throw badInput("invoiceId is required for kind invoice"); + const opened = await ctx.services.patientLinks.open(a.token); + if (!opened) return null; + return a.kind === "invoice" ? ctx.services.documents.patientInvoice(opened.link, a.invoiceId!) : ctx.services.documents.patientReports(opened.link); + }, + patientLink: async (_: unknown, a: { bookingId: string }, ctx: Ctx) => + ctx.services.patientLinks.forBooking(actorOf(ctx), await bookingFor(ctx, a.bookingId, "booking:read", "report:read")), + }, + Mutation: { + revokePatientLinks: async (_: unknown, a: { bookingId: string }, ctx: Ctx) => + ctx.services.patientLinks.revoke(actorOf(ctx), await bookingFor(ctx, a.bookingId, "booking:update")), + }, +}); diff --git a/src/graphql/errors.ts b/src/graphql/errors.ts index a756ec9..ccbcacf 100644 --- a/src/graphql/errors.ts +++ b/src/graphql/errors.ts @@ -5,6 +5,7 @@ import { HttpError } from "@mgpl-diagnostics/report-kit"; import { GraphQLError } from "graphql"; import { BookingPatientMismatchError, FileTooLargeError, ReportAlreadyAppliedError, UnsupportedReportError } from "../services/import.service"; import { BarcodeTakenError } from "../services/booking.service"; +import { PatientLinkRateLimitError } from "../services/patient-link.service"; import { ForbiddenError } from "../services/util"; /** @@ -44,6 +45,7 @@ export function toGraphQLError(e: unknown): unknown { if (e.details.organizations?.length) extra.organizations = e.details.organizations.map((o) => ({ code: o.code, name: o.name })); return apiError(e.code as ErrorCode, e.message, e.statusCode, extra); } + if (e instanceof PatientLinkRateLimitError) return apiError("RATE_LIMITED", e.message, 429, { retryAfterMs: e.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 }); diff --git a/src/graphql/limits.ts b/src/graphql/limits.ts index b48beb4..11cca46 100644 --- a/src/graphql/limits.ts +++ b/src/graphql/limits.ts @@ -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*|importExternalReport)$/; +const DOCUMENT_FIELD = /^(bookingReportDocument|invoiceDocument|renderReport|renderInvoice|letterhead|patientDocument|export[A-Z]\w*|importExternalReport)$/; /** * Rejects operations that select more than `maxFields` fields in all (fragments counted where they are diff --git a/src/graphql/schema.ts b/src/graphql/schema.ts index 7edeb3a..47118da 100644 --- a/src/graphql/schema.ts +++ b/src/graphql/schema.ts @@ -10,6 +10,7 @@ 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 { patientLinksController } from "../controllers/patient-links.controller"; import { systemController } from "../controllers/system.controller"; import { authTypeDefs } from "../schema/auth.graphql"; import { baseTypeDefs } from "../schema/base.graphql"; @@ -21,6 +22,7 @@ import { financeTypeDefs } from "../schema/finance.graphql"; import { importsTypeDefs } from "../schema/imports.graphql"; import { labTypeDefs } from "../schema/lab.graphql"; import { organizationTypeDefs } from "../schema/organization.graphql"; +import { patientLinksTypeDefs } from "../schema/patient-links.graphql"; import { systemTypeDefs } from "../schema/system.graphql"; /** One domain = one SDL file in src/schema + one controller in src/controllers. */ @@ -41,6 +43,7 @@ export const MODULES: GraphQLModule[] = [ { name: "finance", typeDefs: financeTypeDefs, resolvers: financeController }, { name: "documents", typeDefs: documentsTypeDefs, resolvers: documentsController }, { name: "imports", typeDefs: importsTypeDefs, resolvers: importsController }, + { name: "patientLinks", typeDefs: patientLinksTypeDefs, resolvers: patientLinksController }, { name: "system", typeDefs: systemTypeDefs, resolvers: systemController }, ]; diff --git a/src/schema/patient-links.graphql.ts b/src/schema/patient-links.graphql.ts new file mode 100644 index 0000000..405f46e --- /dev/null +++ b/src/schema/patient-links.graphql.ts @@ -0,0 +1,66 @@ +/** + * Patient links (docs/19-patient-links.md): the QR printed on a booking's reports and bills opens a public, + * read-only page with that booking's bill and released reports. The token in the link is the only key; + * unknown, expired, revoked and malformed tokens all answer null. Every patient call is rate-limited per IP. + */ +export const patientLinksTypeDefs = /* GraphQL */ ` + "A booking's patient link, for staff to send or show." + type PatientLinkInfo { + url: String! + expiresAt: DateTime! + } + + type PatientPageBranch { + name: String! + phone: String + address: String + } + + type PatientPageInvoice { + id: ID! + invoiceNo: String! + issuedAt: DateTime! + totalPaise: Int! + paidPaise: Int! + duePaise: Int! + status: String! + } + + type PatientPageReport { + id: ID! + testName: String! + releasedAt: DateTime + } + + "What a patient sees from their link: one booking, nothing else. The name is shortened (\\"Ravi K. S.\\")." + type PatientPage { + labName: String! + patientName: String! + bookingNo: String! + bookedAt: DateTime! + branch: PatientPageBranch! + "Issued bills of the booking (drafts and cancelled bills are left out)." + invoices: [PatientPageInvoice!]! + "Released reports (verified ones are not given out yet)." + reports: [PatientPageReport!]! + "Tests still in the lab, by name." + pending: [String!]! + expiresAt: DateTime! + } + + enum PatientDocumentKind { invoice reports } + + extend type Query { + "Public (no sign-in): the page behind a patient link, or null." + patientPage(token: String!): PatientPage + "Public (no sign-in): a PDF from a patient link: one of its bills (invoiceId) or its released reports. Null when the link does not open." + patientDocument(token: String!, kind: PatientDocumentKind!, invoiceId: ID): Document + "The booking's patient link (made on first use). Needs booking:read and report:read on the booking's branch." + patientLink(bookingId: ID!): PatientLinkInfo! + } + + extend type Mutation { + "Stops every link of the booking (printed QRs stop opening); the next print makes a new one. Returns how many were stopped. Needs booking:update." + revokePatientLinks(bookingId: ID!): Int! + } +`; diff --git a/src/services/document.service.ts b/src/services/document.service.ts index f19a6a2..70ad5ba 100644 --- a/src/services/document.service.ts +++ b/src/services/document.service.ts @@ -1,7 +1,7 @@ import { getTestByCode, isNarrative } from "@mgpl-diagnostics/catalog-kit"; import { ConflictError, NotFoundError, ValidationError, - type Id, type Invoice as DbInvoice, type Patient as DbPatient, type PaymentMode, type Report as DbReport, type Repositories, + type Booking as DbBooking, type Id, type Invoice as DbInvoice, type PatientLink, type Patient as DbPatient, type PaymentMode, type Report as DbReport, type Repositories, } from "@mgpl-diagnostics/db-kit"; import { computeInvoice, INVOICE_SAMPLES, invoiceFromRequest, invoiceTotalsJson, PRICE_LIST, renderInvoiceHtml, renderInvoicePdf, @@ -12,7 +12,11 @@ import { type LabProfile, type Patient as PrintPatient, type Report as PrintReport, type ReportDesign, type ReportRequest, type ReportTestProvenance, type ReportVariant, type Row, type Section, type TestRow, } from "@mgpl-diagnostics/report-kit"; import type { Actor } from "../graphql/context"; + +/** Who prints: the organisation, and the user when there is one (a patient's own copy has none). */ +type Owner = Pick & { userId?: Id | null }; import { printedProvenance } from "./import.service"; +import type { PatientLinkService } from "./patient-link.service"; import { mustOwn, properName } from "./util"; export type DocumentFormat = "pdf" | "html"; @@ -62,10 +66,18 @@ export function printPatient(p: DbPatient, at: Date, extra: Partial { + if (!this.links?.enabled || !booking) return undefined; + return (await this.links.linkFor(orgId, booking, createdBy))?.url; } // ---------------------------------------------------------------- catalogs @@ -109,7 +121,7 @@ export class DocumentService { // ---------------------------------------------------------------- from stored data /** The printed form of a stored invoice (with its payments). */ - async storedInvoice(actor: Actor, invoice: DbInvoice): Promise { + async storedInvoice(actor: Owner, invoice: DbInvoice): Promise { const patient = mustOwn(await this.repos.patients.get(invoice.patientId), actor.orgId, "Patient", invoice.patientId); const payments = await this.repos.payments.find({ invoiceId: invoice.id }, { sort: { field: "receivedAt" } }); const at = invoice.issuedAt ?? invoice.createdAt; @@ -124,7 +136,10 @@ export class DocumentService { })); // refunds print as their own rows in the payments table (invoice-kit ≥ 2.1), not as a note const notes = [invoice.status === "cancelled" ? `CANCELLED: ${invoice.cancelReason ?? ""}`.trim() : ""].filter(Boolean); + // the QR opens the patient's page (this booking's bill and reports) when patient links are on + const qrValue = invoice.bookingId && invoice.status !== "cancelled" ? await this.linkOf(actor.orgId, await this.repos.bookings.get(invoice.bookingId), actor.userId ?? null) : undefined; return { + ...(qrValue ? { qrValue, qrLabel: "Scan for your reports and bill" } : {}), invoiceNo: invoice.invoiceNo, date: at, patient: printPatient(patient, at, sampleId ? { sampleId } : {}), @@ -138,7 +153,7 @@ export class DocumentService { }; } - async renderStoredInvoice(actor: Actor, invoice: DbInvoice, o: RenderOptions) { + async renderStoredInvoice(actor: Owner, invoice: DbInvoice, o: RenderOptions) { const printed = await this.storedInvoice(actor, invoice); // the printed total must equal the stored one (both are integer-paise maths with the same rules) const t = computeInvoice(printed); @@ -146,15 +161,29 @@ export class DocumentService { return this.renderInvoice(printed, { ...o, fileName: o.fileName ?? `${invoice.invoiceNo.replaceAll("/", "-")}.${o.format}` }); } + /** The patient's copy of one of the link's bills (issued or paid, never a draft or cancelled one). */ + async patientInvoice(link: PatientLink, invoiceId: Id) { + const inv = await this.repos.invoices.get(invoiceId); + if (!inv || inv.orgId !== link.orgId || inv.bookingId !== link.bookingId || inv.status === "draft" || inv.status === "cancelled") throw new NotFoundError("Invoice", invoiceId); + return this.renderStoredInvoice({ orgId: link.orgId, userId: null }, inv, { format: "pdf", variant: "header", design: "sidebar" }); + } + + /** The patient's copy of the link's released reports, printed together. */ + async patientReports(link: PatientLink) { + return this.renderBookingReports({ orgId: link.orgId, userId: null }, link.bookingId, null, { format: "pdf", variant: "header", design: "sidebar", releasedOnly: true }); + } + /** * A booking's reports as one printed report. Only verified or released reports print. A report * whose test code is a report-kit template uses that template (results keyed by its field keys); * any other test prints as a standard Test | Result | Unit | Range table. */ - async renderBookingReports(actor: Actor, bookingId: Id, reportIds: Id[] | null, o: RenderOptions) { + async renderBookingReports(actor: Owner, bookingId: Id, reportIds: Id[] | null, o: RenderOptions & { releasedOnly?: boolean }) { const booking = mustOwn(await this.repos.bookings.get(bookingId), actor.orgId, "Booking", bookingId); let reports = await this.repos.reports.find({ bookingId }); if (reportIds?.length) reports = reports.filter((r) => reportIds.includes(r.id)); + // the patient's page gives out released reports only + if (o.releasedOnly) reports = reports.filter((r) => r.status === "released"); if (!reports.length) throw new NotFoundError("Report", reportIds?.join(",") ?? bookingId); const unready = reports.find((r) => r.status !== "verified" && r.status !== "released"); if (unready) throw new ConflictError(`Report ${unready.testName} is ${unready.status}; verify it before printing`); @@ -175,7 +204,8 @@ export class DocumentService { if (provenance && own.length) own[own.length - 1] = { ...own[own.length - 1]!, provenance }; sections.push(...own); } - const report: PrintReport = { patient: printed, sections }; + const qrValue = await this.linkOf(actor.orgId, booking, actor.userId ?? null); + const report: PrintReport = { patient: printed, sections, ...(qrValue ? { qrValue } : {}) }; return this.renderReport(report, { ...o, fileName: o.fileName ?? `${booking.bookingNo}-${o.variant}.${o.format}` }); } diff --git a/src/services/index.ts b/src/services/index.ts index 7418074..98b11b7 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,4 +1,4 @@ -import type { Auth } from "@mgpl-diagnostics/auth-kit"; +import type { Auth, RateLimiter } from "@mgpl-diagnostics/auth-kit"; import type { Repositories } from "@mgpl-diagnostics/db-kit"; import type { LabProfile } from "@mgpl-diagnostics/report-kit"; import { AuthService, type Mailer } from "./auth.service"; @@ -14,6 +14,7 @@ import { ImportService, type ImportLimits } from "./import.service"; import { JobService } from "./job.service"; import type { JobQueue } from "../jobs/types"; import { OrganizationService } from "./organization.service"; +import { PatientLinkService, type PatientLinkConfig } from "./patient-link.service"; import { PatientService } from "./patient.service"; import { PeriodService } from "./period.service"; import { ReceivablesService } from "./receivables.service"; @@ -33,6 +34,10 @@ export interface ServiceDeps { jobs: () => JobQueue; /** Partner-lab PDF limits (IMPORT_* variables). */ imports: ImportLimits; + /** Patient links (PATIENT_LINK_*); off when omitted. */ + patientLinks?: PatientLinkConfig; + /** Limits patient page calls per IP (Redis when REDIS_URL is set, so every replica shares it). */ + patientLinkLimiter?: RateLimiter; /** Update notices (UPDATE_* settings); off when omitted. */ updates?: Config["updates"]; /** The HTTP client for the release feed (tests). */ @@ -46,6 +51,7 @@ const NO_UPDATES: Config["updates"] = { /** Every service, one instance each. Services know nothing about GraphQL; controllers call them. */ export function createServices(d: ServiceDeps) { + const patientLinks = new PatientLinkService(d.repos, d.patientLinks ?? { secret: null, baseUrl: null, ttlDays: 180 }, d.patientLinkLimiter ?? null); return { auth: new AuthService(d.repos, d.auth, { mailer: d.mailer, resetUrl: d.resetUrl, jobs: d.jobs }), organization: new OrganizationService(d.repos), @@ -61,7 +67,8 @@ export function createServices(d: ServiceDeps) { cashBank: new CashBankService(d.repos), periods: new PeriodService(d.repos), exports: new ExportService(d.repos), - documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples }), + documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples, links: patientLinks }), + patientLinks, jobs: new JobService(d.repos, d.jobs), imports: new ImportService(d.repos, d.jobs, d.imports), system: new SystemService(d.updates ?? NO_UPDATES, d.fetch), diff --git a/src/services/patient-link.service.ts b/src/services/patient-link.service.ts new file mode 100644 index 0000000..b86d594 --- /dev/null +++ b/src/services/patient-link.service.ts @@ -0,0 +1,165 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { ConflictError, type Booking, type Id, type PatientLink, type Repositories } from "@mgpl-diagnostics/db-kit"; +import type { RateLimiter } from "@mgpl-diagnostics/auth-kit"; +import type { Actor } from "../graphql/context"; +import { mustOwn } from "./util"; + +export interface PatientLinkConfig { + /** HMAC secret (32+ characters). Without it, links are off and documents print their old QR. */ + secret: string | null; + /** The public page, e.g. "https://dashboard.example.com/r/" (the token is appended). */ + baseUrl: string | null; + /** How long a link works. */ + ttlDays: number; +} + +/** What a patient sees from their link: one booking's bill and released reports. Nothing else. */ +export interface PatientView { + link: PatientLink; + booking: Booking; + patientName: string; + labName: string; + branch: { name: string; phone: string | null; address: string | null }; + invoices: { id: Id; invoiceNo: string; issuedAt: Date; totalPaise: number; paidPaise: number; duePaise: number; status: string }[]; + /** Released reports only (verified ones are not yet given out). */ + reports: { id: Id; testName: string; releasedAt: Date | null }[]; + /** Tests still in the lab, by name. */ + pending: string[]; +} + +/** Too many patient page calls from one address (RATE_LIMITED). */ +export class PatientLinkRateLimitError extends Error { + constructor(readonly retryAfterMs: number) { + super("Too many requests. Try again in a few minutes."); + this.name = "PatientLinkRateLimitError"; + } +} + +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); +const TOKEN = /^[A-Za-z0-9_-]{43}$/; + +/** "Ravi Kumar Sahu" → "Ravi K. S.": enough for the patient to recognise, little for anyone else. */ +export function shortName(name: string): string { + const [first, ...rest] = name.trim().split(/\s+/); + return [first, ...rest.map((w) => `${w[0]!.toUpperCase()}.`)].join(" "); +} + +/** + * Patient links: the QR on a report or a bill opens a read-only page with that booking's bill and released + * reports. The token (32 random-looking bytes, base64url) is HMAC(secret, nonce); only its SHA-256 and the + * nonce are stored, so a database copy alone cannot open any link, and the server can print the same link + * again. Links expire and can be revoked; a revoked booking gets a new link on its next print. + */ +export class PatientLinkService { + constructor(private readonly repos: Repositories, private readonly config: PatientLinkConfig, private readonly limiter: RateLimiter | null = null) {} + + /** Counts one patient page call from `ip` (every call, good token or not); throws when over the limit. */ + async throttle(ip: string | undefined): Promise { + if (!this.limiter) return; + const r = await this.limiter.hit(`patient-link:${ip ?? "unknown"}`); + if (!r.allowed) throw new PatientLinkRateLimitError(r.retryAfterMs); + } + + get enabled(): boolean { + return !!this.config.secret && !!this.config.baseUrl; + } + + private token(nonce: string): string { + return createHmac("sha256", this.config.secret!).update(nonce).digest("base64url"); + } + + private url(token: string): string { + return `${this.config.baseUrl}${token}`; + } + + private live(l: PatientLink, now = new Date()): boolean { + return !l.revokedAt && new Date(l.expiresAt).getTime() > now.getTime(); + } + + /** The booking's current link (created on first use), or null when links are off. */ + async linkFor(orgId: Id, booking: Booking, createdBy: Id | null = null): Promise<{ url: string; expiresAt: Date } | null> { + if (!this.enabled || booking.orgId !== orgId || booking.status === "cancelled") return null; + const existing = (await this.repos.patientLinks.find({ orgId, bookingId: booking.id } as never, { sort: { field: "createdAt", dir: "desc" }, limit: 5 })).find((l) => this.live(l)); + if (existing) { + const token = this.token(existing.nonce); + // a link made with another secret (rotated) no longer opens: make a new one + if (sha256(token) === existing.tokenHash) return { url: this.url(token), expiresAt: existing.expiresAt }; + } + const nonce = randomBytes(24).toString("base64url"); + const token = this.token(nonce); + const expiresAt = new Date(Date.now() + this.config.ttlDays * 86_400_000); + await this.repos.patientLinks.create({ + orgId, branchId: booking.branchId, bookingId: booking.id, patientId: booking.patientId, tokenHash: sha256(token), nonce, expiresAt, createdBy, viewCount: 0, + }); + return { url: this.url(token), expiresAt }; + } + + /** Staff: the link for a booking (to send or show), creating it when needed. */ + async forBooking(actor: Actor, booking: Booking) { + if (!this.enabled) throw new ConflictError("Patient links are off: set PATIENT_LINK_SECRET and PATIENT_LINK_BASE_URL"); + mustOwn(booking, actor.orgId, "Booking", booking.id); + if (booking.status === "cancelled") throw new ConflictError("The booking is cancelled"); + return (await this.linkFor(actor.orgId, booking, actor.userId))!; + } + + /** Staff: stops every link of the booking at once (printed QRs stop opening). The next print makes a new one. */ + async revoke(actor: Actor, booking: Booking): Promise { + mustOwn(booking, actor.orgId, "Booking", booking.id); + const links = (await this.repos.patientLinks.find({ orgId: actor.orgId, bookingId: booking.id } as never)).filter((l) => !l.revokedAt); + const now = new Date(); + for (const l of links) await this.repos.patientLinks.update(l.id, { revokedAt: now, revokedBy: actor.userId }); + if (links.length) await this.repos.audit.log({ orgId: actor.orgId, branchId: booking.branchId, userId: actor.userId, action: "patient_link.revoke", entity: "booking", entityId: booking.id, data: { links: links.length } }); + return links.length; + } + + /** The live link for a token, or null (unknown, expired, revoked and malformed all look the same). */ + async resolve(token: string): Promise { + if (!this.enabled || !TOKEN.test(token)) return null; + const hash = sha256(token); + const link = await this.repos.patientLinks.findOne({ tokenHash: hash } as never); + if (!link || !this.live(link)) return null; + // constant-time check that the token is the one derived from this row (a copied hash alone never opens it) + const want = Buffer.from(this.token(link.nonce)); + const got = Buffer.from(token); + if (want.length !== got.length || !timingSafeEqual(want, got)) return null; + return link; + } + + /** The live link and its booking for a token, or null (also when the booking was cancelled). */ + async open(token: string): Promise<{ link: PatientLink; booking: Booking } | null> { + const link = await this.resolve(token); + if (!link) return null; + const booking = await this.repos.bookings.get(link.bookingId); + if (!booking || booking.orgId !== link.orgId || booking.id !== link.bookingId || booking.status === "cancelled") return null; + return { link, booking }; + } + + /** What the patient's page shows, or null. Counts the view. */ + async view(token: string): Promise { + const opened = await this.open(token); + if (!opened) return null; + const { link, booking } = opened; + const [patient, branch, org, invoices, reports] = await Promise.all([ + this.repos.patients.get(booking.patientId), + this.repos.branches.get(booking.branchId), + this.repos.orgs.get(booking.orgId), + this.repos.invoices.find({ orgId: booking.orgId, bookingId: booking.id } as never, { sort: { field: "createdAt", dir: "asc" } }), + this.repos.reports.find({ bookingId: booking.id }), + ]); + await this.repos.patientLinks.update(link.id, { viewCount: (link.viewCount ?? 0) + 1, lastViewedAt: new Date() }); + const released = reports.filter((r) => r.status === "released"); + const done = new Set(released.map((r) => r.testCode)); + return { + link, + booking, + patientName: shortName(patient?.name ?? "Patient"), + labName: org?.name ?? "", + branch: { name: branch?.name ?? "", phone: branch?.phone ?? null, address: [branch?.address?.line1, branch?.address?.city].filter(Boolean).join(", ") || null }, + invoices: invoices.filter((i) => i.status !== "cancelled" && i.status !== "draft").map((i) => ({ + id: i.id, invoiceNo: i.invoiceNo, issuedAt: i.issuedAt ?? i.createdAt, totalPaise: i.totalPaise, paidPaise: i.paidPaise, duePaise: i.duePaise, status: i.status, + })), + reports: released.map((r) => ({ id: r.id, testName: r.testName, releasedAt: r.releasedAt ?? null })), + pending: booking.tests.filter((t) => !done.has(t.testCode)).map((t) => t.name), + }; + } +} diff --git a/test/run.ts b/test/run.ts index 1e43e12..f97a0a0 100644 --- a/test/run.ts +++ b/test/run.ts @@ -48,7 +48,7 @@ async function suite(db: Db, tag: string) { const repos: Repositories = { ...db.repos, patients: { ...db.repos.patients, getMany: (ids) => { patientBatches++; return db.repos.patients.getMany(ids); } } }; 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", MGPL_SAMPLES: "1", INTROSPECTION: "0", DATABASE_URL: "unused" }) }; + const config = { ...loadConfig({ NODE_ENV: "test", MGPL_SAMPLES: "1", INTROSPECTION: "0", DATABASE_URL: "unused", PATIENT_LINK_SECRET: "test-patient-link-secret-0123456789abcdef", PATIENT_LINK_BASE_URL: "https://lab.example.in/r", PATIENT_LINK_PER_IP: "40" }) }; const container = await createContainer({ config, db: { ...db, repos }, mailer }); const app = await buildServer(container); @@ -148,6 +148,40 @@ async function suite(db: Db, tag: string) { ok(tb.data?.trialBalance.differencePaise === 0 && tb.data.trialBalance.totalDebitPaise > 0 && tb.data.outstandingReceivables.totalDuePaise === 0, `[${tag}] trial balance balances (ledger-kit); nothing outstanding`); const invDoc = await gql(owner, "query($id: ID!) { invoiceDocument(invoiceId: $id) { base64 fileName } h: invoiceDocument(invoiceId: $id, format: html) { html } }", { id: invoiceId }); ok(Buffer.from(invDoc.data?.invoiceDocument.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-" && invDoc.data.h.html.includes(inv.data.issueInvoice.invoiceNo), `[${tag}] invoiceDocument: invoice-kit PDF + HTML of the stored invoice (totals match)`); + // ---------------------------------------------------------------- patient links (the QR on reports and bills) + { + const b = booking.booking.id; + const L = "query($b: ID!) { patientLink(bookingId: $b) { url expiresAt } }"; + const link = (await gql(owner, L, { b })).data?.patientLink; + const token = String(link?.url ?? "").replace("https://lab.example.in/r/", ""); + ok(/^[A-Za-z0-9_-]{43}$/.test(token) && new Date(link.expiresAt).getTime() > Date.now() + 170 * 86_400_000, `[${tag}] patientLink: an https URL under PATIENT_LINK_BASE_URL with a 43-character token, 180 days`); + ok((await gql(owner, L, { b })).data?.patientLink.url === link.url, `[${tag}] …the same link again (printing twice prints the same QR)`); + const rows = await db.rawRepos.patientLinks.find({ bookingId: b } as never); + ok(rows.length === 1 && rows[0]!.tokenHash !== token && !JSON.stringify(rows).includes(token), `[${tag}] …stored as a hash and a nonce, never the token`); + ok(code(await gql(otherOwner, L, { b })) === "NOT_FOUND" && code(await gql(null, L, { b })) === "UNAUTHENTICATED", `[${tag}] patientLink: another organisation → NOT_FOUND, no sign-in → UNAUTHENTICATED`); + const P = "query($t: String!) { patientPage(token: $t) { labName patientName bookingNo branch { name } invoices { id invoiceNo totalPaise duePaise status } reports { id testName } pending expiresAt } }"; + const page = (await gql(null, P, { t: token })).data?.patientPage; + ok(page?.patientName === "Asha P." && page.labName === "API Lab" && page.branch.name === "Branch A" && page.reports.length === 1 && page.pending.length === 0, `[${tag}] patientPage without sign-in: shortened name, lab, branch, the released report`); + ok(page?.invoices.length === 1 && page.invoices[0].invoiceNo === inv.data.issueInvoice.invoiceNo && page.invoices[0].duePaise === 0, `[${tag}] …and the bill, paid`); + const bad = await gql(null, "query($a: String!, $b: String!, $c: String!) { a: patientPage(token: $a) { bookingNo } b: patientPage(token: $b) { bookingNo } c: patientPage(token: $c) { bookingNo } }", { a: "x".repeat(43), b: token.slice(0, 42), c: `${token}a` }); + ok(bad.data && bad.data.a === null && bad.data.b === null && bad.data.c === null && !bad.errors, `[${tag}] unknown, short and long tokens → null (all look the same)`); + const D = "query($t: String!, $k: PatientDocumentKind!, $i: ID) { patientDocument(token: $t, kind: $k, invoiceId: $i) { mimeType base64 } }"; + const rep = (await gql(null, D, { t: token, k: "reports" })).data?.patientDocument; + const bill = (await gql(null, D, { t: token, k: "invoice", i: invoiceId })).data?.patientDocument; + ok(Buffer.from(rep?.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-" && Buffer.from(bill?.base64 ?? "", "base64").subarray(0, 5).toString() === "%PDF-", `[${tag}] patientDocument: the released reports and the bill as PDFs`); + ok(code(await gql(null, D, { t: token, k: "invoice", i: page.reports[0].id })) === "NOT_FOUND", `[${tag}] …a bill of another booking (or any other id) → NOT_FOUND`); + const views = (await db.rawRepos.patientLinks.find({ bookingId: b } as never))[0]!; + ok(views.viewCount === 1 && !!views.lastViewedAt, `[${tag}] the page counts its views`); + ok(code(await gql(desk, "mutation($b: ID!) { revokePatientLinks(bookingId: $b) }", { b })) === undefined, `[${tag}] revokePatientLinks with booking:update (front desk)`); + ok((await gql(null, P, { t: token })).data?.patientPage === null, `[${tag}] …the revoked link no longer opens`); + const next = (await gql(owner, L, { b })).data?.patientLink.url; + ok(!!next && next !== link.url && (await gql(null, P, { t: next.split("/").pop() })).data?.patientPage?.bookingNo === page.bookingNo, `[${tag}] …the next print makes a new link that opens`); + const audit = await db.rawRepos.audit.find({ action: "patient_link.revoke" } as never); + ok(audit.length >= 1, `[${tag}] the revoke is audited`); + let limited: string | undefined; + for (let i = 0; i < 40 && !limited; i++) limited = code(await gql(null, P, { t: "y".repeat(43) })); + ok(limited === "RATE_LIMITED", `[${tag}] patient pages are rate-limited per IP → RATE_LIMITED`); + } // ---------------------------------------------------------------- tube labels, calculated values, methods, refunds on paper { const lft = (await gql(owner, "mutation($i: CreateBookingInput!) { createBooking(input: $i) { booking { id } samples { id barcode } } }", { i: { branchId: a.branch.id, patientId, tests: [{ testCode: "LIFT" }] } })).data?.createBooking;