Files
HealthLog/src/lib/validations/inbound-documents.ts
T

777 lines
28 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";
/** 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];
/** 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.
*/
export const extractedFactRawSchema = 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),
/** 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),
});
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;
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;
}
/** 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[];
/** 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;
/**
* 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;
createdAt: string;
updatedAt: string;
}
export interface InboundDocumentDetailDto extends InboundDocumentDto {
facts: ExtractedFactDto[];
/**
* 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(),
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"],
},
);
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>;
// ─── 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);
/**
* 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). 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(),
episodeIds: episodeIdList.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(),
/**
* 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(),
})
.refine(
(d) =>
d.title !== undefined ||
d.kind !== undefined ||
d.documentDate !== undefined ||
d.episodeIds !== 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 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",
"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(),
})
.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"],
},
);
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;
}
// ─── 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
>;