mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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