mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Default documentDate at store time. A store-only upload no longer lands with a NULL filing date: when the client omits one, default it to the upload day so the column is always set on a stored row. The library list sorts, groups, and range-filters on the same effective date the UI shows, so undated uploads no longer mis-sort under nulls-last, break date-group headers, or fall through the range filter. The date stays user-editable and may be cleared back to NULL via PATCH. Block re-extraction once any fact is APPROVED. A partially-confirmed document stays EXTRACTED, which the CONFIRMED guard does not catch; re-extracting would delete the approved facts (severing the committed record's provenance) and re-stage them as PENDING, letting a second approval duplicate the committed lab / condition / medication. The extract route now refuses with 409 (documents.inbound.alreadyPartlyConfirmed) when approved facts exist; the user must finish reviewing or discard first. RFC 5987-encode the original-download filename. The Content-Disposition now carries both an ASCII filename= fallback and a filename*=UTF-8'' value so a non-ASCII name survives intact; the stored set is inline-safe by construction, so the dead attachment branch is removed. Add a light per-user rate-limit backstop to the original-read, PATCH, and DELETE routes (generous; self-DoS only — the routes are owner-scoped). Lean the list counts: tally facts for the whole page in one grouped count instead of materialising fact-id rows, and exclude REJECTED facts from factCount. Extend the documentDate index with deleted_at to cover the list scan. Document the deliberate plaintext-title PHI tradeoff at the column and the store site.
495 lines
17 KiB
TypeScript
495 lines
17 KiB
TypeScript
/**
|
|
* OpenAPI route table for the documents library (`/api/documents/inbound/*`).
|
|
*
|
|
* Part of the OpenAPI route table; aggregated in `./index.ts`. The confirm +
|
|
* edit + list request bodies reuse the runtime schemas from
|
|
* `@/lib/validations/inbound-documents` so the wire contract stays
|
|
* single-source.
|
|
*
|
|
* The v1.25.x library inverts the original OCR-inbox: upload is STORE-ONLY and
|
|
* provider-free (a file is always filable, encrypted at rest); AI extraction is
|
|
* a separate, opt-in action on an already-stored document. Extraction
|
|
* reproduces what the document states (stated codes/status only) into a staging
|
|
* area — it never interprets or diagnoses. Nothing reaches the structured
|
|
* stores until the confirm route commits the user's approvals; a low-confidence
|
|
* fact fails closed.
|
|
*/
|
|
import type { ZodOpenApiObject } from "zod-openapi";
|
|
import { z } from "zod/v4";
|
|
|
|
import {
|
|
documentUpdateSchema,
|
|
inboundConfirmSchema,
|
|
inboundFactEditSchema,
|
|
INBOUND_DOCUMENT_KINDS,
|
|
INBOUND_DOCUMENT_STATUSES,
|
|
} from "@/lib/validations/inbound-documents";
|
|
|
|
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
|
|
|
const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS);
|
|
const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES);
|
|
|
|
inboundConfirmSchema.meta({
|
|
id: "InboundConfirmRequest",
|
|
description:
|
|
"The approve/reject decisions a human made on the review screen. Each names a staged fact by id. Approved facts are committed to the structured stores (labs / conditions / medications) through their normal create paths; rejected facts are discarded. A fact still flagged `needsReview` (below the confidence floor, never edited) cannot be approved — it is reported back as `needsReview`. No `userId` field — it is narrowed from the session; every fact id is re-scoped to the document + caller.",
|
|
});
|
|
|
|
inboundFactEditSchema.meta({
|
|
id: "InboundFactEditRequest",
|
|
description:
|
|
"A correction to a staged fact before approval (fixes OCR / units / dates / codes). Discriminated by `factType` so the edit cannot change the fact's resource type. A successful edit clears `needsReview` — the values become user-asserted.",
|
|
});
|
|
|
|
documentUpdateSchema.meta({
|
|
id: "DocumentUpdateRequest",
|
|
description:
|
|
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
|
|
});
|
|
|
|
const factProvenance = z.object({
|
|
sourceText: z.string(),
|
|
page: z.number().nullable(),
|
|
confidence: z.number(),
|
|
});
|
|
|
|
const extractedFact = z
|
|
.object({
|
|
id: z.string(),
|
|
factType: z.enum(["CONDITION", "OBSERVATION", "MEDICATION_STATEMENT"]),
|
|
status: z.enum(["PENDING", "APPROVED", "REJECTED"]),
|
|
confidence: z.number(),
|
|
needsReview: z.boolean(),
|
|
data: z.record(z.string(), z.unknown()),
|
|
provenance: factProvenance,
|
|
committedRecordId: z.string().nullable(),
|
|
committedRecordType: z.string().nullable(),
|
|
})
|
|
.meta({
|
|
id: "ExtractedFact",
|
|
description:
|
|
"One STATED fact transcribed from the document, FHIR-staged (Condition / Observation / MedicationStatement) with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact scored below the confidence floor (it cannot be approved until edited).",
|
|
});
|
|
|
|
const inboundDocument = z
|
|
.object({
|
|
id: z.string(),
|
|
kind: kindEnum,
|
|
title: z.string().nullable(),
|
|
filename: z.string().nullable(),
|
|
mimeType: z.string(),
|
|
byteSize: z.number(),
|
|
status: statusEnum,
|
|
providerType: z.string().nullable(),
|
|
reportDate: z.string().nullable(),
|
|
documentDate: z.string().nullable(),
|
|
errorReason: z.string().nullable(),
|
|
factCount: z.number(),
|
|
pendingCount: z.number(),
|
|
createdAt: z.string(),
|
|
updatedAt: z.string(),
|
|
})
|
|
.meta({
|
|
id: "InboundDocument",
|
|
description:
|
|
"A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs).",
|
|
});
|
|
|
|
const inboundDocumentDetail = inboundDocument
|
|
.extend({ facts: z.array(extractedFact) })
|
|
.meta({ id: "InboundDocumentDetail" });
|
|
|
|
const listResponse = z
|
|
.object({
|
|
documents: z.array(inboundDocument),
|
|
nextCursor: z.string().nullable(),
|
|
})
|
|
.meta({
|
|
id: "InboundDocumentList",
|
|
description:
|
|
"A page of documents plus an opaque `nextCursor` (the id to pass back as `cursor` for the next page; null when the list is exhausted).",
|
|
});
|
|
|
|
const confirmResponse = z
|
|
.object({
|
|
approved: z.array(
|
|
z.object({
|
|
factId: z.string(),
|
|
recordType: z.string(),
|
|
recordId: z.string(),
|
|
}),
|
|
),
|
|
rejected: z.array(z.string()),
|
|
needsReview: z.array(z.string()),
|
|
failed: z.array(z.object({ factId: z.string(), reason: z.string() })),
|
|
})
|
|
.meta({
|
|
id: "InboundConfirmResponse",
|
|
description:
|
|
"The outcome per decision: `approved` (committed, with the new record ref), `rejected` (discarded), `needsReview` (refused — edit first, fail-closed), `failed` (a per-fact commit miss, e.g. a numeric observation with no unit).",
|
|
});
|
|
|
|
const kindValues = [...INBOUND_DOCUMENT_KINDS];
|
|
|
|
export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
|
"/api/documents/inbound": {
|
|
get: {
|
|
tags: ["Documents"],
|
|
summary: "List stored documents",
|
|
description:
|
|
"The caller's stored documents with title/filename search, category filter, a `documentDate` range, sort, and keyset pagination. Owner-scoped; gated on the opt-in `inboundDocuments` module.",
|
|
parameters: [
|
|
{
|
|
name: "q",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string", maxLength: 100 },
|
|
description: "Case-insensitive search over title + filename.",
|
|
},
|
|
{
|
|
name: "kind",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string", enum: kindValues },
|
|
description: "Category filter.",
|
|
},
|
|
{
|
|
name: "from",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string", format: "date" },
|
|
description: "Inclusive lower bound on documentDate (YYYY-MM-DD).",
|
|
},
|
|
{
|
|
name: "to",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string", format: "date" },
|
|
description: "Inclusive upper bound on documentDate (YYYY-MM-DD).",
|
|
},
|
|
{
|
|
name: "sort",
|
|
in: "query",
|
|
required: false,
|
|
schema: {
|
|
type: "string",
|
|
enum: ["documentDate", "createdAt", "title"],
|
|
default: "documentDate",
|
|
},
|
|
},
|
|
{
|
|
name: "order",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string", enum: ["asc", "desc"], default: "desc" },
|
|
},
|
|
{
|
|
name: "cursor",
|
|
in: "query",
|
|
required: false,
|
|
schema: { type: "string" },
|
|
description: "Keyset cursor (the `nextCursor` from a prior page).",
|
|
},
|
|
{
|
|
name: "limit",
|
|
in: "query",
|
|
required: false,
|
|
schema: {
|
|
type: "integer",
|
|
minimum: 1,
|
|
maximum: 100,
|
|
default: 50,
|
|
},
|
|
},
|
|
],
|
|
responses: {
|
|
"200": {
|
|
description: "A page of documents.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(listResponse, "InboundDocumentListEnvelope"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
post: {
|
|
tags: ["Documents"],
|
|
summary: "Store a document (no extraction)",
|
|
description:
|
|
"STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider configured. `multipart/form-data`: a `file` (JPEG/PNG/WebP/PDF, validated by magic-byte MIME sniff, ≤ 12 MiB) plus optional `title`, `kind`, and `documentDate` (YYYY-MM-DD) form fields. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`.",
|
|
requestBody: {
|
|
required: true,
|
|
content: {
|
|
"multipart/form-data": {
|
|
schema: z.object({
|
|
file: z.string().meta({
|
|
format: "binary",
|
|
description:
|
|
"The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.",
|
|
}),
|
|
title: z
|
|
.string()
|
|
.optional()
|
|
.meta({ description: "Optional user label." }),
|
|
kind: kindEnum
|
|
.optional()
|
|
.meta({ description: "Optional category." }),
|
|
documentDate: z.string().optional().meta({
|
|
description:
|
|
"Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library sort/filter/display stay aligned.",
|
|
}),
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
"201": {
|
|
description: "The stored document.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(inboundDocument, "InboundDocumentEnvelope"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/documents/inbound/{id}": {
|
|
get: {
|
|
tags: ["Documents"],
|
|
summary: "Get a document + its staged facts",
|
|
description:
|
|
"The document plus every staged fact for the review screen. Owner-scoped.",
|
|
parameters: [
|
|
{
|
|
name: "id",
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
},
|
|
],
|
|
responses: {
|
|
"200": {
|
|
description: "Document detail.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
inboundDocumentDetail,
|
|
"InboundDocumentDetailEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
patch: {
|
|
tags: ["Documents"],
|
|
summary: "Edit a document's metadata",
|
|
description:
|
|
"Rename / recategorise / set the user filing date on a stored document. Owner-scoped; no mass assignment. Returns the updated document + its staged facts.",
|
|
parameters: [
|
|
{
|
|
name: "id",
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
},
|
|
],
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: documentUpdateSchema } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "The updated document.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
inboundDocumentDetail,
|
|
"InboundDocumentUpdateEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
delete: {
|
|
tags: ["Documents"],
|
|
summary: "Discard a document",
|
|
description:
|
|
"Soft-deletes the document + its staging. Facts already approved into the structured stores are independent rows and are NOT affected.",
|
|
parameters: [
|
|
{
|
|
name: "id",
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
},
|
|
],
|
|
responses: {
|
|
"200": {
|
|
description: "Discarded.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
z.object({ id: z.string(), discarded: z.boolean() }),
|
|
"InboundDiscardEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/documents/inbound/{id}/original": {
|
|
get: {
|
|
tags: ["Documents"],
|
|
summary: "View / download the original document",
|
|
description:
|
|
"Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Uploads are constrained at the store path to the inline-safe set (JPEG / PNG / WebP / PDF), so the document is always served `Content-Disposition: inline` for in-tab rendering; a non-ASCII filename is carried via RFC 5987 `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext.",
|
|
parameters: [
|
|
{
|
|
name: "id",
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
},
|
|
],
|
|
responses: {
|
|
"200": {
|
|
description:
|
|
"The decrypted original document bytes with the stored MIME type.",
|
|
content: {
|
|
"application/pdf": {
|
|
schema: { type: "string", format: "binary" },
|
|
},
|
|
"image/*": {
|
|
schema: { type: "string", format: "binary" },
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/documents/inbound/{id}/extract": {
|
|
post: {
|
|
tags: ["Documents"],
|
|
summary: "Extract facts from a stored document",
|
|
description:
|
|
'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
|
parameters: [
|
|
{
|
|
name: "id",
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
},
|
|
],
|
|
requestBody: {
|
|
required: false,
|
|
content: {
|
|
"application/json": {
|
|
schema: z
|
|
.object({
|
|
mode: z.literal("text"),
|
|
text: z.string(),
|
|
kind: kindEnum.optional(),
|
|
})
|
|
.meta({ id: "InboundTextExtractRequest" }),
|
|
},
|
|
},
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "The document with its newly staged facts.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(
|
|
inboundDocumentDetail,
|
|
"InboundExtractEnvelope",
|
|
),
|
|
},
|
|
},
|
|
},
|
|
"409": {
|
|
description:
|
|
"Re-extraction refused: at least one fact on this document is already APPROVED. `meta.errorCode` = `documents.inbound.alreadyPartlyConfirmed`. Re-extracting would sever committed-record provenance and duplicate committed records, so the user must finish reviewing or discard the document first.",
|
|
content: { "application/json": { schema: errorEnvelope } },
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/documents/inbound/{id}/facts/{factId}": {
|
|
patch: {
|
|
tags: ["Documents"],
|
|
summary: "Edit a staged fact before approval",
|
|
description:
|
|
"Corrects OCR / units / dates / codes on a pending fact. Clears `needsReview` (the values become user-asserted). The fact's resource type cannot change.",
|
|
parameters: [
|
|
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
|
{
|
|
name: "factId",
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
},
|
|
],
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: inboundFactEditSchema } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "The updated fact.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(extractedFact, "ExtractedFactEnvelope"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
"/api/documents/inbound/{id}/confirm": {
|
|
post: {
|
|
tags: ["Documents"],
|
|
summary: "Confirm the review decisions",
|
|
description:
|
|
"The ONLY write path out of staging. Commits the user's approvals into the structured stores (labs / conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`.",
|
|
parameters: [
|
|
{
|
|
name: "id",
|
|
in: "path",
|
|
required: true,
|
|
schema: { type: "string" },
|
|
},
|
|
],
|
|
requestBody: {
|
|
required: true,
|
|
content: { "application/json": { schema: inboundConfirmSchema } },
|
|
},
|
|
responses: {
|
|
"200": {
|
|
description: "Per-decision outcome.",
|
|
content: {
|
|
"application/json": {
|
|
schema: dataEnvelope(confirmResponse, "InboundConfirmEnvelope"),
|
|
},
|
|
},
|
|
},
|
|
...stdResponses,
|
|
},
|
|
},
|
|
},
|
|
};
|