mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge v1.25.1: documents library backend — store-without-AI, optional extract, edit, list
# Conflicts: # docs/api/openapi.yaml # src/lib/documents/store.ts # src/lib/openapi/routes/documents.ts
This commit is contained in:
commit
cdd66b6620
12 files changed
+1820
-472
No files matched your search
+262
-22
@@ -8595,12 +8595,82 @@ paths:
|
||||
get:
|
||||
tags:
|
||||
- Documents
|
||||
summary: List inbound clinical documents
|
||||
description: The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on
|
||||
the opt-in `inboundDocuments` module.
|
||||
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:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
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: The document list.
|
||||
description: A page of documents.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -8611,14 +8681,12 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Upload + extract a clinical document
|
||||
description: 'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw
|
||||
document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type.
|
||||
VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus
|
||||
an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the
|
||||
extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what
|
||||
the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores
|
||||
here; the confirm route is the only write path.'
|
||||
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:
|
||||
@@ -8629,22 +8697,30 @@ paths:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.
|
||||
description: The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.
|
||||
title:
|
||||
description: Optional user label.
|
||||
type: string
|
||||
kind:
|
||||
description: Optional document-type label.
|
||||
description: Optional category.
|
||||
type: string
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
documentDate:
|
||||
description: Optional user filing date (YYYY-MM-DD).
|
||||
type: string
|
||||
required:
|
||||
- file
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundTextUploadRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: The created document + staging summary.
|
||||
description: The stored document.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -8674,10 +8750,60 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
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:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
anyOf:
|
||||
- type: string
|
||||
maxLength: 200
|
||||
- type: "null"
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
documentDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
pattern: ^\d{4}-\d{2}-\d{2}$
|
||||
- type: "null"
|
||||
responses:
|
||||
"200":
|
||||
description: The updated document.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundDocumentUpdateEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
delete:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Discard an inbound document
|
||||
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:
|
||||
@@ -8730,6 +8856,40 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
$ref: "#/components/schemas/InboundTextExtractRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: The document with its newly staged facts.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundExtractEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/documents/inbound/{id}/facts/{factId}:
|
||||
patch:
|
||||
tags:
|
||||
@@ -10663,7 +10823,7 @@ components:
|
||||
required:
|
||||
- mode
|
||||
- text
|
||||
InboundTextUploadRequest:
|
||||
InboundTextExtractRequest:
|
||||
type: object
|
||||
properties:
|
||||
mode:
|
||||
@@ -10676,6 +10836,11 @@ components:
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
required:
|
||||
- mode
|
||||
@@ -24941,9 +25106,16 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InboundDocument"
|
||||
nextCursor:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- documents
|
||||
- nextCursor
|
||||
additionalProperties: false
|
||||
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).
|
||||
InboundDocument:
|
||||
type: object
|
||||
properties:
|
||||
@@ -24954,7 +25126,16 @@ components:
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
title:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
filename:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -24966,6 +25147,7 @@ components:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- STORED
|
||||
- EXTRACTING
|
||||
- EXTRACTED
|
||||
- FAILED
|
||||
@@ -24979,6 +25161,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
documentDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
errorReason:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -24994,20 +25180,24 @@ components:
|
||||
required:
|
||||
- id
|
||||
- kind
|
||||
- title
|
||||
- filename
|
||||
- mimeType
|
||||
- byteSize
|
||||
- status
|
||||
- providerType
|
||||
- reportDate
|
||||
- documentDate
|
||||
- errorReason
|
||||
- factCount
|
||||
- pendingCount
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the
|
||||
metadata + staging summary.
|
||||
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).
|
||||
InboundDocumentEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -25052,7 +25242,16 @@ components:
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
title:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
filename:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -25064,6 +25263,7 @@ components:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- STORED
|
||||
- EXTRACTING
|
||||
- EXTRACTED
|
||||
- FAILED
|
||||
@@ -25077,6 +25277,10 @@ components:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
documentDate:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
errorReason:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -25096,12 +25300,14 @@ components:
|
||||
required:
|
||||
- id
|
||||
- kind
|
||||
- title
|
||||
- filename
|
||||
- mimeType
|
||||
- byteSize
|
||||
- status
|
||||
- providerType
|
||||
- reportDate
|
||||
- documentDate
|
||||
- errorReason
|
||||
- factCount
|
||||
- pendingCount
|
||||
@@ -25174,6 +25380,23 @@ components:
|
||||
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).
|
||||
InboundDocumentUpdateEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InboundDocumentDetail"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InboundDiscardEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -25200,6 +25423,23 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InboundExtractEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InboundDocumentDetail"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ExtractedFactEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
-- v1.25 — documents library: store-first, optional extraction.
|
||||
--
|
||||
-- Inverts the inbound-documents surface from an OCR inbox into a document
|
||||
-- library. A user can now STORE any file (encrypted at rest) without a
|
||||
-- configured document-scan provider; extraction becomes an optional action
|
||||
-- on an already-stored row.
|
||||
--
|
||||
-- Schema deltas on `inbound_documents`:
|
||||
-- * `title` — user-given label, plaintext (mirrors `filename`) so
|
||||
-- the list can ILIKE-search + ORDER BY. The document
|
||||
-- BODY stays encrypted; only this short user-authored
|
||||
-- title is clear-text.
|
||||
-- * `document_date` — user-set filing date (editable). Distinct from
|
||||
-- `report_date`, which stays the model-transcribed
|
||||
-- value.
|
||||
-- * a `(user_id, document_date)` index for date-grouped browse + range
|
||||
-- filter.
|
||||
-- * `inbound_document_kind` gains LAB_RESULT / IMAGING / PRESCRIPTION /
|
||||
-- INSURANCE / VACCINATION (the existing 3 stay; OTHER stays the fallback).
|
||||
-- * `inbound_document_status` gains STORED, made the column DEFAULT (the
|
||||
-- library default — uploaded, no extraction run).
|
||||
--
|
||||
-- Additive; no existing row rewritten (the status type-swap maps every
|
||||
-- existing label to itself). Nullable columns, no backfill: existing rows
|
||||
-- keep their current status; `title` / `document_date` start NULL and resolve
|
||||
-- through the display fallback. Idempotent guards so reruns are safe on prod.
|
||||
--
|
||||
-- Postgres note: a freshly-added enum value cannot be USED (e.g. as a column
|
||||
-- DEFAULT) in the same transaction it is added in. Because Prisma runs each
|
||||
-- migration inside a transaction, STORED cannot be added with `ALTER TYPE ...
|
||||
-- ADD VALUE` and then set as the default in the same file. The status enum is
|
||||
-- therefore recreated (RENAME + CREATE with the full value set + column
|
||||
-- type-swap + SET DEFAULT): a type CREATEd in the current transaction MAY
|
||||
-- have its values used in that same transaction, which makes the default
|
||||
-- change safe. The `kind` additions are not used in this migration, so the
|
||||
-- cheap `ADD VALUE IF NOT EXISTS` form is used for them.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP INDEX IF EXISTS "inbound_documents_user_id_document_date_idx";
|
||||
-- ALTER TABLE "inbound_documents" DROP COLUMN IF EXISTS "document_date";
|
||||
-- ALTER TABLE "inbound_documents" DROP COLUMN IF EXISTS "title";
|
||||
-- -- (added enum values are inert if unused; a roll-back leaves them
|
||||
-- -- harmless rather than rewriting the type back.)
|
||||
|
||||
-- AlterTable: user-given title + user-set filing date (both nullable).
|
||||
ALTER TABLE "inbound_documents" ADD COLUMN IF NOT EXISTS "title" TEXT;
|
||||
ALTER TABLE "inbound_documents" ADD COLUMN IF NOT EXISTS "document_date" TIMESTAMP(3);
|
||||
|
||||
-- AlterEnum: richer document-kind taxonomy (additive; not used in this
|
||||
-- migration, so the new values are safe to add in-place).
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'LAB_RESULT';
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'IMAGING';
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'PRESCRIPTION';
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'INSURANCE';
|
||||
ALTER TYPE "inbound_document_kind" ADD VALUE IF NOT EXISTS 'VACCINATION';
|
||||
|
||||
-- AlterEnum: add STORED and make it the column default. Recreate the type so
|
||||
-- the new value can be USED as the default in this same transaction (a
|
||||
-- same-transaction CREATE TYPE permits same-transaction use of its values).
|
||||
-- Guarded on STORED-absence so a rerun is a no-op.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_enum e
|
||||
JOIN pg_type t ON e.enumtypid = t.oid
|
||||
WHERE t.typname = 'inbound_document_status' AND e.enumlabel = 'STORED'
|
||||
) THEN
|
||||
ALTER TYPE "inbound_document_status" RENAME TO "inbound_document_status_old";
|
||||
CREATE TYPE "inbound_document_status" AS ENUM (
|
||||
'STORED', 'EXTRACTING', 'EXTRACTED', 'FAILED', 'CONFIRMED', 'DISCARDED'
|
||||
);
|
||||
ALTER TABLE "inbound_documents" ALTER COLUMN "status" DROP DEFAULT;
|
||||
ALTER TABLE "inbound_documents"
|
||||
ALTER COLUMN "status" TYPE "inbound_document_status"
|
||||
USING ("status"::text::"inbound_document_status");
|
||||
ALTER TABLE "inbound_documents" ALTER COLUMN "status" SET DEFAULT 'STORED';
|
||||
DROP TYPE "inbound_document_status_old";
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- CreateIndex: date-grouped browse + document_date range filter.
|
||||
CREATE INDEX IF NOT EXISTS "inbound_documents_user_id_document_date_idx"
|
||||
ON "inbound_documents"("user_id", "document_date");
|
||||
+28
-8
@@ -5527,20 +5527,29 @@ model CyclePrediction {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// What kind of clinical document was uploaded (free choice by the user;
|
||||
/// drives no interpretation — it is a label only).
|
||||
/// drives no interpretation — it is a label only). The richer set
|
||||
/// (lab/imaging/prescription/insurance/vaccination) makes the library
|
||||
/// browsable by category; the value still drives no interpretation.
|
||||
enum InboundDocumentKind {
|
||||
DOCTOR_REPORT
|
||||
DISCHARGE_LETTER
|
||||
LAB_RESULT
|
||||
IMAGING
|
||||
PRESCRIPTION
|
||||
INSURANCE
|
||||
VACCINATION
|
||||
OTHER
|
||||
|
||||
@@map("inbound_document_kind")
|
||||
}
|
||||
|
||||
/// Lifecycle of an uploaded document. EXTRACTING while the provider runs,
|
||||
/// EXTRACTED once facts are staged for review, FAILED on a provider/parse
|
||||
/// miss, CONFIRMED once the user has acted on every fact, DISCARDED on a
|
||||
/// soft delete.
|
||||
/// Lifecycle of an uploaded document. STORED is the library default — the
|
||||
/// file is uploaded + encrypted at rest with no extraction run. EXTRACTING
|
||||
/// while the provider runs, EXTRACTED once facts are staged for review,
|
||||
/// FAILED on a provider/parse miss, CONFIRMED once the user has acted on
|
||||
/// every fact, DISCARDED on a soft delete.
|
||||
enum InboundDocumentStatus {
|
||||
STORED
|
||||
EXTRACTING
|
||||
EXTRACTED
|
||||
FAILED
|
||||
@@ -5575,6 +5584,11 @@ model InboundDocument {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind InboundDocumentKind @default(OTHER)
|
||||
/// User-given label. Plaintext (like `filename`) so the library list can
|
||||
/// sort + search in SQL. The document BODY stays encrypted; only this
|
||||
/// short, user-authored title is clear-text — it is what makes ILIKE
|
||||
/// search and ORDER BY possible.
|
||||
title String?
|
||||
/// Original filename as uploaded (sanitised, optional).
|
||||
filename String?
|
||||
/// Magic-byte-sniffed MIME type the document was stored as.
|
||||
@@ -5586,13 +5600,18 @@ model InboundDocument {
|
||||
/// shared with the note columns. The original is sensitive and never
|
||||
/// stored in the clear or logged.
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
status InboundDocumentStatus @default(EXTRACTING)
|
||||
status InboundDocumentStatus @default(STORED)
|
||||
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
|
||||
/// "local"). Diagnostic only.
|
||||
/// "local"). Diagnostic only. Null on a store-only document.
|
||||
providerType String? @map("provider_type")
|
||||
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
|
||||
/// transcribed — never inferred.
|
||||
/// transcribed by the model — never inferred, never user-set.
|
||||
reportDate DateTime? @map("report_date")
|
||||
/// User-set filing date (editable). Distinct from `reportDate`, which is
|
||||
/// the model-transcribed value. The library shows `documentDate`,
|
||||
/// defaulting to `reportDate` then `createdAt` when the user hasn't set
|
||||
/// one.
|
||||
documentDate DateTime? @map("document_date")
|
||||
/// Failure reason when status = FAILED (a short code, never the document).
|
||||
errorReason String? @map("error_reason")
|
||||
|
||||
@@ -5607,6 +5626,7 @@ model InboundDocument {
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([userId, deletedAt])
|
||||
@@index([userId, documentDate])
|
||||
@@map("inbound_documents")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.25 — documents library: owner-scoped metadata edit.
|
||||
*
|
||||
* Pins that PATCH is owner-scoped (a caller cannot touch another user's
|
||||
* document — the `where` carries the session userId, so a foreign row resolves
|
||||
* to a 404) and that an own-row edit sets only the sent fields (no mass
|
||||
* assignment).
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findFirstOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `v1.${s}`),
|
||||
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: vi.fn(() => new Uint8Array([1])),
|
||||
decryptFromBytes: vi.fn(() => "{}"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkReq(id: string, body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}`),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
|
||||
function docRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
kind: "DOCTOR_REPORT",
|
||||
title: "Renamed",
|
||||
filename: "x.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 70,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
errorReason: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-02T00:00:00.000Z"),
|
||||
facts: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/documents/inbound/[id]", () => {
|
||||
it("cannot touch another user's document (404, no update)", async () => {
|
||||
// The foreign row does not satisfy `{ id, userId, deletedAt: null }`.
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
mkReq("foreign-doc", { title: "hijack" }) as never,
|
||||
ctx("foreign-doc") as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
|
||||
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0]
|
||||
.where;
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("edits only the sent fields on an owned document", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
} as never);
|
||||
vi.mocked(prisma.inboundDocument.update).mockResolvedValue(
|
||||
docRow() as never,
|
||||
);
|
||||
vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue(
|
||||
docRow({ kind: "IMAGING", title: "Renamed" }) as never,
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
mkReq("doc-1", { title: "Renamed", kind: "IMAGING" }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arg = vi.mocked(prisma.inboundDocument.update).mock.calls[0][0];
|
||||
expect(arg.where).toEqual({ id: "doc-1" });
|
||||
expect(arg.data.title).toBe("Renamed");
|
||||
expect(arg.data.kind).toBe("IMAGING");
|
||||
// documentDate was not sent → not present in the update payload.
|
||||
expect("documentDate" in arg.data).toBe(false);
|
||||
});
|
||||
|
||||
it("422s on an empty update", async () => {
|
||||
const res = await PATCH(mkReq("doc-1", {}) as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.25 — optional extraction action.
|
||||
*
|
||||
* Pins the store-first fix: with NO vision provider configured, extraction
|
||||
* 422s on the ENHANCEMENT only — the already-stored document is left intact
|
||||
* (no fact-staging transaction, no status flip). This is the inverse of the
|
||||
* old behaviour where the absence of a provider blocked the upload itself.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { deleteMany: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `v1.${s}`),
|
||||
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
encryptToBytes: vi.fn(() => new Uint8Array([1])),
|
||||
decryptFromBytes: vi.fn(() => "{}"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/labs/ocr-capability", () => ({
|
||||
resolveVisionProvider: vi.fn(),
|
||||
resolveTextProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertConsentForChain: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-27"),
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
}));
|
||||
vi.mock("@/lib/documents/extract", () => ({
|
||||
runInboundExtraction: vi.fn(),
|
||||
InboundExtractError: class InboundExtractError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, resetAt: Date.now() }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveVisionProvider } from "@/lib/labs/ocr-capability";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
|
||||
function visionReq(id: string): NextRequest {
|
||||
// No JSON content-type → vision mode (operates on the stored original).
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}/extract`),
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
kind: "OTHER",
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract", () => {
|
||||
it("422s without a vision provider and leaves the stored row intact", async () => {
|
||||
vi.mocked(resolveVisionProvider).mockResolvedValue({
|
||||
chain: [],
|
||||
pick: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("documents.inbound.providerUnsupported");
|
||||
|
||||
// The stored document is untouched — no staging transaction, no flip.
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s for a document the caller does not own", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
const res = await POST(
|
||||
visionReq("foreign") as never,
|
||||
ctx("foreign") as never,
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* v1.25 — optional AI extraction on an already-STORED document.
|
||||
*
|
||||
* Extraction is no longer part of upload: a document is stored first
|
||||
* (provider-free), and THIS route is the explicit, user-triggered enhancement.
|
||||
* It carries the entire ingest gauntlet that upload used to run inline —
|
||||
* resolve provider → assertConsentForChain → rate-limit → reserveBudget →
|
||||
* runInboundExtraction → reconcileSpend → stage facts — but now against a row
|
||||
* that already exists. Absent a provider this 422s the ENHANCEMENT only; the
|
||||
* stored document is untouched and remains filed.
|
||||
*
|
||||
* Two modes, dispatched on content-type (mirroring the original upload):
|
||||
* - VISION (no JSON body): decrypt the stored original, re-derive its MIME,
|
||||
* and run the vision-capable provider over it.
|
||||
* - TEXT (application/json, opt-in local OCR): `{ mode: "text", text }` — the
|
||||
* browser OCR'd the document locally and posts only the text to structure.
|
||||
*
|
||||
* The document is UNTRUSTED (prompt-injection): the server never acts on an
|
||||
* instruction inside it. The staged facts land PENDING for the mandatory
|
||||
* review-then-confirm screen; nothing reaches the structured stores here.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
InboundExtractError,
|
||||
runInboundExtraction,
|
||||
type InboundExtractionResult,
|
||||
} from "@/lib/documents/extract";
|
||||
import {
|
||||
decryptDocumentFromBytes,
|
||||
encryptFactData,
|
||||
encryptFactProvenance,
|
||||
serialiseDocumentDetail,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */
|
||||
const EXTRACT_LIMIT_PER_HOUR = 6;
|
||||
const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
|
||||
const TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
|
||||
/** A loaded, owner-scoped, still-extractable document row. */
|
||||
type LoadedDocument = {
|
||||
id: string;
|
||||
kind: string;
|
||||
contentEncrypted: Uint8Array;
|
||||
mimeType: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace the document's staged facts and flip it to EXTRACTED in one
|
||||
* transaction. Re-extraction is allowed (a STORED / FAILED / EXTRACTED row),
|
||||
* so any prior PENDING staging is cleared first; facts already APPROVED into
|
||||
* the structured stores are independent rows and are not touched here.
|
||||
*/
|
||||
async function stageExtraction(
|
||||
documentId: string,
|
||||
userId: string,
|
||||
result: InboundExtractionResult,
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
await tx.extractedFact.deleteMany({ where: { documentId, userId } });
|
||||
await tx.inboundDocument.update({
|
||||
where: { id: documentId },
|
||||
data: {
|
||||
status: "EXTRACTED",
|
||||
providerType: result.providerType,
|
||||
reportDate: result.reportDate
|
||||
? new Date(`${result.reportDate}T00:00:00.000Z`)
|
||||
: null,
|
||||
facts: {
|
||||
create: result.facts.map((f) => ({
|
||||
userId,
|
||||
factType: f.factType,
|
||||
status: "PENDING" as const,
|
||||
confidence: f.confidence,
|
||||
needsReview: f.needsReview,
|
||||
dataEncrypted: encryptFactData(f.data),
|
||||
provenanceEncrypted: encryptFactProvenance(f.provenance),
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
return tx.inboundDocument.findUniqueOrThrow({
|
||||
where: { id: documentId },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
const document = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
contentEncrypted: true,
|
||||
mimeType: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
if (!document) {
|
||||
return apiError("Document not found", 404, {
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
}
|
||||
if (document.status === "CONFIRMED") {
|
||||
return apiError("This document has already been confirmed.", 422, {
|
||||
errorCode: "documents.inbound.alreadyConfirmed",
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return handleTextExtract(request, user.id, document);
|
||||
}
|
||||
return handleVisionExtract(request, user.id, document);
|
||||
},
|
||||
);
|
||||
|
||||
/** TEXT mode — structure in-browser-OCR'd text against the stored row. */
|
||||
async function handleTextExtract(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { labsLocalOcrEnabled: true },
|
||||
});
|
||||
if (!row?.labsLocalOcrEnabled) {
|
||||
annotate({ action: { name: "documents.inbound.providerUnsupported" } });
|
||||
return apiError("Local OCR is not enabled", 422, {
|
||||
errorCode: "documents.inbound.localOcrDisabled",
|
||||
});
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many extractions. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
ocrText: parsed.data.text,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const updated = await stageExtraction(document.id, userId, result);
|
||||
|
||||
await auditLog("documents.inbound.extract", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: updated.facts.length,
|
||||
mode: "text",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "text" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** VISION mode — run the stored original through the vision provider. */
|
||||
async function handleVisionExtract(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many extractions. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Decrypt the stored original and re-derive its MIME from the bytes (never
|
||||
// trust a stored label for the provider call).
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = decryptDocumentFromBytes(document.contentEncrypted);
|
||||
} catch {
|
||||
return apiError("Couldn't read the stored document.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const mime = detectOcrMimeType(buffer);
|
||||
if (!mime) {
|
||||
return apiError(
|
||||
"This document can't be scanned. Use local OCR (text mode).",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.fileType" },
|
||||
);
|
||||
}
|
||||
|
||||
if (mime === "application/pdf" && !pick.pdfSupported) {
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; use local OCR instead.",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
|
||||
);
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
|
||||
const dataBase64 = buffer.toString("base64");
|
||||
const images =
|
||||
mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }];
|
||||
const documents =
|
||||
mime === "application/pdf"
|
||||
? [{ mediaType: "application/pdf" as const, dataBase64 }]
|
||||
: [];
|
||||
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
images,
|
||||
documents,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const updated = await stageExtraction(document.id, userId, result);
|
||||
|
||||
await auditLog("documents.inbound.extract", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: updated.facts.length,
|
||||
mode: "vision",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "vision" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,28 @@
|
||||
/**
|
||||
* v1.25 (W-DOCS-IN) — inbound clinical document by id: detail + discard.
|
||||
* v1.25 — inbound clinical document by id: detail + metadata edit + discard.
|
||||
*
|
||||
* GET returns the document plus its staged facts for the review screen.
|
||||
* PATCH renames / recategorises / sets the user filing date (no mass
|
||||
* assignment; `userId` from the session feeds the `where`).
|
||||
* DELETE soft-deletes (tombstone) and marks the document DISCARDED — facts
|
||||
* already approved into the structured stores are independent rows and stay.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { documentUpdateSchema } from "@/lib/validations/inbound-documents";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -45,6 +54,73 @@ export const GET = apiHandler(
|
||||
},
|
||||
);
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = documentUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document update", 422, {
|
||||
errorCode: "documents.inbound.invalidUpdate",
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Document not found", 404, {
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
}
|
||||
|
||||
// No mass assignment — each editable column is set explicitly only when
|
||||
// the client sent it. `userId` is never a body field.
|
||||
const data: Prisma.InboundDocumentUpdateInput = {};
|
||||
if (parsed.data.title !== undefined) data.title = parsed.data.title;
|
||||
if (parsed.data.kind !== undefined) data.kind = parsed.data.kind;
|
||||
if (parsed.data.documentDate !== undefined) {
|
||||
data.documentDate = parsed.data.documentDate
|
||||
? new Date(`${parsed.data.documentDate}T00:00:00.000Z`)
|
||||
: null;
|
||||
}
|
||||
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
|
||||
const document = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { id: existing.id },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
|
||||
await auditLog("documents.inbound.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: existing.id,
|
||||
fields: Object.keys(data),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.update" },
|
||||
meta: { documentId: existing.id, fields: Object.keys(data).join(",") },
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseDocumentDetail(document, document.facts));
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.25 — documents library: store-only upload + browsable list.
|
||||
*
|
||||
* Pins the store-first inversion: a file uploads + persists as a STORED row
|
||||
* with NO provider / consent / budget call on the path (a self-hoster with no
|
||||
* document-scan provider can still file documents), and the list honours the
|
||||
* `q` / `kind` filters with keyset pagination.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `v1.${s}`),
|
||||
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const post = POST as unknown as (r: Request) => Promise<Response>;
|
||||
const get = GET as unknown as (r: Request) => Promise<Response>;
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==";
|
||||
|
||||
function mkUpload(fields: Record<string, string> = {}) {
|
||||
const body = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64");
|
||||
const blobBytes = new Uint8Array(body.byteLength);
|
||||
blobBytes.set(body);
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"file",
|
||||
new Blob([blobBytes], { type: "image/png" }),
|
||||
"x.png",
|
||||
);
|
||||
for (const [k, v] of Object.entries(fields)) formData.append(k, v);
|
||||
return new Request("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
function docRow(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
kind: "OTHER",
|
||||
title: null,
|
||||
filename: "x.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 70,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: null,
|
||||
errorReason: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound (store-only)", () => {
|
||||
it("stores a file as STORED with no provider call and returns 201", async () => {
|
||||
vi.mocked(prisma.inboundDocument.create).mockResolvedValue(
|
||||
docRow({ kind: "LAB_RESULT", title: "Blood panel" }) as never,
|
||||
);
|
||||
|
||||
const res = await post(
|
||||
mkUpload({ title: "Blood panel", kind: "LAB_RESULT" }),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data.status).toBe("STORED");
|
||||
expect(body.data.kind).toBe("LAB_RESULT");
|
||||
expect(body.data.title).toBe("Blood panel");
|
||||
|
||||
// The row is created with status STORED, no provider, userId from session.
|
||||
const arg = vi.mocked(prisma.inboundDocument.create).mock.calls[0][0];
|
||||
expect(arg.data.status).toBe("STORED");
|
||||
expect(arg.data.userId).toBe("user-1");
|
||||
expect(arg.data.kind).toBe("LAB_RESULT");
|
||||
expect("providerType" in arg.data).toBe(false);
|
||||
// No userId smuggled from the form is honoured — it comes from the session.
|
||||
expect(arg.data.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("rejects a non-image/pdf payload with 415", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", new Blob([new Uint8Array([1, 2, 3, 4])]), "x.bin");
|
||||
const req = new Request("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const res = await post(req);
|
||||
expect(res.status).toBe(415);
|
||||
expect(prisma.inboundDocument.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound (list)", () => {
|
||||
it("applies q + kind filters and returns a keyset page", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ ...docRow({ id: "d1" }), _count: { facts: 0 }, facts: [] },
|
||||
{ ...docRow({ id: "d2" }), _count: { facts: 2 }, facts: [{ id: "f1" }] },
|
||||
] as never);
|
||||
|
||||
const res = await get(
|
||||
new Request(
|
||||
"http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&sort=createdAt&order=desc&limit=10",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.documents).toHaveLength(2);
|
||||
expect(body.data.nextCursor).toBeNull();
|
||||
|
||||
const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0][0];
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
expect(arg.where.deletedAt).toBeNull();
|
||||
expect(arg.where.kind).toBe("LAB_RESULT");
|
||||
expect(arg.where.OR).toEqual([
|
||||
{ title: { contains: "panel", mode: "insensitive" } },
|
||||
{ filename: { contains: "panel", mode: "insensitive" } },
|
||||
]);
|
||||
// limit+1 fetched for the has-more probe.
|
||||
expect(arg.take).toBe(11);
|
||||
});
|
||||
|
||||
it("emits nextCursor when a full page+1 is returned", async () => {
|
||||
const rows = Array.from({ length: 3 }, (_, i) => ({
|
||||
...docRow({ id: `d${i}` }),
|
||||
_count: { facts: 0 },
|
||||
facts: [],
|
||||
}));
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never);
|
||||
|
||||
const res = await get(
|
||||
new Request("http://localhost/api/documents/inbound?limit=2"),
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.data.documents).toHaveLength(2);
|
||||
expect(body.data.nextCursor).toBe("d1");
|
||||
});
|
||||
|
||||
it("422s on a bad sort value", async () => {
|
||||
const res = await get(
|
||||
new Request("http://localhost/api/documents/inbound?sort=nope"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,31 @@
|
||||
/**
|
||||
* v1.25 (W-DOCS-IN) — inbound clinical documents: upload + list.
|
||||
* v1.25 — documents library: store-first upload + browsable list.
|
||||
*
|
||||
* POST ingests an uploaded doctor report / discharge letter through the
|
||||
* EXISTING dedicated OCR/vision provider rails (the v1.22 document-scan
|
||||
* provider), stores the raw document ENCRYPTED at rest, and stages the
|
||||
* extracted STRUCTURED FACTS for a mandatory review-then-confirm screen.
|
||||
* Nothing reaches the structured stores here — the confirm route is the only
|
||||
* write path, and a low-confidence fact fails closed.
|
||||
* POST is STORE-ONLY and provider-free. A self-hoster uploads any clinical
|
||||
* document; it is stored ENCRYPTED at rest with `status: STORED` and NO
|
||||
* extraction run. There is no provider resolution, no AI consent guard, no
|
||||
* budget, and NO egress on this path — a file can always be filed, even on an
|
||||
* account with no document-scan provider configured. Optional AI extraction is
|
||||
* a separate, user-triggered action (`POST /api/documents/inbound/[id]/extract`).
|
||||
*
|
||||
* Two modes, dispatched on the request content-type (mirroring the Lab-OCR
|
||||
* extract route):
|
||||
* - VISION (multipart/form-data): a photo / PDF run through the vision-capable
|
||||
* provider; the original is stored encrypted.
|
||||
* - TEXT (application/json, opt-in local OCR): the browser OCR's the image
|
||||
* and POSTs only the extracted text; the text is stored encrypted (the raw
|
||||
* image never reaches the server).
|
||||
* GET lists the caller's documents with title/filename search, a category
|
||||
* filter, a `documentDate` range, sort, and keyset pagination — all through the
|
||||
* parameter-bound Prisma query builder (no raw SQL).
|
||||
*
|
||||
* Guards mirror the Lab-OCR discipline: requireAuth → module gate → resolve
|
||||
* provider → assertConsentForChain → rate-limit → reserveBudget → extract →
|
||||
* reconcile budget. The document is UNTRUSTED (prompt-injection): the server
|
||||
* never acts on an instruction inside it — the review step is the safety
|
||||
* boundary, and "extract, never interpret" is enforced end-to-end.
|
||||
* The document is UNTRUSTED (prompt-injection): the server never acts on an
|
||||
* instruction inside it. Storing it does nothing with its contents; the later
|
||||
* review-then-confirm step is the safety boundary for any extracted facts.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
InboundExtractError,
|
||||
runInboundExtraction,
|
||||
type StagedFactInput,
|
||||
} from "@/lib/documents/extract";
|
||||
import {
|
||||
encryptDocumentToBytes,
|
||||
encryptFactData,
|
||||
encryptFactProvenance,
|
||||
serialiseDocument,
|
||||
} from "@/lib/documents/store";
|
||||
import {
|
||||
resolveTextProvider,
|
||||
resolveVisionProvider,
|
||||
} from "@/lib/labs/ocr-capability";
|
||||
import {
|
||||
BodyTooLargeError,
|
||||
detectOcrMimeType,
|
||||
@@ -66,60 +36,26 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
inboundTextExtractSchema,
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
type InboundDocumentKindValue,
|
||||
documentCreateSchema,
|
||||
documentListQuerySchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Inbound extractions are expensive (vision) / metered (text). 6/hour. */
|
||||
const EXTRACT_LIMIT_PER_HOUR = 6;
|
||||
const EXTRACT_WINDOW_MS = 60 * 60 * 1000;
|
||||
const TEXT_BODY_MAX_BYTES = 512 * 1024;
|
||||
/**
|
||||
* A store-only upload touches no provider — the only abuse vector is disk, so
|
||||
* a generous per-user ceiling is enough (extraction has its own tight gate).
|
||||
*/
|
||||
const UPLOAD_LIMIT_PER_HOUR = 60;
|
||||
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Persist the document + its staged facts and return the list DTO. */
|
||||
async function persistExtraction(args: {
|
||||
userId: string;
|
||||
kind: InboundDocumentKindValue;
|
||||
filename: string | null;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
contentEncrypted: Uint8Array<ArrayBuffer>;
|
||||
providerType: string;
|
||||
reportDate: string | null;
|
||||
facts: StagedFactInput[];
|
||||
}) {
|
||||
const document = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: args.userId,
|
||||
kind: args.kind,
|
||||
filename: args.filename,
|
||||
mimeType: args.mimeType,
|
||||
byteSize: args.byteSize,
|
||||
contentEncrypted: args.contentEncrypted,
|
||||
status: "EXTRACTED",
|
||||
providerType: args.providerType,
|
||||
reportDate: args.reportDate
|
||||
? new Date(`${args.reportDate}T00:00:00.000Z`)
|
||||
: null,
|
||||
facts: {
|
||||
create: args.facts.map((f) => ({
|
||||
userId: args.userId,
|
||||
factType: f.factType,
|
||||
status: "PENDING" as const,
|
||||
confidence: f.confidence,
|
||||
needsReview: f.needsReview,
|
||||
dataEncrypted: encryptFactData(f.data),
|
||||
provenanceEncrypted: encryptFactProvenance(f.provenance),
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { facts: true },
|
||||
});
|
||||
return document;
|
||||
/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */
|
||||
function isoDateToUtc(value: string): Date {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** POST — store an uploaded document encrypted at rest. No extraction. */
|
||||
export const POST = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -128,42 +64,10 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return handleTextUpload(request, user.id);
|
||||
}
|
||||
return handleVisionUpload(request, user.id);
|
||||
});
|
||||
|
||||
/** TEXT mode — structure in-browser-OCR'd text; store the text encrypted. */
|
||||
async function handleTextUpload(
|
||||
request: Request,
|
||||
userId: string,
|
||||
): Promise<Response> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { labsLocalOcrEnabled: true },
|
||||
});
|
||||
if (!row?.labsLocalOcrEnabled) {
|
||||
annotate({ action: { name: "documents.inbound.providerUnsupported" } });
|
||||
return apiError("Local OCR is not enabled", 422, {
|
||||
errorCode: "documents.inbound.localOcrDisabled",
|
||||
});
|
||||
}
|
||||
|
||||
const { chain, pick } = await resolveTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
`documents-upload:${user.id}`,
|
||||
UPLOAD_LIMIT_PER_HOUR,
|
||||
UPLOAD_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many uploads. Try again later.", 429, {
|
||||
@@ -175,304 +79,174 @@ async function handleTextUpload(
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document text payload", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > OCR_MAX_BYTES) {
|
||||
return apiError("File is too large (max 12 MB).", 413, {
|
||||
errorCode: "documents.inbound.fileTooLarge",
|
||||
});
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
ocrText: parsed.data.text,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const textBytes = Buffer.from(parsed.data.text, "utf8");
|
||||
const document = await persistExtraction({
|
||||
userId,
|
||||
kind: parsed.data.kind ?? result.kind,
|
||||
filename: null,
|
||||
mimeType: "text/plain",
|
||||
byteSize: textBytes.byteLength,
|
||||
contentEncrypted: encryptDocumentToBytes(textBytes),
|
||||
providerType: result.providerType,
|
||||
reportDate: result.reportDate,
|
||||
facts: result.facts,
|
||||
});
|
||||
|
||||
await auditLog("documents.inbound.upload", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: document.facts.length,
|
||||
mode: "text",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
serialiseDocument(document, {
|
||||
factCount: document.facts.length,
|
||||
pendingCount: document.facts.length,
|
||||
}),
|
||||
201,
|
||||
);
|
||||
const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "text" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** VISION mode — run a photo / PDF through the vision provider; store it. */
|
||||
async function handleVisionUpload(
|
||||
request: Request,
|
||||
userId: string,
|
||||
): Promise<Response> {
|
||||
const { chain, pick } = await resolveVisionProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No vision-capable AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertConsentForChain({ userId, chain, surface: "insights" });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-inbound:${userId}`,
|
||||
EXTRACT_LIMIT_PER_HOUR,
|
||||
EXTRACT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many uploads. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtract.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > OCR_MAX_BYTES) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
return apiError("File is too large (max 12 MB).", 413, {
|
||||
errorCode: "documents.inbound.fileTooLarge",
|
||||
});
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES);
|
||||
formData = await new Response(new Blob([bytes]), {
|
||||
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
||||
}).formData();
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof BodyTooLargeError) {
|
||||
return apiError("File is too large (max 12 MB).", 413, {
|
||||
errorCode: "documents.inbound.fileTooLarge",
|
||||
});
|
||||
}
|
||||
return apiError("Invalid multipart body", 400);
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
// Optional document-kind label (a label only; drives no interpretation).
|
||||
const kindRaw = formData.get("kind");
|
||||
const kind: InboundDocumentKindValue =
|
||||
typeof kindRaw === "string" &&
|
||||
(INBOUND_DOCUMENT_KINDS as readonly string[]).includes(kindRaw)
|
||||
? (kindRaw as InboundDocumentKindValue)
|
||||
: "OTHER";
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(await file.arrayBuffer());
|
||||
} catch {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
return apiError("Failed to read uploaded file", 400);
|
||||
}
|
||||
|
||||
const mime = detectOcrMimeType(buffer);
|
||||
if (!mime) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, {
|
||||
errorCode: "documents.inbound.fileType",
|
||||
});
|
||||
}
|
||||
|
||||
if (mime === "application/pdf" && !pick.pdfSupported) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
return apiError(
|
||||
"PDF scanning needs a Claude vision provider; upload a photo instead.",
|
||||
422,
|
||||
{ errorCode: "documents.inbound.pdfNeedsAnthropic" },
|
||||
);
|
||||
}
|
||||
|
||||
const dataBase64 = buffer.toString("base64");
|
||||
const images =
|
||||
mime === "application/pdf" ? [] : [{ mediaType: mime, dataBase64 }];
|
||||
const documents =
|
||||
mime === "application/pdf"
|
||||
? [{ mediaType: "application/pdf" as const, dataBase64 }]
|
||||
: [];
|
||||
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
images,
|
||||
documents,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const document = await persistExtraction({
|
||||
userId,
|
||||
kind,
|
||||
filename:
|
||||
typeof file.name === "string" ? file.name.slice(0, 255) : null,
|
||||
mimeType: mime,
|
||||
byteSize: buffer.byteLength,
|
||||
contentEncrypted: encryptDocumentToBytes(buffer),
|
||||
providerType: result.providerType,
|
||||
reportDate: result.reportDate,
|
||||
facts: result.facts,
|
||||
});
|
||||
|
||||
await auditLog("documents.inbound.upload", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: document.facts.length,
|
||||
mode: "vision",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
serialiseDocument(document, {
|
||||
factCount: document.facts.length,
|
||||
pendingCount: document.facts.length,
|
||||
}),
|
||||
201,
|
||||
);
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError(
|
||||
"Couldn't read the document. Try a clearer copy.",
|
||||
422,
|
||||
{
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
},
|
||||
);
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "vision" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey).catch(
|
||||
() => {},
|
||||
);
|
||||
throw err;
|
||||
return apiError("Invalid multipart body", 400);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET — list the caller's inbound documents (newest first, live only). */
|
||||
export const GET = apiHandler(async () => {
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return apiError("Field 'file' must be a file", 422);
|
||||
}
|
||||
|
||||
// Optional metadata (title / kind / documentDate). The file is read
|
||||
// separately; these are the form fields beside it.
|
||||
const parsed = documentCreateSchema.safeParse({
|
||||
title: formData.get("title") ?? undefined,
|
||||
kind: formData.get("kind") ?? undefined,
|
||||
documentDate: formData.get("documentDate") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid document metadata", 422, {
|
||||
errorCode: "documents.inbound.invalidMetadata",
|
||||
});
|
||||
}
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = Buffer.from(await file.arrayBuffer());
|
||||
} catch {
|
||||
return apiError("Failed to read uploaded file", 400);
|
||||
}
|
||||
|
||||
const mime = detectOcrMimeType(buffer);
|
||||
if (!mime) {
|
||||
return apiError("Upload a JPEG, PNG, WebP, or PDF.", 415, {
|
||||
errorCode: "documents.inbound.fileType",
|
||||
});
|
||||
}
|
||||
|
||||
// No mass assignment — every column is set field-by-field; `userId` comes
|
||||
// from the session, never the body. `status` is STORED (the library default);
|
||||
// no provider / consent / budget call runs here.
|
||||
const document = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: parsed.data.kind ?? "OTHER",
|
||||
title: parsed.data.title ?? null,
|
||||
filename: typeof file.name === "string" ? file.name.slice(0, 255) : null,
|
||||
mimeType: mime,
|
||||
byteSize: buffer.byteLength,
|
||||
contentEncrypted: encryptDocumentToBytes(buffer),
|
||||
status: "STORED",
|
||||
documentDate: parsed.data.documentDate
|
||||
? isoDateToUtc(parsed.data.documentDate)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("documents.inbound.store", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId: document.id, mime },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.store" },
|
||||
meta: { documentId: document.id, byteSize: document.byteSize },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
serialiseDocument(document, { factCount: 0, pendingCount: 0 }),
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
/** GET — list the caller's documents (search / filter / sort / paginate). */
|
||||
export const GET = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const documents = await prisma.inboundDocument.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
const url = new URL(request.url);
|
||||
const parsed = documentListQuerySchema.safeParse(
|
||||
Object.fromEntries(url.searchParams),
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return apiError("Invalid list query", 422, {
|
||||
errorCode: "documents.inbound.invalidQuery",
|
||||
});
|
||||
}
|
||||
const { q, kind, from, to, sort, order, cursor, limit } = parsed.data;
|
||||
|
||||
const where: Prisma.InboundDocumentWhereInput = {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
};
|
||||
if (kind) where.kind = kind;
|
||||
if (from || to) {
|
||||
where.documentDate = {
|
||||
...(from ? { gte: isoDateToUtc(from) } : {}),
|
||||
// inclusive end-of-day for the `to` bound
|
||||
...(to ? { lt: new Date(isoDateToUtc(to).getTime() + 86_400_000) } : {}),
|
||||
};
|
||||
}
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ title: { contains: q, mode: "insensitive" } },
|
||||
{ filename: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Keyset pagination on the sort column + a stable `id` tiebreak; nullable
|
||||
// sort columns sort nulls last so undated documents trail.
|
||||
const primaryOrder:
|
||||
Prisma.InboundDocumentOrderByWithRelationInput | undefined =
|
||||
sort === "documentDate"
|
||||
? { documentDate: { sort: order, nulls: "last" } }
|
||||
: sort === "title"
|
||||
? { title: { sort: order, nulls: "last" } }
|
||||
: { createdAt: order };
|
||||
const orderBy: Prisma.InboundDocumentOrderByWithRelationInput[] = [
|
||||
primaryOrder,
|
||||
{ id: order },
|
||||
];
|
||||
|
||||
const rows = await prisma.inboundDocument.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
include: {
|
||||
_count: { select: { facts: true } },
|
||||
facts: { where: { status: "PENDING" }, select: { id: true } },
|
||||
},
|
||||
take: 200,
|
||||
take: limit + 1,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const nextCursor = hasMore ? (page[page.length - 1]?.id ?? null) : null;
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.list" },
|
||||
meta: { count: documents.length },
|
||||
meta: { count: page.length, sort, order, filtered: Boolean(q || kind) },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
documents: documents.map((doc) =>
|
||||
documents: page.map((doc) =>
|
||||
serialiseDocument(doc, {
|
||||
factCount: doc._count.facts,
|
||||
pendingCount: doc.facts.length,
|
||||
}),
|
||||
),
|
||||
nextCursor,
|
||||
});
|
||||
});
|
||||
@@ -36,10 +36,13 @@ export function encryptDocumentToBytes(bytes: Buffer): Uint8Array<ArrayBuffer> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored document back to its raw bytes. Throws on a bad / missing
|
||||
* key id (fail-closed) — the caller must treat a throw as "cannot serve" and
|
||||
* never fall back to returning the ciphertext. The original document is PHI;
|
||||
* the only path that reads it is the owner-scoped download route.
|
||||
* Decrypt a stored document's `Bytes` payload back to its raw bytes. The exact
|
||||
* inverse of `encryptDocumentToBytes`: the column holds `encrypt()`-string
|
||||
* UTF-8 bytes whose plaintext is the base64 of the original binary. Throws on a
|
||||
* bad / missing key id (fail-closed) — the caller must treat a throw as "cannot
|
||||
* serve" and never fall back to returning the ciphertext. The original document
|
||||
* is PHI; the owner-scoped download route and the optional extraction action are
|
||||
* the only paths that read it.
|
||||
*/
|
||||
export function decryptDocumentFromBytes(buf: Uint8Array): Buffer {
|
||||
const base64 = decrypt(Buffer.from(buf).toString("utf8"));
|
||||
@@ -85,6 +88,7 @@ export function serialiseDocument(
|
||||
return {
|
||||
id: doc.id,
|
||||
kind: doc.kind as InboundDocumentKindValue,
|
||||
title: doc.title,
|
||||
filename: doc.filename,
|
||||
mimeType: doc.mimeType,
|
||||
byteSize: doc.byteSize,
|
||||
@@ -93,6 +97,9 @@ export function serialiseDocument(
|
||||
reportDate: doc.reportDate
|
||||
? doc.reportDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
documentDate: doc.documentDate
|
||||
? doc.documentDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
errorReason: doc.errorReason,
|
||||
factCount: counts.factCount,
|
||||
pendingCount: counts.pendingCount,
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
/**
|
||||
* OpenAPI route table for inbound clinical documents (`/api/documents/inbound/*`).
|
||||
* OpenAPI route table for the documents library (`/api/documents/inbound/*`).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`. The confirm +
|
||||
* edit request bodies reuse the runtime schemas from
|
||||
* edit + list request bodies reuse the runtime schemas from
|
||||
* `@/lib/validations/inbound-documents` so the wire contract stays
|
||||
* single-source. The upload is a `multipart/form-data` binary body (or a JSON
|
||||
* text-mode body); the response shapes are declared here.
|
||||
* single-source.
|
||||
*
|
||||
* Safety contract surfaced in the descriptions: 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.
|
||||
* 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, stdResponses } from "./shared";
|
||||
|
||||
const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS);
|
||||
const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES);
|
||||
|
||||
inboundConfirmSchema.meta({
|
||||
id: "InboundConfirmRequest",
|
||||
description:
|
||||
@@ -35,6 +42,12 @@ inboundFactEditSchema.meta({
|
||||
"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(),
|
||||
@@ -62,19 +75,15 @@ const extractedFact = z
|
||||
const inboundDocument = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"]),
|
||||
kind: kindEnum,
|
||||
title: z.string().nullable(),
|
||||
filename: z.string().nullable(),
|
||||
mimeType: z.string(),
|
||||
byteSize: z.number(),
|
||||
status: z.enum([
|
||||
"EXTRACTING",
|
||||
"EXTRACTED",
|
||||
"FAILED",
|
||||
"CONFIRMED",
|
||||
"DISCARDED",
|
||||
]),
|
||||
status: statusEnum,
|
||||
providerType: z.string().nullable(),
|
||||
reportDate: z.string().nullable(),
|
||||
documentDate: z.string().nullable(),
|
||||
errorReason: z.string().nullable(),
|
||||
factCount: z.number(),
|
||||
pendingCount: z.number(),
|
||||
@@ -84,7 +93,7 @@ const inboundDocument = z
|
||||
.meta({
|
||||
id: "InboundDocument",
|
||||
description:
|
||||
"An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary.",
|
||||
"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
|
||||
@@ -92,8 +101,15 @@ const inboundDocumentDetail = inboundDocument
|
||||
.meta({ id: "InboundDocumentDetail" });
|
||||
|
||||
const listResponse = z
|
||||
.object({ documents: z.array(inboundDocument) })
|
||||
.meta({ id: "InboundDocumentList" });
|
||||
.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({
|
||||
@@ -114,16 +130,82 @@ const confirmResponse = z
|
||||
"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 inbound clinical documents",
|
||||
summary: "List stored documents",
|
||||
description:
|
||||
"The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on the opt-in `inboundDocuments` module.",
|
||||
"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: "The document list.",
|
||||
description: "A page of documents.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(listResponse, "InboundDocumentListEnvelope"),
|
||||
@@ -135,9 +217,9 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
post: {
|
||||
tags: ["Documents"],
|
||||
summary: "Upload + extract a clinical document",
|
||||
summary: "Store a document (no extraction)",
|
||||
description:
|
||||
'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
||||
"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: {
|
||||
@@ -146,30 +228,25 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
file: z.string().meta({
|
||||
format: "binary",
|
||||
description:
|
||||
"The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.",
|
||||
"The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.",
|
||||
}),
|
||||
kind: z
|
||||
.enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"])
|
||||
title: z
|
||||
.string()
|
||||
.optional()
|
||||
.meta({ description: "Optional document-type label." }),
|
||||
.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).",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
"application/json": {
|
||||
schema: z
|
||||
.object({
|
||||
mode: z.literal("text"),
|
||||
text: z.string(),
|
||||
kind: z
|
||||
.enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"])
|
||||
.optional(),
|
||||
})
|
||||
.meta({ id: "InboundTextUploadRequest" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "The created document + staging summary.",
|
||||
description: "The stored document.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(inboundDocument, "InboundDocumentEnvelope"),
|
||||
@@ -209,9 +286,41 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...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 an inbound document",
|
||||
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: [
|
||||
@@ -272,6 +381,50 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/{id}/facts/{factId}": {
|
||||
patch: {
|
||||
tags: ["Documents"],
|
||||
|
||||
@@ -49,10 +49,27 @@ export type InboundFactType = (typeof INBOUND_FACT_TYPES)[number];
|
||||
export const INBOUND_DOCUMENT_KINDS = [
|
||||
"DOCTOR_REPORT",
|
||||
"DISCHARGE_LETTER",
|
||||
"LAB_RESULT",
|
||||
"IMAGING",
|
||||
"PRESCRIPTION",
|
||||
"INSURANCE",
|
||||
"VACCINATION",
|
||||
"OTHER",
|
||||
] as const;
|
||||
export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number];
|
||||
|
||||
/** The document lifecycle states. STORED is the library default. */
|
||||
export const INBOUND_DOCUMENT_STATUSES = [
|
||||
"STORED",
|
||||
"EXTRACTING",
|
||||
"EXTRACTED",
|
||||
"FAILED",
|
||||
"CONFIRMED",
|
||||
"DISCARDED",
|
||||
] as const;
|
||||
export type InboundDocumentStatusValue =
|
||||
(typeof INBOUND_DOCUMENT_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* The coding systems a fact's STATED code may belong to. The provider emits a
|
||||
* code ONLY when the document writes one — it never machine-guesses. Mirrors
|
||||
@@ -183,12 +200,17 @@ export interface ExtractedFactDto {
|
||||
export interface InboundDocumentDto {
|
||||
id: string;
|
||||
kind: InboundDocumentKindValue;
|
||||
/** User-given title (plaintext), or null when never set. */
|
||||
title: string | null;
|
||||
filename: string | null;
|
||||
mimeType: string;
|
||||
byteSize: number;
|
||||
status: "EXTRACTING" | "EXTRACTED" | "FAILED" | "CONFIRMED" | "DISCARDED";
|
||||
status: InboundDocumentStatusValue;
|
||||
providerType: string | null;
|
||||
/** Model-transcribed report/collection date (YYYY-MM-DD), or null. */
|
||||
reportDate: string | null;
|
||||
/** User-set filing date (YYYY-MM-DD), or null. */
|
||||
documentDate: string | null;
|
||||
errorReason: string | null;
|
||||
factCount: number;
|
||||
pendingCount: number;
|
||||
@@ -309,3 +331,88 @@ export const inboundTextExtractSchema = z.object({
|
||||
});
|
||||
|
||||
export type InboundTextExtractInput = z.infer<typeof inboundTextExtractSchema>;
|
||||
|
||||
// ─── Library: store / edit / list ──────────────────────────────────────────
|
||||
|
||||
/** A bare YYYY-MM-DD date string (no time component). */
|
||||
const isoDateString = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected a YYYY-MM-DD date");
|
||||
|
||||
export const DOCUMENT_TITLE_MAX = 200;
|
||||
|
||||
/**
|
||||
* The store-only upload metadata (the multipart form fields beside the file).
|
||||
* Every field is optional — a bare file upload is valid and lands as a STORED
|
||||
* document with no title / category / filing date. No `userId` field; it is
|
||||
* always narrowed from the session.
|
||||
*/
|
||||
export const documentCreateSchema = z.object({
|
||||
title: z.string().trim().min(1).max(DOCUMENT_TITLE_MAX).optional(),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
||||
documentDate: isoDateString.optional(),
|
||||
});
|
||||
|
||||
export type DocumentCreateInput = z.infer<typeof documentCreateSchema>;
|
||||
|
||||
/**
|
||||
* Metadata edit (rename / recategorise / set the filing date). At least one
|
||||
* field must be present. `title` accepts null to clear it; `documentDate`
|
||||
* accepts null to clear it. No `userId` field — narrowed from the session and
|
||||
* fed to the Prisma `where` alongside the row id.
|
||||
*/
|
||||
export const documentUpdateSchema = z
|
||||
.object({
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(DOCUMENT_TITLE_MAX)
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? null : v)),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
||||
documentDate: isoDateString.nullable().optional(),
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
d.title !== undefined ||
|
||||
d.kind !== undefined ||
|
||||
d.documentDate !== undefined,
|
||||
{ message: "Provide at least one field to update" },
|
||||
);
|
||||
|
||||
export type DocumentUpdateInput = z.infer<typeof documentUpdateSchema>;
|
||||
|
||||
/** Library list sort columns + page size. */
|
||||
export const DOCUMENT_LIST_SORTS = [
|
||||
"documentDate",
|
||||
"createdAt",
|
||||
"title",
|
||||
] as const;
|
||||
export type DocumentListSort = (typeof DOCUMENT_LIST_SORTS)[number];
|
||||
|
||||
export const DOCUMENT_LIST_MAX_LIMIT = 100;
|
||||
export const DOCUMENT_LIST_DEFAULT_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* The library list query: title/filename search, category filter, a
|
||||
* `documentDate` range, sort + keyset pagination. Parsed off `searchParams`;
|
||||
* `safeParse` returns 422 on a bad value.
|
||||
*/
|
||||
export const documentListQuerySchema = z.object({
|
||||
q: z.string().trim().max(100).optional(),
|
||||
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
|
||||
from: isoDateString.optional(),
|
||||
to: isoDateString.optional(),
|
||||
sort: z.enum(DOCUMENT_LIST_SORTS).default("documentDate"),
|
||||
order: z.enum(["asc", "desc"]).default("desc"),
|
||||
cursor: z.string().trim().min(1).max(40).optional(),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(DOCUMENT_LIST_MAX_LIMIT)
|
||||
.default(DOCUMENT_LIST_DEFAULT_LIMIT),
|
||||
});
|
||||
|
||||
export type DocumentListQuery = z.infer<typeof documentListQuerySchema>;
|
||||
Reference in new issue
Block a user