Files
HealthLog/src/lib/validations/inbound-documents.ts
T
Marc-André Bombeck 1eac05b454 fix(documents): key imports by source instance, keep a token on its origin, and harden the import path
- Source keys carry the normalised origin of the Paperless-ngx or Papra
  they came from (source_instance on the document, the alias and the
  purge ledger; unique NULLS NOT DISTINCT). A key stored without one
  (v1.39.2 imports) matches any instance of its system, and a lookup
  without one matches any stored instance. The upload route, the lookup
  route and the import script accept or send it; backups carry it.
- Saving a connection reuses the stored token only for the same origin;
  a new origin needs the token again (422 tokenRequired).
- Document ids are checked against what each system issues (Paperless
  digits, Papra [A-Za-z0-9_-], never . or ..) before any URL is built;
  the Papra organization id likewise.
- Paperless API 9 filters dates on created__gte / created__lte, in the
  picker and the script. A search page the local re-check empties reads
  on, up to five source pages. Tags are read past the first 250.
- The import's key pre-check uses the lookup allowance, a failed fetch
  no longer charges the upload allowance, and one import per key runs at
  a time under an advisory lock, so a double click downloads once.
- Reading and deleting one's own connection works with the documents
  module off and the operator list unset.
2026-09-26 14:20:17 +02:00

1113 lines
41 KiB
TypeScript

/**
* v1.25 (W-DOCS-IN) — inbound clinical-document ingestion contract.
*
* A self-hoster uploads a doctor report / discharge letter they received. The
* dedicated OCR/vision provider transcribes STRUCTURED FACTS that land on a
* MANDATORY review-then-confirm screen; only the user-approved facts reach the
* structured stores (labs / conditions / medications) through their normal
* create paths.
*
* The safety line this module encodes: EXTRACT, NEVER INTERPRET. The provider
* is asked to reproduce what is written (text, value, code, date) with
* provenance and a confidence gate — it never infers a code, never flags a
* value high/low, never links a condition to a medication, never assigns
* meaning, severity, or a diagnosis. Diagnoses are stored as the document's
* STATED assertion about the patient, never as the app's conclusion.
*
* This module holds:
* - `inboundExtractionSchema` — the schema the provider's JSON is validated
* against (UNTRUSTED model output). Liberal on shape; the review screen is
* the safety boundary.
* - the staged-fact DTO shapes the routes return + the UI consumes.
* - `inboundFactEditSchema` — the per-fact correction the review screen sends
* before approval (fixes OCR / units / dates / codes).
* - `inboundConfirmSchema` — the approve/reject decisions. No `userId` field;
* it is always narrowed from the session.
*/
import { z } from "zod/v4";
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
/** Max facts a single document may stage / confirm. A dense letter is ~40. */
export const INBOUND_MAX_FACTS = 120;
/**
* Confidence floor. A fact scoring below this fails closed: it is staged with
* `needsReview = true` and cannot be approved until the user edits it (the
* values then become user-asserted). When unsure, the app surfaces raw text
* for manual entry rather than guessing a value.
*/
export const INBOUND_CONFIDENCE_FLOOR = 0.6;
/** The three FHIR resources a fact maps to (stated status only). */
export const INBOUND_FACT_TYPES = [
"CONDITION",
"OBSERVATION",
"MEDICATION_STATEMENT",
] as const;
export type InboundFactType = (typeof INBOUND_FACT_TYPES)[number];
/** The document-kind labels (no interpretation — a label only). */
export const INBOUND_DOCUMENT_KINDS = [
"DOCTOR_REPORT",
"DISCHARGE_LETTER",
"LAB_RESULT",
"IMAGING",
"PRESCRIPTION",
"REFERRAL",
"INSURANCE",
"VACCINATION",
"OTHER",
] as const;
export type InboundDocumentKindValue = (typeof INBOUND_DOCUMENT_KINDS)[number];
/**
* How a document's content index was produced (mirrors the server `source`
* column). `vision` means an AI provider read the original (the AI-first path);
* every other value is a provider-free local extraction. The UI reads it to
* tell an AI-read document from a locally-indexed one — the former already had
* the richer read, the latter can still be offered "Read with AI".
*/
export const DOCUMENT_CONTENT_INDEX_SOURCES = [
"vision",
"text-ocr",
"local-pdf",
"local-ocr",
] as const;
export type DocumentContentIndexSourceValue =
(typeof DOCUMENT_CONTENT_INDEX_SOURCES)[number];
/** True when the index was produced by an AI provider reading the original. */
export function isAiReadSource(
source: DocumentContentIndexSourceValue | string | null,
): boolean {
return source === "vision";
}
/**
* Narrow the free-String DB `source` column to the DTO union, or `null` for an
* absent / unrecognised value. The column is a free String server-side (adding
* a source needs no migration) so the read path guards it before it hits the
* contract.
*/
export function toContentIndexSource(
source: string | null | undefined,
): DocumentContentIndexSourceValue | null {
return DOCUMENT_CONTENT_INDEX_SOURCES.includes(
source as DocumentContentIndexSourceValue,
)
? (source as DocumentContentIndexSourceValue)
: null;
}
/**
* What became of a document's plain-language summary. Mirrors the Prisma
* `DocumentSummaryState` enum. The detail view branches on this rather than on
* a null summary, which cannot tell "never attempted" from "in flight" from
* "withheld" from "could not produce".
*/
export const DOCUMENT_SUMMARY_STATES = [
"NONE",
"PENDING",
"READY",
"WITHHELD",
"UNAVAILABLE",
] as const;
export type DocumentSummaryStateValue =
(typeof DOCUMENT_SUMMARY_STATES)[number];
/**
* Refs #776 — why a document's most recent content-index attempt produced no
* index. Mirrors the free-String `lastIndexOutcome` column (closed here, not
* in the schema, so a new reason needs no migration); the detail view renders
* a plain-words line per value instead of a bare "not indexed" that cannot
* tell "never tried" from "tried and failed".
*/
export const DOCUMENT_INDEX_OUTCOMES = [
"local-empty",
"local-unsupported",
"decrypt-error",
"raster-failed",
"provider-error",
"pdf-needs-anthropic",
"empty-transcription",
] as const;
export type DocumentIndexOutcomeValue =
(typeof DOCUMENT_INDEX_OUTCOMES)[number];
/** Narrow the free-String DB column to the DTO union (null when unknown). */
export function toIndexOutcome(
outcome: string | null | undefined,
): DocumentIndexOutcomeValue | null {
return DOCUMENT_INDEX_OUTCOMES.includes(outcome as DocumentIndexOutcomeValue)
? (outcome as DocumentIndexOutcomeValue)
: null;
}
/** 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
* the systems the existing FHIR mappers already speak (SNOMED/ICD-10 for a
* Condition, LOINC for an Observation, RxNorm/ATC for a MedicationStatement).
*/
export const INBOUND_CODE_SYSTEMS = [
"SNOMED",
"ICD10",
"LOINC",
"RXNORM",
"ATC",
] as const;
export type InboundCodeSystem = (typeof INBOUND_CODE_SYSTEMS)[number];
const confidenceScore = z.number().min(0).max(1).catch(0);
const nullableText = (max: number) =>
z.string().trim().min(1).max(max).nullable().catch(null);
/**
* One fact as the PROVIDER returns it (untrusted). A single flat shape across
* the three fact types so the wire stays simple; the route maps it to the
* per-type staged payload. Everything is `.catch`-guarded so a malformed field
* degrades to null + low confidence rather than 422-ing the whole extraction.
*/
const extractedFactRawFields = z.object({
type: z.enum(INBOUND_FACT_TYPES),
/** Verbatim label / diagnosis text / medication name as written. */
label: z.string().trim().min(1).max(300).catch(""),
/** STATED code, present only when the document writes one. */
code: nullableText(64),
codeSystem: z.enum(INBOUND_CODE_SYSTEMS).nullable().catch(null),
/** Condition: stated clinical / verification status (verbatim). */
clinicalStatus: nullableText(64),
verificationStatus: nullableText(64),
/** Observation: numeric XOR qualitative value, unit, stated ref bounds. */
value: z.number().finite().nullable().catch(null),
valueText: nullableText(300),
unit: nullableText(80),
referenceLow: z.number().finite().nullable().catch(null),
referenceHigh: z.number().finite().nullable().catch(null),
/**
* Observation: the reference range EXACTLY as printed, verbatim. A report
* writes its window in prose ("3,5 - 5,0", "< 5", "bis 5,0", "negativ"), and
* `referenceLow` / `referenceHigh` above can only carry the cases that
* reduce to two numbers. This field is what stops every other case from
* being dropped on the floor — the string is kept whether or not the parser
* gets bounds out of it.
*/
referenceText: nullableText(120),
/** MedicationStatement: dose + stated status (verbatim). */
dose: nullableText(120),
medicationStatus: nullableText(64),
/** A stated date for the fact (onset / effective / report), ISO or null. */
effectiveDate: z.string().nullable().catch(null),
/** Provenance: the verbatim source span this fact came from. */
sourceText: z.string().trim().max(2000).catch(""),
/** Optional 0-based page index the fact was read from. */
page: z.number().int().min(0).max(10000).nullable().catch(null),
/** The model's self-reported overall confidence for this fact (0..1). */
confidence: confidenceScore.default(0),
});
/**
* A window whose floor sits above its ceiling cannot be true, so it is dropped
* rather than repaired. Swapping the pair would invent a range the report never
* printed, and this schema cannot reject the fact the way `labs.ts` does —
* every field above is `.catch()`ed precisely so one bad value does not sink a
* whole extraction. `referenceText` survives either way, so nothing the
* document actually stated is lost: the verbatim string is still there for a
* person to read on the review screen.
*
* `parseReferenceRange` already answers a transposed PRINTED window this way.
* These are the numbers the model reported instead, and they travelled
* unchecked: an integration run against a real Postgres wrote a 100/30 pair
* onto the row AND onto the freshly minted catalog marker, which then handed
* the impossible window to every later reading of that analyte.
*/
export const extractedFactRawSchema = extractedFactRawFields.transform(
(fact) => {
if (
fact.referenceLow !== null &&
fact.referenceHigh !== null &&
fact.referenceLow > fact.referenceHigh
) {
return { ...fact, referenceLow: null, referenceHigh: null };
}
return fact;
},
);
export type ExtractedFactRaw = z.infer<typeof extractedFactRawSchema>;
/** The full JSON envelope the provider returns. */
export const inboundExtractionSchema = z.object({
reportDate: z.string().nullable().catch(null),
kind: z.enum(INBOUND_DOCUMENT_KINDS).nullable().catch(null),
facts: z.array(extractedFactRawSchema).max(INBOUND_MAX_FACTS).catch([]),
});
export type InboundExtraction = z.infer<typeof inboundExtractionSchema>;
// ─── Staged-fact payloads (FHIR-staged, stated status only) ────────────────
/** Per-field provenance carried on every staged fact. */
export interface FactProvenance {
/**
* The verbatim source span the value was transcribed from, read back out of
* the extracted document text — NOT the model's echo of it. Empty string when
* the span could not be located (see `anchored`).
*/
sourceText: string;
/**
* True only when `sourceText` was resolved against the extracted document
* text. False means the quote is unverifiable and none is stored: either the
* model's echo matched nothing in the source, or the extraction ran in vision
* mode, where there is no extracted text to verify against. A reviewer must
* read an unanchored fact against the original document itself.
*/
anchored: boolean;
/** Character offset of the span in the extracted text; null when unanchored. */
sourceOffset: number | null;
/** Optional 0-based page index. */
page: number | null;
/** The model's self-reported confidence (0..1). */
confidence: number;
}
/** Condition staging payload — maps to FHIR `Condition` (stated status). */
export interface ConditionFactData {
label: string;
code: string | null;
codeSystem: InboundCodeSystem | null;
clinicalStatus: string | null;
verificationStatus: string | null;
onsetDate: string | null;
}
/** Observation staging payload — maps to FHIR `Observation` (no range-flag). */
export interface ObservationFactData {
label: string;
code: string | null;
codeSystem: InboundCodeSystem | null;
value: number | null;
valueText: string | null;
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
/**
* The reference range as printed on the report, verbatim.
*
* Optional because it genuinely is: a fact staged before this field existed
* carries no key for it, and that is an honest absence rather than a shim
* for a caller we do not ship. Read it as `?? null`.
*/
referenceText?: string | null;
effectiveDate: string | null;
}
/** MedicationStatement staging payload — maps to FHIR `MedicationStatement`. */
export interface MedicationStatementFactData {
name: string;
dose: string | null;
rxNormCode: string | null;
atcCode: string | null;
statusStated: string | null;
effectiveDate: string | null;
}
export type FactData =
ConditionFactData | ObservationFactData | MedicationStatementFactData;
/** The staged-fact DTO the routes return + the review UI consumes. */
export interface ExtractedFactDto {
id: string;
factType: InboundFactType;
status: "PENDING" | "APPROVED" | "REJECTED";
confidence: number;
needsReview: boolean;
data: FactData;
provenance: FactProvenance;
committedRecordId: string | null;
committedRecordType: string | null;
}
/** One condition link on a document DTO (chip → `/illness/[id]`). */
export interface DocumentConditionLinkDto {
episodeId: string;
/** The episode's user-facing label. */
name: string;
}
/**
* One visit link on a document DTO — "this letter belongs to that appointment".
*
* Carries the visit's KIND as the enum constant rather than a rendered name.
* A condition's label is the person's own words and reads the same in every
* language; a visit kind is one of eight closed values whose human name the
* reader's own bundle owns, so publishing the constant is what lets a German
* reader and an English one each see it named correctly. The date and the id
* are resolved server-side as usual.
*/
export interface DocumentEncounterLinkDto {
encounterId: string;
/** `ROUTINE` | `ACUTE` | … — the client renders the name. */
kind: string;
/** ISO-8601 instant of the visit. */
occurredAt: string | null;
}
/**
* One vaccination link on a document detail DTO — "this page records that
* dose".
*
* `catalogSlug` is the catalogue entry when this release still knows the
* dose's slug, so the reader's own bundle names it
* (`vaccinations.catalog.<slug>`); `null` is the signal to show
* `vaccineName`, the person's own wording. The same degrade the vaccination
* DTO's `catalogEntry: null` carries.
*/
export interface DocumentVaccinationLinkDto {
vaccinationId: string;
/** ISO-8601 instant of the dose (UTC midnight — a Pass carries dates). */
occurredAt: string | null;
catalogSlug: string | null;
vaccineName: string | null;
}
/** The document DTO (list + detail). */
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: 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;
/** Count of the document's non-REJECTED staged facts. */
factCount: number;
/** Count of staged facts still PENDING review. */
pendingCount: number;
/** Linked illness/condition episodes (empty when unlinked). */
conditionLinks: DocumentConditionLinkDto[];
/** Visits this document is filed against (empty when unlinked). */
encounterLinks: DocumentEncounterLinkDto[];
/** How the serve route delivers the original: render inline or download. */
servingClass: "inline" | "attachment";
/**
* Whether the document has a content-search index (encrypted extracted text +
* blind token array). `false` until the document is indexed (auto-indexed on
* upload; the UI reads this for the searchable status, not as a to-do).
*/
hasContentIndex: boolean;
/**
* How that index was produced — `vision` (an AI provider read the original)
* vs a local extraction (`local-pdf` / `text-ocr` / `local-ocr`), or `null`
* when `hasContentIndex` is false. Lets the UI tell an AI-read document from
* a locally-indexed one and offer a richer "Read with AI" pass on the latter.
*/
contentIndexSource: DocumentContentIndexSourceValue | null;
/**
* Refs #776 — when the most recent index attempt finished (ISO 8601),
* successful or not. Null until any attempt ran (pre-existing rows, or a
* document whose auto-index never fired).
*/
lastIndexAttemptAt: string | null;
/**
* Refs #776 — why the most recent index attempt produced no index, or null
* (attempt succeeded, or never ran — `lastIndexAttemptAt` tells the two
* apart). The detail view renders the reason in plain words.
*/
lastIndexOutcome: DocumentIndexOutcomeValue | null;
/**
* Whether the document has an encrypted preview thumbnail (rendered in the
* background after upload). Gates the card's `<img>`: when false the card
* shows its kind icon instead of fetching a thumbnail that 404s.
*/
hasThumbnail: boolean;
/**
* v1.39.2 (#1038) — the system an imported document came from, or null for
* one added in HealthLog. Paired with `sourceId`, its id over there.
*/
sourceSystem: DocumentSourceSystemValue | null;
sourceId: string | null;
createdAt: string;
updatedAt: string;
}
export interface InboundDocumentDetailDto extends InboundDocumentDto {
facts: ExtractedFactDto[];
/**
* Doses this document is filed against, newest link last. `null` when the
* caller acts inside somebody else's record under a grant that does not
* cover the health background the doses live in: the page is shared, the
* doses are not, and an empty list would claim the page records none.
*/
vaccinationLinks: DocumentVaccinationLinkDto[] | null;
/**
* Short (3-4 sentence) plain-language summary of WHAT the document is,
* generated once in the background after upload when the `documentsAutoAiRead`
* opt-in is ON. Null when auto-read is OFF, no provider is configured, or the
* background summary has not run yet. Descriptive only — never a diagnosis.
*/
summary: string | null;
/** When the background summary was generated (ISO 8601), or null. */
summaryGeneratedAt: string | null;
/**
* What became of the summary. A null `summary` is ambiguous on its own —
* never attempted, mid-flight, withheld by the safety screen and could-not-
* produce all look identical — so the view reads this instead of guessing
* "still generating".
*/
summaryState: DocumentSummaryStateValue;
}
// ─── Edit (correction before approval) ─────────────────────────────────────
const reqText = (max: number) => z.string().trim().min(1).max(max);
const optText = (max: number) =>
z
.string()
.trim()
.max(max)
.nullable()
.optional()
.transform((v) => (v === "" ? null : v));
const optCode = z.enum(INBOUND_CODE_SYSTEMS).nullable().optional();
const isoDate = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/u, "Expected a YYYY-MM-DD date")
.nullable()
.optional();
const conditionEditSchema = z.object({
factType: z.literal("CONDITION"),
label: reqText(300),
code: optText(64),
codeSystem: optCode,
clinicalStatus: optText(64),
verificationStatus: optText(64),
onsetDate: isoDate,
});
const observationEditSchema = z
.object({
factType: z.literal("OBSERVATION"),
label: reqText(300),
code: optText(64),
codeSystem: optCode,
value: z.number().finite().nullable().optional(),
valueText: optText(300),
unit: optText(80),
referenceLow: z.number().finite().nullable().optional(),
referenceHigh: z.number().finite().nullable().optional(),
referenceText: optText(120),
effectiveDate: isoDate,
})
// Numeric XOR qualitative, mirroring the lab-result discipline. A null/absent
// value with a null/absent valueText is allowed (the user may still be
// entering it) but not BOTH set.
.refine(
(d) => !(typeof d.value === "number" && typeof d.valueText === "string"),
{
message: "Provide a numeric value OR qualitative text, not both",
path: ["value"],
},
)
// The same ordering rule the three lab schemas enforce. This is a person
// typing on the review screen, not a model guessing, so it refuses rather
// than dropping the pair silently — they can see the two numbers and fix
// which one is which.
.refine(
(d) =>
d.referenceLow === undefined ||
d.referenceLow === null ||
d.referenceHigh === undefined ||
d.referenceHigh === null ||
d.referenceLow <= d.referenceHigh,
{
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
);
const medicationEditSchema = z.object({
factType: z.literal("MEDICATION_STATEMENT"),
name: reqText(200),
dose: optText(120),
rxNormCode: optText(20),
atcCode: optText(16),
statusStated: optText(64),
effectiveDate: isoDate,
});
/**
* A correction to a staged fact (the review screen edits OCR / units / dates /
* codes before approval). Discriminated by `factType` so the edit can never
* change a fact's resource type. A successful edit clears `needsReview` — the
* values become user-asserted.
*/
export const inboundFactEditSchema = z.discriminatedUnion("factType", [
conditionEditSchema,
observationEditSchema,
medicationEditSchema,
]);
export type InboundFactEdit = z.infer<typeof inboundFactEditSchema>;
// ─── Confirm (approve / reject) ────────────────────────────────────────────
/**
* The approve/reject decisions the user made on the review screen. Each
* decision names a staged fact by id. Approved facts are committed to the
* structured stores; rejected facts are discarded. No `userId` field — it is
* always narrowed from the session; the route also re-scopes every fact id to
* the document + the caller.
*/
export const inboundConfirmSchema = z.object({
decisions: z
.array(
z.object({
factId: z.string().trim().min(1).max(40),
action: z.enum(["approve", "reject"]),
}),
)
.min(1)
.max(INBOUND_MAX_FACTS),
});
export type InboundConfirmInput = z.infer<typeof inboundConfirmSchema>;
/** The text-mode (local-OCR) extract body, mirroring the Lab-OCR text mode. */
export const INBOUND_TEXT_MAX_CHARS = 200_000;
export const inboundTextExtractSchema = z.object({
mode: z.literal("text"),
text: z.string().trim().min(1).max(INBOUND_TEXT_MAX_CHARS),
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
});
export type InboundTextExtractInput = z.infer<typeof inboundTextExtractSchema>;
/**
* The stored-text extract body: structure the document's OWN stored extracted
* text (the content index the read/index step already produced) into staged
* facts — no re-upload, no second read of the original. The document detail's
* "Extract lab values" action posts this when an automatic staging run was
* skipped or failed; the server refuses with `documents.inbound.notIndexed`
* when there is no stored text to structure.
*/
export const inboundStoredExtractSchema = z.object({
mode: z.literal("stored"),
});
export type InboundStoredExtractInput = z.infer<
typeof inboundStoredExtractSchema
>;
// ─── 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;
/** Max condition links a single document may carry / receive per request. */
export const DOCUMENT_MAX_EPISODE_LINKS = 20;
const episodeIdList = z
.array(z.string().trim().min(1).max(40))
.max(DOCUMENT_MAX_EPISODE_LINKS);
/**
* Max visit links a single document may carry / receive per request.
*
* The same bound as the condition links, and for the same reason: a document
* filed against twenty visits is a client defect rather than a large but
* legitimate batch.
*/
export const DOCUMENT_MAX_ENCOUNTER_LINKS = 20;
const encounterIdList = z
.array(z.string().trim().min(1).max(40))
.max(DOCUMENT_MAX_ENCOUNTER_LINKS);
/**
* Max vaccination links a single document may carry / receive per request.
*
* Higher than the visit and condition bounds on purpose. One childhood
* record routinely covers every dose of a whole primary schedule, which is
* well past twenty rows, and it is the normal case rather than an edge one.
* The link service's own per-call ceiling is the bound.
*/
export const DOCUMENT_MAX_VACCINATION_LINKS = 100;
const vaccinationIdList = z
.array(z.string().trim().min(1).max(40))
.max(DOCUMENT_MAX_VACCINATION_LINKS);
/**
* 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. `episodeIds` pre-links the
* document to the caller's illness/condition episodes (repeated `episodeIds`
* form fields; ownership is re-checked in the route), and `encounterIds` does
* the same for the caller's visits — the review step offers the visit the
* document's own date falls near, and the link rides the upload rather than
* becoming a second trip. No `userId` field; it is always narrowed from the
* session.
*/
/**
* v1.39.2 (#1038) — the systems an imported document can say it came from.
* Closed on purpose: the detail sheet names each one, and a free string here
* would be a label the person never chose rendered as if HealthLog vouched for
* it. OTHER covers any importer that is neither.
*/
export const DOCUMENT_SOURCE_SYSTEMS = ["PAPERLESS", "PAPRA", "OTHER"] as const;
export type DocumentSourceSystemValue =
(typeof DOCUMENT_SOURCE_SYSTEMS)[number];
/** Narrow a stored `sourceSystem` string to the closed set (null otherwise). */
export function toDocumentSourceSystem(
value: string | null | undefined,
): DocumentSourceSystemValue | null {
return (DOCUMENT_SOURCE_SYSTEMS as readonly string[]).includes(value ?? "")
? (value as DocumentSourceSystemValue)
: null;
}
/** Max length of an imported document's id in its source system. */
export const DOCUMENT_SOURCE_ID_MAX = 128;
/**
* The document's id in its source system. Printable ASCII only: it is a key,
* not prose, and it is echoed on the detail sheet.
*/
export const documentSourceIdSchema = z
.string()
.trim()
.min(1)
.max(DOCUMENT_SOURCE_ID_MAX)
.regex(/^[\x21-\x7e]+$/u, "Expected printable characters without spaces")
// A path segment of its own name: an importer that builds a URL from the id
// must never be handed one that walks up the source's path.
.refine((v) => v !== "." && v !== "..", "Not a document id");
/** Longest source instance (an origin) a key may carry. */
export const DOCUMENT_SOURCE_INSTANCE_MAX = 512;
/**
* v1.39.3 — the instance of the source system a key belongs to, as the
* normalised origin (`scheme://host[:port]`) of the Paperless-ngx or Papra it
* came from, or null when the value is not one. A path, query or credentials
* in the value are dropped: two addresses on one origin are one instance.
*/
export function normaliseSourceInstance(value: string): string | null {
try {
const url = new URL(value.trim());
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
return url.origin;
} catch {
return null;
}
}
/** The instance as a request field: validated, then normalised to its origin. */
export const documentSourceInstanceSchema = z
.string()
.trim()
.min(1)
.max(DOCUMENT_SOURCE_INSTANCE_MAX)
.refine((v) => normaliseSourceInstance(v) !== null, {
message: "Expected the http(s) address of the source instance",
})
.transform((v) => normaliseSourceInstance(v) as string);
/**
* A source key on its own — the query-string form the upload accepts ahead of
* the body, and the lookup route's query. Both halves required.
*/
export const documentSourceKeySchema = z.object({
sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS),
sourceId: documentSourceIdSchema,
/** Optional: without it the key matches any instance of the system. */
sourceInstance: documentSourceInstanceSchema.optional(),
});
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(),
episodeIds: episodeIdList.optional(),
encounterIds: encounterIdList.optional(),
/** Where an imported document came from (provenance, shown on detail). */
sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS).optional(),
/**
* The document's id in `sourceSystem`. Printable ASCII only: it is a key,
* not prose, and it is echoed on the detail sheet.
*/
sourceId: documentSourceIdSchema.optional(),
/**
* v1.39.3 — which instance of `sourceSystem` (its origin). Optional; a
* key without one matches any instance.
*/
sourceInstance: documentSourceInstanceSchema.optional(),
/**
* `defer` holds back automatic AI reading for this upload: the thumbnail
* and a local text index still run, the summary and the lab staging do
* not. Absent means today's behaviour.
*/
aiRead: z.enum(["defer"]).optional(),
})
.refine((v) => v.sourceId === undefined || v.sourceSystem !== undefined, {
path: ["sourceSystem"],
message: "sourceId needs a sourceSystem",
})
.refine((v) => v.sourceInstance === undefined || v.sourceId !== undefined, {
path: ["sourceInstance"],
message: "sourceInstance needs a sourceId",
});
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(),
/**
* Replace-set condition links: the document's links become exactly this
* set (an empty array unlinks everything). Ownership of every episode id
* is re-checked in the route against the caller's episodes.
*/
episodeIds: episodeIdList.optional(),
/** Replace-set visit links, same semantics as `episodeIds`. */
encounterIds: encounterIdList.optional(),
/**
* Replace-set vaccination links, same semantics. The same table the
* dose's own form writes from the other end.
*/
vaccinationIds: vaccinationIdList.optional(),
})
.refine(
(d) =>
d.title !== undefined ||
d.kind !== undefined ||
d.documentDate !== undefined ||
d.episodeIds !== undefined ||
d.encounterIds !== undefined ||
d.vaccinationIds !== 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(),
/**
* Category filter — OR inside the facet. Repeated `kind` params or a
* comma-separated value; the route normalises to an array before parsing.
*/
kind: z.array(z.enum(INBOUND_DOCUMENT_KINDS)).max(16).optional(),
/** Only documents linked to this illness/condition episode. */
episodeId: z.string().trim().min(1).max(40).optional(),
/** Only documents linked to this visit. */
encounterId: z.string().trim().min(1).max(40).optional(),
/** Only documents whose filing date falls in this calendar year (UTC). */
year: z.coerce.number().int().min(1900).max(9999).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>;
// ─── Vault: bulk actions ───────────────────────────────────────────────────
/** Max document ids one bulk request may touch. */
export const DOCUMENT_BULK_MAX_IDS = 100;
export const DOCUMENT_BULK_ACTIONS = [
"setKind",
"linkEpisode",
"unlinkEpisode",
"linkEncounter",
"unlinkEncounter",
"delete",
"restore",
] as const;
export type DocumentBulkAction = (typeof DOCUMENT_BULK_ACTIONS)[number];
/**
* One bulk action over up to 100 owner-scoped documents. `setKind` requires
* `kind`; `linkEpisode` / `unlinkEpisode` require `episodeId` (ownership
* re-checked in the route). Per-id outcomes ride the response so a partial
* failure never aborts the batch. No `userId` field — narrowed from the
* session.
*/
export const documentBulkSchema = z
.object({
ids: z
.array(z.string().trim().min(1).max(40))
.min(1)
.max(DOCUMENT_BULK_MAX_IDS),
action: z.enum(DOCUMENT_BULK_ACTIONS),
kind: z.enum(INBOUND_DOCUMENT_KINDS).optional(),
episodeId: z.string().trim().min(1).max(40).optional(),
encounterId: z.string().trim().min(1).max(40).optional(),
})
.refine((d) => d.action !== "setKind" || d.kind !== undefined, {
message: "Action 'setKind' requires a kind",
path: ["kind"],
})
.refine(
(d) =>
(d.action !== "linkEpisode" && d.action !== "unlinkEpisode") ||
d.episodeId !== undefined,
{
message: "Episode actions require an episodeId",
path: ["episodeId"],
},
)
.refine(
(d) =>
(d.action !== "linkEncounter" && d.action !== "unlinkEncounter") ||
d.encounterId !== undefined,
{
message: "Visit actions require an encounterId",
path: ["encounterId"],
},
);
export type DocumentBulkInput = z.infer<typeof documentBulkSchema>;
/** Per-id outcome in the bulk response. */
export interface DocumentBulkResultDto {
id: string;
ok: boolean;
/** Short machine reason when `ok` is false (e.g. "notFound", "conflict"). */
error: string | null;
}
/** The usage endpoint payload the UI reads before offering an upload. */
export interface DocumentUsageDto {
usedBytes: number;
quotaBytes: number;
maxFileBytes: number;
acceptedExtensions: string[];
/**
* Episodes carrying at least one LIVE document link — the vault filter
* bar's condition chips. Sourced server-side so a chip exists even when
* every linked document sits pages deep in the timeline.
*/
linkedEpisodes: DocumentConditionLinkDto[];
/**
* Whether the AI "Suggest details" action can run for this caller — true when
* a provider (vision, or text + local OCR) is configured. The UI hides the
* action when false so it never offers what the endpoint would 422.
*/
assistAvailable: boolean;
/**
* Content-search index state: whether indexing is available to the caller
* (`enabled`, same provider precondition as assist), how many live documents
* are indexed, and the live-document total — so the UI can gauge coverage and
* offer the "index all documents" backfill.
*/
contentIndex: {
enabled: boolean;
indexedCount: number;
totalCount: number;
};
}
/** Where a document read egresses, vendor-blind. */
export type DocumentEgressClass = "local" | "external";
/**
* The document-scoped AI capability probe response
* (`GET /api/documents/inbound/capability`). Resolved over the DOCUMENT
* provider order (local-first, codex last), so `mode` / `pdfSupported` /
* `egress` match exactly what the document AI routes will do. `egress` drives
* the vault's per-egress "this leaves your machine to a third-party AI" notice.
*/
export interface DocumentAiCapabilityDto {
available: boolean;
mode: "vision" | "text" | null;
reason: "no-provider" | "enable-local-ocr" | null;
pdfSupported: boolean;
/**
* Where a document read will egress with the current provider order:
* - "local": stays on the operator's machine (self-hosted model).
* - "external": leaves the machine to a third-party AI service.
* - null: no read is available (see `reason`).
*/
egress: DocumentEgressClass | null;
/**
* The `documentAi` capability for this record. When it is closed by
* anything other than a missing provider or a missing consent receipt,
* `available` is false with a null `reason`, and this says why.
*/
ai: AiCapabilityState;
}
// ─── AI assist / summary / index (Document vault P2) ────────────────────────
/**
* The filing-metadata suggestion the assist endpoint returns. Drafts ONLY — the
* client prefills the edit form and the user saves; nothing is written by the
* suggest call. Every field is nullable (the model leaves it null when it
* cannot read it confidently).
*/
export interface DocumentSuggestionDto {
title: string | null;
kind: InboundDocumentKindValue | null;
documentDate: string | null;
}
/** The session-only summary/extracted-text mode the summary route serves. */
export const DOCUMENT_SUMMARY_MODES = ["summary", "text"] as const;
export type DocumentSummaryMode = (typeof DOCUMENT_SUMMARY_MODES)[number];
// ─── Chat about a document (Document vault P4) ──────────────────────────────
/** Max characters of a single user turn in a document chat. */
export const DOCUMENT_CHAT_MESSAGE_MAX = 4000;
/**
* Request body for `POST /api/documents/inbound/{id}/chat`. No `userId` and no
* `documentId` field — the user is narrowed from the session and the document id
* comes from the path. `conversationId` continues an existing thread scoped to
* this document; omitting it starts a new one. `locale` is the reply language
* (the document chat, like the Coach, replies in de/en).
*/
export const documentChatRequestSchema = z
.object({
conversationId: z.string().trim().min(1).max(64).optional(),
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
locale: z.enum(["en", "de"]).optional(),
})
.meta({ id: "DocumentChatRequest" });
export type DocumentChatRequestInput = z.infer<
typeof documentChatRequestSchema
>;
/**
* Query for `GET /api/documents/inbound/{id}/chat`. With `conversationId`, the
* endpoint returns that one thread's messages (owner + document scoped); without
* it, the paginated list of the document's chat threads for the sheet's rail.
*/
export const documentChatHistoryQuerySchema = z.object({
conversationId: z.string().trim().min(1).max(64).optional(),
cursor: z.string().trim().min(1).max(64).optional(),
limit: z.coerce.number().int().min(1).max(50).optional(),
});
export type DocumentChatHistoryQuery = z.infer<
typeof documentChatHistoryQuerySchema
>;
// ─── Fenced multi-document coach chat (S7) ──────────────────────────────────
/** Max documents attachable to one coach conversation. Bounds prompt size + the
* numeric-grounding union; five labelled documents is already past the point a
* chat answer stays useful. */
export const MAX_COACH_ATTACHMENTS = 5;
/**
* Request body for `POST /api/insights/chat/fenced`. `.strict()` — the schema
* REFUSES the tool-mode fields (`scope` / `guidedQuestion` / `prefill`) and
* `userId` rather than silently dropping them, so a confused client fails loudly
* in test. `conversationId` continues an existing FENCED thread; `attachmentIds`
* (first-turn only, min 1) creates a fresh fenced thread — supplying BOTH is a
* 422 (attach-to-existing goes through the attach endpoint; one write path per
* concern). No `documentId` / `userId` field — the owner is narrowed from the
* session, the documents come from the join table.
*/
export const fencedChatRequestSchema = z
.object({
conversationId: z.string().trim().min(1).max(64).optional(),
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
locale: z.enum(["en", "de"]).optional(),
attachmentIds: z
.array(z.string().trim().min(1).max(64))
.min(1)
.max(MAX_COACH_ATTACHMENTS)
.optional(),
})
.strict()
.meta({ id: "FencedCoachChatRequest" });
export type FencedChatRequestInput = z.infer<typeof fencedChatRequestSchema>;
/**
* Request body for `POST /api/insights/chat/{id}/attachments` — attach ONE
* already-stored document to an existing conversation. `.strict()`; no `userId`.
*/
export const coachAttachmentCreateSchema = z
.object({
documentId: z.string().trim().min(1).max(64),
})
.strict()
.meta({ id: "CoachAttachmentCreateRequest" });
export type CoachAttachmentCreateInput = z.infer<
typeof coachAttachmentCreateSchema
>;