feat(documents): rebuild upload and list on the vault contract

POST /api/documents/inbound now runs the policy layer end to end: bounded
read aborts at the admin-set cap (413 + meta.reason=fileTooLarge with the
limit), magic-byte classification (415 + unsupportedType), per-user quota
checked inside the insert transaction over every non-purged row —
tombstones keep counting, so deleted bytes are never invisible weight
(413 + quotaExceeded with the figures), sha256 dedupe (a same-bytes
re-upload answers 200 + meta.duplicate with the existing live row; the
partial unique index closes the race), an honoured Idempotency-Key, the
binary2 codec on write, and optional episodeIds[] pre-linking narrowed to
the caller's live episodes.

GET stops fetching the encrypted blob per row (omit: contentEncrypted —
a 50-row page dragged up to 50 × cap ciphertext before), learns kind[] /
episodeId / year facets on top of q/from/to, and returns conditionLinks +
servingClass on every DTO via one grouped link query (no N+1). PATCH gains
replace-set episodeIds with the same 404-shaped refusal for a foreign
episode. The extract path decrypts through the codec dispatcher.

The document-kind labels gain REFERRAL in all six locales.

Tasks: W1-T5, W1-T6, W1-T7.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 11:05:12 +02:00
1 parent bd64b78e5d
commit b76873ee1f
15 files changed
+846 -153

No files matched your search

+1
View File
@@ -8035,6 +8035,7 @@
"LAB_RESULT": "Laborbefund",
"IMAGING": "Bildgebung",
"PRESCRIPTION": "Rezept",
"REFERRAL": "Überweisung",
"INSURANCE": "Versicherung",
"VACCINATION": "Impfung"
},
+1
View File
@@ -8035,6 +8035,7 @@
"LAB_RESULT": "Lab result",
"IMAGING": "Imaging",
"PRESCRIPTION": "Prescription",
"REFERRAL": "Referral",
"INSURANCE": "Insurance",
"VACCINATION": "Vaccination"
},
+1
View File
@@ -8035,6 +8035,7 @@
"LAB_RESULT": "Resultado de laboratorio",
"IMAGING": "Imagen",
"PRESCRIPTION": "Receta",
"REFERRAL": "Derivación",
"INSURANCE": "Seguro",
"VACCINATION": "Vacunación"
},
+1
View File
@@ -8035,6 +8035,7 @@
"LAB_RESULT": "Résultat de laboratoire",
"IMAGING": "Imagerie",
"PRESCRIPTION": "Ordonnance",
"REFERRAL": "Lettre d'adressage",
"INSURANCE": "Assurance",
"VACCINATION": "Vaccination"
},
+1
View File
@@ -8035,6 +8035,7 @@
"LAB_RESULT": "Risultato di laboratorio",
"IMAGING": "Diagnostica per immagini",
"PRESCRIPTION": "Prescrizione",
"REFERRAL": "Impegnativa",
"INSURANCE": "Assicurazione",
"VACCINATION": "Vaccinazione"
},
+1
View File
@@ -8035,6 +8035,7 @@
"LAB_RESULT": "Wynik laboratoryjny",
"IMAGING": "Obrazowanie",
"PRESCRIPTION": "Recepta",
"REFERRAL": "Skierowanie",
"INSURANCE": "Ubezpieczenie",
"VACCINATION": "Szczepienie"
},
@@ -17,12 +17,28 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
findFirstOrThrow: vi.fn(),
},
documentConditionLink: {
findMany: vi.fn(),
deleteMany: vi.fn(),
createMany: vi.fn(),
},
illnessEpisode: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `v1.${s}`),
decrypt: vi.fn((s: string) => s.replace(/^v1\./u, "")),
encryptBytes: vi.fn((b: Buffer) => b),
decryptBytes: vi.fn((b: Buffer) => b),
}));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
@@ -106,6 +122,15 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.documentConditionLink.deleteMany).mockResolvedValue({
count: 0,
} as never);
vi.mocked(prisma.documentConditionLink.createMany).mockResolvedValue({
count: 0,
} as never);
});
describe("PATCH /api/documents/inbound/[id]", () => {
@@ -158,4 +183,57 @@ describe("PATCH /api/documents/inbound/[id]", () => {
expect(res.status).toBe(422);
expect(prisma.inboundDocument.findFirst).not.toHaveBeenCalled();
});
it("replace-sets condition links from episodeIds", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
} as never);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{ id: "ep-1" },
{ id: "ep-2" },
] as never);
vi.mocked(prisma.inboundDocument.findFirstOrThrow).mockResolvedValue(
docRow() as never,
);
const res = await PATCH(
mkReq("doc-1", { episodeIds: ["ep-1", "ep-2"] }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
// Replace-set: everything outside the set is deleted, the set is upserted.
expect(prisma.documentConditionLink.deleteMany).toHaveBeenCalledWith({
where: {
userId: "user-1",
documentId: "doc-1",
episodeId: { notIn: ["ep-1", "ep-2"] },
},
});
expect(prisma.documentConditionLink.createMany).toHaveBeenCalledWith({
data: [
{ documentId: "doc-1", episodeId: "ep-1", userId: "user-1" },
{ documentId: "doc-1", episodeId: "ep-2", userId: "user-1" },
],
skipDuplicates: true,
});
// A links-only PATCH does not touch the metadata columns.
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
});
it("refuses a foreign episodeId with a 404-shaped response", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
} as never);
// The owner-narrowed lookup misses the foreign id.
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
const res = await PATCH(
mkReq("doc-1", { episodeIds: ["ep-foreign"] }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(404);
expect(prisma.documentConditionLink.deleteMany).not.toHaveBeenCalled();
expect(prisma.documentConditionLink.createMany).not.toHaveBeenCalled();
});
});
@@ -46,7 +46,7 @@ import {
type InboundExtractionResult,
} from "@/lib/documents/extract";
import {
decryptDocumentFromBytes,
decryptDocumentContent,
encryptFactData,
encryptFactProvenance,
serialiseDocumentDetail,
@@ -75,6 +75,7 @@ type LoadedDocument = {
id: string;
kind: string;
contentEncrypted: Uint8Array;
contentCodec: string;
mimeType: string;
status: string;
};
@@ -135,6 +136,7 @@ export const POST = apiHandler(
id: true,
kind: true,
contentEncrypted: true,
contentCodec: true,
mimeType: true,
status: true,
},
@@ -324,7 +326,10 @@ async function handleVisionExtract(
// trust a stored label for the provider call).
let buffer: Buffer;
try {
buffer = decryptDocumentFromBytes(document.contentEncrypted);
buffer = decryptDocumentContent(
document.contentEncrypted,
document.contentCodec,
);
} catch {
return apiError("Couldn't read the stored document.", 422, {
errorCode: "documents.inbound.extractFailed",
+56 -8
View File
@@ -18,6 +18,11 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
loadConditionLinks,
narrowOwnedEpisodeIds,
replaceConditionLinks,
} from "@/lib/documents/links";
import { serialiseDocumentDetail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
@@ -62,6 +67,7 @@ export const GET = apiHandler(
const { id } = await params;
const document = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id, deletedAt: null },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
if (!document) {
@@ -70,12 +76,20 @@ export const GET = apiHandler(
});
}
const links = await loadConditionLinks(user.id, [document.id]);
annotate({
action: { name: "documents.inbound.get" },
meta: { documentId: id, facts: document.facts.length },
});
return apiSuccess(serialiseDocumentDetail(document, document.facts));
return apiSuccess(
serialiseDocumentDetail(
document,
document.facts,
links.get(document.id) ?? [],
),
);
},
);
@@ -110,6 +124,22 @@ export const PATCH = apiHandler(
});
}
// Replace-set condition links: every episode id must be a LIVE episode
// of the caller — a foreign/unknown id gets a 404-shaped refusal.
let nextEpisodeIds: string[] | undefined;
if (parsed.data.episodeIds !== undefined) {
const narrowed = await narrowOwnedEpisodeIds(
user.id,
parsed.data.episodeIds,
);
if (narrowed === null) {
return apiError("Episode not found", 404, {
errorCode: "documents.inbound.episodeNotFound",
});
}
nextEpisodeIds = narrowed;
}
// 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 = {};
@@ -121,31 +151,49 @@ export const PATCH = apiHandler(
: null;
}
await prisma.inboundDocument.update({
where: { id: existing.id },
data,
});
if (Object.keys(data).length > 0) {
await prisma.inboundDocument.update({
where: { id: existing.id },
data,
});
}
if (nextEpisodeIds !== undefined) {
await replaceConditionLinks(prisma, user.id, existing.id, nextEpisodeIds);
}
const document = await prisma.inboundDocument.findFirstOrThrow({
where: { id: existing.id },
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const links = await loadConditionLinks(user.id, [document.id]);
const touched = [
...Object.keys(data),
...(nextEpisodeIds !== undefined ? ["episodeIds"] : []),
];
await auditLog("documents.inbound.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
documentId: existing.id,
fields: Object.keys(data),
fields: touched,
},
});
annotate({
action: { name: "documents.inbound.update" },
meta: { documentId: existing.id, fields: Object.keys(data).join(",") },
meta: { documentId: existing.id, fields: touched.join(",") },
});
return apiSuccess(serialiseDocumentDetail(document, document.facts));
return apiSuccess(
serialiseDocumentDetail(
document,
document.facts,
links.get(document.id) ?? [],
),
);
},
);
@@ -1,30 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* v1.25 — documents library: store-only upload + browsable list.
* Document vault: 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.
* Pins the vault upload contract: a file persists as a STORED row with the
* binary codec + plaintext sha256 and NO provider / consent / budget call on
* the path, the policy-layer error contract (413 fileTooLarge / quotaExceeded
* with limits in `meta`, 415 unsupportedType), the duplicate short-circuit
* (200 + `meta.duplicate`, no second row), and — hardening — that the list
* query NEVER selects the encrypted blob column.
*/
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: {
create: vi.fn(),
findMany: vi.fn(),
},
extractedFact: {
groupBy: vi.fn(),
},
},
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const { txQueryRaw, txCreate, txCreateMany } = vi.hoisted(() => ({
txQueryRaw: vi.fn(),
txCreate: vi.fn(),
txCreateMany: 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/db", () => {
const tx = {
$queryRaw: txQueryRaw,
inboundDocument: { create: txCreate },
documentConditionLink: { createMany: txCreateMany },
};
return {
prisma: {
inboundDocument: {
create: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
},
extractedFact: {
groupBy: vi.fn(),
},
documentConditionLink: {
findMany: vi.fn(),
},
illnessEpisode: {
findMany: vi.fn(),
},
appSettings: {
findUnique: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
$transaction: vi.fn(async (cb: (t: unknown) => Promise<unknown>) =>
cb(tx),
),
},
};
});
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
@@ -56,6 +85,8 @@ vi.mock("next/headers", () => ({
})),
}));
import { createHash } from "node:crypto";
import { POST, GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -71,18 +102,23 @@ const SESSION_OK = {
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==";
const PNG_BYTES = Buffer.from(ONE_BY_ONE_PNG_BASE64, "base64");
const PNG_SHA256 = createHash("sha256").update(PNG_BYTES).digest("hex");
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);
function mkUpload(
fields: Record<string, string | string[]> = {},
bytes: Buffer = PNG_BYTES,
filename = "x.png",
) {
const blobBytes = new Uint8Array(bytes.byteLength);
blobBytes.set(bytes);
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);
formData.append("file", new Blob([blobBytes]), filename);
for (const [k, v] of Object.entries(fields)) {
for (const item of Array.isArray(v) ? v : [v]) {
formData.append(k, item);
}
}
return new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: formData,
@@ -103,6 +139,8 @@ function docRow(over: Record<string, unknown> = {}) {
reportDate: null,
documentDate: null,
errorReason: null,
contentSha256: PNG_SHA256,
contentCodec: "binary2",
createdAt: new Date("2026-06-01T00:00:00.000Z"),
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
...over,
@@ -114,11 +152,21 @@ beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(null as never);
// No settings row / no user override → policy defaults (25 MiB / 1 GiB).
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
txQueryRaw.mockResolvedValue([{ used: BigInt(0) }]);
txCreate.mockResolvedValue(docRow() as never);
txCreateMany.mockResolvedValue({ count: 0 } 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(
describe("POST /api/documents/inbound (vault upload)", () => {
it("stores a file binary-codec encrypted with its plaintext sha256", async () => {
txCreate.mockResolvedValue(
docRow({ kind: "LAB_RESULT", title: "Blood panel" }) as never,
);
@@ -132,47 +180,151 @@ describe("POST /api/documents/inbound (store-only)", () => {
expect(body.data.status).toBe("STORED");
expect(body.data.kind).toBe("LAB_RESULT");
expect(body.data.title).toBe("Blood panel");
expect(body.data.servingClass).toBe("inline");
expect(body.data.conditionLinks).toEqual([]);
// The row is created with status STORED, no provider, userId from session.
const arg = vi.mocked(prisma.inboundDocument.create).mock.calls[0]![0]!;
// The row is created with status STORED, no provider, userId from the
// session, the ACTIVE binary codec, and the plaintext sha256.
const arg = txCreate.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(arg.data.contentCodec).toBe("binary2");
expect(arg.data.contentSha256).toBe(PNG_SHA256);
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");
// The persisted blob is ciphertext, never the plaintext bytes.
const stored = Buffer.from(arg.data.contentEncrypted as Uint8Array);
expect(stored.equals(PNG_BYTES)).toBe(false);
// The create never round-trips the blob back out.
expect(arg.omit).toEqual({ contentEncrypted: true });
});
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);
it("rejects an unidentifiable payload with 415 + reason unsupportedType", async () => {
const res = await post(
mkUpload({}, Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]), "x.bin"),
);
expect(res.status).toBe(415);
expect(prisma.inboundDocument.create).not.toHaveBeenCalled();
const body = await res.json();
expect(body.meta.reason).toBe("unsupportedType");
expect(txCreate).not.toHaveBeenCalled();
});
it("rejects an over-cap file with 413 + the configured limit", async () => {
// Admin capped uploads below the PNG's 70 bytes (clamped ≥ 1 in the
// resolver); the file itself trips the per-file check.
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 16,
documentQuotaBytes: BigInt(1_073_741_824),
} as never);
const res = await post(mkUpload());
expect(res.status).toBe(413);
const body = await res.json();
expect(body.meta.reason).toBe("fileTooLarge");
expect(body.meta.maxFileBytes).toBe(16);
expect(txCreate).not.toHaveBeenCalled();
});
it("rejects an upload past the quota with 413 + quota figures", async () => {
// Per-user override of 100 bytes; 60 already used; the 70-byte PNG tips it.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentQuotaBytes: BigInt(100),
} as never);
txQueryRaw.mockResolvedValue([{ used: BigInt(60) }]);
const res = await post(mkUpload());
expect(res.status).toBe(413);
const body = await res.json();
expect(body.meta.reason).toBe("quotaExceeded");
expect(body.meta.quotaBytes).toBe(100);
expect(body.meta.usedBytes).toBe(60);
expect(txCreate).not.toHaveBeenCalled();
});
it("returns the existing live row with meta.duplicate on a re-upload", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
docRow({ id: "doc-existing" }) as never,
);
const res = await post(mkUpload());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta.duplicate).toBe(true);
expect(body.data.id).toBe("doc-existing");
// The duplicate lookup is scoped to the caller's LIVE rows by sha256.
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0]![0]!
.where!;
expect(where.userId).toBe("user-1");
expect(where.contentSha256).toBe(PNG_SHA256);
expect(where.deletedAt).toBeNull();
expect(txCreate).not.toHaveBeenCalled();
});
it("pre-links the caller's episodes and refuses a foreign episode id", async () => {
// Owned episode → link rows land in the same transaction.
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([
{ id: "ep-1" },
] as never);
let res = await post(mkUpload({ episodeIds: ["ep-1"] }));
expect(res.status).toBe(201);
expect(txCreateMany).toHaveBeenCalledWith({
data: [{ documentId: "doc-1", episodeId: "ep-1", userId: "user-1" }],
});
// Foreign/unknown episode id → 404-shaped refusal, nothing persisted.
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireModuleEnabled).mockResolvedValue({
enabled: true,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
null as never,
);
vi.mocked(prisma.illnessEpisode.findMany).mockResolvedValue([] as never);
res = await post(mkUpload({ episodeIds: ["ep-foreign"] }));
expect(res.status).toBe(404);
expect(txCreate).not.toHaveBeenCalled();
});
});
describe("GET /api/documents/inbound (list)", () => {
it("applies q + kind filters and returns a keyset page with grouped counts", async () => {
it("never selects the encrypted blob column (omit hardening)", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const res = await get(
new Request("http://localhost/api/documents/inbound"),
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!;
expect(arg.omit).toEqual({ contentEncrypted: true });
expect(arg.select).toBeUndefined();
expect(arg.include).toBeUndefined();
});
it("applies q + multi-kind + episode filters and returns grouped counts", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
docRow({ id: "d1" }),
docRow({ id: "d2" }),
] as never);
// factCount excludes REJECTED; pendingCount is the PENDING subset. The
// counts come from a single grouped query, not from materialised fact rows.
vi.mocked(prisma.extractedFact.groupBy).mockResolvedValue([
{ documentId: "d2", status: "PENDING", _count: { _all: 1 } },
{ documentId: "d2", status: "APPROVED", _count: { _all: 1 } },
{ documentId: "d2", status: "REJECTED", _count: { _all: 3 } },
] as never);
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue([
{
documentId: "d1",
episodeId: "ep-1",
episode: { label: "Knie" },
},
] as never);
const res = await get(
new Request(
"http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&sort=createdAt&order=desc&limit=10",
"http://localhost/api/documents/inbound?q=panel&kind=LAB_RESULT&kind=IMAGING&episodeId=ep-1&sort=createdAt&order=desc&limit=10",
),
);
expect(res.status).toBe(200);
@@ -180,11 +332,11 @@ describe("GET /api/documents/inbound (list)", () => {
expect(body.data.documents).toHaveLength(2);
expect(body.data.nextCursor).toBeNull();
// d1 has no facts; d2 has 1 pending + 1 approved + 3 rejected → factCount 2
// (rejected excluded), pendingCount 1.
// d1 carries its condition link; d2 has 1 pending + 1 approved +
// 3 rejected → factCount 2 (rejected excluded), pendingCount 1.
const [d1, d2] = body.data.documents;
expect(d1.conditionLinks).toEqual([{ episodeId: "ep-1", name: "Knie" }]);
expect(d1.factCount).toBe(0);
expect(d1.pendingCount).toBe(0);
expect(d2.factCount).toBe(2);
expect(d2.pendingCount).toBe(1);
@@ -192,23 +344,32 @@ describe("GET /api/documents/inbound (list)", () => {
const where = arg.where!;
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
expect(where.kind).toBe("LAB_RESULT");
expect(where.kind).toEqual({ in: ["LAB_RESULT", "IMAGING"] });
expect(where.conditionLinks).toEqual({ some: { episodeId: "ep-1" } });
expect(where.OR).toEqual([
{ title: { contains: "panel", mode: "insensitive" } },
{ filename: { contains: "panel", mode: "insensitive" } },
]);
// No fact rows are materialised in the page query — counts come from groupBy.
expect(arg.include).toBeUndefined();
// limit+1 fetched for the has-more probe.
expect(arg.take).toBe(11);
});
it("maps the year filter to a UTC calendar-year documentDate range", async () => {
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([] as never);
const res = await get(
new Request("http://localhost/api/documents/inbound?year=2025"),
);
expect(res.status).toBe(200);
const where = vi.mocked(prisma.inboundDocument.findMany).mock.calls[0]![0]!
.where!;
expect(where.documentDate).toEqual({
gte: new Date(Date.UTC(2025, 0, 1)),
lt: new Date(Date.UTC(2026, 0, 1)),
});
});
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: [],
}));
const rows = Array.from({ length: 3 }, (_, i) => docRow({ id: `d${i}` }));
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue(rows as never);
const res = await get(
+287 -79
View File
@@ -1,39 +1,54 @@
/**
* v1.25 — documents library: store-first upload + browsable list.
* Document vault: store-first upload + browsable list.
*
* 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`).
* POST is STORE-ONLY and provider-free. A self-hoster uploads any accepted
* document; it is stored ENCRYPTED at rest (binary codec) 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.
*
* 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).
* The upload path enforces the vault policy layer
* (`src/lib/documents/upload-policy.ts`): magic-byte classification (the wire
* Content-Type is never trusted), the admin-tunable per-file cap (bounded
* read aborts at the cap), the per-user quota (checked in the same
* transaction as the insert, tombstone-inclusive), sha256 duplicate detection
* (a same-user re-upload returns the existing live row, never a second copy),
* an honoured `Idempotency-Key`, and optional `episodeIds[]` pre-linking.
*
* GET lists the caller's documents with title/filename search, kind /
* episode / year / date-range filters, sort, and keyset pagination — and
* NEVER selects the encrypted blob column (`omit: { contentEncrypted: true }`).
*
* 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.
* instruction inside it. Storing it does nothing with its contents.
*/
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
encryptDocumentToBytes,
loadConditionLinks,
narrowOwnedEpisodeIds,
} from "@/lib/documents/links";
import {
encryptDocumentContent,
serialiseDocument,
type SerialisableDocument,
} from "@/lib/documents/store";
import {
BodyTooLargeError,
detectOcrMimeType,
OCR_MAX_BYTES,
readBoundedBody,
} from "@/lib/labs/ocr-upload";
detectDocumentType,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
import { withIdempotency } from "@/lib/idempotency";
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { isP2002 } from "@/lib/prisma-errors";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
documentCreateSchema,
@@ -50,13 +65,78 @@ export const dynamic = "force-dynamic";
const UPLOAD_LIMIT_PER_HOUR = 60;
const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
/**
* Multipart envelope allowance on top of the per-file cap: boundaries plus
* the small metadata fields (title / kind / documentDate / episodeIds). The
* exact per-file cap is re-enforced on the extracted file bytes below.
*/
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
/** 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`);
}
/** §3.2 — 413 fileTooLarge with the configured limit in `meta`. */
function fileTooLarge(maxFileBytes: number): NextResponse {
return apiError("File is too large.", 413, {
errorCode: "documents.inbound.fileTooLarge",
reason: "fileTooLarge",
maxFileBytes,
});
}
/** Internal signal: the quota gate inside the insert transaction tripped. */
class QuotaExceededError extends Error {
constructor(public readonly usedBytes: number) {
super("Document quota exceeded");
this.name = "QuotaExceededError";
}
}
/**
* §3.2 — a duplicate upload is NOT an error: return the existing live row
* with `meta.duplicate: true` at the envelope level (the UI toasts "already
* stored" and highlights the row).
*/
async function duplicateResponse(
userId: string,
existing: SerialisableDocument,
): Promise<NextResponse> {
const [links, groups] = await Promise.all([
loadConditionLinks(userId, [existing.id]),
prisma.extractedFact.groupBy({
by: ["status"],
where: { userId, documentId: existing.id },
_count: { _all: true },
}),
]);
let factCount = 0;
let pendingCount = 0;
for (const g of groups) {
if (g.status !== "REJECTED") factCount += g._count._all;
if (g.status === "PENDING") pendingCount += g._count._all;
}
annotate({
action: { name: "documents.vault.upload" },
meta: { documentId: existing.id, duplicate: true },
});
return NextResponse.json(
{
data: serialiseDocument(
existing,
{ factCount, pendingCount },
links.get(existing.id) ?? [],
),
error: null,
meta: { duplicate: true },
},
{ status: 200 },
);
}
/** POST — store an uploaded document encrypted at rest. No extraction. */
export const POST = apiHandler(async (request: Request) => {
async function postUpload(request: Request): Promise<Response> {
const { user } = await requireAuth();
// Opt-in module gate — even a valid Bearer token is refused when the surface
@@ -79,24 +159,25 @@ export const POST = apiHandler(async (request: Request) => {
return response;
}
const limits = await resolveDocumentLimits(user.id);
const bodyCap = limits.maxFileBytes + MULTIPART_OVERHEAD_BYTES;
// Instant rejection on a declared oversize (a CD/ISO-sized upload never
// allocates); the bounded read below covers chunked/undeclared bodies.
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",
});
if (contentLength > bodyCap) {
return fileTooLarge(limits.maxFileBytes);
}
let formData: FormData;
try {
const bytes = await readBoundedBody(request.body, OCR_MAX_BYTES);
const bytes = await readBoundedBody(request.body, bodyCap);
formData = await new Response(new Blob([bytes]), {
headers: { "content-type": request.headers.get("content-type") ?? "" },
}).formData();
} catch (err) {
if (err instanceof BodyTooLargeError) {
return apiError("File is too large (max 12 MB).", 413, {
errorCode: "documents.inbound.fileTooLarge",
});
return fileTooLarge(limits.maxFileBytes);
}
return apiError("Invalid multipart body", 400);
}
@@ -106,12 +187,17 @@ export const POST = apiHandler(async (request: Request) => {
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.
// Optional metadata (title / kind / documentDate / episodeIds). The file is
// read separately; these are the form fields beside it. `episodeIds` may be
// repeated.
const rawEpisodeIds = formData
.getAll("episodeIds")
.filter((v): v is string => typeof v === "string");
const parsed = documentCreateSchema.safeParse({
title: formData.get("title") ?? undefined,
kind: formData.get("kind") ?? undefined,
documentDate: formData.get("documentDate") ?? undefined,
episodeIds: rawEpisodeIds.length > 0 ? rawEpisodeIds : undefined,
});
if (!parsed.success) {
return apiError("Invalid document metadata", 422, {
@@ -125,63 +211,155 @@ export const POST = apiHandler(async (request: Request) => {
} 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",
if (buffer.byteLength > limits.maxFileBytes) {
return fileTooLarge(limits.maxFileBytes);
}
if (buffer.byteLength === 0) {
return apiError("Uploaded file is empty", 422, {
errorCode: "documents.inbound.invalidMetadata",
});
}
// 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.
//
// `documentDate` is the effective filing date the library sorts, groups, and
// range-filters on. When the user does not supply one at upload, default it to
// the upload day (now) — the same effective date the UI shows — so the column
// is never NULL on a stored row. This keeps display == sort == filter: an
// undated upload no longer sinks below older dated rows under `nulls: "last"`,
// and a "this month" range filter sees it. (At store time `reportDate` is
// still null; it only exists after an extraction, so the default resolves to
// the upload day.) The date stays user-editable via PATCH, which still allows
// clearing it back to NULL.
const document = await prisma.inboundDocument.create({
data: {
userId: user.id,
kind: parsed.data.kind ?? "OTHER",
// Stored plaintext on purpose (mirrors `filename`) so the list can
// ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that is the
// accepted tradeoff for server-side search/sort — the document body stays
// encrypted.
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)
: new Date(),
},
// §3.1 — magic-byte classification; the wire Content-Type is never trusted.
const detected = detectDocumentType(
buffer,
typeof file.name === "string" ? file.name : null,
);
if (!detected) {
return apiError("This file type is not supported.", 415, {
errorCode: "documents.inbound.fileType",
reason: "unsupportedType",
});
}
// Pre-linking: every episode id must be a LIVE episode of the caller.
const episodeIds = await narrowOwnedEpisodeIds(
user.id,
parsed.data.episodeIds ?? [],
);
if (episodeIds === null) {
return apiError("Episode not found", 404, {
errorCode: "documents.inbound.episodeNotFound",
});
}
// sha256 of the PLAINTEXT for same-user duplicate detection.
const contentSha256 = createHash("sha256").update(buffer).digest("hex");
// Fast-path dedupe check; the partial unique index closes the race below.
const existing = await prisma.inboundDocument.findFirst({
where: { userId: user.id, contentSha256, deletedAt: null },
omit: { contentEncrypted: true },
});
if (existing) {
return duplicateResponse(user.id, existing);
}
// Quota gate + insert + pre-links in ONE transaction. Usage counts every
// non-purged row — tombstones still hold TOAST bytes, so "deleted" bytes
// are never invisible weight (undo-delete never changes usage).
let document: SerialisableDocument;
try {
document = await prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ used: bigint }>>`
SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
FROM inbound_documents
WHERE user_id = ${user.id}
`;
const usedBytes = Number(rows[0]?.used ?? 0);
if (usedBytes + buffer.byteLength > limits.quotaBytes) {
throw new QuotaExceededError(usedBytes);
}
const { content, codec } = encryptDocumentContent(buffer);
// No mass assignment — every column is set field-by-field; `userId`
// comes from the session, never the body. `documentDate` defaults to
// the upload day so display == sort == filter (user-editable later).
const created = await tx.inboundDocument.create({
data: {
userId: user.id,
kind: parsed.data.kind ?? "OTHER",
// Stored plaintext on purpose (mirrors `filename`) so the list can
// ILIKE-search + ORDER BY it. It MAY hold PHI the user types; that
// is the accepted tradeoff for server-side search/sort — the
// document body stays encrypted.
title: parsed.data.title ?? null,
filename:
typeof file.name === "string" ? file.name.slice(0, 255) : null,
mimeType: detected.mimeType,
byteSize: buffer.byteLength,
contentEncrypted: content,
contentCodec: codec,
contentSha256,
status: "STORED",
documentDate: parsed.data.documentDate
? isoDateToUtc(parsed.data.documentDate)
: new Date(),
},
omit: { contentEncrypted: true },
});
if (episodeIds.length > 0) {
await tx.documentConditionLink.createMany({
data: episodeIds.map((episodeId) => ({
documentId: created.id,
episodeId,
userId: user.id,
})),
});
}
return created;
});
} catch (err) {
if (err instanceof QuotaExceededError) {
return apiError("Storage quota exceeded.", 413, {
errorCode: "documents.inbound.quotaExceeded",
reason: "quotaExceeded",
quotaBytes: limits.quotaBytes,
usedBytes: err.usedBytes,
});
}
if (isP2002(err)) {
// A racing upload of the same bytes won the partial unique index.
// Surface the winner as the duplicate — same outcome as the fast path.
const winner = await prisma.inboundDocument.findFirst({
where: { userId: user.id, contentSha256, deletedAt: null },
omit: { contentEncrypted: true },
});
if (winner) return duplicateResponse(user.id, winner);
}
throw err;
}
await auditLog("documents.inbound.store", {
userId: user.id,
ipAddress: getClientIp(request),
details: { documentId: document.id, mime },
details: { documentId: document.id, mime: detected.mimeType },
});
annotate({
action: { name: "documents.inbound.store" },
meta: { documentId: document.id, byteSize: document.byteSize },
action: { name: "documents.vault.upload" },
meta: {
documentId: document.id,
byteSize: document.byteSize,
servingClass: detected.servingClass,
linked: episodeIds.length,
},
});
const links = await loadConditionLinks(user.id, [document.id]);
return apiSuccess(
serialiseDocument(document, { factCount: 0, pendingCount: 0 }),
serialiseDocument(
document,
{ factCount: 0, pendingCount: 0 },
links.get(document.id) ?? [],
),
201,
);
});
}
export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
/** GET — list the caller's documents (search / filter / sort / paginate). */
export const GET = apiHandler(async (request: Request) => {
@@ -191,22 +369,37 @@ export const GET = apiHandler(async (request: Request) => {
if (!gate.enabled) return gate.response;
const url = new URL(request.url);
const parsed = documentListQuerySchema.safeParse(
Object.fromEntries(url.searchParams),
);
// `kind` is a multi-value facet: repeated params and/or comma-separated.
const kinds = url.searchParams
.getAll("kind")
.flatMap((v) => v.split(","))
.map((v) => v.trim())
.filter((v) => v.length > 0);
const single = Object.fromEntries(url.searchParams);
const parsed = documentListQuerySchema.safeParse({
...single,
kind: kinds.length > 0 ? kinds : undefined,
});
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 { q, kind, episodeId, year, 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) {
if (kind && kind.length > 0) where.kind = { in: kind };
if (episodeId) where.conditionLinks = { some: { episodeId } };
if (year !== undefined) {
where.documentDate = {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
};
} else if (from || to) {
where.documentDate = {
...(from ? { gte: isoDateToUtc(from) } : {}),
// inclusive end-of-day for the `to` bound
@@ -234,8 +427,11 @@ export const GET = apiHandler(async (request: Request) => {
{ id: order },
];
// Hardening: the list NEVER fetches the encrypted blob column — a page of
// 50 rows would otherwise drag up to 50 × cap ciphertext bytes per request.
const rows = await prisma.inboundDocument.findMany({
where,
omit: { contentEncrypted: true },
orderBy,
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
@@ -247,8 +443,8 @@ export const GET = apiHandler(async (request: Request) => {
// Fact tallies for the whole page in ONE grouped count — no per-document
// fan-out and no materialising fact-id rows. `factCount` counts every
// non-REJECTED fact (a rejected fact is discarded, not part of the document's
// tally), `pendingCount` the PENDING subset still awaiting review.
// non-REJECTED fact (a rejected fact is discarded, not part of the
// document's tally), `pendingCount` the PENDING subset awaiting review.
const factCounts = new Map<
string,
{ factCount: number; pendingCount: number }
@@ -271,9 +467,20 @@ export const GET = apiHandler(async (request: Request) => {
}
}
// Condition links for the page in ONE grouped query (no N+1).
const linkMap = await loadConditionLinks(
user.id,
page.map((d) => d.id),
);
annotate({
action: { name: "documents.inbound.list" },
meta: { count: page.length, sort, order, filtered: Boolean(q || kind) },
meta: {
count: page.length,
sort,
order,
filtered: Boolean(q || (kind && kind.length > 0) || episodeId || year),
},
});
return apiSuccess({
@@ -281,6 +488,7 @@ export const GET = apiHandler(async (request: Request) => {
serialiseDocument(
doc,
factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 },
linkMap.get(doc.id) ?? [],
),
),
nextCursor,
@@ -59,6 +59,8 @@ function doc(
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [],
servingClass: "inline",
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T08:00:00.000Z",
...over,
@@ -49,6 +49,8 @@ function doc(
errorReason: null,
factCount: 0,
pendingCount: 0,
conditionLinks: [],
servingClass: "inline",
createdAt: "2026-06-20T08:00:00.000Z",
updatedAt: "2026-06-20T08:00:00.000Z",
...over,
+83
View File
@@ -0,0 +1,83 @@
/**
* Document ⇄ condition link helpers shared by the vault routes.
*
* One grouped query per page (no N+1), owner-scoped everywhere: every episode
* id a client sends is re-narrowed against the caller's live episodes before
* it can land in `document_condition_links`.
*/
import { prisma } from "@/lib/db";
import type { DocumentConditionLinkDto } from "@/lib/validations/inbound-documents";
/**
* Load the condition links for a page of documents in ONE grouped query.
* Returns a map documentId → link DTOs (documents without links are absent).
*/
export async function loadConditionLinks(
userId: string,
documentIds: string[],
): Promise<Map<string, DocumentConditionLinkDto[]>> {
const map = new Map<string, DocumentConditionLinkDto[]>();
if (documentIds.length === 0) return map;
const links = await prisma.documentConditionLink.findMany({
where: { userId, documentId: { in: documentIds } },
select: {
documentId: true,
episodeId: true,
episode: { select: { label: true } },
},
orderBy: { createdAt: "asc" },
});
for (const link of links) {
const entry = map.get(link.documentId) ?? [];
entry.push({ episodeId: link.episodeId, name: link.episode.label });
map.set(link.documentId, entry);
}
return map;
}
/**
* Narrow a client-sent episode-id list to the caller's LIVE episodes.
* Returns the deduplicated owned ids, or null when any id is unknown /
* foreign / deleted — the caller answers with a 404-shaped refusal (an
* attacker probing another user's episode ids learns nothing beyond
* "not found").
*/
export async function narrowOwnedEpisodeIds(
userId: string,
episodeIds: string[],
): Promise<string[] | null> {
const unique = [...new Set(episodeIds)];
if (unique.length === 0) return [];
const owned = await prisma.illnessEpisode.findMany({
where: { id: { in: unique }, userId, deletedAt: null },
select: { id: true },
});
if (owned.length !== unique.length) return null;
return unique;
}
/**
* Replace-set a document's condition links inside the given transaction-ish
* client: delete links no longer in the set, insert the missing ones.
* `episodeIds` MUST already be owner-narrowed (`narrowOwnedEpisodeIds`).
*/
export async function replaceConditionLinks(
tx: Pick<typeof prisma, "documentConditionLink">,
userId: string,
documentId: string,
episodeIds: string[],
): Promise<void> {
await tx.documentConditionLink.deleteMany({
where: { userId, documentId, episodeId: { notIn: episodeIds } },
});
if (episodeIds.length > 0) {
await tx.documentConditionLink.createMany({
data: episodeIds.map((episodeId) => ({
documentId,
episodeId,
userId,
})),
skipDuplicates: true,
});
}
}
+104 -4
View File
@@ -52,6 +52,7 @@ export const INBOUND_DOCUMENT_KINDS = [
"LAB_RESULT",
"IMAGING",
"PRESCRIPTION",
"REFERRAL",
"INSURANCE",
"VACCINATION",
"OTHER",
@@ -196,6 +197,13 @@ export interface ExtractedFactDto {
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;
@@ -216,6 +224,10 @@ export interface InboundDocumentDto {
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";
createdAt: string;
updatedAt: string;
}
@@ -343,16 +355,26 @@ const isoDateString = z
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. No `userId` field; it is
* always narrowed from the session.
* 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>;
@@ -374,12 +396,19 @@ export const documentUpdateSchema = z
.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.documentDate !== undefined ||
d.episodeIds !== undefined,
{ message: "Provide at least one field to update" },
);
@@ -403,7 +432,15 @@ export const DOCUMENT_LIST_DEFAULT_LIMIT = 50;
*/
export const documentListQuerySchema = z.object({
q: z.string().trim().max(100).optional(),
kind: z.enum(INBOUND_DOCUMENT_KINDS).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"),
@@ -418,3 +455,66 @@ export const documentListQuerySchema = z.object({
});
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[];
}