mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'feat/vault-w4-qa' into release/v1.27.17
This commit is contained in:
commit
03058b0210
81 files changed
+10544
-3246
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* The document vault's decrypt-and-serve route is framed same-origin by the
|
||||
* detail preview (`<iframe>` for inline-class PDFs). The proxy's blanket
|
||||
* `X-Frame-Options: DENY` + `frame-ancestors 'none'` would refuse that
|
||||
* embed, and the blanket page CSP overrides whatever the route sets
|
||||
* (middleware headers win) — so the proxy carves out EXACTLY the
|
||||
* `/original` path with a same-origin framing posture and a document CSP
|
||||
* (`default-src 'none'`; deliberately no `sandbox` — Chromium
|
||||
* force-downloads sandboxed PDFs instead of rendering them).
|
||||
*
|
||||
* These tests pin both sides of that carve-out: the serve path gets the
|
||||
* narrow posture, and every neighbouring documents API path keeps the full
|
||||
* DENY posture.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function setNodeEnv(value: "development" | "production") {
|
||||
vi.stubEnv("NODE_ENV", value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setNodeEnv("production");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function makeRequest(pathname: string): NextRequest {
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
headers: { cookie: "healthlog_session=sess-1" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy.ts document serve-route framing carve-out", () => {
|
||||
it("allows same-origin framing on the /original serve path", () => {
|
||||
const res = proxy(makeRequest("/api/documents/inbound/doc123/original"));
|
||||
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).toContain("frame-ancestors 'self'");
|
||||
expect(csp).toContain("default-src 'none'");
|
||||
// No `sandbox` directive: Chromium downloads sandboxed PDFs instead of
|
||||
// rendering them, which would break the inline preview outright.
|
||||
expect(csp).not.toMatch(/sandbox/);
|
||||
// No script sources of any kind for a served document.
|
||||
expect(csp).not.toMatch(/script-src/);
|
||||
});
|
||||
|
||||
it("keeps the DENY posture on the documents list route", () => {
|
||||
const res = proxy(makeRequest("/api/documents/inbound"));
|
||||
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(res.headers.get("content-security-policy") ?? "").toContain(
|
||||
"frame-ancestors 'none'",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the DENY posture on the document detail route", () => {
|
||||
const res = proxy(makeRequest("/api/documents/inbound/doc123"));
|
||||
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
||||
});
|
||||
|
||||
it("does not match nested or suffixed lookalike paths", () => {
|
||||
for (const path of [
|
||||
"/api/documents/inbound/doc123/original/extra",
|
||||
"/api/documents/inbound/doc123/originalx",
|
||||
"/api/documents/inbound//original",
|
||||
]) {
|
||||
const res = proxy(makeRequest(path));
|
||||
expect(res.headers.get("x-frame-options"), path).toBe("DENY");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the DENY posture on app pages, including /documents itself", () => {
|
||||
const res = proxy(makeRequest("/documents"));
|
||||
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(res.headers.get("content-security-policy") ?? "").toContain(
|
||||
"frame-ancestors 'none'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,13 @@ export const GET = apiHandler(async () => {
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
// Document vault limits (per-file cap + per-user quota default). BigInt
|
||||
// column → Number for the JSON envelope (values are far below 2^53).
|
||||
documentMaxFileBytes: settings?.documentMaxFileBytes ?? 26_214_400,
|
||||
documentQuotaBytes:
|
||||
settings?.documentQuotaBytes !== undefined
|
||||
? Number(settings.documentQuotaBytes)
|
||||
: 1_073_741_824,
|
||||
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the
|
||||
// resolver"; surfacing the raw value lets the admin UI render
|
||||
// an empty picker placeholder until they opt in.
|
||||
@@ -176,6 +183,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
|
||||
}
|
||||
|
||||
// Document vault limits (schema already bounds both; the upload-time
|
||||
// resolver clamps the cap to the hard ceiling again as defence-in-depth).
|
||||
if (data.documentMaxFileBytes !== undefined) {
|
||||
updates.documentMaxFileBytes = data.documentMaxFileBytes;
|
||||
auditDetails.documentMaxFileBytes = data.documentMaxFileBytes;
|
||||
}
|
||||
if (data.documentQuotaBytes !== undefined) {
|
||||
updates.documentQuotaBytes = BigInt(data.documentQuotaBytes);
|
||||
auditDetails.documentQuotaBytes = data.documentQuotaBytes;
|
||||
}
|
||||
|
||||
// v1.4.25 W7 — server-default timezone for new signups.
|
||||
// Empty string clears the override (resolver falls back to
|
||||
// Europe/Berlin); a non-empty string must pass Intl validation
|
||||
@@ -240,6 +258,8 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
documentMaxFileBytes: settings.documentMaxFileBytes,
|
||||
documentQuotaBytes: Number(settings.documentQuotaBytes),
|
||||
defaultUserTimezone: settings.defaultUserTimezone,
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,16 @@ const updateUserSchema = z.object({
|
||||
// v1.23 — per-user "require a second factor" override. Effective requirement
|
||||
// is the OR of this and the instance-wide AppSettings.mfaRequired policy.
|
||||
mfaEnforced: z.boolean().optional(),
|
||||
// Document vault — per-user storage-quota override in bytes. Null clears
|
||||
// the override (the instance default applies again). Same bounds as the
|
||||
// instance-wide setting.
|
||||
documentQuotaBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(10_485_760)
|
||||
.max(1_099_511_627_776)
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(
|
||||
@@ -70,6 +80,12 @@ export const PUT = apiHandler(
|
||||
...(parsed.data.mfaEnforced !== undefined && {
|
||||
mfaEnforced: parsed.data.mfaEnforced,
|
||||
}),
|
||||
...(parsed.data.documentQuotaBytes !== undefined && {
|
||||
documentQuotaBytes:
|
||||
parsed.data.documentQuotaBytes === null
|
||||
? null
|
||||
: BigInt(parsed.data.documentQuotaBytes),
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -78,6 +94,7 @@ export const PUT = apiHandler(
|
||||
role: true,
|
||||
createdAt: true,
|
||||
mfaEnforced: true,
|
||||
documentQuotaBytes: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,6 +104,13 @@ export const PUT = apiHandler(
|
||||
details: { targetUserId: id, changes: parsed.data },
|
||||
});
|
||||
|
||||
return apiSuccess(updatedUser);
|
||||
return apiSuccess({
|
||||
...updatedUser,
|
||||
// BigInt → Number for the JSON envelope; null = no override.
|
||||
documentQuotaBytes:
|
||||
updatedUser.documentQuotaBytes === null
|
||||
? null
|
||||
: Number(updatedUser.documentQuotaBytes),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -67,6 +67,8 @@ describe("GET /api/admin/users", () => {
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created,
|
||||
mfaEnforced: true,
|
||||
documentQuotaBytes: BigInt(2_147_483_648),
|
||||
_count: { passkeys: 2 },
|
||||
},
|
||||
{
|
||||
@@ -75,6 +77,8 @@ describe("GET /api/admin/users", () => {
|
||||
email: null,
|
||||
role: "USER",
|
||||
createdAt: created,
|
||||
mfaEnforced: false,
|
||||
documentQuotaBytes: null,
|
||||
_count: { passkeys: 0 },
|
||||
},
|
||||
] as never);
|
||||
@@ -91,10 +95,15 @@ describe("GET /api/admin/users", () => {
|
||||
email: "alice@example.com",
|
||||
role: "ADMIN",
|
||||
createdAt: created.toISOString(),
|
||||
mfaEnforced: true,
|
||||
// BigInt in Prisma, plain number on the wire.
|
||||
documentQuotaBytes: 2_147_483_648,
|
||||
passkeyCount: 2,
|
||||
});
|
||||
expect(body.data[1].passkeyCount).toBe(0);
|
||||
expect(body.data[1].email).toBeNull();
|
||||
// No override → explicit null (the instance default applies).
|
||||
expect(body.data[1].documentQuotaBytes).toBeNull();
|
||||
});
|
||||
|
||||
it("queries Prisma with the safe select shape (no password / sensitive cols)", async () => {
|
||||
@@ -112,6 +121,7 @@ describe("GET /api/admin/users", () => {
|
||||
role: true,
|
||||
createdAt: true,
|
||||
mfaEnforced: true,
|
||||
documentQuotaBytes: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
|
||||
@@ -17,6 +17,7 @@ export const GET = apiHandler(async () => {
|
||||
role: true,
|
||||
createdAt: true,
|
||||
mfaEnforced: true,
|
||||
documentQuotaBytes: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -32,6 +33,11 @@ export const GET = apiHandler(async () => {
|
||||
// v1.23 — per-user "require a second factor" override. The effective
|
||||
// requirement is the OR of this and the instance-wide policy.
|
||||
mfaEnforced: u.mfaEnforced,
|
||||
// Document vault — per-user storage-quota override. Null = the
|
||||
// instance default applies. BigInt in Prisma, number on the wire
|
||||
// (max override 1 TiB, comfortably inside Number's safe range).
|
||||
documentQuotaBytes:
|
||||
u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes),
|
||||
passkeyCount: u._count.passkeys,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -104,6 +104,7 @@ describe("GET /api/documents/inbound/[id]/original", () => {
|
||||
id: "doc-1",
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
contentCodec: "base64v1",
|
||||
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
|
||||
} as never);
|
||||
|
||||
@@ -130,6 +131,7 @@ describe("GET /api/documents/inbound/[id]/original", () => {
|
||||
id: "doc-2",
|
||||
filename: null,
|
||||
mimeType: "image/png",
|
||||
contentCodec: "base64v1",
|
||||
contentEncrypted: encryptDocumentToBytes(Buffer.from("png bytes")),
|
||||
} as never);
|
||||
|
||||
@@ -147,6 +149,7 @@ describe("GET /api/documents/inbound/[id]/original", () => {
|
||||
id: "doc-4",
|
||||
filename: "Müller-Bericht.pdf",
|
||||
mimeType: "application/pdf",
|
||||
contentCodec: "base64v1",
|
||||
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
|
||||
} as never);
|
||||
|
||||
@@ -176,6 +179,7 @@ describe("GET /api/documents/inbound/[id]/original", () => {
|
||||
id: "doc-3",
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
contentCodec: "base64v1",
|
||||
contentEncrypted: encryptDocumentToBytes(PDF_BYTES),
|
||||
} as never);
|
||||
decryptMock.mockImplementation(() => {
|
||||
@@ -189,3 +193,68 @@ describe("GET /api/documents/inbound/[id]/original", () => {
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serving-posture header matrix (Class A inline vs Class B attachment)", () => {
|
||||
function seed(mimeType: string, filename: string) {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-m",
|
||||
filename,
|
||||
mimeType,
|
||||
contentCodec: "base64v1",
|
||||
contentEncrypted: encryptDocumentToBytes(Buffer.from("payload")),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["application/pdf", "scan.pdf"],
|
||||
["image/jpeg", "photo.jpg"],
|
||||
["image/png", "scan.png"],
|
||||
["image/webp", "scan.webp"],
|
||||
["image/gif", "anim.gif"],
|
||||
])("Class A %s → inline, true type, nosniff", async (mime, name) => {
|
||||
seed(mime, name);
|
||||
const res = await callGet(makeReq("doc-m"), ctx("doc-m"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe(mime);
|
||||
expect(res.headers.get("Content-Disposition")).toContain("inline");
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
// The response CSP is owned by the proxy carve-out (`default-src
|
||||
// 'none'; frame-ancestors 'self'`, deliberately no sandbox —
|
||||
// Chromium force-downloads sandboxed PDFs). A route-level value
|
||||
// would be overwritten there, so the route must not set one; see
|
||||
// src/__tests__/proxy-document-serve-framing.test.ts for the pin.
|
||||
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"letter.docx",
|
||||
],
|
||||
["application/msword", "letter.doc"],
|
||||
["text/plain", "befund.txt"],
|
||||
["text/csv", "werte.csv"],
|
||||
["application/rtf", "note.rtf"],
|
||||
["image/tiff", "scan.tif"],
|
||||
["image/heic", "photo.heic"],
|
||||
["application/xml", "export.xml"],
|
||||
["application/json", "export.json"],
|
||||
])(
|
||||
"Class B %s → attachment + octet-stream, never inline",
|
||||
async (mime, name) => {
|
||||
seed(mime, name);
|
||||
const res = await callGet(makeReq("doc-m"), ctx("doc-m"));
|
||||
expect(res.status).toBe(200);
|
||||
// The true stored type is deliberately NOT surfaced on the wire.
|
||||
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
expect(disposition.startsWith("attachment;")).toBe(true);
|
||||
expect(disposition).toContain(name);
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
// CSP is proxy-owned on every serve response; the route sets none.
|
||||
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,23 +1,38 @@
|
||||
/**
|
||||
* v1.25.1 (W-DOCS-IN) — view / download the original uploaded document.
|
||||
* Document vault: view / download the original uploaded document.
|
||||
*
|
||||
* The raw doctor report / discharge letter is stored encrypted at rest
|
||||
* (`InboundDocument.contentEncrypted`, AES-256-GCM). This route is the ONLY
|
||||
* path that decrypts and serves it, so the user can re-read the source the
|
||||
* extracted facts came from. Owner-scoped + module-gated; the bytes are PHI:
|
||||
* never logged, never served cross-user, fail-closed on a decrypt error.
|
||||
* The raw document is stored encrypted at rest
|
||||
* (`InboundDocument.contentEncrypted`, AES-256-GCM, codec per row). This
|
||||
* route is the ONLY path that decrypts and serves it. Owner-scoped +
|
||||
* module-gated; the bytes are PHI: never logged, never served cross-user,
|
||||
* fail-closed on a decrypt error.
|
||||
*
|
||||
* The response carries the stored `mimeType`. Uploads are constrained at the
|
||||
* store path to the inline-safe set (JPEG / PNG / WebP / PDF, magic-byte
|
||||
* sniffed), so the document is always served `Content-Disposition: inline` for
|
||||
* in-tab rendering. The filename is sanitised before it reaches the header.
|
||||
* Serving posture IS the security boundary (upload policy §N1): the serving
|
||||
* class derives from the stored MIME type via `servingClassFor` —
|
||||
*
|
||||
* - inline (Class A: PDF/JPEG/PNG/WebP/GIF): true Content-Type,
|
||||
* `Content-Disposition: inline`, `X-Content-Type-Options: nosniff`,
|
||||
* `Cache-Control: private, no-store`.
|
||||
* - attachment (Class B: Office/text/TIFF/HEIC/XML/JSON): ALWAYS
|
||||
* `Content-Disposition: attachment` with `Content-Type:
|
||||
* application/octet-stream` + `nosniff` — never inline, regardless of
|
||||
* the stored type, so a misclassified file cannot execute in-origin.
|
||||
*
|
||||
* The response CSP is owned by the proxy, not this route: `src/proxy.ts`
|
||||
* carves out exactly this path with `default-src 'none'; frame-ancestors
|
||||
* 'self'` (middleware headers win over route headers, so anything set here
|
||||
* would be dead weight). Deliberately no `sandbox` directive anywhere —
|
||||
* Chromium force-downloads PDFs framed in sandboxed documents instead of
|
||||
* rendering them, which would break the inline preview.
|
||||
* `src/__tests__/proxy-document-serve-framing.test.ts` pins that posture.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptDocumentFromBytes } from "@/lib/documents/store";
|
||||
import { decryptDocumentContent } from "@/lib/documents/store";
|
||||
import { servingClassFor } from "@/lib/documents/upload-policy";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -40,6 +55,22 @@ const MIME_EXTENSION: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
"image/tiff": "tif",
|
||||
"image/heic": "heic",
|
||||
"application/msword": "doc",
|
||||
"application/vnd.ms-excel": "xls",
|
||||
"application/vnd.ms-powerpoint": "ppt",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
"docx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
"pptx",
|
||||
"text/plain": "txt",
|
||||
"text/csv": "csv",
|
||||
"application/rtf": "rtf",
|
||||
"application/xml": "xml",
|
||||
"application/json": "json",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -109,6 +140,7 @@ export const GET = apiHandler(
|
||||
filename: true,
|
||||
mimeType: true,
|
||||
contentEncrypted: true,
|
||||
contentCodec: true,
|
||||
},
|
||||
});
|
||||
if (!document) {
|
||||
@@ -119,10 +151,14 @@ export const GET = apiHandler(
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = decryptDocumentFromBytes(document.contentEncrypted);
|
||||
bytes = decryptDocumentContent(
|
||||
document.contentEncrypted,
|
||||
document.contentCodec,
|
||||
);
|
||||
} catch {
|
||||
// Fail closed — never fall back to the raw ciphertext. The error reason
|
||||
// (bad / missing key id) is logged by the annotation, never the bytes.
|
||||
// (bad / missing key id, unknown codec) is logged by the annotation,
|
||||
// never the bytes.
|
||||
annotate({
|
||||
action: { name: "documents.inbound.original.decryptFailed" },
|
||||
meta: { documentId: document.id },
|
||||
@@ -132,16 +168,18 @@ export const GET = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
const servingClass = servingClassFor(document.mimeType);
|
||||
const downloadName = safeDownloadName(
|
||||
document.filename,
|
||||
document.id,
|
||||
document.mimeType,
|
||||
);
|
||||
// ASCII fallback for the bare `filename=` (replace any non-ASCII byte) plus
|
||||
// an RFC 5987 `filename*` that carries the real UTF-8 name to compliant
|
||||
// clients. The stored set is inline-safe by construction, so always inline.
|
||||
// ASCII fallback for the bare `filename=` (replace any non-ASCII byte)
|
||||
// plus an RFC 5987 `filename*` that carries the real UTF-8 name to
|
||||
// compliant clients.
|
||||
const asciiName = downloadName.replace(/[^ -~]/g, "_");
|
||||
const disposition = `inline; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`;
|
||||
const dispositionType = servingClass === "inline" ? "inline" : "attachment";
|
||||
const disposition = `${dispositionType}; filename="${asciiName}"; filename*=UTF-8''${encodeRfc5987(downloadName)}`;
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.inbound.original.get" },
|
||||
@@ -149,19 +187,32 @@ export const GET = apiHandler(
|
||||
documentId: document.id,
|
||||
mimeType: document.mimeType,
|
||||
bytes: bytes.byteLength,
|
||||
disposition: "inline",
|
||||
disposition: dispositionType,
|
||||
},
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
// Class B leaves the origin ONLY as an opaque download — the true
|
||||
// stored type is deliberately not surfaced on the wire.
|
||||
"Content-Type":
|
||||
servingClass === "inline"
|
||||
? document.mimeType
|
||||
: "application/octet-stream",
|
||||
"Content-Length": String(bytes.byteLength),
|
||||
"Content-Disposition": disposition,
|
||||
// The disposition/type pair is authoritative — never MIME-sniffed.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
// Private PHI — never cache in a shared / disk cache.
|
||||
"Cache-Control": "private, no-store",
|
||||
// No Content-Security-Policy here: the proxy's serve-route carve-out
|
||||
// owns it (`default-src 'none'; frame-ancestors 'self'`, no sandbox —
|
||||
// Chromium force-downloads sandboxed PDFs). A route-level CSP would be
|
||||
// silently overwritten by the middleware header pass.
|
||||
};
|
||||
|
||||
return new NextResponse(bytes as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": document.mimeType,
|
||||
"Content-Length": String(bytes.byteLength),
|
||||
"Content-Disposition": disposition,
|
||||
// Private PHI — never cache in a shared / disk cache.
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Document vault: restore a soft-deleted (tombstoned) document.
|
||||
*
|
||||
* Delete is undo-able: the DELETE route tombstones (`deletedAt`), the UI
|
||||
* shows an "Undo" toast that calls this route, and the daily purge job
|
||||
* physically removes tombstones older than the 30-day grace window. This
|
||||
* route clears the tombstone; it answers 409 when the row is gone (purged —
|
||||
* indistinguishable from never-existed by construction, and owner-scoped
|
||||
* either way) or when restoring would collide with a live duplicate of the
|
||||
* same bytes (the `inbound_documents_user_sha_live` partial unique index).
|
||||
*/
|
||||
import { NextRequest } 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 { loadConditionLinks } from "@/lib/documents/links";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
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 type { InboundDocumentStatus } from "@/generated/prisma/client";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Shares the write posture of the other owner-scoped document mutations. */
|
||||
const WRITE_LIMIT_PER_HOUR = 240;
|
||||
const WRITE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Post-restore lifecycle status, derived from the staged facts: no facts →
|
||||
* STORED, any PENDING → EXTRACTED, otherwise every fact was acted on →
|
||||
* CONFIRMED. (DELETE stamped DISCARDED; the prior status is not persisted.)
|
||||
*/
|
||||
function restoredStatus(
|
||||
facts: Array<{ status: string }>,
|
||||
): InboundDocumentStatus {
|
||||
if (facts.length === 0) return "STORED";
|
||||
return facts.some((f) => f.status === "PENDING") ? "EXTRACTED" : "CONFIRMED";
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-write:${user.id}`,
|
||||
WRITE_LIMIT_PER_HOUR,
|
||||
WRITE_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.inboundDocument.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
// Purged (past the 30-day grace) or never existed — the tombstone is
|
||||
// gone either way, so the undo window is over: 409, not 404.
|
||||
return apiError("This document can no longer be restored.", 409, {
|
||||
errorCode: "documents.inbound.restoreGone",
|
||||
reason: "purged",
|
||||
});
|
||||
}
|
||||
|
||||
if (row.deletedAt === null) {
|
||||
// Already live — restoring twice is a no-op, not an error (the undo
|
||||
// toast may fire after a concurrent restore).
|
||||
const links = await loadConditionLinks(user.id, [row.id]);
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(row, row.facts, links.get(row.id) ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
// A live duplicate of the same bytes blocks the restore (the partial
|
||||
// unique index would reject it; check first for a clean 409).
|
||||
if (row.contentSha256) {
|
||||
const conflict = await prisma.inboundDocument.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
contentSha256: row.contentSha256,
|
||||
deletedAt: null,
|
||||
id: { not: row.id },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (conflict) {
|
||||
return apiError("An identical document is already stored.", 409, {
|
||||
errorCode: "documents.inbound.restoreDuplicate",
|
||||
reason: "duplicateExists",
|
||||
existingId: conflict.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id: row.id },
|
||||
data: { deletedAt: null, status: restoredStatus(row.facts) },
|
||||
});
|
||||
} catch (err) {
|
||||
if (isP2002(err)) {
|
||||
// Raced a concurrent identical upload into the partial unique index.
|
||||
return apiError("An identical document is already stored.", 409, {
|
||||
errorCode: "documents.inbound.restoreDuplicate",
|
||||
reason: "duplicateExists",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const restored = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { id: row.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
const links = await loadConditionLinks(user.id, [restored.id]);
|
||||
|
||||
await auditLog("documents.inbound.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId: restored.id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.vault.restore" },
|
||||
meta: { documentId: restored.id },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(
|
||||
restored,
|
||||
restored.facts,
|
||||
links.get(restored.id) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Document vault: bulk actions over up to 100 owner-scoped documents.
|
||||
*
|
||||
* `{ ids[], action, kind?, episodeId? }` → a per-id result array. Actions:
|
||||
* `setKind` (recategorise), `linkEpisode` / `unlinkEpisode` (condition
|
||||
* links), `delete` (tombstone, undo-able), `restore` (clear tombstone). A
|
||||
* partial failure never aborts the batch — each id reports `ok` or a short
|
||||
* machine reason. Every id is narrowed to the caller; a foreign id reads as
|
||||
* "notFound", exactly like the single-document routes.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
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 {
|
||||
documentBulkSchema,
|
||||
type DocumentBulkResultDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* One bulk call covers up to 100 rows, so the bucket is tighter than the
|
||||
* single-row write bucket (240/h) while allowing the same overall throughput.
|
||||
*/
|
||||
const BULK_LIMIT_PER_HOUR = 60;
|
||||
const BULK_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-bulk:${user.id}`,
|
||||
BULK_LIMIT_PER_HOUR,
|
||||
BULK_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests. Try again later.", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
});
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = documentBulkSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const { ids, action, kind, episodeId } = parsed.data;
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
|
||||
// Episode actions: the target episode must be a LIVE episode of the caller.
|
||||
if (action === "linkEpisode" || action === "unlinkEpisode") {
|
||||
const episode = await prisma.illnessEpisode.findFirst({
|
||||
where: { id: episodeId!, userId: user.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!episode) {
|
||||
return apiError("Episode not found", 404, {
|
||||
errorCode: "documents.inbound.episodeNotFound",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// One owner-scoped sweep resolves every id's current state; per-id work
|
||||
// then never touches a row the caller does not own.
|
||||
const owned = await prisma.inboundDocument.findMany({
|
||||
where: { id: { in: uniqueIds }, userId: user.id },
|
||||
select: { id: true, deletedAt: true, contentSha256: true },
|
||||
});
|
||||
const byId = new Map(owned.map((d) => [d.id, d]));
|
||||
|
||||
const results: DocumentBulkResultDto[] = [];
|
||||
for (const id of uniqueIds) {
|
||||
const row = byId.get(id);
|
||||
if (!row) {
|
||||
results.push({ id, ok: false, error: "notFound" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
switch (action) {
|
||||
case "setKind": {
|
||||
if (row.deletedAt !== null) {
|
||||
results.push({ id, ok: false, error: "notFound" });
|
||||
continue;
|
||||
}
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id },
|
||||
data: { kind: kind! },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "linkEpisode": {
|
||||
if (row.deletedAt !== null) {
|
||||
results.push({ id, ok: false, error: "notFound" });
|
||||
continue;
|
||||
}
|
||||
await prisma.documentConditionLink.createMany({
|
||||
data: [{ documentId: id, episodeId: episodeId!, userId: user.id }],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "unlinkEpisode": {
|
||||
await prisma.documentConditionLink.deleteMany({
|
||||
where: { documentId: id, episodeId: episodeId!, userId: user.id },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
if (row.deletedAt !== null) {
|
||||
// Already tombstoned — deleting twice is a no-op success.
|
||||
break;
|
||||
}
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), status: "DISCARDED" },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "restore": {
|
||||
if (row.deletedAt === null) {
|
||||
// Already live — restoring twice is a no-op success.
|
||||
break;
|
||||
}
|
||||
if (row.contentSha256) {
|
||||
const conflict = await prisma.inboundDocument.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
contentSha256: row.contentSha256,
|
||||
deletedAt: null,
|
||||
id: { not: id },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (conflict) {
|
||||
results.push({ id, ok: false, error: "conflict" });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const facts = await prisma.extractedFact.findMany({
|
||||
where: { documentId: id, userId: user.id },
|
||||
select: { status: true },
|
||||
});
|
||||
const status =
|
||||
facts.length === 0
|
||||
? "STORED"
|
||||
: facts.some((f) => f.status === "PENDING")
|
||||
? "EXTRACTED"
|
||||
: "CONFIRMED";
|
||||
await prisma.inboundDocument.update({
|
||||
where: { id },
|
||||
data: { deletedAt: null, status },
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
results.push({ id, ok: true, error: null });
|
||||
} catch (err) {
|
||||
if (isP2002(err)) {
|
||||
results.push({ id, ok: false, error: "conflict" });
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const okCount = results.filter((r) => r.ok).length;
|
||||
|
||||
await auditLog("documents.inbound.bulk", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { action, requested: uniqueIds.length, succeeded: okCount },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.vault.bulk" },
|
||||
meta: { bulkAction: action, requested: uniqueIds.length, ok: okCount },
|
||||
});
|
||||
|
||||
return apiSuccess({ results });
|
||||
});
|
||||
@@ -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,167 @@ 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) => {
|
||||
// Serialise the quota gate per user: without this, N concurrent
|
||||
// uploads all read the same SUM before any of them commits and the
|
||||
// quota can be overshot by up to N × cap in one burst. The advisory
|
||||
// lock is transaction-scoped (released on commit/rollback) and keyed
|
||||
// on the user id, so uploads by different users never queue on each
|
||||
// other.
|
||||
// (`pg_advisory_xact_lock` returns void, which the client cannot
|
||||
// deserialize as a column — selecting FROM it yields a plain int row.)
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended('documents-quota:' || ${user.id}, 0))
|
||||
`;
|
||||
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 +381,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 +439,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 +455,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 +479,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 +500,7 @@ export const GET = apiHandler(async (request: Request) => {
|
||||
serialiseDocument(
|
||||
doc,
|
||||
factCounts.get(doc.id) ?? { factCount: 0, pendingCount: 0 },
|
||||
linkMap.get(doc.id) ?? [],
|
||||
),
|
||||
),
|
||||
nextCursor,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Document vault: storage usage + effective limits for the calling user.
|
||||
*
|
||||
* The UI reads this before offering an upload (quota bar above 80 % usage,
|
||||
* client-side pre-flight against `maxFileBytes`, picker `accept` list from
|
||||
* `acceptedExtensions`). `usedBytes` counts every non-purged row — tombstones
|
||||
* still hold TOAST bytes until the purge job reclaims them, so "deleted"
|
||||
* bytes are never invisible weight and an undo never changes usage.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
DOCUMENT_ACCEPTED_EXTENSIONS,
|
||||
resolveDocumentLimits,
|
||||
} from "@/lib/documents/upload-policy";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const [limits, rows, linkRows] = await Promise.all([
|
||||
resolveDocumentLimits(user.id),
|
||||
prisma.$queryRaw<Array<{ used: bigint }>>`
|
||||
SELECT COALESCE(SUM(byte_size), 0)::bigint AS used
|
||||
FROM inbound_documents
|
||||
WHERE user_id = ${user.id}
|
||||
`,
|
||||
// Episodes that carry at least one LIVE document link — the filter
|
||||
// bar's condition chips. Sourced here (not from the loaded corpus) so
|
||||
// a chip exists even when its documents sit pages deep in the
|
||||
// timeline; one indexed grouped query, no blobs.
|
||||
prisma.documentConditionLink.findMany({
|
||||
where: { userId: user.id, document: { deletedAt: null } },
|
||||
select: { episodeId: true, episode: { select: { label: true } } },
|
||||
distinct: ["episodeId"],
|
||||
orderBy: { episodeId: "asc" },
|
||||
}),
|
||||
]);
|
||||
const usedBytes = Number(rows[0]?.used ?? 0);
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.vault.usage" },
|
||||
meta: { usedBytes, quotaBytes: limits.quotaBytes },
|
||||
});
|
||||
|
||||
const payload: DocumentUsageDto = {
|
||||
usedBytes,
|
||||
quotaBytes: limits.quotaBytes,
|
||||
maxFileBytes: limits.maxFileBytes,
|
||||
acceptedExtensions: [...DOCUMENT_ACCEPTED_EXTENSIONS],
|
||||
linkedEpisodes: linkRows.map((row) => ({
|
||||
episodeId: row.episodeId,
|
||||
name: row.episode.label,
|
||||
})),
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
+12
-39
@@ -1,45 +1,18 @@
|
||||
"use client";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { InboundDocumentsView } from "@/components/documents/inbound-documents-view";
|
||||
import { DocumentsView } from "@/components/documents/documents-view";
|
||||
|
||||
/**
|
||||
* v1.25.0 (W-DOCS-IN) — inbound clinical documents entry.
|
||||
*
|
||||
* Born-gated on the resolved `modules.inboundDocuments` flag from
|
||||
* `GET /api/auth/me` (opt-in / default-off — the per-user opt-in AND the
|
||||
* operator server-wide kill-switch). An unauthenticated visitor is bounced to
|
||||
* login; an authenticated account without the module opted in is bounced home
|
||||
* (the nav entry is already hidden for them, so this only catches a direct URL
|
||||
* hit). Every `/api/documents/inbound/*` route also enforces the gate
|
||||
* server-side, so this is a UX redirect, not the security boundary.
|
||||
* The Dokumente vault — server shell only. The client surface owns the
|
||||
* module gate, the URL-backed filter state (which needs `useSearchParams`,
|
||||
* hence the Suspense boundary), the virtualized timeline, and the upload
|
||||
* queue. The route rides the AuthShell's wide-container flag: the shell
|
||||
* owns the page frame, this file never re-implements paddings.
|
||||
*/
|
||||
export default function DocumentsPage() {
|
||||
const { user, isLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const enabled = user?.modules?.inboundDocuments === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (isLoading || !isAuthenticated || !enabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <InboundDocumentsView />;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DocumentsView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+24
-2
@@ -4,7 +4,15 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Pencil, Plus, ScanLine, Wrench } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
ScanLine,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import { LabForm } from "@/components/labs/lab-form";
|
||||
import { LabList } from "@/components/labs/lab-list";
|
||||
@@ -27,7 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function LabsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -128,6 +136,20 @@ export default function LabsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Entry point into the document vault, pre-filtered to lab-result
|
||||
documents (the original report PDFs behind these values). Only
|
||||
rendered when the documents module is enabled for this account. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href="/documents?kind=LAB_RESULT"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-4" aria-hidden />
|
||||
{t("labs.documentsLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
|
||||
@@ -28,6 +28,9 @@ export interface AdminUser {
|
||||
createdAt: string;
|
||||
// v1.23 — per-user "require a second factor" override.
|
||||
mfaEnforced: boolean;
|
||||
// Document vault — per-user storage-quota override in bytes; null means
|
||||
// the instance-wide default applies.
|
||||
documentQuotaBytes: number | null;
|
||||
passkeyCount: number;
|
||||
}
|
||||
|
||||
@@ -88,6 +91,11 @@ export interface AdminSettings {
|
||||
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
|
||||
defaultUserTimezone: string | null;
|
||||
moodLogGlobal?: boolean;
|
||||
// Document vault — the two admin-tunable limits (bytes on the wire; the
|
||||
// UI edits them as MB / GB). The per-file cap clamps to the hard 100 MiB
|
||||
// ceiling server-side.
|
||||
documentMaxFileBytes: number;
|
||||
documentQuotaBytes: number;
|
||||
}
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared";
|
||||
import { listSupportedTimezones } from "@/lib/tz/format";
|
||||
|
||||
// Document-vault limits: bytes on the wire, MB / GB in the UI. The bounds
|
||||
// mirror the server-side schema (per-file cap hard-clamped to 100 MiB —
|
||||
// single-shot GCM + bounded in-memory reads are load-bearing past that).
|
||||
const MIB = 1_048_576;
|
||||
const GIB = 1_073_741_824;
|
||||
const MAX_FILE_MB_MIN = 1;
|
||||
const MAX_FILE_MB_MAX = 100;
|
||||
const QUOTA_GB_MIN = 0.1;
|
||||
const QUOTA_GB_MAX = 1024;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
|
||||
// retired; the shared `<NativeSelect>` primitive owns the visual
|
||||
// contract now. The admin copy previously diverged on the
|
||||
@@ -26,6 +41,43 @@ export function GeneralSettingsSection() {
|
||||
|
||||
const currentDefaultTz = settings?.defaultUserTimezone ?? "";
|
||||
|
||||
// Draft-while-editing pattern for the two limit inputs: null = not
|
||||
// editing (show the stored value), string = the in-progress draft.
|
||||
// Commit clamps to the schema bounds and converts to bytes.
|
||||
const [maxFileDraft, setMaxFileDraft] = useState<string | null>(null);
|
||||
const [quotaDraft, setQuotaDraft] = useState<string | null>(null);
|
||||
|
||||
const storedMaxFileMb =
|
||||
settings?.documentMaxFileBytes != null
|
||||
? String(Math.round(settings.documentMaxFileBytes / MIB))
|
||||
: "";
|
||||
const storedQuotaGb =
|
||||
settings?.documentQuotaBytes != null
|
||||
? String(Math.round((settings.documentQuotaBytes / GIB) * 10) / 10)
|
||||
: "";
|
||||
|
||||
const commitMaxFile = () => {
|
||||
if (maxFileDraft === null) return;
|
||||
const parsed = Number(maxFileDraft);
|
||||
setMaxFileDraft(null);
|
||||
if (!Number.isFinite(parsed) || maxFileDraft.trim() === "") return;
|
||||
const mb = clamp(Math.round(parsed), MAX_FILE_MB_MIN, MAX_FILE_MB_MAX);
|
||||
const bytes = mb * MIB;
|
||||
if (bytes === settings?.documentMaxFileBytes) return;
|
||||
updateSettings.mutate({ documentMaxFileBytes: bytes });
|
||||
};
|
||||
|
||||
const commitQuota = () => {
|
||||
if (quotaDraft === null) return;
|
||||
const parsed = Number(quotaDraft);
|
||||
setQuotaDraft(null);
|
||||
if (!Number.isFinite(parsed) || quotaDraft.trim() === "") return;
|
||||
const gb = clamp(parsed, QUOTA_GB_MIN, QUOTA_GB_MAX);
|
||||
const bytes = Math.round(gb * GIB);
|
||||
if (bytes === settings?.documentQuotaBytes) return;
|
||||
updateSettings.mutate({ documentQuotaBytes: bytes });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
@@ -117,6 +169,65 @@ export function GeneralSettingsSection() {
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
{/* Document vault — the two admin-tunable limits (per-file cap +
|
||||
per-user storage quota). Everything else about the vault is
|
||||
committed behaviour; these are deliberately the only knobs. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t("admin.documentMaxFile")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.documentMaxFileDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={MAX_FILE_MB_MIN}
|
||||
max={MAX_FILE_MB_MAX}
|
||||
step={1}
|
||||
className="w-24 text-right tabular-nums"
|
||||
aria-label={t("admin.documentMaxFile")}
|
||||
value={maxFileDraft ?? storedMaxFileMb}
|
||||
onChange={(e) => setMaxFileDraft(e.target.value)}
|
||||
onBlur={commitMaxFile}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitMaxFile();
|
||||
}}
|
||||
disabled={updateSettings.isPending || settings == null}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t("admin.documentQuota")}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.documentQuotaDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={QUOTA_GB_MIN}
|
||||
max={QUOTA_GB_MAX}
|
||||
step={0.1}
|
||||
className="w-24 text-right tabular-nums"
|
||||
aria-label={t("admin.documentQuota")}
|
||||
value={quotaDraft ?? storedQuotaGb}
|
||||
onChange={(e) => setQuotaDraft(e.target.value)}
|
||||
onBlur={commitQuota}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitQuota();
|
||||
}}
|
||||
disabled={updateSettings.isPending || settings == null}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">GB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,9 @@ export function UserManagementSection() {
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
|
||||
const [editUsername, setEditUsername] = useState("");
|
||||
const [editEmail, setEditEmail] = useState("");
|
||||
// Document vault — per-user storage-quota override, edited in GB.
|
||||
// Empty string = no override (the instance default applies).
|
||||
const [editQuotaGb, setEditQuotaGb] = useState("");
|
||||
const [resetUser, setResetUser] = useState<AdminUser | null>(null);
|
||||
const [resetPassword, setResetPassword] = useState("");
|
||||
const [resetMsg, setResetMsg] = useState<string | null>(null);
|
||||
@@ -142,6 +145,11 @@ export function UserManagementSection() {
|
||||
setEditingUser(u);
|
||||
setEditUsername(u.username);
|
||||
setEditEmail(u.email ?? "");
|
||||
setEditQuotaGb(
|
||||
u.documentQuotaBytes === null
|
||||
? ""
|
||||
: String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
|
||||
);
|
||||
}
|
||||
|
||||
function startReset(u: AdminUser) {
|
||||
@@ -448,11 +456,22 @@ export function UserManagementSection() {
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (updateUser.isPending) return;
|
||||
const quotaTrimmed = editQuotaGb.trim();
|
||||
const quotaParsed = Number(quotaTrimmed);
|
||||
updateUser.mutate({
|
||||
id: editingUser.id,
|
||||
data: {
|
||||
username: editUsername,
|
||||
email: editEmail || null,
|
||||
// Empty clears the override; a value clamps to the same
|
||||
// bounds the server schema enforces (0.1–1024 GB).
|
||||
documentQuotaBytes:
|
||||
quotaTrimmed === "" || !Number.isFinite(quotaParsed)
|
||||
? null
|
||||
: Math.round(
|
||||
Math.min(1024, Math.max(0.1, quotaParsed)) *
|
||||
1_073_741_824,
|
||||
),
|
||||
},
|
||||
});
|
||||
}}
|
||||
@@ -481,6 +500,32 @@ export function UserManagementSection() {
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="edit-document-quota">
|
||||
{t("admin.userDocumentQuota")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="edit-document-quota"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0.1}
|
||||
max={1024}
|
||||
step={0.1}
|
||||
className="w-28 text-right tabular-nums"
|
||||
value={editQuotaGb}
|
||||
onChange={(e) => setEditQuotaGb(e.target.value)}
|
||||
placeholder={t("admin.userDocumentQuotaDefault")}
|
||||
autoComplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">GB</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.userDocumentQuotaHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* The floating bulk bar, pinned via single-pass static renders: a labelled
|
||||
* toolbar carrying the selected count and the four bulk verbs. The
|
||||
* link-condition menu only renders when the account actually has episodes
|
||||
* to link — no dead affordance on an episode-free account.
|
||||
*/
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentBulkBar } from "../document-bulk-bar";
|
||||
|
||||
function render(episodes: { id: string; label: string }[]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentBulkBar
|
||||
selectedCount={3}
|
||||
episodes={episodes}
|
||||
busy={false}
|
||||
onSetKind={() => {}}
|
||||
onLinkEpisode={() => {}}
|
||||
onDelete={() => {}}
|
||||
onClear={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DocumentBulkBar>", () => {
|
||||
it("renders a labelled toolbar with count and the bulk verbs", () => {
|
||||
const html = render([{ id: "ep1", label: "Knee" }]);
|
||||
expect(html).toContain('data-slot="document-bulk-bar"');
|
||||
expect(html).toContain('role="toolbar"');
|
||||
expect(html).toContain("3 selected");
|
||||
expect(html).toContain("Change type");
|
||||
expect(html).toContain("Link condition");
|
||||
expect(html).toContain("Delete");
|
||||
expect(html).toContain("Clear selection");
|
||||
});
|
||||
|
||||
it("omits the link-condition verb when the account has no episodes", () => {
|
||||
const html = render([]);
|
||||
expect(html).not.toContain("Link condition");
|
||||
expect(html).toContain("Change type");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentCard, UploadStateCard } from "../document-card";
|
||||
import type { UploadQueueItem } from "../use-document-upload";
|
||||
|
||||
/**
|
||||
* The vault card's render contract: title falls back filename → "untitled",
|
||||
* the meta line stays muted, the attachment-class badge appears ONLY for
|
||||
* download-only formats, condition links paint as pills, and the two
|
||||
* transient upload cards (in-flight ring / translated failure) render from
|
||||
* the same footprint.
|
||||
*/
|
||||
|
||||
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
return {
|
||||
id: "doc-1",
|
||||
kind: "IMAGING",
|
||||
title: "MRT Knie",
|
||||
filename: "mrt-knie.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 2_500_000,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "2025-10-04",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
|
||||
servingClass: "inline",
|
||||
createdAt: "2025-10-05T08:00:00.000Z",
|
||||
updatedAt: "2025-10-05T08:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("<DocumentCard>", () => {
|
||||
it("renders title, condition pill, and NO attachment badge for inline docs", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc()}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("MRT Knie");
|
||||
expect(html).toContain("Knie");
|
||||
expect(html).toContain('data-document-id="doc-1"');
|
||||
expect(html).not.toContain("Download only");
|
||||
});
|
||||
|
||||
it("shows the download-only badge for attachment-class documents", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({
|
||||
servingClass: "attachment",
|
||||
mimeType: "application/octet-stream",
|
||||
filename: "befund.docx",
|
||||
title: null,
|
||||
})}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Download only");
|
||||
// Title falls back to the filename.
|
||||
expect(html).toContain("befund.docx");
|
||||
});
|
||||
|
||||
it("falls back to the untitled label and rings when highlighted", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({ title: null, filename: null })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Untitled document");
|
||||
expect(html).toContain("ring-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<UploadStateCard>", () => {
|
||||
const base: UploadQueueItem = {
|
||||
localId: "u1",
|
||||
fileName: "scan.jpg",
|
||||
byteSize: 4_000_000,
|
||||
status: "uploading",
|
||||
progress: 0.4,
|
||||
};
|
||||
|
||||
it("paints the progress ring while uploading", () => {
|
||||
const html = render(<UploadStateCard item={base} onDismiss={noop} />);
|
||||
expect(html).toContain('role="progressbar"');
|
||||
expect(html).toContain('aria-valuenow="40"');
|
||||
expect(html).toContain("scan.jpg");
|
||||
});
|
||||
|
||||
it("renders the translated over-limit reason with the configured cap", () => {
|
||||
const html = render(
|
||||
<UploadStateCard
|
||||
item={{
|
||||
...base,
|
||||
status: "error",
|
||||
failure: {
|
||||
ok: false,
|
||||
reason: "fileTooLarge",
|
||||
maxFileBytes: 26_214_400,
|
||||
},
|
||||
}}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("25 MB");
|
||||
expect(html).not.toContain('role="progressbar"');
|
||||
});
|
||||
|
||||
it("renders the quota reason with used/quota figures", () => {
|
||||
const html = render(
|
||||
<UploadStateCard
|
||||
item={{
|
||||
...base,
|
||||
status: "error",
|
||||
failure: {
|
||||
ok: false,
|
||||
reason: "quotaExceeded",
|
||||
quotaBytes: 1_073_741_824,
|
||||
usedBytes: 1_020_054_732,
|
||||
},
|
||||
}}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Storage is full");
|
||||
expect(html).toContain("1 GB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* Static-render pins for the timeline's accessibility contract: the
|
||||
* virtualized container is a labelled list, and the transient upload queue
|
||||
* is a polite live region. (The windowed rows themselves only mount
|
||||
* client-side once the virtualizer measures the scroll container — the
|
||||
* keyboard path is covered by the e2e battery.)
|
||||
*/
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DocumentTimeline } from "../document-timeline";
|
||||
import type { UploadQueueItem } from "../use-document-upload";
|
||||
|
||||
function render(uploadItems: UploadQueueItem[]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentTimeline
|
||||
documents={[]}
|
||||
uploadItems={uploadItems}
|
||||
onDismissUpload={() => {}}
|
||||
hasNextPage={false}
|
||||
isFetchingNextPage={false}
|
||||
onLoadMore={() => {}}
|
||||
selectedIds={new Set()}
|
||||
onToggleSelected={() => {}}
|
||||
onOpen={() => {}}
|
||||
highlightId={null}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DocumentTimeline> semantics", () => {
|
||||
it("renders the virtualized container as a labelled list", () => {
|
||||
const html = render([]);
|
||||
expect(html).toContain('role="list"');
|
||||
expect(html).toContain('aria-label="Documents timeline"');
|
||||
});
|
||||
|
||||
it("announces the upload queue through a polite live region", () => {
|
||||
const html = render([
|
||||
{
|
||||
localId: "l1",
|
||||
fileName: "scan.pdf",
|
||||
byteSize: 1024,
|
||||
status: "uploading",
|
||||
progress: 0.4,
|
||||
},
|
||||
]);
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain("scan.pdf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The vault page's designed states, pinned via single-pass static renders:
|
||||
*
|
||||
* - first load paints the skeleton grid (`documents-loading`) — a shape
|
||||
* that matches the loaded card grid, never a bare spinner;
|
||||
* - an empty unfiltered corpus paints the TEACHING empty state (what
|
||||
* belongs here + an upload affordance), not the error copy;
|
||||
* - an empty FILTERED view paints the no-matches state with a
|
||||
* clear-filters affordance — a distinct state, per the standards a
|
||||
* failed query may never fall through to either.
|
||||
*/
|
||||
|
||||
let mockSearch = "";
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
|
||||
usePathname: () => "/documents",
|
||||
useSearchParams: () => new URLSearchParams(mockSearch),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: { inboundDocuments: true } },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
// Never resolves within the synchronous SSR render → queries whose
|
||||
// cache is not seeded stay pending.
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPatch: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { DocumentsView } from "../documents-view";
|
||||
|
||||
function render(search: string, seedList?: { kinds?: never } | object) {
|
||||
mockSearch = search;
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
if (seedList !== undefined) {
|
||||
queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), {
|
||||
pages: [{ documents: [], nextCursor: null }],
|
||||
pageParams: [null],
|
||||
});
|
||||
}
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentsView />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DocumentsView> states", () => {
|
||||
it("first load: skeleton grid matching the card layout, no spinner", () => {
|
||||
const html = render("");
|
||||
expect(html).toContain('data-slot="documents-loading"');
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
expect(html).toContain('data-slot="document-filter-bar"');
|
||||
expect(html).toContain('data-slot="document-upload-zone"');
|
||||
// A pending list must never read as an empty vault.
|
||||
expect(html).not.toContain("Your document vault is empty");
|
||||
});
|
||||
|
||||
it("empty unfiltered corpus: the teaching empty state with an upload CTA", () => {
|
||||
const html = render("", {});
|
||||
expect(html).toContain('data-slot="empty-state"');
|
||||
expect(html).toContain("Your document vault is empty");
|
||||
expect(html).toContain("Upload a first document");
|
||||
expect(html).not.toContain("No matching documents");
|
||||
});
|
||||
|
||||
it("empty filtered view: the distinct no-matches state with clear-filters", () => {
|
||||
const html = render("kind=IMAGING", { kinds: ["IMAGING"] });
|
||||
expect(html).toContain("No matching documents");
|
||||
expect(html).toContain("Clear filters");
|
||||
expect(html).not.toContain("Your document vault is empty");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* The illness page's "Dokumente" section, pinned via static renders:
|
||||
* module-gated (renders NOTHING when the account has not opted in), a
|
||||
* quiet one-line affordance on an episode without documents (never a
|
||||
* teaching empty state), and compact deep-linking rows when links exist.
|
||||
*/
|
||||
|
||||
let moduleEnabled = true;
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", modules: { inboundDocuments: moduleEnabled } },
|
||||
isLoading: false,
|
||||
isAuthenticated: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { EpisodeDocumentsCard } from "../episode-documents-card";
|
||||
|
||||
function doc(id: string, title: string): InboundDocumentDto {
|
||||
return {
|
||||
id,
|
||||
kind: "IMAGING",
|
||||
title,
|
||||
filename: `${id}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 2048,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "2025-10-04",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [{ episodeId: "ep1", name: "Knee" }],
|
||||
servingClass: "inline",
|
||||
createdAt: "2025-10-05T08:00:00.000Z",
|
||||
updatedAt: "2025-10-05T08:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function render(seed?: InboundDocumentDto[]) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
if (seed !== undefined) {
|
||||
queryClient.setQueryData(queryKeys.inboundDocumentEpisodePreview("ep1"), {
|
||||
documents: seed,
|
||||
nextCursor: null,
|
||||
});
|
||||
}
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<EpisodeDocumentsCard episodeId="ep1" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<EpisodeDocumentsCard>", () => {
|
||||
it("renders nothing when the documents module is off", () => {
|
||||
moduleEnabled = false;
|
||||
expect(render([])).toBe("");
|
||||
moduleEnabled = true;
|
||||
});
|
||||
|
||||
it("shows a quiet one-line affordance on an episode without documents", () => {
|
||||
const html = render([]);
|
||||
expect(html).toContain('data-slot="episode-documents-card"');
|
||||
expect(html).toContain("No documents linked to this condition yet.");
|
||||
expect(html).toContain("Link");
|
||||
expect(html).toContain("Upload");
|
||||
// Not the vault's teaching empty state.
|
||||
expect(html).not.toContain('data-slot="empty-state"');
|
||||
});
|
||||
|
||||
it("lists linked documents as rows deep-linking into the vault", () => {
|
||||
const html = render([doc("d1", "MRT Knie"), doc("d2", "Röntgen")]);
|
||||
expect(html).toContain("MRT Knie");
|
||||
expect(html).toContain("Röntgen");
|
||||
expect(html).toContain("/documents?episode=ep1&doc=d1");
|
||||
// The pre-linked upload deep link carries the episode filter.
|
||||
expect(html).toContain('href="/documents?episode=ep1"');
|
||||
});
|
||||
});
|
||||
@@ -1,373 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
ExtractedFactDto,
|
||||
InboundDocumentDetailDto,
|
||||
InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* v1.25 — the Documents library.
|
||||
*
|
||||
* The harness is SSR-only (node env, no jsdom): we seed the TanStack cache and
|
||||
* assert the static markup. Interaction-driven paths (the exact request params,
|
||||
* the provider-unsupported classification) are covered by the pure-helper unit
|
||||
* tests in `library-utils.test.ts`; here we assert the rendered library, the
|
||||
* detail/review surface, the edit form, and the inline provider note.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
|
||||
"@/lib/api/api-fetch",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiFetch: () => new Promise(() => {}),
|
||||
apiPost: vi.fn(),
|
||||
apiPatch: vi.fn(),
|
||||
apiDelete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
AlreadyConfirmedNote,
|
||||
DocumentDetail,
|
||||
DocumentMetaEditForm,
|
||||
InboundDocumentsView,
|
||||
ProviderUnsupportedNote,
|
||||
} from "../inbound-documents-view";
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
over: Partial<InboundDocumentDto> = {},
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id,
|
||||
kind: "LAB_RESULT",
|
||||
title: null,
|
||||
filename: `${id}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1000,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "2026-06-20",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
createdAt: "2026-06-20T08:00:00.000Z",
|
||||
updatedAt: "2026-06-20T08:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
seed?.(queryClient);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<InboundDocumentsView> library", () => {
|
||||
it("renders the grouped list with titles, badges, and load-more", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{
|
||||
pages: [
|
||||
{
|
||||
documents: [
|
||||
doc("d1", {
|
||||
title: "Cardiology report",
|
||||
documentDate: "2026-06-20",
|
||||
pendingCount: 2,
|
||||
factCount: 3,
|
||||
}),
|
||||
doc("d2", {
|
||||
title: "Blood panel",
|
||||
documentDate: "2026-06-20",
|
||||
}),
|
||||
],
|
||||
nextCursor: "cursor-next",
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Library + toolbar shells render.
|
||||
expect(html).toContain('data-slot="documents-library"');
|
||||
expect(html).toContain('data-slot="documents-toolbar"');
|
||||
// Date group header + rows.
|
||||
expect(html).toContain('data-slot="documents-date-group"');
|
||||
expect(html).toContain('data-slot="documents-row"');
|
||||
// Titles surface (title preferred over filename).
|
||||
expect(html).toContain("Cardiology report");
|
||||
expect(html).toContain("Blood panel");
|
||||
// Kind + status badges from the EN bundle.
|
||||
expect(html).toContain("Lab result");
|
||||
expect(html).toContain("Stored");
|
||||
// pendingCount badge.
|
||||
expect(html).toContain("2 to review");
|
||||
// nextCursor → load-more button.
|
||||
expect(html).toContain("Load more");
|
||||
// A11Y-M2: the row's aria-controls points at the detail region id.
|
||||
expect(html).toContain('aria-controls="documents-detail-d1"');
|
||||
// QoL: accepted formats + size ceiling are shown by the file field.
|
||||
expect(html).toContain("JPEG, PNG, WebP or PDF · up to 12 MB");
|
||||
});
|
||||
|
||||
it("disables the upload button and shows a hint until a file is chosen", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
// A11Y-M4: with no file staged, an inline hint explains why upload is
|
||||
// disabled (the hint only renders on the !hasFile / not-pending path).
|
||||
expect(html).toContain("Choose a file to upload.");
|
||||
});
|
||||
|
||||
it("renders the document-date range filter inputs", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
// PERF-L: from/to date inputs are wired into the toolbar.
|
||||
expect(html).toContain('id="doc-from"');
|
||||
expect(html).toContain('id="doc-to"');
|
||||
});
|
||||
|
||||
it("shows the empty state when there are no documents", () => {
|
||||
const html = render(<InboundDocumentsView />, (qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocumentList({
|
||||
q: undefined,
|
||||
kind: undefined,
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
}),
|
||||
{ pages: [{ documents: [], nextCursor: null }], pageParams: [null] },
|
||||
);
|
||||
});
|
||||
expect(html).toContain("No documents yet.");
|
||||
expect(html).not.toContain('data-slot="documents-row"');
|
||||
});
|
||||
});
|
||||
|
||||
function fact(id: string): ExtractedFactDto {
|
||||
return {
|
||||
id,
|
||||
factType: "OBSERVATION",
|
||||
status: "PENDING",
|
||||
confidence: 0.9,
|
||||
needsReview: false,
|
||||
data: {
|
||||
label: "LDL Cholesterol",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 95,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
effectiveDate: "2026-06-20",
|
||||
},
|
||||
provenance: { sourceText: "LDL 95 mg/dL", page: 0, confidence: 0.9 },
|
||||
committedRecordId: null,
|
||||
committedRecordType: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("<DocumentDetail>", () => {
|
||||
function detail(over: Partial<InboundDocumentDetailDto> = {}) {
|
||||
return {
|
||||
...doc("d1", { title: "Cardiology report", status: "EXTRACTED" }),
|
||||
facts: [fact("f1")],
|
||||
...over,
|
||||
} as InboundDocumentDetailDto;
|
||||
}
|
||||
|
||||
it("renders the original link, edit + extract controls, and the review facts", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
expect(html).toContain('data-slot="documents-detail"');
|
||||
// Original view link points at the raw-bytes route.
|
||||
expect(html).toContain("/api/documents/inbound/d1/original");
|
||||
expect(html).toContain('data-slot="documents-edit-trigger"');
|
||||
// Extract button is present (status is not CONFIRMED).
|
||||
expect(html).toContain('data-slot="documents-extract"');
|
||||
expect(html).toContain("Extract facts");
|
||||
// The staged fact's transcription summary renders for review.
|
||||
expect(html).toContain("LDL Cholesterol");
|
||||
expect(html).toContain("Save approved facts");
|
||||
});
|
||||
|
||||
it("hides the extract button once the document is confirmed", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "CONFIRMED", facts: [] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).not.toContain('data-slot="documents-extract"');
|
||||
});
|
||||
|
||||
it("hides the extract button once any fact is approved (PERF-H1 gate)", () => {
|
||||
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "EXTRACTED", factCount: 1, facts: [approved] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).not.toContain('data-slot="documents-extract"');
|
||||
});
|
||||
|
||||
it("threads the region id onto the detail card for aria-controls", () => {
|
||||
const html = render(
|
||||
<DocumentDetail
|
||||
documentId="d1"
|
||||
regionId="documents-detail-d1"
|
||||
onClosed={() => {}}
|
||||
/>,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
expect(html).toContain('id="documents-detail-d1"');
|
||||
});
|
||||
|
||||
it("falls back to the kind label when title and filename are absent", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ title: null, filename: null, kind: "IMAGING" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(html).toContain("Imaging");
|
||||
});
|
||||
|
||||
it("shows the committed facts summary once approved, not the empty note", () => {
|
||||
const approved: ExtractedFactDto = { ...fact("f1"), status: "APPROVED" };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ status: "CONFIRMED", factCount: 1, facts: [approved] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
// M6: provenance stays visible — the approved facts are listed read-only…
|
||||
expect(html).toContain('data-slot="documents-committed"');
|
||||
expect(html).toContain("Approved from this document");
|
||||
expect(html).toContain("LDL Cholesterol");
|
||||
// …instead of stranding the user on "Nothing left to review."
|
||||
expect(html).not.toContain("Nothing left to review.");
|
||||
});
|
||||
|
||||
it("explains why a low-confidence fact can't be approved yet", () => {
|
||||
const lowConf: ExtractedFactDto = { ...fact("f1"), needsReview: true };
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(
|
||||
queryKeys.inboundDocument("d1"),
|
||||
detail({ facts: [lowConf] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
// M7: the disabled Approve is tied to a plain-language reason.
|
||||
expect(html).toContain("Edit this fact to approve it.");
|
||||
expect(html).toContain("Needs review");
|
||||
});
|
||||
|
||||
it("offers a confirm step before discarding", () => {
|
||||
const html = render(
|
||||
<DocumentDetail documentId="d1" onClosed={() => {}} />,
|
||||
(qc) => {
|
||||
qc.setQueryData(queryKeys.inboundDocument("d1"), detail());
|
||||
},
|
||||
);
|
||||
// A11Y-M3: discard is gated behind an AlertDialog trigger, not one click.
|
||||
expect(html).toContain('data-slot="documents-discard-trigger"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentMetaEditForm>", () => {
|
||||
it("renders the metadata fields seeded from the document", () => {
|
||||
const html = render(
|
||||
<DocumentMetaEditForm
|
||||
doc={doc("d1", { title: "Old title", documentDate: "2026-06-20" })}
|
||||
onSaved={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="documents-edit-form"');
|
||||
expect(html).toContain('value="Old title"');
|
||||
expect(html).toContain('value="2026-06-20"');
|
||||
expect(html).toContain("Save");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ProviderUnsupportedNote>", () => {
|
||||
it("renders the calm inline note as a polite live region (A11Y-H1)", () => {
|
||||
const html = render(<ProviderUnsupportedNote />);
|
||||
expect(html).toContain('data-slot="documents-provider-note"');
|
||||
expect(html).toContain("Configure a document-scan provider");
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AlreadyConfirmedNote>", () => {
|
||||
it("renders a calm polite live region when extraction is refused", () => {
|
||||
const html = render(<AlreadyConfirmedNote />);
|
||||
expect(html).toContain('data-slot="documents-already-confirmed-note"');
|
||||
expect(html).toContain('role="status"');
|
||||
expect(html).toContain('aria-live="polite"');
|
||||
expect(html).toContain("already been reviewed");
|
||||
});
|
||||
});
|
||||
@@ -1,283 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import enMessages from "../../../../messages/en.json";
|
||||
import {
|
||||
buildDocumentListSearch,
|
||||
classifyUploadError,
|
||||
commitFailureReasonKey,
|
||||
confirmFailureReasonKey,
|
||||
documentDateKey,
|
||||
formatDateGroupLabel,
|
||||
groupDocumentsByDate,
|
||||
isAlreadyConfirmedError,
|
||||
isLocalOcrDisabledError,
|
||||
isProviderUnsupportedError,
|
||||
} from "../library-utils";
|
||||
|
||||
/** Resolve a dotted i18n key against a bundle (the dynamic reason keys aren't
|
||||
* literal `t()` call sites, so the call-site coverage guard can't see them —
|
||||
* assert here that each one actually resolves to a string). */
|
||||
function resolveKey(bundle: unknown, key: string): unknown {
|
||||
return key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(acc, part) =>
|
||||
acc && typeof acc === "object"
|
||||
? (acc as Record<string, unknown>)[part]
|
||||
: undefined,
|
||||
bundle,
|
||||
);
|
||||
}
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
over: Partial<InboundDocumentDto> = {},
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id,
|
||||
kind: "OTHER",
|
||||
title: null,
|
||||
filename: `${id}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1000,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: null,
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
createdAt: "2026-06-20T08:00:00.000Z",
|
||||
updatedAt: "2026-06-20T08:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDocumentListSearch", () => {
|
||||
it("emits the active filters, sort, order, and limit", () => {
|
||||
const qs = buildDocumentListSearch(
|
||||
{
|
||||
q: "cardio",
|
||||
kind: "LAB_RESULT",
|
||||
from: "2026-01-01",
|
||||
to: "2026-06-01",
|
||||
sort: "title",
|
||||
order: "asc",
|
||||
},
|
||||
null,
|
||||
25,
|
||||
);
|
||||
const sp = new URLSearchParams(qs);
|
||||
expect(sp.get("q")).toBe("cardio");
|
||||
expect(sp.get("kind")).toBe("LAB_RESULT");
|
||||
expect(sp.get("from")).toBe("2026-01-01");
|
||||
expect(sp.get("to")).toBe("2026-06-01");
|
||||
expect(sp.get("sort")).toBe("title");
|
||||
expect(sp.get("order")).toBe("asc");
|
||||
expect(sp.get("limit")).toBe("25");
|
||||
expect(sp.has("cursor")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits empty filters and appends the keyset cursor when present", () => {
|
||||
const qs = buildDocumentListSearch(
|
||||
{ sort: "documentDate", order: "desc" },
|
||||
"cursor-123",
|
||||
);
|
||||
const sp = new URLSearchParams(qs);
|
||||
expect(sp.has("q")).toBe(false);
|
||||
expect(sp.has("kind")).toBe(false);
|
||||
expect(sp.get("sort")).toBe("documentDate");
|
||||
expect(sp.get("order")).toBe("desc");
|
||||
expect(sp.get("cursor")).toBe("cursor-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentDateKey", () => {
|
||||
it("prefers the user filing date", () => {
|
||||
expect(documentDateKey(doc("a", { documentDate: "2026-03-15" }))).toBe(
|
||||
"2026-03-15",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the upload day from createdAt", () => {
|
||||
expect(
|
||||
documentDateKey(doc("a", { createdAt: "2026-06-20T08:00:00.000Z" })),
|
||||
).toBe("2026-06-20");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupDocumentsByDate", () => {
|
||||
it("buckets consecutive same-date documents, preserving server order", () => {
|
||||
const groups = groupDocumentsByDate([
|
||||
doc("a", { documentDate: "2026-06-20" }),
|
||||
doc("b", { documentDate: "2026-06-20" }),
|
||||
doc("c", { documentDate: "2026-06-19" }),
|
||||
]);
|
||||
expect(groups.map((g) => g.key)).toEqual(["2026-06-20", "2026-06-19"]);
|
||||
expect(groups[0].documents.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(groups[1].documents.map((d) => d.id)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
it("returns an empty array for no documents", () => {
|
||||
expect(groupDocumentsByDate([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isProviderUnsupportedError", () => {
|
||||
it("matches the extract 422 provider-unsupported signal", () => {
|
||||
const err = new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
expect(isProviderUnsupportedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isProviderUnsupportedError(
|
||||
new ApiError("rate", 429, {
|
||||
errorCode: "documents.inbound.rateLimited",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isProviderUnsupportedError(new Error("boom"))).toBe(false);
|
||||
expect(isProviderUnsupportedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAlreadyConfirmedError", () => {
|
||||
it("matches the extract 409 partly-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 409, {
|
||||
errorCode: "documents.inbound.alreadyPartlyConfirmed",
|
||||
});
|
||||
expect(isAlreadyConfirmedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the extract 422 fully-confirmed signal", () => {
|
||||
const err = new ApiError("already confirmed", 422, {
|
||||
errorCode: "documents.inbound.alreadyConfirmed",
|
||||
});
|
||||
expect(isAlreadyConfirmedError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isAlreadyConfirmedError(
|
||||
new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isAlreadyConfirmedError(new Error("boom"))).toBe(false);
|
||||
expect(isAlreadyConfirmedError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyUploadError", () => {
|
||||
it("maps each upload error code to its kind", () => {
|
||||
const code = (c: string) =>
|
||||
classifyUploadError(new ApiError("x", 400, { errorCode: c }));
|
||||
expect(code("documents.inbound.fileTooLarge")).toBe("tooLarge");
|
||||
expect(code("documents.inbound.fileType")).toBe("fileType");
|
||||
expect(code("documents.inbound.rateLimited")).toBe("rateLimited");
|
||||
expect(code("documents.inbound.invalidMetadata")).toBe("invalidMetadata");
|
||||
});
|
||||
|
||||
it("falls back to the HTTP status when the body carries no error code", () => {
|
||||
expect(classifyUploadError(new ApiError("too big", 413))).toBe("tooLarge");
|
||||
expect(classifyUploadError(new ApiError("wrong type", 415))).toBe(
|
||||
"fileType",
|
||||
);
|
||||
expect(classifyUploadError(new ApiError("slow down", 429))).toBe(
|
||||
"rateLimited",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns generic for unknown errors and non-API errors", () => {
|
||||
expect(classifyUploadError(new ApiError("boom", 500))).toBe("generic");
|
||||
expect(classifyUploadError(new Error("network"))).toBe("generic");
|
||||
expect(classifyUploadError(null)).toBe("generic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalOcrDisabledError", () => {
|
||||
it("matches the extract 422 local-OCR-disabled signal", () => {
|
||||
const err = new ApiError("local ocr off", 422, {
|
||||
errorCode: "documents.inbound.localOcrDisabled",
|
||||
});
|
||||
expect(isLocalOcrDisabledError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other API errors and non-errors", () => {
|
||||
expect(
|
||||
isLocalOcrDisabledError(
|
||||
new ApiError("no provider", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isLocalOcrDisabledError(new Error("boom"))).toBe(false);
|
||||
expect(isLocalOcrDisabledError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("commitFailureReasonKey", () => {
|
||||
it("maps each commit-failure code to a reason key that resolves", () => {
|
||||
const cases: Record<string, string> = {
|
||||
"observation.unitMismatch": "documents.review.commitError.unitMismatch",
|
||||
"observation.unitRequired": "documents.review.commitError.unitRequired",
|
||||
"something.unknown": "documents.review.commitError.generic",
|
||||
};
|
||||
for (const [code, key] of Object.entries(cases)) {
|
||||
expect(commitFailureReasonKey(code)).toBe(key);
|
||||
// Every reason the toast can surface resolves to a real string.
|
||||
expect(typeof resolveKey(enMessages, key)).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmFailureReasonKey", () => {
|
||||
it("surfaces the shared reason when every failed fact agrees", () => {
|
||||
const key = confirmFailureReasonKey([
|
||||
{ reason: "observation.unitMismatch" },
|
||||
{ reason: "observation.unitMismatch" },
|
||||
]);
|
||||
expect(key).toBe("documents.review.commitError.unitMismatch");
|
||||
expect(typeof resolveKey(enMessages, key)).toBe("string");
|
||||
});
|
||||
|
||||
it("falls back to the generic reason when failures disagree", () => {
|
||||
const key = confirmFailureReasonKey([
|
||||
{ reason: "observation.unitMismatch" },
|
||||
{ reason: "observation.unitRequired" },
|
||||
]);
|
||||
expect(key).toBe("documents.review.commitError.generic");
|
||||
});
|
||||
|
||||
it("uses the single failure's reason for a one-item batch", () => {
|
||||
expect(
|
||||
confirmFailureReasonKey([{ reason: "observation.unitRequired" }]),
|
||||
).toBe("documents.review.commitError.unitRequired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateGroupLabel", () => {
|
||||
it("delegates the calendar day to the supplied app formatter", () => {
|
||||
const seen: string[] = [];
|
||||
const formatDate = (value: string) => {
|
||||
seen.push(value);
|
||||
return "FORMATTED";
|
||||
};
|
||||
expect(formatDateGroupLabel("2026-06-20", formatDate)).toBe("FORMATTED");
|
||||
// Handed over as a noon-UTC instant so the day never shifts under the tz.
|
||||
expect(seen).toEqual(["2026-06-20T12:00:00.000Z"]);
|
||||
});
|
||||
|
||||
it("returns a malformed key unchanged without calling the formatter", () => {
|
||||
const formatDate = () => {
|
||||
throw new Error("should not be called");
|
||||
};
|
||||
expect(formatDateGroupLabel("not-a-date", formatDate)).toBe("not-a-date");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
buildTimelineItems,
|
||||
buildVaultListApiSearch,
|
||||
classifyUploadFailure,
|
||||
countActiveFilters,
|
||||
documentDateKey,
|
||||
expandRangeSelection,
|
||||
formatBytes,
|
||||
formatMonthLabel,
|
||||
parseUploadResponse,
|
||||
parseVaultSearchParams,
|
||||
vaultFiltersToSearch,
|
||||
} from "../vault-utils";
|
||||
|
||||
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
return {
|
||||
id: "doc-1",
|
||||
kind: "DOCTOR_REPORT",
|
||||
title: null,
|
||||
filename: "letter.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1024,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "2026-03-10",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [],
|
||||
servingClass: "inline",
|
||||
createdAt: "2026-03-11T08:00:00.000Z",
|
||||
updatedAt: "2026-03-11T08:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("URL facet round-trip", () => {
|
||||
it("parses q / kind / episode / year and serialises back identically", () => {
|
||||
const search = "q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025";
|
||||
const filters = parseVaultSearchParams(new URLSearchParams(search));
|
||||
expect(filters).toEqual({
|
||||
q: "MRT",
|
||||
kinds: ["IMAGING", "LAB_RESULT"],
|
||||
episodeId: "ep1",
|
||||
year: 2025,
|
||||
});
|
||||
expect(vaultFiltersToSearch(filters)).toBe(
|
||||
"q=MRT&kind=IMAGING%2CLAB_RESULT&episode=ep1&year=2025",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts repeated kind params and drops unknown kinds", () => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.append("kind", "IMAGING");
|
||||
sp.append("kind", "NOT_A_KIND");
|
||||
sp.append("kind", "IMAGING"); // duplicate collapses
|
||||
const filters = parseVaultSearchParams(sp);
|
||||
expect(filters.kinds).toEqual(["IMAGING"]);
|
||||
});
|
||||
|
||||
it("normalises kind order so the cache key is stable", () => {
|
||||
const a = parseVaultSearchParams(
|
||||
new URLSearchParams("kind=LAB_RESULT,IMAGING"),
|
||||
);
|
||||
const b = parseVaultSearchParams(
|
||||
new URLSearchParams("kind=IMAGING,LAB_RESULT"),
|
||||
);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("ignores a malformed year and returns the empty default view", () => {
|
||||
expect(parseVaultSearchParams(new URLSearchParams("year=20x5"))).toEqual(
|
||||
{},
|
||||
);
|
||||
expect(vaultFiltersToSearch({})).toBe("");
|
||||
});
|
||||
|
||||
it("counts active facets per chip (search counts once)", () => {
|
||||
expect(countActiveFilters({})).toBe(0);
|
||||
expect(
|
||||
countActiveFilters({
|
||||
q: "a",
|
||||
kinds: ["IMAGING", "LAB_RESULT"],
|
||||
episodeId: "e",
|
||||
year: 2026,
|
||||
}),
|
||||
).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API list search", () => {
|
||||
it("pins timeline sort, maps episode → episodeId, threads the cursor", () => {
|
||||
const search = buildVaultListApiSearch(
|
||||
{ kinds: ["IMAGING"], episodeId: "ep1", year: 2025 },
|
||||
"cursor-9",
|
||||
50,
|
||||
);
|
||||
const sp = new URLSearchParams(search);
|
||||
expect(sp.get("kind")).toBe("IMAGING");
|
||||
expect(sp.get("episodeId")).toBe("ep1");
|
||||
expect(sp.get("year")).toBe("2025");
|
||||
expect(sp.get("sort")).toBe("documentDate");
|
||||
expect(sp.get("order")).toBe("desc");
|
||||
expect(sp.get("cursor")).toBe("cursor-9");
|
||||
expect(sp.get("limit")).toBe("50");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeline items", () => {
|
||||
it("buckets by month and chunks rows to the column count", () => {
|
||||
const docs = [
|
||||
doc({ id: "a", documentDate: "2026-03-20" }),
|
||||
doc({ id: "b", documentDate: "2026-03-15" }),
|
||||
doc({ id: "c", documentDate: "2026-03-10" }),
|
||||
doc({ id: "d", documentDate: "2026-02-28" }),
|
||||
];
|
||||
const items = buildTimelineItems(docs, 2);
|
||||
expect(items.map((i) => i.type)).toEqual([
|
||||
"month",
|
||||
"row",
|
||||
"row",
|
||||
"month",
|
||||
"row",
|
||||
]);
|
||||
expect(items[0]).toEqual({ type: "month", key: "2026-03" });
|
||||
expect(items[1]).toMatchObject({ documents: [docs[0], docs[1]] });
|
||||
expect(items[2]).toMatchObject({ documents: [docs[2]] });
|
||||
expect(items[4]).toMatchObject({ documents: [docs[3]] });
|
||||
});
|
||||
|
||||
it("files an undated document under its upload day", () => {
|
||||
const d = doc({ documentDate: null, createdAt: "2026-01-05T10:00:00Z" });
|
||||
expect(documentDateKey(d)).toBe("2026-01-05");
|
||||
const items = buildTimelineItems([d], 3);
|
||||
expect(items[0]).toEqual({ type: "month", key: "2026-01" });
|
||||
});
|
||||
|
||||
it("keeps the DOM bounded: item count scales with rows, not documents", () => {
|
||||
const docs = Array.from({ length: 1000 }, (_, i) =>
|
||||
doc({ id: `d${i}`, documentDate: "2026-03-01" }),
|
||||
);
|
||||
const items = buildTimelineItems(docs, 4);
|
||||
// 1 month header + 250 rows — the virtualizer then windows over these.
|
||||
expect(items).toHaveLength(251);
|
||||
});
|
||||
|
||||
it("formats a month key in the viewer's locale", () => {
|
||||
expect(formatMonthLabel("2026-03", "de")).toBe("März 2026");
|
||||
expect(formatMonthLabel("not-a-month", "de")).toBe("not-a-month");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byte formatting", () => {
|
||||
it("scales units and keeps one decimal below 10", () => {
|
||||
expect(formatBytes(0, "en")).toBe("0 B");
|
||||
expect(formatBytes(512, "en")).toBe("512 B");
|
||||
expect(formatBytes(26_214_400, "en")).toBe("25 MB");
|
||||
expect(formatBytes(1_288_490_189, "en")).toBe("1.2 GB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload response contract (§3.2)", () => {
|
||||
it("HTTP 200 + meta.duplicate returns the EXISTING row as a duplicate", () => {
|
||||
const body = JSON.stringify({
|
||||
data: doc({ id: "existing-row" }),
|
||||
error: null,
|
||||
meta: { duplicate: true },
|
||||
});
|
||||
const result = parseUploadResponse(200, body);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
duplicate: true,
|
||||
document: { id: "existing-row" },
|
||||
});
|
||||
});
|
||||
|
||||
it("a plain 201 success is not a duplicate", () => {
|
||||
const body = JSON.stringify({ data: doc({ id: "fresh" }), error: null });
|
||||
expect(parseUploadResponse(201, body)).toMatchObject({
|
||||
ok: true,
|
||||
duplicate: false,
|
||||
document: { id: "fresh" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps every meta.reason to its translated-copy class", () => {
|
||||
expect(
|
||||
parseUploadResponse(
|
||||
413,
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
error: "too large",
|
||||
meta: { reason: "fileTooLarge", maxFileBytes: 26_214_400 },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "fileTooLarge",
|
||||
maxFileBytes: 26_214_400,
|
||||
quotaBytes: undefined,
|
||||
usedBytes: undefined,
|
||||
existingId: undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
parseUploadResponse(
|
||||
413,
|
||||
JSON.stringify({
|
||||
data: null,
|
||||
error: "quota",
|
||||
meta: {
|
||||
reason: "quotaExceeded",
|
||||
quotaBytes: 1_073_741_824,
|
||||
usedBytes: 1_000_000_000,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
reason: "quotaExceeded",
|
||||
quotaBytes: 1_073_741_824,
|
||||
usedBytes: 1_000_000_000,
|
||||
});
|
||||
|
||||
expect(
|
||||
classifyUploadFailure(415, { reason: "unsupportedType" }),
|
||||
).toMatchObject({ reason: "unsupportedType" });
|
||||
expect(classifyUploadFailure(409, { reason: "purged" })).toMatchObject({
|
||||
reason: "purged",
|
||||
});
|
||||
expect(
|
||||
classifyUploadFailure(409, {
|
||||
reason: "duplicateExists",
|
||||
existingId: "row-1",
|
||||
}),
|
||||
).toMatchObject({ reason: "duplicateExists", existingId: "row-1" });
|
||||
});
|
||||
|
||||
it("falls back to the HTTP status when a proxy strips the body", () => {
|
||||
expect(parseUploadResponse(413, "")).toMatchObject({
|
||||
reason: "fileTooLarge",
|
||||
});
|
||||
expect(parseUploadResponse(415, "<html>")).toMatchObject({
|
||||
reason: "unsupportedType",
|
||||
});
|
||||
expect(parseUploadResponse(429, "")).toMatchObject({
|
||||
reason: "rateLimited",
|
||||
});
|
||||
expect(parseUploadResponse(500, "")).toMatchObject({ reason: "generic" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift-click range selection", () => {
|
||||
const order = ["a", "b", "c", "d", "e"];
|
||||
|
||||
it("selects the inclusive range between anchor and target, both ways", () => {
|
||||
expect(
|
||||
[...expandRangeSelection(order, new Set(["b"]), "b", "d")].sort(),
|
||||
).toEqual(["b", "c", "d"]);
|
||||
expect(
|
||||
[...expandRangeSelection(order, new Set(["d"]), "d", "b")].sort(),
|
||||
).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("is additive — a range gesture never deselects", () => {
|
||||
const next = expandRangeSelection(order, new Set(["a", "c"]), "c", "e");
|
||||
expect([...next].sort()).toEqual(["a", "c", "d", "e"]);
|
||||
});
|
||||
|
||||
it("falls back to a plain toggle without a live anchor", () => {
|
||||
expect([...expandRangeSelection(order, new Set(), null, "c")]).toEqual([
|
||||
"c",
|
||||
]);
|
||||
// Anchor filtered out of the current order → plain toggle (deselect).
|
||||
expect([
|
||||
...expandRangeSelection(order, new Set(["c"]), "gone", "c"),
|
||||
]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Floating bulk-action bar for the vault's multi-select mode. Appears once
|
||||
* at least one document is selected, pinned above the bottom nav on phones
|
||||
* and near the bottom edge on desktop. Carries the selected count and the
|
||||
* four bulk verbs — set type, link condition, delete (undo-able), clear —
|
||||
* all driving `POST /api/documents/inbound/bulk` from the page.
|
||||
*
|
||||
* A `role="toolbar"` with proper labels; the destructive verb sits last
|
||||
* before Clear so a stray tap sequence never ends on Delete. The bar is a
|
||||
* deliberate hand-rolled shell (a floating toolbar is not a Card surface):
|
||||
* dense-tile padding `p-3` per the standards.
|
||||
*/
|
||||
import { FolderPlus, Tag, Trash2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
|
||||
|
||||
export interface BulkEpisodeOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DocumentBulkBar({
|
||||
selectedCount,
|
||||
episodes,
|
||||
busy,
|
||||
onSetKind,
|
||||
onLinkEpisode,
|
||||
onDelete,
|
||||
onClear,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
/** The caller's live illness episodes — the link-condition targets. */
|
||||
episodes: BulkEpisodeOption[];
|
||||
/** A bulk call is in flight — the verbs disable, Clear stays live. */
|
||||
busy: boolean;
|
||||
onSetKind: (kind: InboundDocumentKindValue) => void;
|
||||
onLinkEpisode: (episodeId: string) => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="document-bulk-bar"
|
||||
role="toolbar"
|
||||
aria-label={t("documents.bulk.barLabel")}
|
||||
className={cn(
|
||||
"bg-card border-border fixed bottom-20 left-1/2 z-40 -translate-x-1/2 md:bottom-6",
|
||||
"flex w-[calc(100%-2rem)] max-w-2xl flex-wrap items-center gap-2 rounded-xl border p-3 shadow-lg",
|
||||
)}
|
||||
>
|
||||
<p className="px-1 text-sm font-medium whitespace-nowrap" role="status">
|
||||
{t("documents.selection.count", { count: selectedCount })}
|
||||
</p>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={busy}>
|
||||
<Tag className="size-4" aria-hidden />
|
||||
{t("documents.bulk.setKind")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{DOCUMENT_KIND_ORDER.map((kind) => {
|
||||
const Icon = DOCUMENT_KIND_ICONS[kind];
|
||||
return (
|
||||
<DropdownMenuItem key={kind} onSelect={() => onSetKind(kind)}>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
{t(`documents.kind.${kind}`)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{episodes.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={busy}>
|
||||
<FolderPlus className="size-4" aria-hidden />
|
||||
{t("documents.bulk.linkCondition")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{episodes.map((episode) => (
|
||||
<DropdownMenuItem
|
||||
key={episode.id}
|
||||
onSelect={() => onLinkEpisode(episode.id)}
|
||||
>
|
||||
<span className="max-w-56 truncate">{episode.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{/* Solid destructive (matching the detail sheet's Delete) — the
|
||||
outline variant's destructive text on the card surface fails the
|
||||
WCAG contrast gate. */}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("documents.bulk.delete")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={onClear}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
{t("documents.selection.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One document in the vault timeline (compact-density Card) plus the two
|
||||
* transient upload-state cards (in-flight with a live progress ring, failed
|
||||
* with the translated §3.2 reason). All three share the card footprint so an
|
||||
* optimistic entry morphs into the stored row without the grid jumping.
|
||||
*
|
||||
* Anatomy per the design standards: kind icon `text-foreground size-5`,
|
||||
* title in foreground (`text-sm font-medium`, truncated), one muted meta
|
||||
* line (date · size · filename), condition-tag pills, an attachment-class
|
||||
* badge for download-only formats, and a selection checkbox that appears on
|
||||
* hover / focus / while selected. The whole card is clickable through an
|
||||
* invisible overlay button; the checkbox floats above it.
|
||||
*/
|
||||
import { Download, X } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import type { UploadQueueItem } from "./use-document-upload";
|
||||
import { documentDateKey, formatBytes } from "./vault-utils";
|
||||
|
||||
/** Press-and-hold duration that flips a touch press into "select" mode. */
|
||||
const LONG_PRESS_MS = 500;
|
||||
|
||||
export function DocumentCard({
|
||||
document,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
onOpen,
|
||||
onDelete,
|
||||
highlighted,
|
||||
tabIndex = 0,
|
||||
onCardFocus,
|
||||
onPrefetch,
|
||||
}: {
|
||||
document: InboundDocumentDto;
|
||||
selected: boolean;
|
||||
/** `range` = extend the selection from the last anchor (shift-click). */
|
||||
onToggleSelected: (id: string, range?: boolean) => void;
|
||||
onOpen: (id: string) => void;
|
||||
/** Delete key on the focused card — undo-able delete owned by the page. */
|
||||
onDelete?: (id: string) => void;
|
||||
/** Brief ring after a duplicate upload resolved to this existing row. */
|
||||
highlighted: boolean;
|
||||
/** Roving-tabindex slot from the timeline (0 = the one tabbable card). */
|
||||
tabIndex?: number;
|
||||
/** The open-button gained focus (keyboard/roving bookkeeping). */
|
||||
onCardFocus?: (id: string) => void;
|
||||
/** Hover/focus intent — prefetches the detail metadata (never the blob). */
|
||||
onPrefetch?: (id: string) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
// Touch long-press selects instead of opening; the click that the
|
||||
// browser fires after the release is swallowed once.
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const longPressFired = useRef(false);
|
||||
const cancelLongPress = () => {
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const title =
|
||||
document.title ?? document.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[document.kind];
|
||||
const date = format.date(`${documentDateKey(document)}T12:00:00.000Z`);
|
||||
const size = formatBytes(document.byteSize, locale);
|
||||
const showFilename =
|
||||
document.filename !== null && document.filename !== title;
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="document-card"
|
||||
data-document-id={document.id}
|
||||
className={cn(
|
||||
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
|
||||
"hover:shadow-sm",
|
||||
highlighted && "ring-primary ring-2",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex h-full flex-col gap-2 px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon
|
||||
className="text-foreground mt-0.5 size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium">{title}</p>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
// Explicit click handling instead of onCheckedChange: the mouse
|
||||
// event carries `shiftKey` for file-manager range selection.
|
||||
// preventDefault stops Radix's internal toggle (state is fully
|
||||
// controlled by the page's selection set anyway).
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onToggleSelected(document.id, e.shiftKey);
|
||||
}}
|
||||
aria-label={t("documents.card.selectLabel", { title })}
|
||||
className={cn(
|
||||
"relative z-10 shrink-0 transition-opacity",
|
||||
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
|
||||
selected && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{date} · {size}
|
||||
{showFilename ? ` · ${document.filename}` : ""}
|
||||
</p>
|
||||
{document.conditionLinks.length > 0 ||
|
||||
document.servingClass === "attachment" ? (
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
|
||||
{document.conditionLinks.map((link) => (
|
||||
<span
|
||||
key={link.episodeId}
|
||||
className="bg-muted text-foreground inline-flex max-w-40 items-center rounded-full px-2 py-0.5 text-xs"
|
||||
>
|
||||
<span className="truncate">{link.name}</span>
|
||||
</span>
|
||||
))}
|
||||
{document.servingClass === "attachment" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground gap-1 text-xs font-normal"
|
||||
>
|
||||
<Download className="size-3" aria-hidden />
|
||||
{t("documents.card.attachmentBadge")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
{/* Whole-card click target. Painted last so it sits above the content
|
||||
(the checkbox stays reachable via its own z-10). Keyboard contract
|
||||
(roving tabindex owned by the timeline): Enter opens (native
|
||||
click), Space toggles selection, Delete removes with undo. Touch:
|
||||
press-and-hold selects instead of opening. */}
|
||||
<button
|
||||
type="button"
|
||||
data-slot="document-open"
|
||||
tabIndex={tabIndex}
|
||||
onClick={() => {
|
||||
if (longPressFired.current) {
|
||||
longPressFired.current = false;
|
||||
return;
|
||||
}
|
||||
onOpen(document.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
onToggleSelected(document.id, e.shiftKey);
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
onDelete?.(document.id);
|
||||
}
|
||||
}}
|
||||
onTouchStart={() => {
|
||||
longPressFired.current = false;
|
||||
cancelLongPress();
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
longPressFired.current = true;
|
||||
onToggleSelected(document.id);
|
||||
}, LONG_PRESS_MS);
|
||||
}}
|
||||
onTouchMove={cancelLongPress}
|
||||
onTouchEnd={cancelLongPress}
|
||||
onTouchCancel={cancelLongPress}
|
||||
onMouseEnter={() => onPrefetch?.(document.id)}
|
||||
onFocus={() => {
|
||||
onCardFocus?.(document.id);
|
||||
onPrefetch?.(document.id);
|
||||
}}
|
||||
aria-label={t("documents.card.openLabel", { title })}
|
||||
className="focus-visible:ring-ring/50 absolute inset-0 rounded-xl focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** SVG progress ring for the in-flight upload card (0..1). */
|
||||
function ProgressRing({ fraction }: { fraction: number }) {
|
||||
const clamped = Math.min(1, Math.max(0, fraction));
|
||||
const r = 13;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
className="size-8 shrink-0 -rotate-90"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(clamped * 100)}
|
||||
>
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<circle
|
||||
cx="16"
|
||||
cy="16"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="stroke-primary transition-[stroke-dashoffset] duration-200"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * (1 - clamped)}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transient card for a queue entry: uploading (progress ring) or failed
|
||||
* (translated reason + dismiss). Shares the DocumentCard footprint.
|
||||
*/
|
||||
export function UploadStateCard({
|
||||
item,
|
||||
onDismiss,
|
||||
}: {
|
||||
item: UploadQueueItem;
|
||||
onDismiss: (localId: string) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
|
||||
const failure = item.failure;
|
||||
let failureCopy: string | null = null;
|
||||
if (failure) {
|
||||
switch (failure.reason) {
|
||||
case "fileTooLarge":
|
||||
failureCopy = t("documents.error.fileTooLarge", {
|
||||
maxSize: formatBytes(failure.maxFileBytes ?? 0, locale),
|
||||
});
|
||||
break;
|
||||
case "quotaExceeded":
|
||||
failureCopy = t("documents.error.quotaExceeded", {
|
||||
used: formatBytes(failure.usedBytes ?? 0, locale),
|
||||
quota: formatBytes(failure.quotaBytes ?? 0, locale),
|
||||
});
|
||||
break;
|
||||
case "unsupportedType":
|
||||
failureCopy = t("documents.error.unsupportedType");
|
||||
break;
|
||||
case "purged":
|
||||
failureCopy = t("documents.error.purged");
|
||||
break;
|
||||
case "duplicateExists":
|
||||
failureCopy = t("documents.error.duplicateExists");
|
||||
break;
|
||||
case "rateLimited":
|
||||
failureCopy = t("documents.error.rateLimited");
|
||||
break;
|
||||
default:
|
||||
failureCopy = t("documents.error.generic");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="document-upload-card"
|
||||
className={cn(
|
||||
"h-full gap-2 border-dashed py-3 md:py-4",
|
||||
item.status === "error" && "border-destructive/50",
|
||||
)}
|
||||
>
|
||||
<CardContent className="flex h-full flex-col gap-2 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.status === "uploading" ? (
|
||||
<ProgressRing fraction={item.progress} />
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.fileName}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{item.status === "uploading"
|
||||
? t("documents.card.uploading")
|
||||
: formatBytes(item.byteSize, locale)}
|
||||
</p>
|
||||
</div>
|
||||
{item.status === "error" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => onDismiss(item.localId)}
|
||||
aria-label={t("documents.card.dismissFailed")}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{failureCopy ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{failureCopy}
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Document detail on `ResponsiveSheet` (bottom sheet on phones, dialog on
|
||||
* desktop). Inline-class documents (PDF / browser-native images) preview
|
||||
* inline against the owner-scoped decrypt-and-serve route — skeleton first,
|
||||
* content on load, internal scroll only (`overscroll-contain`). Attachment-
|
||||
* class documents get NO fake preview: type glyph, filename, size, and a
|
||||
* prominent Download button.
|
||||
*
|
||||
* Metadata edits save on commit (title on Enter/blur, kind and filing date
|
||||
* on change, condition links on toggle — replace-set PATCH). Mutation
|
||||
* errors surface inline (`role="alert"`), never as a QueryErrorCard.
|
||||
* Delete soft-deletes with an Undo toast (restore endpoint); a restore
|
||||
* refusal maps `meta.reason` (`purged`, `duplicateExists`) to translated
|
||||
* copy. No share affordance anywhere — deliberately.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Download, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
apiDelete,
|
||||
ApiError,
|
||||
apiGet,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
type InboundDocumentDetailDto,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import { formatBytes } from "./vault-utils";
|
||||
|
||||
type PatchInput = {
|
||||
title?: string | null;
|
||||
kind?: InboundDocumentKindValue;
|
||||
documentDate?: string | null;
|
||||
episodeIds?: string[];
|
||||
};
|
||||
|
||||
/** Inline preview for Class A documents; skeleton until the blob paints. */
|
||||
function InlinePreview({
|
||||
documentId,
|
||||
mimeType,
|
||||
title,
|
||||
}: {
|
||||
documentId: string;
|
||||
mimeType: string;
|
||||
title: string;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const src = `/api/documents/inbound/${documentId}/original`;
|
||||
const isPdf = mimeType === "application/pdf";
|
||||
// Chromium's PDF viewer grabs focus while it initialises — BEFORE the
|
||||
// frame's `load` event — stranding keyboard events inside the embedded
|
||||
// document, so Escape stops closing the sheet the moment a PDF preview
|
||||
// opens. While the guard window is open (armed at mount, extended at
|
||||
// load, ended early by a deliberate pointer interaction with the
|
||||
// preview), any focus landing on the frame bounces straight back to the
|
||||
// dialog container; a user who clicks into the PDF is left alone.
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const stealGuardUntil = useRef(0);
|
||||
const reclaimFocus = (frame: HTMLIFrameElement) => {
|
||||
if (
|
||||
Date.now() < stealGuardUntil.current &&
|
||||
document.activeElement === frame
|
||||
) {
|
||||
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
// Arm at mount, then run a frame-by-frame sweep for the steal variants
|
||||
// that never dispatch a focus event on the frame element.
|
||||
// Self-terminates with the guard.
|
||||
stealGuardUntil.current = Date.now() + 3_000;
|
||||
let raf = 0;
|
||||
const sweep = () => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame || Date.now() >= stealGuardUntil.current) return;
|
||||
if (document.activeElement === frame) {
|
||||
frame.closest<HTMLElement>('[role="dialog"]')?.focus();
|
||||
}
|
||||
raf = requestAnimationFrame(sweep);
|
||||
};
|
||||
raf = requestAnimationFrame(sweep);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative max-h-[55vh] overflow-auto overscroll-contain rounded-lg">
|
||||
{!loaded ? (
|
||||
<Skeleton
|
||||
className={cn("w-full rounded-lg", isPdf ? "h-[55vh]" : "h-64")}
|
||||
/>
|
||||
) : null}
|
||||
{isPdf ? (
|
||||
// No `sandbox` attribute by design: Chromium force-downloads PDFs
|
||||
// in sandboxed frames instead of rendering them. The serve
|
||||
// response itself carries `default-src 'none'` +
|
||||
// `frame-ancestors 'self'` + true Content-Type + nosniff, and the
|
||||
// upload policy denies every format whose inline render could
|
||||
// execute script.
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
src={src}
|
||||
title={title}
|
||||
onLoad={(event) => {
|
||||
setLoaded(true);
|
||||
// The viewer may still initialise (and steal focus) after
|
||||
// `load` — keep the guard open a little longer.
|
||||
stealGuardUntil.current = Math.max(
|
||||
stealGuardUntil.current,
|
||||
Date.now() + 2_000,
|
||||
);
|
||||
reclaimFocus(event.currentTarget);
|
||||
}}
|
||||
onFocus={(event) => reclaimFocus(event.currentTarget)}
|
||||
onPointerDown={() => {
|
||||
// Deliberate interaction with the preview ends the guard.
|
||||
stealGuardUntil.current = 0;
|
||||
}}
|
||||
className={cn(
|
||||
"border-border h-[55vh] w-full rounded-lg border",
|
||||
!loaded && "absolute inset-0 opacity-0",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
// Decrypted same-origin blob stream: next/image cannot optimise it
|
||||
// and would only proxy the bytes through a second request.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={title}
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={cn(
|
||||
"mx-auto max-h-[55vh] w-auto max-w-full rounded-lg object-contain",
|
||||
!loaded && "absolute inset-0 opacity-0",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentDetailSheet({
|
||||
documentId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
documentId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: queryKeys.inboundDocument(documentId ?? "none"),
|
||||
enabled: open && documentId !== null,
|
||||
queryFn: () =>
|
||||
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${documentId}`),
|
||||
});
|
||||
const doc = detail.data;
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
|
||||
// Reset transient edit state whenever another document opens —
|
||||
// render-phase derived-state adjustment, not an effect.
|
||||
const [lastDocumentId, setLastDocumentId] = useState(documentId);
|
||||
if (lastDocumentId !== documentId) {
|
||||
setLastDocumentId(documentId);
|
||||
setEditingTitle(false);
|
||||
setMutationError(null);
|
||||
}
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: (input: PatchInput) =>
|
||||
apiPatch<InboundDocumentDetailDto>(
|
||||
`/api/documents/inbound/${documentId}`,
|
||||
input,
|
||||
),
|
||||
onSuccess: () => {
|
||||
setMutationError(null);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
},
|
||||
onError: () => setMutationError(t("documents.detail.saveError")),
|
||||
});
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiPost(`/api/documents/inbound/${id}/restore`, {}),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
},
|
||||
onError: (error) => {
|
||||
const reason =
|
||||
error instanceof ApiError && typeof error.meta?.reason === "string"
|
||||
? error.meta.reason
|
||||
: "";
|
||||
if (reason === "purged") {
|
||||
toast.error(t("documents.error.purged"));
|
||||
} else if (reason === "duplicateExists") {
|
||||
toast.error(t("documents.error.duplicateExists"));
|
||||
} else {
|
||||
toast.error(t("documents.toast.restoreFailed"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`),
|
||||
onSuccess: (_data, id) => {
|
||||
onOpenChange(false);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
toast.success(t("documents.toast.deleted"), {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => restore.mutate(id),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: () => setMutationError(t("documents.detail.deleteError")),
|
||||
});
|
||||
|
||||
const commitTitle = () => {
|
||||
if (!doc) return;
|
||||
setEditingTitle(false);
|
||||
const next = titleDraft.trim();
|
||||
const current = doc.title ?? "";
|
||||
if (next === current) return;
|
||||
patch.mutate({ title: next === "" ? null : next });
|
||||
};
|
||||
|
||||
const toggleEpisode = (episodeId: string) => {
|
||||
if (!doc) return;
|
||||
const current = doc.conditionLinks.map((l) => l.episodeId);
|
||||
const next = current.includes(episodeId)
|
||||
? current.filter((id) => id !== episodeId)
|
||||
: [...current, episodeId];
|
||||
patch.mutate({ episodeIds: next });
|
||||
};
|
||||
|
||||
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
|
||||
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
|
||||
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={title}
|
||||
description={t("documents.detail.title")}
|
||||
contentWidth="3xl"
|
||||
footer={
|
||||
doc ? (
|
||||
<>
|
||||
<Button variant="outline" asChild data-slot="document-download">
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t("documents.detail.download")}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => remove.mutate(doc.id)}
|
||||
disabled={remove.isPending}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("documents.detail.delete")}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{detail.isPending && open ? (
|
||||
<div className="space-y-3" data-slot="document-detail-loading">
|
||||
<Skeleton className="h-48 w-full rounded-lg" />
|
||||
<Skeleton className="h-9 w-2/3" />
|
||||
<Skeleton className="h-9 w-1/2" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("documents.detail.loadError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{doc ? (
|
||||
<div className="space-y-6">
|
||||
{doc.servingClass === "inline" ? (
|
||||
<InlinePreview
|
||||
documentId={doc.id}
|
||||
mimeType={doc.mimeType}
|
||||
title={title}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-border flex flex-col items-center gap-2 rounded-lg border px-4 py-8 text-center">
|
||||
{Icon ? (
|
||||
<Icon className="text-muted-foreground size-8" aria-hidden />
|
||||
) : null}
|
||||
<p className="max-w-full truncate text-sm font-medium">
|
||||
{doc.filename ?? title}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.previewUnavailable")} ·{" "}
|
||||
{formatBytes(doc.byteSize, locale)}
|
||||
</p>
|
||||
<Button asChild size="sm" className="mt-1">
|
||||
<a href={originalHref} download={doc.filename ?? undefined}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{t("documents.detail.download")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mutationError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{mutationError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-title-input">
|
||||
{t("documents.detail.titleLabel")}
|
||||
</Label>
|
||||
{editingTitle ? (
|
||||
<Input
|
||||
id="document-title-input"
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitTitle();
|
||||
if (e.key === "Escape") setEditingTitle(false);
|
||||
}}
|
||||
maxLength={200}
|
||||
placeholder={t("documents.detail.titlePlaceholder")}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
id="document-title-input"
|
||||
onClick={() => {
|
||||
setTitleDraft(doc.title ?? "");
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
className="border-input hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-10 w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
doc.title === null && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{doc.title ?? t("documents.detail.titlePlaceholder")}
|
||||
</span>
|
||||
<Pencil
|
||||
className="text-muted-foreground size-3.5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-kind-select">
|
||||
{t("documents.detail.kindLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={doc.kind}
|
||||
onValueChange={(value) =>
|
||||
patch.mutate({ kind: value as InboundDocumentKindValue })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="document-kind-select" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{INBOUND_DOCUMENT_KINDS.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{t(`documents.kind.${kind}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="document-date-field">
|
||||
{t("documents.detail.dateLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="document-date-field"
|
||||
value={doc.documentDate ?? ""}
|
||||
onChange={(value) =>
|
||||
patch.mutate({ documentDate: value === "" ? null : value })
|
||||
}
|
||||
aria-label={t("documents.detail.dateLabel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.conditionsLabel")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{doc.conditionLinks.map((link) => (
|
||||
<Link
|
||||
key={link.episodeId}
|
||||
href={`/illness/${link.episodeId}`}
|
||||
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-48 items-center rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span className="truncate">{link.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
{doc.conditionLinks.length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.noConditions")}
|
||||
</span>
|
||||
) : null}
|
||||
{(episodes.data?.length ?? 0) > 0 ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 rounded-full px-2.5 text-xs"
|
||||
>
|
||||
<Plus className="size-3" aria-hidden />
|
||||
{t("documents.detail.linkCondition")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-2">
|
||||
<ul className="max-h-56 space-y-0.5 overflow-y-auto overscroll-contain">
|
||||
{episodes.data?.map((episode) => {
|
||||
const linked = doc.conditionLinks.some(
|
||||
(l) => l.episodeId === episode.id,
|
||||
);
|
||||
return (
|
||||
<li key={episode.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEpisode(episode.id)}
|
||||
className="hover:bg-muted focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
aria-pressed={linked}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
linked ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="truncate">
|
||||
{episode.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.detail.uploadedMeta", {
|
||||
date: format.date(doc.createdAt),
|
||||
size: formatBytes(doc.byteSize, locale),
|
||||
})}
|
||||
{doc.filename ? ` · ${doc.filename}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault's sticky filter rail: debounced title/filename search, the
|
||||
* nine-kind type-chip row (multi-select, OR inside the facet), condition
|
||||
* chips (episodes that actually carry links), a year segmenter (years
|
||||
* present in the corpus), and a one-tap clear with the active-facet count.
|
||||
*
|
||||
* Purely presentational — the filter state lives in the page URL and is
|
||||
* owned by `documents-view.tsx`; this bar only renders the controls. It
|
||||
* sticks below the top edge inside the shell's scroll container (sticky,
|
||||
* translucent background, border-b — no viewport-height tricks).
|
||||
*/
|
||||
import { Search, X } from "lucide-react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
import { DOCUMENT_KIND_ICONS, DOCUMENT_KIND_ORDER } from "./document-kind-meta";
|
||||
|
||||
/** Shared chip chrome — mirrors the app-wide FilterBar pill vocabulary. */
|
||||
const CHIP_CLASSES =
|
||||
"border-border bg-card text-foreground inline-flex min-h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-sm whitespace-nowrap shadow-xs transition-colors focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none";
|
||||
const CHIP_ACTIVE_CLASSES = "border-primary/40 bg-primary/10";
|
||||
|
||||
export interface ConditionChip {
|
||||
episodeId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function DocumentFilterBar({
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
searchInputRef,
|
||||
activeKinds,
|
||||
onToggleKind,
|
||||
conditionChips,
|
||||
activeEpisodeId,
|
||||
onToggleEpisode,
|
||||
years,
|
||||
activeYear,
|
||||
onToggleYear,
|
||||
activeCount,
|
||||
onClearAll,
|
||||
}: {
|
||||
searchValue: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
/** Focused by the page-level `/` shortcut. */
|
||||
searchInputRef: RefObject<HTMLInputElement | null>;
|
||||
activeKinds: ReadonlySet<InboundDocumentKindValue>;
|
||||
onToggleKind: (kind: InboundDocumentKindValue) => void;
|
||||
conditionChips: ConditionChip[];
|
||||
activeEpisodeId: string | undefined;
|
||||
onToggleEpisode: (episodeId: string) => void;
|
||||
years: number[];
|
||||
activeYear: number | undefined;
|
||||
onToggleYear: (year: number) => void;
|
||||
activeCount: number;
|
||||
onClearAll: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="document-filter-bar"
|
||||
className="bg-background/95 border-border sticky top-0 z-10 -mx-4 border-b px-4 pt-1 pb-3 backdrop-blur md:-mx-6 md:px-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-full max-w-md">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("documents.filter.searchPlaceholder")}
|
||||
aria-label={t("documents.filter.searchLabel")}
|
||||
className="pr-8 pl-9"
|
||||
aria-keyshortcuts="/"
|
||||
/>
|
||||
<kbd
|
||||
aria-hidden
|
||||
className="border-border text-muted-foreground pointer-events-none absolute top-1/2 right-2.5 hidden -translate-y-1/2 rounded border px-1.5 font-mono text-xs sm:inline-block"
|
||||
>
|
||||
/
|
||||
</kbd>
|
||||
</div>
|
||||
{activeCount > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="text-muted-foreground shrink-0"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden />
|
||||
{t("documents.filter.clear")}
|
||||
{activeCount > 1 ? (
|
||||
<span className="tabular-nums">({activeCount})</span>
|
||||
) : null}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="-mx-4 mt-3 flex [scrollbar-width:none] items-center gap-2 overflow-x-auto px-4 md:mx-0 md:flex-wrap md:overflow-visible md:px-0 [&::-webkit-scrollbar]:hidden"
|
||||
role="group"
|
||||
aria-label={t("documents.filter.groupLabel")}
|
||||
>
|
||||
<span
|
||||
role="group"
|
||||
aria-label={t("documents.filter.typeGroup")}
|
||||
className="contents"
|
||||
>
|
||||
{DOCUMENT_KIND_ORDER.map((kind) => {
|
||||
const Icon = DOCUMENT_KIND_ICONS[kind];
|
||||
const active = activeKinds.has(kind);
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggleKind(kind)}
|
||||
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" aria-hidden />
|
||||
{t(`documents.kind.${kind}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
|
||||
{conditionChips.length > 0 ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
|
||||
/>
|
||||
<span
|
||||
role="group"
|
||||
aria-label={t("documents.filter.conditionGroup")}
|
||||
className="contents"
|
||||
>
|
||||
{conditionChips.map((chip) => {
|
||||
const active = chip.episodeId === activeEpisodeId;
|
||||
return (
|
||||
<button
|
||||
key={chip.episodeId}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggleEpisode(chip.episodeId)}
|
||||
className={cn(CHIP_CLASSES, active && CHIP_ACTIVE_CLASSES)}
|
||||
>
|
||||
<span className="max-w-40 truncate">{chip.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{years.length > 0 ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden
|
||||
className="bg-border mx-1 h-5 w-px shrink-0 self-center"
|
||||
/>
|
||||
<span
|
||||
role="group"
|
||||
aria-label={t("documents.filter.yearGroup")}
|
||||
className="contents"
|
||||
>
|
||||
{years.map((year) => {
|
||||
const active = year === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={year}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onToggleYear(year)}
|
||||
className={cn(
|
||||
CHIP_CLASSES,
|
||||
"tabular-nums",
|
||||
active && CHIP_ACTIVE_CLASSES,
|
||||
)}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The vault's kind vocabulary: one Lucide glyph per document kind, in the
|
||||
* fixed display order the type-chip rail and the cards share. Icons are
|
||||
* passed as components — the consuming primitive owns size and colour per
|
||||
* the design standards.
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
File,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Pill,
|
||||
ScanLine,
|
||||
ShieldCheck,
|
||||
Stethoscope,
|
||||
Syringe,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** Display order for the type-chip rail (everyday kinds first, OTHER last). */
|
||||
export const DOCUMENT_KIND_ORDER: readonly InboundDocumentKindValue[] = [
|
||||
"DOCTOR_REPORT",
|
||||
"LAB_RESULT",
|
||||
"IMAGING",
|
||||
"DISCHARGE_LETTER",
|
||||
"PRESCRIPTION",
|
||||
"REFERRAL",
|
||||
"VACCINATION",
|
||||
"INSURANCE",
|
||||
"OTHER",
|
||||
];
|
||||
|
||||
export const DOCUMENT_KIND_ICONS: Record<
|
||||
InboundDocumentKindValue,
|
||||
ComponentType<{ className?: string }>
|
||||
> = {
|
||||
DOCTOR_REPORT: Stethoscope,
|
||||
DISCHARGE_LETTER: FileText,
|
||||
LAB_RESULT: FlaskConical,
|
||||
IMAGING: ScanLine,
|
||||
PRESCRIPTION: Pill,
|
||||
REFERRAL: ArrowRightLeft,
|
||||
INSURANCE: ShieldCheck,
|
||||
VACCINATION: Syringe,
|
||||
OTHER: File,
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* "Verknüpfen" — link existing vault documents to an illness episode from
|
||||
* the episode's detail page. A `ResponsiveSheet` with a debounced search
|
||||
* over the vault (title/filename, same recall path as the vault page) and
|
||||
* one toggle row per document; toggling fires the bulk link/unlink action
|
||||
* for that single id (idempotent by contract, no-op-success on
|
||||
* already-in-state rows) and refreshes every document read through the
|
||||
* `["documents"]` prefix.
|
||||
*
|
||||
* Shows one page (50) of matches — the search input is the way to reach an
|
||||
* older document, mirroring the vault's own recall model.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Search } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DocumentBulkResultDto,
|
||||
InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import { documentDateKey } from "./vault-utils";
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export function DocumentLinkPicker({
|
||||
episodeId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
episodeId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchDraft, setSearchDraft] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// 200 ms debounce, matching the vault's search feel.
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onSearchChange = (value: string) => {
|
||||
setSearchDraft(value);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setQuery(value.trim()), 200);
|
||||
};
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentPicker(query),
|
||||
enabled: open,
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
if (query) sp.set("q", query);
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
sp.set("limit", "50");
|
||||
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: (input: { documentId: string; linked: boolean }) =>
|
||||
apiPost<{ results: DocumentBulkResultDto[] }>(
|
||||
"/api/documents/inbound/bulk",
|
||||
{
|
||||
ids: [input.documentId],
|
||||
action: input.linked ? "unlinkEpisode" : "linkEpisode",
|
||||
episodeId,
|
||||
},
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
if (data.results.some((r) => !r.ok)) {
|
||||
toast.error(t("documents.bulk.failed"));
|
||||
}
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
onSettled: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
},
|
||||
});
|
||||
|
||||
const documents = list.data?.documents ?? [];
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("documents.linkPicker.title")}
|
||||
description={t("documents.linkPicker.description")}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
||||
aria-hidden
|
||||
/>
|
||||
<Input
|
||||
type="search"
|
||||
value={searchDraft}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t("documents.linkPicker.searchPlaceholder")}
|
||||
aria-label={t("documents.linkPicker.searchPlaceholder")}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{list.isPending && open ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("documents.list.loadError")}
|
||||
</p>
|
||||
) : documents.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("documents.linkPicker.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
|
||||
{documents.map((doc) => {
|
||||
const linked = doc.conditionLinks.some(
|
||||
(l) => l.episodeId === episodeId,
|
||||
);
|
||||
const title =
|
||||
doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
return (
|
||||
<li key={doc.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={linked}
|
||||
disabled={toggle.isPending}
|
||||
onClick={() =>
|
||||
toggle.mutate({ documentId: doc.id, linked })
|
||||
}
|
||||
className={cn(
|
||||
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
|
||||
linked && "border-primary/40 bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
|
||||
</span>
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
"text-primary size-4 shrink-0",
|
||||
linked ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault timeline: a virtualized, month-sectioned card grid windowed
|
||||
* with `@tanstack/react-virtual` over the shell's scroll container
|
||||
* (`#main-content` — single scroll owner per the design standards; the
|
||||
* timeline never brings its own scrollport). The flat item list (month
|
||||
* labels + chunked card rows) comes from `buildTimelineItems`, so the
|
||||
* mounted DOM stays bounded (< ~400 nodes) regardless of corpus size.
|
||||
*
|
||||
* Columns are measured, not breakpoint-classed: a ResizeObserver on the
|
||||
* grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps
|
||||
* the virtualizer's row model and the painted grid in lockstep.
|
||||
*
|
||||
* In-flight / failed uploads render as a small non-virtualized grid above
|
||||
* the timeline — they are few (client concurrency 3) and must appear
|
||||
* < 100 ms after file selection, before any query settles.
|
||||
*/
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentCard, UploadStateCard } from "./document-card";
|
||||
import type { UploadQueueItem } from "./use-document-upload";
|
||||
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
|
||||
|
||||
const SCROLL_CONTAINER_ID = "main-content";
|
||||
|
||||
/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */
|
||||
function columnsForWidth(width: number): number {
|
||||
if (width >= 1200) return 4;
|
||||
if (width >= 900) return 3;
|
||||
if (width >= 600) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function DocumentTimeline({
|
||||
documents,
|
||||
uploadItems,
|
||||
onDismissUpload,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
onOpen,
|
||||
onDelete,
|
||||
highlightId,
|
||||
onPrefetch,
|
||||
}: {
|
||||
documents: InboundDocumentDto[];
|
||||
uploadItems: UploadQueueItem[];
|
||||
onDismissUpload: (localId: string) => void;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
selectedIds: ReadonlySet<string>;
|
||||
onToggleSelected: (id: string, range?: boolean) => void;
|
||||
onOpen: (id: string) => void;
|
||||
/** Delete key on the focused card — the page owns the undo-able delete. */
|
||||
onDelete?: (id: string) => void;
|
||||
highlightId: string | null;
|
||||
onPrefetch?: (id: string) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [columns, setColumns] = useState(1);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
|
||||
// Roving tabindex over the card grid: exactly one card is tabbable; the
|
||||
// arrow keys move the active slot. Falls back to the first document when
|
||||
// the remembered card left the corpus (filter change, deletion).
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// Measured columns — the ResizeObserver drives the row chunking.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setColumns(columnsForWidth(el.clientWidth));
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const items = useMemo(
|
||||
() => buildTimelineItems(documents, columns),
|
||||
[documents, columns],
|
||||
);
|
||||
|
||||
// The timeline does not start at the scrollport's top edge (page header,
|
||||
// filter bar, upload row sit above it) — feed the offset to the
|
||||
// virtualizer so window positions line up. Re-measured when the content
|
||||
// above changes height (upload cards appearing/leaving) and on resize.
|
||||
useEffect(() => {
|
||||
const scrollEl = document.getElementById(SCROLL_CONTAINER_ID);
|
||||
const listEl = listRef.current;
|
||||
if (!scrollEl || !listEl) return;
|
||||
const measure = () => {
|
||||
const margin =
|
||||
listEl.getBoundingClientRect().top -
|
||||
scrollEl.getBoundingClientRect().top +
|
||||
scrollEl.scrollTop;
|
||||
setScrollMargin(margin);
|
||||
};
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => window.removeEventListener("resize", measure);
|
||||
}, [uploadItems.length, columns]);
|
||||
|
||||
// The React Compiler cannot memoize across TanStack Virtual's instance
|
||||
// API (library-level opt-out, not a fixable call-site problem) — the
|
||||
// windowing still works, the compiler just skips this component.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID),
|
||||
estimateSize: (index) => (items[index].type === "month" ? 40 : 140),
|
||||
getItemKey: (index) => items[index].key,
|
||||
overscan: 6,
|
||||
scrollMargin,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
// ── Keyboard navigation (roving tabindex) ─────────────────────────────
|
||||
// The tabbable slot: the remembered active card, else the first document.
|
||||
const rovingId =
|
||||
activeId !== null && documents.some((d) => d.id === activeId)
|
||||
? activeId
|
||||
: (documents[0]?.id ?? null);
|
||||
|
||||
// Which virtual item (grid row) a document renders in — the arrow-key
|
||||
// handler scrolls that row into the window before focusing the card.
|
||||
const rowIndexById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
items.forEach((item, index) => {
|
||||
if (item.type !== "row") return;
|
||||
for (const doc of item.documents) map.set(doc.id, index);
|
||||
});
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
const focusDocument = (id: string) => {
|
||||
setActiveId(id);
|
||||
const rowIndex = rowIndexById.get(id);
|
||||
if (rowIndex !== undefined) {
|
||||
virtualizer.scrollToIndex(rowIndex, { align: "auto" });
|
||||
}
|
||||
// The row may only mount on the next virtualizer paint — retry across
|
||||
// a few frames, bounded.
|
||||
let attempts = 0;
|
||||
const tryFocus = () => {
|
||||
const button = listRef.current?.querySelector<HTMLButtonElement>(
|
||||
`[data-document-id="${CSS.escape(id)}"] [data-slot="document-open"]`,
|
||||
);
|
||||
if (button) {
|
||||
button.focus();
|
||||
return;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts < 20) requestAnimationFrame(tryFocus);
|
||||
};
|
||||
requestAnimationFrame(tryFocus);
|
||||
};
|
||||
|
||||
const onGridKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (documents.length === 0) return;
|
||||
const currentIndex = rovingId
|
||||
? documents.findIndex((d) => d.id === rovingId)
|
||||
: 0;
|
||||
let nextIndex: number | null = null;
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
nextIndex = Math.min(documents.length - 1, currentIndex + 1);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
nextIndex = Math.max(0, currentIndex - 1);
|
||||
break;
|
||||
case "ArrowDown":
|
||||
nextIndex = Math.min(documents.length - 1, currentIndex + columns);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
nextIndex = Math.max(0, currentIndex - columns);
|
||||
break;
|
||||
case "Home":
|
||||
nextIndex = 0;
|
||||
break;
|
||||
case "End":
|
||||
nextIndex = documents.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (nextIndex !== currentIndex) {
|
||||
focusDocument(documents[nextIndex].id);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyset infinite feed: pull the next page when the window nears the end.
|
||||
const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage &&
|
||||
lastIndex >= items.length - columns * 3 - 1
|
||||
) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [
|
||||
lastIndex,
|
||||
items.length,
|
||||
columns,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div data-slot="document-timeline" className="space-y-4">
|
||||
{uploadItems.length > 0 ? (
|
||||
<div
|
||||
data-slot="document-upload-queue"
|
||||
aria-live="polite"
|
||||
className="grid gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{uploadItems.map((item) => (
|
||||
<UploadStateCard
|
||||
key={item.localId}
|
||||
item={item}
|
||||
onDismiss={onDismissUpload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* List semantics over the virtualized window: the container is one
|
||||
list, each windowed item (month label or card row) one list item —
|
||||
honest for a windowed structure where per-document posinset would
|
||||
lie whenever pages are still loading. Keyboard contract on the
|
||||
grid: arrows move the roving slot, Enter opens, Space selects,
|
||||
Delete removes (undo-able), documented on the cards. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
role="list"
|
||||
aria-label={t("documents.timeline.listLabel")}
|
||||
onKeyDown={onGridKeyDown}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const item = items[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualItem.index}
|
||||
role="listitem"
|
||||
className="absolute inset-x-0 top-0"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
virtualItem.start - scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{item.type === "month" ? (
|
||||
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
|
||||
{formatMonthLabel(item.key, locale)}
|
||||
</h2>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4 pb-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{item.documents.map((doc) => (
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
selected={selectedIds.has(doc.id)}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onOpen={onOpen}
|
||||
onDelete={onDelete}
|
||||
highlighted={highlightId === doc.id}
|
||||
tabIndex={rovingId === doc.id ? 0 : -1}
|
||||
onCardFocus={setActiveId}
|
||||
onPrefetch={onPrefetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetchingNextPage ? (
|
||||
<div
|
||||
className="text-muted-foreground flex items-center justify-center gap-2 py-4 text-sm"
|
||||
role="status"
|
||||
>
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("documents.timeline.loadingMore")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
|
||||
*
|
||||
* Filter state lives in the URL (`?q&kind&episode&year`) so every view is
|
||||
* shareable, back-button-safe, and deep-linkable from the illness page and
|
||||
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
|
||||
* virtualized; uploads appear optimistically above it in < 100 ms.
|
||||
*
|
||||
* Born-gated on the resolved `modules.inboundDocuments` flag from
|
||||
* `GET /api/auth/me` (per-user opt-in AND the operator availability layer).
|
||||
* An unauthenticated visitor bounces to login; an account without the
|
||||
* module bounces home. Every `/api/documents/inbound/*` route re-enforces
|
||||
* the gate server-side — this is a UX redirect, not the security boundary.
|
||||
*/
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
SearchX,
|
||||
Upload,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
|
||||
import {
|
||||
DOCUMENT_BULK_MAX_IDS,
|
||||
type DocumentBulkAction,
|
||||
type DocumentBulkResultDto,
|
||||
type DocumentUsageDto,
|
||||
type InboundDocumentDetailDto,
|
||||
type InboundDocumentDto,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DocumentBulkBar } from "./document-bulk-bar";
|
||||
import { DocumentDetailSheet } from "./document-detail-sheet";
|
||||
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
|
||||
import { DocumentTimeline } from "./document-timeline";
|
||||
import { UploadZone } from "./upload-zone";
|
||||
import { useDocumentUpload } from "./use-document-upload";
|
||||
import { usePageFileDrop } from "./use-page-file-drop";
|
||||
import {
|
||||
buildVaultListApiSearch,
|
||||
countActiveFilters,
|
||||
documentDateKey,
|
||||
expandRangeSelection,
|
||||
parseVaultSearchParams,
|
||||
vaultFiltersToSearch,
|
||||
} from "./vault-utils";
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export function DocumentsView() {
|
||||
const { t } = useTranslations();
|
||||
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
|
||||
// UX redirect only — the API routes enforce the gate server-side.
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!moduleEnabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [authLoading, isAuthenticated, moduleEnabled, router]);
|
||||
|
||||
// ── URL-owned filter state ────────────────────────────────────────────
|
||||
const filters = useMemo(
|
||||
() => parseVaultSearchParams(new URLSearchParams(searchParams.toString())),
|
||||
[searchParams],
|
||||
);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: DocumentVaultFilters, mode: "push" | "replace") => {
|
||||
const search = vaultFiltersToSearch(next);
|
||||
const href = search ? `${pathname}?${search}` : pathname;
|
||||
if (mode === "push") {
|
||||
router.push(href, { scroll: false });
|
||||
} else {
|
||||
router.replace(href, { scroll: false });
|
||||
}
|
||||
},
|
||||
[pathname, router],
|
||||
);
|
||||
|
||||
// Search draft debounces into the URL (200 ms); an external URL change
|
||||
// (back button, deep link) re-seeds the draft — render-phase derived-state
|
||||
// adjustment, not an effect.
|
||||
const [searchDraft, setSearchDraft] = useState(filters.q ?? "");
|
||||
const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? "");
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
if ((filters.q ?? "") !== lastUrlQ) {
|
||||
setLastUrlQ(filters.q ?? "");
|
||||
setSearchDraft(filters.q ?? "");
|
||||
}
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const trimmed = searchDraft.trim();
|
||||
if (trimmed === (filters.q ?? "")) return;
|
||||
applyFilters(
|
||||
{ ...filters, q: trimmed === "" ? undefined : trimmed },
|
||||
"replace",
|
||||
);
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchDraft, filters, applyFilters]);
|
||||
|
||||
// `/` focuses the search from anywhere on the page.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey)
|
||||
return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
const toggleKind = (kind: InboundDocumentKindValue) => {
|
||||
const current = new Set(filters.kinds ?? []);
|
||||
if (current.has(kind)) {
|
||||
current.delete(kind);
|
||||
} else {
|
||||
current.add(kind);
|
||||
}
|
||||
applyFilters(
|
||||
{
|
||||
...filters,
|
||||
kinds: current.size > 0 ? [...current].sort() : undefined,
|
||||
},
|
||||
"push",
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEpisode = (episodeId: string) => {
|
||||
applyFilters(
|
||||
{
|
||||
...filters,
|
||||
episodeId: filters.episodeId === episodeId ? undefined : episodeId,
|
||||
},
|
||||
"push",
|
||||
);
|
||||
};
|
||||
|
||||
const toggleYear = (year: number) => {
|
||||
applyFilters(
|
||||
{ ...filters, year: filters.year === year ? undefined : year },
|
||||
"push",
|
||||
);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchDraft("");
|
||||
applyFilters({}, "push");
|
||||
};
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────
|
||||
const usage = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentUsage(),
|
||||
enabled: moduleEnabled,
|
||||
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
|
||||
});
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
queryKey: queryKeys.inboundDocumentList(filters),
|
||||
enabled: moduleEnabled,
|
||||
queryFn: ({ pageParam }) =>
|
||||
apiGet<ListPage>(
|
||||
`/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`,
|
||||
),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
});
|
||||
|
||||
const documents = useMemo(
|
||||
() => list.data?.pages.flatMap((p) => p.documents) ?? [],
|
||||
[list.data],
|
||||
);
|
||||
|
||||
const upload = useDocumentUpload(
|
||||
usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined,
|
||||
);
|
||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// One intake path for every source (picker, zone drop, page-wide drop,
|
||||
// clipboard paste): pre-links to the active episode filter and feeds the
|
||||
// sr-only live region — the drop overlay itself is decorative.
|
||||
const [uploadAnnouncement, setUploadAnnouncement] = useState("");
|
||||
const enqueueRef = useRef(upload.enqueue);
|
||||
useEffect(() => {
|
||||
enqueueRef.current = upload.enqueue;
|
||||
}, [upload.enqueue]);
|
||||
const episodeIdFilter = filters.episodeId;
|
||||
const enqueueFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
enqueueRef.current(files, { episodeId: episodeIdFilter });
|
||||
setUploadAnnouncement(
|
||||
t("documents.upload.queuedAnnouncement", { count: files.length }),
|
||||
);
|
||||
},
|
||||
[episodeIdFilter, t],
|
||||
);
|
||||
const { dropActive } = usePageFileDrop(enqueueFiles);
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
|
||||
// Condition chips: every episode carrying at least one live document
|
||||
// link, served by the usage endpoint (NOT derived from the loaded corpus
|
||||
// — an old linked document pages deep must still surface its chip), plus
|
||||
// the actively filtered episode (a deep link must always show its own
|
||||
// chip, even when its last link was just removed).
|
||||
const conditionChips = useMemo<ConditionChip[]>(() => {
|
||||
const byId = new Map<string, string>();
|
||||
for (const link of usage.data?.linkedEpisodes ?? []) {
|
||||
byId.set(link.episodeId, link.name);
|
||||
}
|
||||
if (filters.episodeId && !byId.has(filters.episodeId)) {
|
||||
const episode = episodes.data?.find((e) => e.id === filters.episodeId);
|
||||
byId.set(filters.episodeId, episode?.label ?? filters.episodeId);
|
||||
}
|
||||
return [...byId.entries()]
|
||||
.map(([episodeId, name]) => ({ episodeId, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
|
||||
|
||||
// Year segmenter: years present in the loaded corpus (+ the active year).
|
||||
const years = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
for (const doc of documents) {
|
||||
set.add(Number(documentDateKey(doc).slice(0, 4)));
|
||||
}
|
||||
if (filters.year !== undefined) set.add(filters.year);
|
||||
return [...set].sort((a, b) => b - a);
|
||||
}, [documents, filters.year]);
|
||||
|
||||
// ── Selection + bulk actions ──────────────────────────────────────────
|
||||
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
|
||||
new Set(),
|
||||
);
|
||||
// Anchor for shift-click ranges: the last plainly-toggled id.
|
||||
const rangeAnchorRef = useRef<string | null>(null);
|
||||
const toggleSelected = useCallback(
|
||||
(id: string, range?: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
if (range) {
|
||||
return expandRangeSelection(
|
||||
documents.map((d) => d.id),
|
||||
prev,
|
||||
rangeAnchorRef.current,
|
||||
id,
|
||||
);
|
||||
}
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (!range) rangeAnchorRef.current = id;
|
||||
},
|
||||
[documents],
|
||||
);
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
rangeAnchorRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Escape clears the selection — unless a dialog/popover owns the key.
|
||||
useEffect(() => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('[role="dialog"],[role="menu"]')) return;
|
||||
clearSelection();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [selectedIds.size, clearSelection]);
|
||||
|
||||
/**
|
||||
* One bulk POST per ≤100-id chunk (the endpoint's cap); per-id results
|
||||
* are merged so a partial failure in any chunk surfaces once. The
|
||||
* endpoint is no-op-success on already-in-state rows, so an undo toast
|
||||
* firing twice is safe by contract.
|
||||
*/
|
||||
const bulk = useMutation({
|
||||
mutationFn: async (input: {
|
||||
ids: string[];
|
||||
action: DocumentBulkAction;
|
||||
kind?: InboundDocumentKindValue;
|
||||
episodeId?: string;
|
||||
}) => {
|
||||
const results: DocumentBulkResultDto[] = [];
|
||||
for (let i = 0; i < input.ids.length; i += DOCUMENT_BULK_MAX_IDS) {
|
||||
const chunk = input.ids.slice(i, i + DOCUMENT_BULK_MAX_IDS);
|
||||
const page = await apiPost<{ results: DocumentBulkResultDto[] }>(
|
||||
"/api/documents/inbound/bulk",
|
||||
{
|
||||
ids: chunk,
|
||||
action: input.action,
|
||||
...(input.kind !== undefined && { kind: input.kind }),
|
||||
...(input.episodeId !== undefined && {
|
||||
episodeId: input.episodeId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
results.push(...page.results);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
onSettled: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
},
|
||||
});
|
||||
|
||||
const reportBulkOutcome = useCallback(
|
||||
(results: DocumentBulkResultDto[], successMessage: string) => {
|
||||
const failed = results.filter((r) => !r.ok).length;
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
t("documents.bulk.partialFailure", {
|
||||
failed,
|
||||
total: results.length,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const runBulk = useCallback(
|
||||
(
|
||||
action: Exclude<DocumentBulkAction, "delete" | "restore">,
|
||||
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
|
||||
) => {
|
||||
const ids = [...selectedIds];
|
||||
bulk.mutate(
|
||||
{ ids, action, ...extra },
|
||||
{
|
||||
onSuccess: (results) => {
|
||||
reportBulkOutcome(
|
||||
results,
|
||||
t("documents.bulk.updated", {
|
||||
count: results.filter((r) => r.ok).length,
|
||||
}),
|
||||
);
|
||||
clearSelection();
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
},
|
||||
);
|
||||
},
|
||||
[selectedIds, bulk, reportBulkOutcome, clearSelection, t],
|
||||
);
|
||||
|
||||
const restoreBulk = useCallback(
|
||||
(ids: string[]) => {
|
||||
bulk.mutate(
|
||||
{ ids, action: "restore" },
|
||||
{
|
||||
onSuccess: (results) =>
|
||||
reportBulkOutcome(
|
||||
results,
|
||||
t("documents.bulk.restored", {
|
||||
count: results.filter((r) => r.ok).length,
|
||||
}),
|
||||
),
|
||||
onError: () => toast.error(t("documents.toast.restoreFailed")),
|
||||
},
|
||||
);
|
||||
},
|
||||
[bulk, reportBulkOutcome, t],
|
||||
);
|
||||
|
||||
const deleteBulk = useCallback(
|
||||
(ids: string[]) => {
|
||||
bulk.mutate(
|
||||
{ ids, action: "delete" },
|
||||
{
|
||||
onSuccess: (results) => {
|
||||
const okIds = results.filter((r) => r.ok).map((r) => r.id);
|
||||
const failed = results.length - okIds.length;
|
||||
if (failed > 0) {
|
||||
toast.error(
|
||||
t("documents.bulk.partialFailure", {
|
||||
failed,
|
||||
total: results.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (okIds.length > 0) {
|
||||
// ONE aggregate undo for the whole batch (bulk restore).
|
||||
toast.success(
|
||||
t("documents.bulk.deleted", { count: okIds.length }),
|
||||
{
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => restoreBulk(okIds),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
clearSelection();
|
||||
},
|
||||
onError: () => toast.error(t("documents.bulk.failed")),
|
||||
},
|
||||
);
|
||||
},
|
||||
[bulk, clearSelection, restoreBulk, t],
|
||||
);
|
||||
|
||||
// ── Detail sheet ──────────────────────────────────────────────────────
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const openDetail = useCallback((id: string) => {
|
||||
setDetailId(id);
|
||||
setDetailOpen(true);
|
||||
}, []);
|
||||
|
||||
// `?doc=<id>` deep link (used by the illness page's document rows): open
|
||||
// the detail sheet once per value; closing the sheet strips the param so
|
||||
// back/forward and refresh behave. Render-phase derived-state adjustment
|
||||
// (the file's established pattern), consumed only once auth has resolved
|
||||
// so a deep link never burns before the module flag is known. The id is
|
||||
// shape-checked before it is interpolated into an API path.
|
||||
const docParam = searchParams.get("doc");
|
||||
const [consumedDocParam, setConsumedDocParam] = useState<string | null>(null);
|
||||
if (!authLoading && moduleEnabled && docParam !== consumedDocParam) {
|
||||
setConsumedDocParam(docParam);
|
||||
if (docParam !== null && /^[a-zA-Z0-9_-]{1,40}$/.test(docParam)) {
|
||||
setDetailId(docParam);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDetailOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setDetailOpen(open);
|
||||
if (!open && searchParams.get("doc")) {
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
sp.delete("doc");
|
||||
const search = sp.toString();
|
||||
router.replace(search ? `${pathname}?${search}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
|
||||
// Hover/focus intent prefetches the detail METADATA (never the blob —
|
||||
// the blob fetch starts when the sheet mounts its preview element).
|
||||
const prefetchDetail = useCallback(
|
||||
(id: string) => {
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.inboundDocument(id),
|
||||
queryFn: () =>
|
||||
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${id}`),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
if (authLoading || !isAuthenticated || !moduleEnabled) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center" role="status">
|
||||
<Loader2
|
||||
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="sr-only">{t("nav.loadingScreen")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeCount = countActiveFilters(filters);
|
||||
const isFiltered = activeCount > 0;
|
||||
const showEmpty =
|
||||
list.isSuccess && documents.length === 0 && upload.items.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={t("documents.title")}
|
||||
description={t("documents.subtitle")}
|
||||
actions={
|
||||
<Button onClick={() => uploadInputRef.current?.click()}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.pageUpload")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<UploadZone
|
||||
usage={usage.data}
|
||||
inputRef={uploadInputRef}
|
||||
onFiles={enqueueFiles}
|
||||
/>
|
||||
|
||||
<DocumentFilterBar
|
||||
searchValue={searchDraft}
|
||||
onSearchChange={setSearchDraft}
|
||||
searchInputRef={searchInputRef}
|
||||
activeKinds={new Set(filters.kinds ?? [])}
|
||||
onToggleKind={toggleKind}
|
||||
conditionChips={conditionChips}
|
||||
activeEpisodeId={filters.episodeId}
|
||||
onToggleEpisode={toggleEpisode}
|
||||
years={years}
|
||||
activeYear={filters.year}
|
||||
onToggleYear={toggleYear}
|
||||
activeCount={activeCount}
|
||||
onClearAll={clearFilters}
|
||||
/>
|
||||
|
||||
{list.isPending ? (
|
||||
<div
|
||||
data-slot="documents-loading"
|
||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("documents.list.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
) : showEmpty && !isFiltered ? (
|
||||
<EmptyState
|
||||
icon={<FolderOpen className="size-6" aria-hidden />}
|
||||
title={t("documents.empty.title")}
|
||||
description={t("documents.empty.description")}
|
||||
ctaSize="lg"
|
||||
action={
|
||||
<Button onClick={() => uploadInputRef.current?.click()}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.empty.action")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : showEmpty && isFiltered ? (
|
||||
<EmptyState
|
||||
icon={<SearchX className="size-6" aria-hidden />}
|
||||
title={t("documents.empty.noMatchesTitle")}
|
||||
description={t("documents.empty.noMatchesDescription")}
|
||||
action={
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
{t("documents.filter.clear")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DocumentTimeline
|
||||
documents={documents}
|
||||
uploadItems={upload.items}
|
||||
onDismissUpload={upload.dismiss}
|
||||
hasNextPage={list.hasNextPage}
|
||||
isFetchingNextPage={list.isFetchingNextPage}
|
||||
onLoadMore={() => void list.fetchNextPage()}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelected={toggleSelected}
|
||||
onOpen={openDetail}
|
||||
onDelete={(id) => deleteBulk([id])}
|
||||
highlightId={upload.highlightId}
|
||||
onPrefetch={prefetchDetail}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentDetailSheet
|
||||
documentId={detailId}
|
||||
open={detailOpen}
|
||||
onOpenChange={handleDetailOpenChange}
|
||||
/>
|
||||
|
||||
{selectedIds.size > 0 ? (
|
||||
<DocumentBulkBar
|
||||
selectedCount={selectedIds.size}
|
||||
episodes={(episodes.data ?? []).map((e) => ({
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
}))}
|
||||
busy={bulk.isPending}
|
||||
onSetKind={(kind) => runBulk("setKind", { kind })}
|
||||
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
|
||||
onDelete={() => deleteBulk([...selectedIds])}
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
|
||||
itself announces through the live region below. */}
|
||||
{dropActive ? (
|
||||
<div
|
||||
aria-hidden
|
||||
data-slot="document-drop-overlay"
|
||||
className="bg-background/80 fixed inset-0 z-50 p-4 backdrop-blur-sm md:p-8"
|
||||
>
|
||||
<div className="border-primary bg-primary/5 flex h-full items-center justify-center rounded-xl border-2 border-dashed">
|
||||
<div className="flex flex-col items-center gap-2 px-4 text-center">
|
||||
<UploadCloud className="text-primary size-8" aria-hidden />
|
||||
<p className="text-base font-medium">
|
||||
{t("documents.dropOverlay.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.dropOverlay.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<p aria-live="polite" role="status" className="sr-only">
|
||||
{uploadAnnouncement}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The "Dokumente" section on an illness-episode detail page — the
|
||||
* condition side of the condition ⇄ documents linking (the document side
|
||||
* links back via the detail sheet's condition chips). Lists the episode's
|
||||
* most recent linked documents as compact rows deep-linking straight into
|
||||
* the vault (`?episode=…&doc=…` opens the detail sheet), plus the three
|
||||
* affordances: link an existing document, upload pre-linked (deep link
|
||||
* with the episode filter active — the vault threads it into the upload),
|
||||
* and the filtered "show all" view.
|
||||
*
|
||||
* Renders nothing when the documents module is off for this account —
|
||||
* module-gated surfaces never leak. An episode without documents shows a
|
||||
* quiet one-line affordance, not a teaching empty state.
|
||||
*/
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FolderOpen, Link2, Upload } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentLinkPicker } from "./document-link-picker";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import { documentDateKey } from "./vault-utils";
|
||||
|
||||
/** Compact preview size — "Alle anzeigen" carries the long tail. */
|
||||
const PREVIEW_LIMIT = 5;
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentEpisodePreview(episodeId),
|
||||
enabled: moduleEnabled,
|
||||
queryFn: () => {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("episodeId", episodeId);
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
sp.set("limit", String(PREVIEW_LIMIT + 1));
|
||||
return apiGet<ListPage>(`/api/documents/inbound?${sp.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (!moduleEnabled) return null;
|
||||
|
||||
if (list.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
title={t("documents.episodeCard.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const documents = (list.data?.documents ?? []).slice(0, PREVIEW_LIMIT);
|
||||
const hasMore =
|
||||
(list.data?.documents.length ?? 0) > PREVIEW_LIMIT ||
|
||||
list.data?.nextCursor != null;
|
||||
const vaultHref = `/documents?episode=${encodeURIComponent(episodeId)}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card data-slot="episode-documents-card">
|
||||
<CardContent className="space-y-4">
|
||||
<TileHeader
|
||||
icon={FolderOpen}
|
||||
title={t("documents.episodeCard.title")}
|
||||
right={
|
||||
documents.length > 0 ? (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{documents.length}
|
||||
{hasMore ? "+" : ""}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{list.isPending ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : documents.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("documents.episodeCard.emptyLine")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{documents.map((doc) => {
|
||||
const title =
|
||||
doc.title ?? doc.filename ?? t("documents.card.untitled");
|
||||
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
|
||||
return (
|
||||
<li key={doc.id}>
|
||||
<ListRow asChild>
|
||||
<Link
|
||||
href={`${vaultHref}&doc=${encodeURIComponent(doc.id)}`}
|
||||
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<Icon
|
||||
className="text-foreground size-5 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{title}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{format.date(`${documentDateKey(doc)}T12:00:00.000Z`)}
|
||||
</span>
|
||||
</Link>
|
||||
</ListRow>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Link2 className="size-4" aria-hidden />
|
||||
{t("documents.episodeCard.link")}
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={vaultHref}>
|
||||
<Upload className="size-4" aria-hidden />
|
||||
{t("documents.episodeCard.upload")}
|
||||
</Link>
|
||||
</Button>
|
||||
{hasMore ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground ml-auto"
|
||||
>
|
||||
<Link href={vaultHref}>
|
||||
{t("documents.episodeCard.viewAll")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<DocumentLinkPicker
|
||||
episodeId={episodeId}
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* v1.25 — pure helpers for the documents library (search-param building, date
|
||||
* grouping, provider-unsupported classification). Kept out of the client
|
||||
* component so they stay trivially unit-testable without a render harness.
|
||||
*/
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import type { DocumentListParams } from "@/lib/query-keys/documents";
|
||||
import {
|
||||
DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
type InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* Build the `/api/documents/inbound` query string from the active filters and
|
||||
* a keyset cursor. Empty / undefined fields are omitted so the URL stays clean
|
||||
* and the server applies its own defaults. The cursor is the `nextCursor` the
|
||||
* previous page returned (null on the first page).
|
||||
*/
|
||||
export function buildDocumentListSearch(
|
||||
params: DocumentListParams,
|
||||
cursor: string | null,
|
||||
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.q && params.q.trim() !== "") sp.set("q", params.q.trim());
|
||||
if (params.kind) sp.set("kind", params.kind);
|
||||
if (params.from) sp.set("from", params.from);
|
||||
if (params.to) sp.set("to", params.to);
|
||||
sp.set("sort", params.sort);
|
||||
sp.set("order", params.order);
|
||||
sp.set("limit", String(limit));
|
||||
if (cursor) sp.set("cursor", cursor);
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
/** The date a document files under — its user filing date, else its upload day. */
|
||||
export function documentDateKey(doc: InboundDocumentDto): string {
|
||||
if (doc.documentDate) return doc.documentDate;
|
||||
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
|
||||
return doc.createdAt.slice(0, 10);
|
||||
}
|
||||
|
||||
export interface DocumentDateGroup {
|
||||
/** The YYYY-MM-DD key the group files under. */
|
||||
key: string;
|
||||
documents: InboundDocumentDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Group an already-sorted document list into consecutive date buckets,
|
||||
* preserving the server's sort order. A document's bucket is its `documentDate`
|
||||
* (fallback: its upload day). The first time a date appears it opens a new
|
||||
* group; later documents on the same date join it. Order is preserved exactly
|
||||
* as received, so the server stays the single source of truth for sorting.
|
||||
*/
|
||||
export function groupDocumentsByDate(
|
||||
documents: InboundDocumentDto[],
|
||||
): DocumentDateGroup[] {
|
||||
const groups: DocumentDateGroup[] = [];
|
||||
let current: DocumentDateGroup | null = null;
|
||||
for (const doc of documents) {
|
||||
const key = documentDateKey(doc);
|
||||
if (!current || current.key !== key) {
|
||||
current = { key, documents: [doc] };
|
||||
groups.push(current);
|
||||
} else {
|
||||
current.documents.push(doc);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is the extract route's 422 "no document-scan provider
|
||||
* configured" signal. The UI surfaces this as a calm inline note (configure a
|
||||
* provider) rather than a hard error toast — the stored document is untouched.
|
||||
*/
|
||||
export function isProviderUnsupportedError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "documents.inbound.providerUnsupported"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is the extract route's "already confirmed" signal. Two
|
||||
* shapes reach here:
|
||||
* - 409 `alreadyPartlyConfirmed` — some facts approved, the rest still pending;
|
||||
* - 422 `alreadyConfirmed` — the whole document is CONFIRMED (a cross-tab race
|
||||
* can fire Extract against a document a second tab just finished confirming).
|
||||
* Re-extraction is refused server-side in both cases; the UI surfaces a calm
|
||||
* inline note rather than a hard error toast, and hides the Extract control to
|
||||
* match.
|
||||
*/
|
||||
export function isAlreadyConfirmedError(error: unknown): boolean {
|
||||
if (!(error instanceof ApiError)) return false;
|
||||
const code = error.meta?.errorCode;
|
||||
return (
|
||||
code === "documents.inbound.alreadyPartlyConfirmed" ||
|
||||
code === "documents.inbound.alreadyConfirmed"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an extract failure is the route's "local OCR not enabled" signal
|
||||
* (`documents.inbound.localOcrDisabled`, 422). The TEXT extract path refuses
|
||||
* when in-browser OCR is switched off in settings; surface a clear message that
|
||||
* names the setting rather than the generic "couldn't extract" toast.
|
||||
*/
|
||||
export function isLocalOcrDisabledError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "documents.inbound.localOcrDisabled"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a per-fact commit-failure code to the i18n key for its plain-language
|
||||
* reason. The confirm route returns HTTP 200 even when it rejects a fact into
|
||||
* its `failed[]` array (e.g. a stated unit that disagrees with the saved
|
||||
* marker), so the client must surface why — otherwise the fact silently stays
|
||||
* PENDING with the user told nothing.
|
||||
*/
|
||||
export function commitFailureReasonKey(code: string): string {
|
||||
switch (code) {
|
||||
case "observation.unitMismatch":
|
||||
return "documents.review.commitError.unitMismatch";
|
||||
case "observation.unitRequired":
|
||||
return "documents.review.commitError.unitRequired";
|
||||
default:
|
||||
return "documents.review.commitError.generic";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the single reason key that best describes a batch of failed facts: the
|
||||
* shared reason when every failure agrees, else a generic fallback. Used to
|
||||
* build the partial-failure toast after a confirm that rejected one or more
|
||||
* facts server-side.
|
||||
*/
|
||||
export function confirmFailureReasonKey(failed: { reason: string }[]): string {
|
||||
const codes = new Set(failed.map((f) => f.reason));
|
||||
if (codes.size === 1) return commitFailureReasonKey([...codes][0]);
|
||||
return "documents.review.commitError.generic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side upload ceiling. Mirrors the server's `OCR_MAX_BYTES` (12 MB) so an
|
||||
* oversized file is rejected before it is sent, instead of after a full upload
|
||||
* round-trips and returns 413.
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The class of an upload (`POST /api/documents/inbound`) failure. The route
|
||||
* reports precise, actionable codes the generic toast would throw away; this
|
||||
* maps them to a stable kind so the view can show the right message and keep
|
||||
* every `t()` key a literal call site.
|
||||
*/
|
||||
export type UploadErrorKind =
|
||||
"tooLarge" | "fileType" | "rateLimited" | "invalidMetadata" | "generic";
|
||||
|
||||
/** Classify an upload failure by the route's `errorCode`, then HTTP status. */
|
||||
export function classifyUploadError(error: unknown): UploadErrorKind {
|
||||
if (!(error instanceof ApiError)) return "generic";
|
||||
switch (error.meta?.errorCode) {
|
||||
case "documents.inbound.fileTooLarge":
|
||||
return "tooLarge";
|
||||
case "documents.inbound.fileType":
|
||||
return "fileType";
|
||||
case "documents.inbound.rateLimited":
|
||||
return "rateLimited";
|
||||
case "documents.inbound.invalidMetadata":
|
||||
return "invalidMetadata";
|
||||
}
|
||||
// A reverse proxy can strip the JSON body (and its `meta`) on some statuses;
|
||||
// fall back to the HTTP code so size / type / rate-limit still classify.
|
||||
if (error.status === 413) return "tooLarge";
|
||||
if (error.status === 415) return "fileType";
|
||||
if (error.status === 429) return "rateLimited";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD group key into a date label via the app's own date
|
||||
* formatter (`useFormatters().date`), so the group headers follow the user's
|
||||
* date-format preference (numeric) like the rest of the app instead of a textual
|
||||
* `dateStyle: "medium"` month. The key is handed over as a noon-UTC instant so
|
||||
* the calendar day never shifts under the display timezone.
|
||||
*/
|
||||
export function formatDateGroupLabel(
|
||||
key: string,
|
||||
formatDate: (value: string) => string,
|
||||
): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return key;
|
||||
return formatDate(`${key}T12:00:00.000Z`);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault's upload entry: a dashed drop-target card with a file button.
|
||||
* The `accept` list comes verbatim from the usage endpoint (HEIC is
|
||||
* deliberately absent so the iOS picker transcodes camera photos to JPEG),
|
||||
* `multiple` is on, and there is NO `capture` attribute — mobile Safari
|
||||
* then offers camera AND library. Dropping files onto the card feeds the
|
||||
* same queue as the picker.
|
||||
*
|
||||
* The quota bar renders only above 80 % usage — a calm surface until
|
||||
* storage actually becomes a topic.
|
||||
*/
|
||||
import { UploadCloud } from "lucide-react";
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
|
||||
import { formatBytes } from "./vault-utils";
|
||||
|
||||
/** Usage fraction above which the quota bar appears. */
|
||||
const QUOTA_BAR_THRESHOLD = 0.8;
|
||||
|
||||
export function UploadZone({
|
||||
usage,
|
||||
onFiles,
|
||||
inputRef,
|
||||
}: {
|
||||
/** Limits + usage from `GET /api/documents/inbound/usage` (undefined while loading). */
|
||||
usage: DocumentUsageDto | undefined;
|
||||
onFiles: (files: File[]) => void;
|
||||
/** Exposed so the page header's Upload button can open the same picker. */
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const dragDepth = useRef(0);
|
||||
|
||||
const accept = usage?.acceptedExtensions.join(",");
|
||||
const usedFraction =
|
||||
usage && usage.quotaBytes > 0 ? usage.usedBytes / usage.quotaBytes : 0;
|
||||
|
||||
const pickFiles = (list: FileList | null) => {
|
||||
if (!list || list.length === 0) return;
|
||||
onFiles(Array.from(list));
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-slot="document-upload-zone" className="space-y-2">
|
||||
<div
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={() => {
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDragOver(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
pickFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={cn(
|
||||
"border-border rounded-xl border-2 border-dashed px-4 py-6 text-center transition-colors",
|
||||
dragOver && "border-primary bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<UploadCloud
|
||||
className={cn(
|
||||
"text-muted-foreground size-6",
|
||||
dragOver && "text-primary",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-sm font-medium">
|
||||
{t("documents.upload.zoneTitle")}
|
||||
</p>
|
||||
{usage ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.upload.zoneHint", {
|
||||
maxSize: formatBytes(usage.maxFileBytes, locale),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{t("documents.upload.browse")}
|
||||
</Button>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={accept}
|
||||
className="sr-only"
|
||||
aria-label={t("documents.upload.browse")}
|
||||
onChange={(e) => {
|
||||
pickFiles(e.target.files);
|
||||
// Allow re-selecting the same file (duplicate flow) immediately.
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{usage && usedFraction >= QUOTA_BAR_THRESHOLD ? (
|
||||
<div data-slot="document-quota-bar" className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("documents.upload.quotaAlmostFull")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("documents.upload.quotaUsed", {
|
||||
used: formatBytes(usage.usedBytes, locale),
|
||||
quota: formatBytes(usage.quotaBytes, locale),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(100, usedFraction * 100)}
|
||||
aria-label={t("documents.upload.quotaUsed", {
|
||||
used: formatBytes(usage.usedBytes, locale),
|
||||
quota: formatBytes(usage.quotaBytes, locale),
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault's upload manager: a client-side queue with concurrency 3, one
|
||||
* multipart XHR POST per file (XHR because it exposes real upload-progress
|
||||
* events with zero dependencies — fetch upload streaming is not portable),
|
||||
* client-side image downscaling, and per-file terminal states.
|
||||
*
|
||||
* Perceived-speed contract: `enqueue()` adds pending entries synchronously
|
||||
* (< 100 ms — before any network work), each carrying live progress. A
|
||||
* finished file leaves the queue and the list/usage queries refetch; a
|
||||
* failed file stays as an inline error card until dismissed (an over-limit
|
||||
* file fails individually, the batch continues).
|
||||
*
|
||||
* Duplicate contract (§3.2): a same-user re-upload returns HTTP 200 with
|
||||
* envelope-level `meta.duplicate: true` and the EXISTING row — the manager
|
||||
* toasts "already stored" and hands the row id to the timeline for a brief
|
||||
* highlight. This is the one call site that must read success-side `meta`;
|
||||
* `parseUploadResponse` in `vault-utils.ts` owns that parse.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
parseUploadResponse,
|
||||
type UploadFailure,
|
||||
type UploadResult,
|
||||
} from "./vault-utils";
|
||||
|
||||
/** Client-side parallelism — matches the plan's NAS memory envelope. */
|
||||
const CONCURRENCY = 3;
|
||||
|
||||
/** Longest-edge ceiling for the client-side image downscale. */
|
||||
const IMAGE_MAX_EDGE = 3000;
|
||||
|
||||
/** How long a duplicate-highlight ring stays on the existing row. */
|
||||
const HIGHLIGHT_MS = 2600;
|
||||
|
||||
/** Only raster photo formats are re-encoded — never PDFs/Office, never GIF
|
||||
* (re-encoding would flatten an animation and GIF photos don't exist). */
|
||||
const DOWNSCALE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
|
||||
|
||||
export interface UploadQueueItem {
|
||||
/** Local id — NOT a server id; stable for the item's queue lifetime. */
|
||||
localId: string;
|
||||
fileName: string;
|
||||
byteSize: number;
|
||||
status: "uploading" | "error";
|
||||
/** 0..1 upload progress (transport bytes, not server processing). */
|
||||
progress: number;
|
||||
/** Terminal failure detail, present when `status === "error"`. */
|
||||
failure?: UploadFailure;
|
||||
}
|
||||
|
||||
export interface EnqueueOptions {
|
||||
/** Pre-link every uploaded document to this episode (deep-link uploads). */
|
||||
episodeId?: string;
|
||||
}
|
||||
|
||||
interface UploadLimits {
|
||||
maxFileBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downscale a raster photo to ≤ 3000 px longest edge (canvas, JPEG 0.85).
|
||||
* Returns the original file untouched when the type is not a raster photo,
|
||||
* the image is already small enough, or anything about decoding fails —
|
||||
* a downscale must never block an upload.
|
||||
*/
|
||||
async function downscaleImage(file: File): Promise<File> {
|
||||
if (!DOWNSCALE_TYPES.has(file.type)) return file;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const longest = Math.max(bitmap.width, bitmap.height);
|
||||
if (longest <= IMAGE_MAX_EDGE) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
const scale = IMAGE_MAX_EDGE / longest;
|
||||
const width = Math.round(bitmap.width * scale);
|
||||
const height = Math.round(bitmap.height * scale);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/jpeg", 0.85),
|
||||
);
|
||||
if (!blob) return file;
|
||||
const stem = file.name.replace(/\.[^.]+$/u, "") || "photo";
|
||||
return new File([blob], `${stem}.jpg`, { type: "image/jpeg" });
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
/** One multipart POST via XHR, reporting upload progress. */
|
||||
function uploadViaXhr(
|
||||
file: File,
|
||||
options: EnqueueOptions,
|
||||
onProgress: (fraction: number) => void,
|
||||
): Promise<UploadResult> {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/documents/inbound");
|
||||
// Retry-safe by construction: the server honours Idempotency-Key, so a
|
||||
// network blip that DID land server-side cannot double-store on retry.
|
||||
xhr.setRequestHeader("Idempotency-Key", crypto.randomUUID());
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
onProgress(event.loaded / event.total);
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
resolve(parseUploadResponse(xhr.status, xhr.responseText));
|
||||
});
|
||||
xhr.addEventListener("error", () =>
|
||||
resolve({ ok: false, reason: "generic" }),
|
||||
);
|
||||
xhr.addEventListener("abort", () =>
|
||||
resolve({ ok: false, reason: "generic" }),
|
||||
);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (options.episodeId) fd.append("episodeIds", options.episodeId);
|
||||
xhr.send(fd);
|
||||
});
|
||||
}
|
||||
|
||||
export interface DocumentUploadManager {
|
||||
/** Live queue — uploading entries first-in-first, then error entries. */
|
||||
items: UploadQueueItem[];
|
||||
/** Add files to the queue; returns immediately (optimistic entries). */
|
||||
enqueue: (files: File[], options?: EnqueueOptions) => void;
|
||||
/** Remove a terminal (error) entry from the queue. */
|
||||
dismiss: (localId: string) => void;
|
||||
/** Row id to briefly highlight (duplicate upload → the existing row). */
|
||||
highlightId: string | null;
|
||||
}
|
||||
|
||||
export function useDocumentUpload(
|
||||
limits: UploadLimits | undefined,
|
||||
): DocumentUploadManager {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [items, setItems] = useState<UploadQueueItem[]>([]);
|
||||
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||||
|
||||
const activeRef = useRef(0);
|
||||
const pendingRef = useRef<
|
||||
{ localId: string; file: File; options: EnqueueOptions }[]
|
||||
>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (highlightTimer.current) clearTimeout(highlightTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const patchItem = useCallback(
|
||||
(localId: string, patch: Partial<UploadQueueItem>) => {
|
||||
if (!mountedRef.current) return;
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.localId === localId ? { ...it, ...patch } : it)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeItem = useCallback((localId: string) => {
|
||||
if (!mountedRef.current) return;
|
||||
setItems((prev) => prev.filter((it) => it.localId !== localId));
|
||||
}, []);
|
||||
|
||||
const flashHighlight = useCallback((id: string) => {
|
||||
if (!mountedRef.current) return;
|
||||
setHighlightId(id);
|
||||
if (highlightTimer.current) clearTimeout(highlightTimer.current);
|
||||
highlightTimer.current = setTimeout(() => {
|
||||
if (mountedRef.current) setHighlightId(null);
|
||||
}, HIGHLIGHT_MS);
|
||||
}, []);
|
||||
|
||||
const settle = useCallback(
|
||||
(localId: string, result: UploadResult) => {
|
||||
if (result.ok) {
|
||||
removeItem(localId);
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
if (result.duplicate) {
|
||||
toast.info(t("documents.toast.duplicate"));
|
||||
flashHighlight(result.document.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
patchItem(localId, { status: "error", failure: result, progress: 0 });
|
||||
},
|
||||
[flashHighlight, patchItem, queryClient, removeItem, t],
|
||||
);
|
||||
|
||||
const pump = useCallback(() => {
|
||||
while (
|
||||
activeRef.current < CONCURRENCY &&
|
||||
pendingRef.current.length > 0 &&
|
||||
mountedRef.current
|
||||
) {
|
||||
const next = pendingRef.current.shift();
|
||||
if (!next) return;
|
||||
activeRef.current += 1;
|
||||
void (async () => {
|
||||
try {
|
||||
const prepared = await downscaleImage(next.file);
|
||||
const result = await uploadViaXhr(prepared, next.options, (f) =>
|
||||
patchItem(next.localId, { progress: f }),
|
||||
);
|
||||
settle(next.localId, result);
|
||||
} finally {
|
||||
activeRef.current -= 1;
|
||||
pump();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [patchItem, settle]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(files: File[], options: EnqueueOptions = {}) => {
|
||||
if (files.length === 0) return;
|
||||
const fresh: UploadQueueItem[] = [];
|
||||
for (const file of files) {
|
||||
const localId = crypto.randomUUID();
|
||||
// Client pre-flight against the server's configured cap: an obvious
|
||||
// giant (a CD image) fails instantly and locally — friendly reason,
|
||||
// no wasted transfer. The server re-enforces regardless.
|
||||
if (limits && file.size > limits.maxFileBytes) {
|
||||
fresh.push({
|
||||
localId,
|
||||
fileName: file.name,
|
||||
byteSize: file.size,
|
||||
status: "error",
|
||||
progress: 0,
|
||||
failure: {
|
||||
ok: false,
|
||||
reason: "fileTooLarge",
|
||||
maxFileBytes: limits.maxFileBytes,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
fresh.push({
|
||||
localId,
|
||||
fileName: file.name,
|
||||
byteSize: file.size,
|
||||
status: "uploading",
|
||||
progress: 0,
|
||||
});
|
||||
pendingRef.current.push({ localId, file, options });
|
||||
}
|
||||
setItems((prev) => [...prev, ...fresh]);
|
||||
pump();
|
||||
},
|
||||
[limits, pump],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
(localId: string) => removeItem(localId),
|
||||
[removeItem],
|
||||
);
|
||||
|
||||
return { items, enqueue, dismiss, highlightId };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Page-wide file intake for the vault: dragging files anywhere over the
|
||||
* window raises a full-page drop overlay (the whole page is the target —
|
||||
* not just the upload zone card), and pasting files/screenshots from the
|
||||
* clipboard feeds the same queue. Window-level listeners, mounted only
|
||||
* while `/documents` is on screen.
|
||||
*
|
||||
* Details that matter:
|
||||
* - Only FILE drags react (`dataTransfer.types` includes `"Files"`) — text
|
||||
* selections and in-page drags never raise the overlay.
|
||||
* - `dragenter`/`dragleave` fire per element; a depth counter tells "left
|
||||
* the window" apart from "moved between children".
|
||||
* - `dragover` must be prevented while a file drag is over the window or
|
||||
* the browser navigates to the dropped file on a miss.
|
||||
* - Escape dismisses the overlay (the OS drag itself cannot be cancelled
|
||||
* from the page; a subsequent drop then falls through to the browser
|
||||
* default outside the page contract).
|
||||
* - Paste only intercepts when the clipboard actually carries files, so
|
||||
* pasting text into the search input stays untouched.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function usePageFileDrop(onFiles: (files: File[]) => void): {
|
||||
dropActive: boolean;
|
||||
} {
|
||||
const [dropActive, setDropActive] = useState(false);
|
||||
const depthRef = useRef(0);
|
||||
const onFilesRef = useRef(onFiles);
|
||||
|
||||
useEffect(() => {
|
||||
onFilesRef.current = onFiles;
|
||||
}, [onFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
const isFileDrag = (event: DragEvent) =>
|
||||
event.dataTransfer?.types?.includes("Files") ?? false;
|
||||
|
||||
const onDragEnter = (event: DragEvent) => {
|
||||
if (!isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
depthRef.current += 1;
|
||||
setDropActive(true);
|
||||
};
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
if (!isFileDrag(event)) return;
|
||||
// Without this the browser handles the drop itself (navigates away).
|
||||
event.preventDefault();
|
||||
};
|
||||
const onDragLeave = (event: DragEvent) => {
|
||||
if (!isFileDrag(event)) return;
|
||||
depthRef.current = Math.max(0, depthRef.current - 1);
|
||||
if (depthRef.current === 0) setDropActive(false);
|
||||
};
|
||||
const onDrop = (event: DragEvent) => {
|
||||
if (!isFileDrag(event)) return;
|
||||
event.preventDefault();
|
||||
depthRef.current = 0;
|
||||
setDropActive(false);
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.length > 0) onFilesRef.current(files);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || depthRef.current === 0) return;
|
||||
depthRef.current = 0;
|
||||
setDropActive(false);
|
||||
};
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const files = Array.from(event.clipboardData?.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
onFilesRef.current(files);
|
||||
};
|
||||
|
||||
window.addEventListener("dragenter", onDragEnter);
|
||||
window.addEventListener("dragover", onDragOver);
|
||||
window.addEventListener("dragleave", onDragLeave);
|
||||
window.addEventListener("drop", onDrop);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => {
|
||||
window.removeEventListener("dragenter", onDragEnter);
|
||||
window.removeEventListener("dragover", onDragOver);
|
||||
window.removeEventListener("dragleave", onDragLeave);
|
||||
window.removeEventListener("drop", onDrop);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("paste", onPaste);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { dropActive };
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Pure helpers for the Dokumente vault surface: URL-facet parsing and
|
||||
* serialisation (the page's filter state lives in the URL so every view is
|
||||
* deep-linkable and back-button-safe), the API query-string builder, the
|
||||
* month bucketing + row chunking the virtualized timeline renders from, the
|
||||
* upload-response contract (including the HTTP-200 `meta.duplicate` path),
|
||||
* and byte formatting. Kept out of the client components so they stay
|
||||
* trivially unit-testable without a render harness.
|
||||
*/
|
||||
import {
|
||||
DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
type InboundDocumentDto,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
|
||||
|
||||
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
|
||||
|
||||
/**
|
||||
* Parse the vault's URL search params (`?q&kind&episode&year`) into the
|
||||
* filter object. Unknown kinds are dropped (a hand-edited URL never 422s the
|
||||
* page), `kind` accepts repeats AND comma-separated values, and the result is
|
||||
* normalised (kinds sorted, empties omitted) so the same view always produces
|
||||
* the same TanStack cache key.
|
||||
*/
|
||||
export function parseVaultSearchParams(
|
||||
params: URLSearchParams,
|
||||
): DocumentVaultFilters {
|
||||
const filters: DocumentVaultFilters = {};
|
||||
|
||||
const q = params.get("q")?.trim();
|
||||
if (q) filters.q = q.slice(0, 100);
|
||||
|
||||
const kinds = params
|
||||
.getAll("kind")
|
||||
.flatMap((v) => v.split(","))
|
||||
.map((v) => v.trim())
|
||||
.filter((v): v is InboundDocumentKindValue => KIND_SET.has(v));
|
||||
if (kinds.length > 0) filters.kinds = [...new Set(kinds)].sort();
|
||||
|
||||
const episode = params.get("episode")?.trim();
|
||||
if (episode) filters.episodeId = episode.slice(0, 40);
|
||||
|
||||
const yearRaw = params.get("year");
|
||||
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
|
||||
const year = Number(yearRaw);
|
||||
if (year >= 1900 && year <= 9999) filters.year = year;
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise the filter object back into the page URL's search string (no
|
||||
* leading `?`; empty string for the default view). Inverse of
|
||||
* `parseVaultSearchParams` — round-tripping is pinned by test so deep links
|
||||
* from the illness page / labs (`?episode=`, `?kind=LAB_RESULT`) stay stable.
|
||||
*/
|
||||
export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (filters.q) sp.set("q", filters.q);
|
||||
if (filters.kinds && filters.kinds.length > 0) {
|
||||
sp.set("kind", [...filters.kinds].sort().join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episode", filters.episodeId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
/** Count of active facets (search counts as one) for the clear-all pill. */
|
||||
export function countActiveFilters(filters: DocumentVaultFilters): number {
|
||||
let count = 0;
|
||||
if (filters.q) count += 1;
|
||||
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
|
||||
if (filters.episodeId) count += 1;
|
||||
if (filters.year !== undefined) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `/api/documents/inbound` query string from the active facets and
|
||||
* a keyset cursor. The sort is pinned to the filing date, newest first — the
|
||||
* timeline IS the sort order. The API takes `episodeId` (the page URL uses
|
||||
* the shorter `episode`).
|
||||
*/
|
||||
export function buildVaultListApiSearch(
|
||||
filters: DocumentVaultFilters,
|
||||
cursor: string | null,
|
||||
limit: number = DOCUMENT_LIST_DEFAULT_LIMIT,
|
||||
): string {
|
||||
const sp = new URLSearchParams();
|
||||
if (filters.q) sp.set("q", filters.q);
|
||||
if (filters.kinds && filters.kinds.length > 0) {
|
||||
sp.set("kind", filters.kinds.join(","));
|
||||
}
|
||||
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
|
||||
if (filters.year !== undefined) sp.set("year", String(filters.year));
|
||||
sp.set("sort", "documentDate");
|
||||
sp.set("order", "desc");
|
||||
sp.set("limit", String(limit));
|
||||
if (cursor) sp.set("cursor", cursor);
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
/** The date a document files under — its user filing date, else upload day. */
|
||||
export function documentDateKey(doc: InboundDocumentDto): string {
|
||||
if (doc.documentDate) return doc.documentDate;
|
||||
// createdAt is an ISO timestamp; the leading YYYY-MM-DD is the upload day.
|
||||
return doc.createdAt.slice(0, 10);
|
||||
}
|
||||
|
||||
/** The YYYY-MM month bucket a document files under. */
|
||||
export function documentMonthKey(doc: InboundDocumentDto): string {
|
||||
return documentDateKey(doc).slice(0, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* One flat item in the virtualized timeline: a month section label or one
|
||||
* grid row of up to `columns` documents. The virtualizer windows over this
|
||||
* flat list so the mounted DOM stays bounded regardless of corpus size.
|
||||
*/
|
||||
export type TimelineItem =
|
||||
| { type: "month"; key: string }
|
||||
| { type: "row"; key: string; documents: InboundDocumentDto[] };
|
||||
|
||||
/**
|
||||
* Bucket an already-sorted document list into consecutive YYYY-MM sections
|
||||
* and chunk each section into rows of `columns` documents. Order is preserved
|
||||
* exactly as received — the server stays the single source of truth for
|
||||
* sorting; a month opens the first time its key appears.
|
||||
*/
|
||||
export function buildTimelineItems(
|
||||
documents: InboundDocumentDto[],
|
||||
columns: number,
|
||||
): TimelineItem[] {
|
||||
const cols = Math.max(1, columns);
|
||||
const items: TimelineItem[] = [];
|
||||
let month: string | null = null;
|
||||
let row: InboundDocumentDto[] = [];
|
||||
|
||||
const flushRow = () => {
|
||||
if (row.length > 0 && month !== null) {
|
||||
items.push({ type: "row", key: `${month}:${row[0].id}`, documents: row });
|
||||
row = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const doc of documents) {
|
||||
const key = documentMonthKey(doc);
|
||||
if (key !== month) {
|
||||
flushRow();
|
||||
month = key;
|
||||
items.push({ type: "month", key });
|
||||
}
|
||||
row.push(doc);
|
||||
if (row.length === cols) flushRow();
|
||||
}
|
||||
flushRow();
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM month key as a locale month label ("März 2026"). Handed
|
||||
* over as a noon-UTC instant so the month never shifts under the display
|
||||
* timezone.
|
||||
*/
|
||||
export function formatMonthLabel(key: string, locale: string): string {
|
||||
if (!/^\d{4}-\d{2}$/.test(key)) return key;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${key}-15T12:00:00.000Z`));
|
||||
}
|
||||
|
||||
/** Locale byte formatting: 0 B / 412 KB / 1.2 MB / 2.4 GB. */
|
||||
export function formatBytes(bytes: number, locale: string): string {
|
||||
const units = ["B", "KB", "MB", "GB"] as const;
|
||||
let value = Math.max(0, bytes);
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
const digits = unit === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 1;
|
||||
return `${new Intl.NumberFormat(locale, {
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift-click range selection over the timeline's visual order. Selects
|
||||
* every id between the last plainly-toggled anchor and the target
|
||||
* (inclusive) — ADDITIVE, matching file-manager convention (a range gesture
|
||||
* never deselects). Falls back to a plain toggle when the anchor is gone
|
||||
* (filtered away, deleted) or was never set.
|
||||
*/
|
||||
export function expandRangeSelection(
|
||||
orderedIds: readonly string[],
|
||||
selected: ReadonlySet<string>,
|
||||
anchorId: string | null,
|
||||
targetId: string,
|
||||
): Set<string> {
|
||||
const next = new Set(selected);
|
||||
const anchorIndex = anchorId === null ? -1 : orderedIds.indexOf(anchorId);
|
||||
const targetIndex = orderedIds.indexOf(targetId);
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
if (next.has(targetId)) {
|
||||
next.delete(targetId);
|
||||
} else {
|
||||
next.add(targetId);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
const [from, to] =
|
||||
anchorIndex <= targetIndex
|
||||
? [anchorIndex, targetIndex]
|
||||
: [targetIndex, anchorIndex];
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
next.add(orderedIds[i]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// ─── Upload response contract (§3.2) ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The §3.2 failure reasons the upload/restore paths translate client-side,
|
||||
* plus the transport-level fallbacks. `rateLimited` maps 429; `generic`
|
||||
* covers everything unclassifiable (proxy-stripped bodies included).
|
||||
*/
|
||||
export type UploadFailureReason =
|
||||
| "fileTooLarge"
|
||||
| "quotaExceeded"
|
||||
| "unsupportedType"
|
||||
| "purged"
|
||||
| "duplicateExists"
|
||||
| "rateLimited"
|
||||
| "generic";
|
||||
|
||||
export interface UploadFailure {
|
||||
ok: false;
|
||||
reason: UploadFailureReason;
|
||||
/** §3.2 — present on `fileTooLarge`. */
|
||||
maxFileBytes?: number;
|
||||
/** §3.2 — present on `quotaExceeded`. */
|
||||
quotaBytes?: number;
|
||||
usedBytes?: number;
|
||||
/** §3.2 — present on `duplicateExists` (restore-conflict shape). */
|
||||
existingId?: string;
|
||||
}
|
||||
|
||||
export interface UploadSuccess {
|
||||
ok: true;
|
||||
document: InboundDocumentDto;
|
||||
/**
|
||||
* §3.2 — a same-user re-upload is HTTP 200 with envelope-level
|
||||
* `meta.duplicate: true` and the EXISTING live row as `data`. Not an
|
||||
* error: the UI toasts "already stored" and highlights that row.
|
||||
*/
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
export type UploadResult = UploadSuccess | UploadFailure;
|
||||
|
||||
const REASONS: ReadonlySet<string> = new Set([
|
||||
"fileTooLarge",
|
||||
"quotaExceeded",
|
||||
"unsupportedType",
|
||||
"purged",
|
||||
"duplicateExists",
|
||||
]);
|
||||
|
||||
function numberOrUndefined(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/** Map a `{ status, meta }` failure onto the translated-reason union. */
|
||||
export function classifyUploadFailure(
|
||||
status: number,
|
||||
meta: Record<string, unknown> | undefined,
|
||||
): UploadFailure {
|
||||
const reason = typeof meta?.reason === "string" ? meta.reason : "";
|
||||
if (REASONS.has(reason)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: reason as UploadFailureReason,
|
||||
maxFileBytes: numberOrUndefined(meta?.maxFileBytes),
|
||||
quotaBytes: numberOrUndefined(meta?.quotaBytes),
|
||||
usedBytes: numberOrUndefined(meta?.usedBytes),
|
||||
existingId:
|
||||
typeof meta?.existingId === "string" ? meta.existingId : undefined,
|
||||
};
|
||||
}
|
||||
// A reverse proxy can strip the JSON body (and its `meta`); fall back to
|
||||
// the HTTP status so size / type / rate-limit still classify.
|
||||
if (status === 413) return { ok: false, reason: "fileTooLarge" };
|
||||
if (status === 415) return { ok: false, reason: "unsupportedType" };
|
||||
if (status === 429) return { ok: false, reason: "rateLimited" };
|
||||
return { ok: false, reason: "generic" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a completed upload's raw response (status + body text) against the
|
||||
* envelope contract. This is the ONE place the duplicate contract lives:
|
||||
* unlike every other read (which unwraps `data` and ignores `meta`), the
|
||||
* upload MUST also read the success envelope's `meta.duplicate` flag.
|
||||
*/
|
||||
export function parseUploadResponse(
|
||||
status: number,
|
||||
bodyText: string,
|
||||
): UploadResult {
|
||||
let envelope: {
|
||||
data?: unknown;
|
||||
meta?: Record<string, unknown>;
|
||||
} | null = null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(bodyText);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
envelope = parsed as { data?: unknown; meta?: Record<string, unknown> };
|
||||
}
|
||||
} catch {
|
||||
envelope = null;
|
||||
}
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
const doc = envelope?.data;
|
||||
if (doc && typeof doc === "object" && "id" in doc) {
|
||||
return {
|
||||
ok: true,
|
||||
document: doc as InboundDocumentDto,
|
||||
duplicate: envelope?.meta?.duplicate === true,
|
||||
};
|
||||
}
|
||||
return { ok: false, reason: "generic" };
|
||||
}
|
||||
return classifyUploadFailure(status, envelope?.meta);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
import { useState } from "react";
|
||||
|
||||
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -143,6 +144,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
<IllnessCorrelationCard episodeId={episodeId} />
|
||||
)}
|
||||
|
||||
{/* Condition ⇄ documents linking, condition side: the episode's
|
||||
linked documents plus link/upload entries into the vault. Renders
|
||||
nothing when the documents module is off for this account. */}
|
||||
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
|
||||
|
||||
{episode ? (
|
||||
<>
|
||||
<LogDaySheet
|
||||
|
||||
@@ -36,6 +36,13 @@ const PUBLIC_PATHS = [
|
||||
"/c/",
|
||||
];
|
||||
|
||||
// Routes that opt into the wide content container (`max-w-screen-2xl`
|
||||
// instead of the standard `max-w-screen-xl`). The shell stays the single
|
||||
// owner of the page frame — a wide page never re-implements paddings or
|
||||
// margins; it is listed here instead. Currently: the Dokumente vault,
|
||||
// whose multi-column document timeline earns the extra width.
|
||||
const WIDE_CONTENT_PATHS = ["/documents"];
|
||||
|
||||
export function AuthShell({
|
||||
children,
|
||||
demoMode = false,
|
||||
@@ -69,6 +76,9 @@ export function AuthShell({
|
||||
pathname.startsWith("/about") ||
|
||||
pathname.startsWith("/c/");
|
||||
const isAdminPage = pathname.startsWith("/admin");
|
||||
const isWidePage = WIDE_CONTENT_PATHS.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
const isOnboardingPage = pathname === "/onboarding";
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
@@ -333,7 +343,13 @@ export function AuthShell({
|
||||
the desktop bottom-6 anchor), so the last line of content
|
||||
can always scroll out from under the floating button.
|
||||
*/}
|
||||
<div className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6">
|
||||
<div
|
||||
className={
|
||||
isWidePage
|
||||
? "mx-auto max-w-screen-2xl px-4 pt-6 pb-20 md:px-6"
|
||||
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -20,8 +20,15 @@
|
||||
* compact instead of as a long always-expanded checklist.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronDown, Download, FileText, Loader2 } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -32,6 +39,7 @@ import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -117,6 +125,7 @@ function buildSelectionSections(s: SectionState) {
|
||||
|
||||
export function HealthRecordExportPanel() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const [format, setFormat] = useState<ExportFormat>("pdf");
|
||||
const [days, setDays] = useState<number>(90);
|
||||
const [practiceName, setPracticeName] = useState("");
|
||||
@@ -250,6 +259,20 @@ export function HealthRecordExportPanel() {
|
||||
<option value="180">{t("settings.healthRecord.range180")}</option>
|
||||
<option value="365">{t("settings.healthRecord.range365")}</option>
|
||||
</NativeSelect>
|
||||
{/* Entry point into the document vault for the report period
|
||||
(navigation only — attaching documents to the PDF is a later
|
||||
phase). Year granularity is what the vault's URL filters
|
||||
offer; the current year covers the bulk of any range. Only
|
||||
rendered when the documents module is enabled. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href={`/documents?year=${new Date().getFullYear()}`}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-3.5" aria-hidden />
|
||||
{t("settings.healthRecord.documentsLink")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Binary file not shown.
@@ -264,6 +264,105 @@ export function reencryptToActive(encoded: string): string {
|
||||
return encrypt(decrypt(encoded));
|
||||
}
|
||||
|
||||
// ─── Binary codec ("binary2") ────────────────────────────────────────────────
|
||||
//
|
||||
// AES-256-GCM over raw bytes, skipping the base64 detour the string codec
|
||||
// takes (−33 % at rest, one fewer full copy in memory). Written for large
|
||||
// binary payloads (the document vault's `contentEncrypted`); the consumer
|
||||
// records which codec a row uses in an explicit column — the layout is never
|
||||
// header-sniffed against the string codec.
|
||||
//
|
||||
// Layout: [version 0x02][keyIdLen u8][keyId ascii][iv 12][tag 16][ciphertext]
|
||||
//
|
||||
// Same versioned-key discipline as the string codec: encrypt always writes
|
||||
// the active key id, decrypt is fail-closed on an unknown version, a
|
||||
// malformed header, or an unconfigured key id.
|
||||
|
||||
/** Version byte identifying the binary2 layout. */
|
||||
const BYTES_CODEC_VERSION = 0x02;
|
||||
|
||||
/** Encrypt raw bytes with the active key into the binary2 layout. */
|
||||
export function encryptBytes(plaintext: Buffer): Buffer {
|
||||
const { id, key } = getActiveKey();
|
||||
const keyId = Buffer.from(id, "ascii");
|
||||
if (keyId.byteLength < 1 || keyId.byteLength > 32) {
|
||||
// loadKeys() already constrains ids to [A-Za-z0-9_-]{1,32}; belt-and-braces.
|
||||
throw new Error(`Encryption key id '${id}' is not header-encodable`);
|
||||
}
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
const header = Buffer.from([BYTES_CODEC_VERSION, keyId.byteLength]);
|
||||
return Buffer.concat([header, keyId, iv, tag, ct]);
|
||||
}
|
||||
|
||||
/** Parse a binary2 header. Throws on any malformation (fail-closed). */
|
||||
function parseBytesHeader(payload: Buffer): {
|
||||
keyId: string;
|
||||
iv: Buffer;
|
||||
tag: Buffer;
|
||||
ct: Buffer;
|
||||
} {
|
||||
if (payload.byteLength < 2) {
|
||||
throw new Error("binary2 payload is truncated (no header)");
|
||||
}
|
||||
if (payload[0] !== BYTES_CODEC_VERSION) {
|
||||
throw new Error(
|
||||
`Unknown binary ciphertext version ${payload[0]} (expected ${BYTES_CODEC_VERSION})`,
|
||||
);
|
||||
}
|
||||
const keyIdLen = payload[1];
|
||||
if (keyIdLen < 1 || keyIdLen > 32) {
|
||||
throw new Error("binary2 payload has an invalid key-id length");
|
||||
}
|
||||
const fixed = 2 + keyIdLen + IV_LENGTH + AUTH_TAG_LENGTH;
|
||||
if (payload.byteLength < fixed) {
|
||||
throw new Error("binary2 payload is truncated");
|
||||
}
|
||||
const keyId = payload.subarray(2, 2 + keyIdLen).toString("ascii");
|
||||
if (!/^[A-Za-z0-9_-]{1,32}$/.test(keyId)) {
|
||||
throw new Error("binary2 payload carries a malformed key id");
|
||||
}
|
||||
const iv = payload.subarray(2 + keyIdLen, 2 + keyIdLen + IV_LENGTH);
|
||||
const tag = payload.subarray(2 + keyIdLen + IV_LENGTH, fixed);
|
||||
const ct = payload.subarray(fixed);
|
||||
return { keyId, iv, tag, ct };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a binary2 payload back to its raw bytes. Fail-closed: an unknown
|
||||
* version, a malformed header, or an unconfigured key id throws — the caller
|
||||
* must treat a throw as "cannot serve", never fall back to the ciphertext.
|
||||
*/
|
||||
export function decryptBytes(payload: Buffer): Buffer {
|
||||
const { keyId, iv, tag, ct } = parseBytesHeader(payload);
|
||||
const key = getKeyById(keyId);
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`Encryption key id '${keyId}' is not configured. Add it to ` +
|
||||
`ENCRYPTION_KEYS before decrypting rows written under that key.`,
|
||||
);
|
||||
}
|
||||
const dec = createDecipheriv(ALGORITHM, key, iv);
|
||||
dec.setAuthTag(tag);
|
||||
return Buffer.concat([dec.update(ct), dec.final()]);
|
||||
}
|
||||
|
||||
/** The key id a binary2 payload was written under, or null when unparsable. */
|
||||
export function extractKeyIdFromBytes(payload: Buffer): string | null {
|
||||
try {
|
||||
return parseBytesHeader(payload).keyId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-encrypt a binary2 payload with the active key. Used by rotation. */
|
||||
export function reencryptBytesToActive(payload: Buffer): Buffer {
|
||||
return encryptBytes(decryptBytes(payload));
|
||||
}
|
||||
|
||||
/** Returns the key id portion of a versioned ciphertext, or null for legacy. */
|
||||
export function extractKeyId(encoded: string): string | null {
|
||||
const dot = encoded.indexOf(".");
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import {
|
||||
encrypt,
|
||||
encryptBytes,
|
||||
extractKeyIdFromBytes,
|
||||
getActiveKeyId,
|
||||
_resetCryptoCacheForTests,
|
||||
} from "@/lib/crypto";
|
||||
import {
|
||||
rotateColumn,
|
||||
scanColumn,
|
||||
BLOB_ROTATION_BATCH_SIZE,
|
||||
type CorpusClient,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
import type { EncryptedColumn } from "@/lib/crypto/encrypted-columns";
|
||||
|
||||
/**
|
||||
* Codec-dispatched blob rotation (the document vault's `contentEncrypted`):
|
||||
* the walk is id-cursor paginated with a bounded batch size — never an
|
||||
* unbounded blob `findMany` — re-encrypts each row under its OWN codec,
|
||||
* fails closed per row on an unknown codec, and resumes cleanly after an
|
||||
* interrupted run because already-active rows are skipped.
|
||||
*/
|
||||
|
||||
const KEY_V1 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const KEY_V2 =
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
|
||||
|
||||
const DOC_COLUMN: EncryptedColumn = {
|
||||
model: "InboundDocument",
|
||||
field: "contentEncrypted",
|
||||
kind: "bytes",
|
||||
codecField: "contentCodec",
|
||||
};
|
||||
|
||||
interface DocRow {
|
||||
id: string;
|
||||
contentEncrypted: Uint8Array;
|
||||
contentCodec: string;
|
||||
}
|
||||
|
||||
interface FindManyArgs {
|
||||
select: Record<string, true>;
|
||||
orderBy?: Record<string, "asc" | "desc">;
|
||||
take?: number;
|
||||
cursor?: Record<string, unknown>;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination-honouring fake delegate: applies orderBy(id) + cursor + skip +
|
||||
* take exactly like Prisma keyset pagination, and records every findMany
|
||||
* call so the test can assert the batch boundedness.
|
||||
*/
|
||||
function makeDocClient(rows: DocRow[]): {
|
||||
client: CorpusClient;
|
||||
store: DocRow[];
|
||||
findManyCalls: FindManyArgs[];
|
||||
failUpdatesFor: Set<string>;
|
||||
} {
|
||||
const store = [...rows];
|
||||
const findManyCalls: FindManyArgs[] = [];
|
||||
const failUpdatesFor = new Set<string>();
|
||||
const client = {
|
||||
inboundDocument: {
|
||||
findMany: async (args: FindManyArgs) => {
|
||||
findManyCalls.push(args);
|
||||
let list = [...store].sort((a, b) => a.id.localeCompare(b.id));
|
||||
if (args.cursor) {
|
||||
const idx = list.findIndex((r) => r.id === args.cursor!.id);
|
||||
list = list.slice(idx + (args.skip ?? 0));
|
||||
}
|
||||
if (args.take !== undefined) list = list.slice(0, args.take);
|
||||
return list.map((row) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const f of Object.keys(args.select)) {
|
||||
out[f] = row[f as keyof DocRow];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
},
|
||||
update: async ({
|
||||
where,
|
||||
data,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
if (failUpdatesFor.has(where.id)) {
|
||||
throw new Error("simulated interruption");
|
||||
}
|
||||
const row = store.find((r) => r.id === where.id);
|
||||
if (row) Object.assign(row, data);
|
||||
return row;
|
||||
},
|
||||
},
|
||||
} as unknown as CorpusClient;
|
||||
return { client, store, findManyCalls, failUpdatesFor };
|
||||
}
|
||||
|
||||
function binaryUnder(keyId: "v1" | "v2", plaintext: Buffer): Uint8Array {
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
|
||||
_resetCryptoCacheForTests();
|
||||
const out = encryptBytes(plaintext);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
_resetCryptoCacheForTests();
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
function legacyUnder(keyId: "v1" | "v2", plaintext: string): Uint8Array {
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
|
||||
_resetCryptoCacheForTests();
|
||||
const ct = encrypt(Buffer.from(plaintext).toString("base64"));
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
_resetCryptoCacheForTests();
|
||||
return new Uint8Array(Buffer.from(ct, "utf8"));
|
||||
}
|
||||
|
||||
function pad(i: number): string {
|
||||
return `doc-${String(i).padStart(5, "0")}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({ v1: KEY_V1, v2: KEY_V2 });
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v2";
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
describe("codec-dispatched blob rotation (bounded batches)", () => {
|
||||
it("walks a 500-row corpus in bounded id-cursor batches and rotates both codecs", async () => {
|
||||
const rows: DocRow[] = [];
|
||||
// 200 binary2 under the stale v1, 150 legacy base64v1 under v1,
|
||||
// 100 binary2 already on v2 (skipped), 50 legacy already on v2 (skipped).
|
||||
for (let i = 0; i < 200; i++) {
|
||||
rows.push({
|
||||
id: pad(i),
|
||||
contentEncrypted: binaryUnder("v1", Buffer.from(`bin-${i}`)),
|
||||
contentCodec: "binary2",
|
||||
});
|
||||
}
|
||||
for (let i = 200; i < 350; i++) {
|
||||
rows.push({
|
||||
id: pad(i),
|
||||
contentEncrypted: legacyUnder("v1", `legacy-${i}`),
|
||||
contentCodec: "base64v1",
|
||||
});
|
||||
}
|
||||
for (let i = 350; i < 450; i++) {
|
||||
rows.push({
|
||||
id: pad(i),
|
||||
contentEncrypted: binaryUnder("v2", Buffer.from(`bin-${i}`)),
|
||||
contentCodec: "binary2",
|
||||
});
|
||||
}
|
||||
for (let i = 450; i < 500; i++) {
|
||||
rows.push({
|
||||
id: pad(i),
|
||||
contentEncrypted: legacyUnder("v2", `legacy-${i}`),
|
||||
contentCodec: "base64v1",
|
||||
});
|
||||
}
|
||||
|
||||
const { client, store, findManyCalls } = makeDocClient(rows);
|
||||
const result = await rotateColumn(client, DOC_COLUMN);
|
||||
|
||||
expect(result.scanned).toBe(500);
|
||||
expect(result.rotated).toBe(350);
|
||||
expect(result.errors).toBe(0);
|
||||
|
||||
// BOUNDED: every page requested at most the batch size, and the walk
|
||||
// needed ceil(500 / batch) full pages (+1 terminating empty page, since
|
||||
// 500 is an exact multiple) — never one unbounded findMany.
|
||||
expect(findManyCalls.length).toBe(
|
||||
Math.ceil(500 / BLOB_ROTATION_BATCH_SIZE) + 1,
|
||||
);
|
||||
for (const call of findManyCalls) {
|
||||
expect(call.take).toBe(BLOB_ROTATION_BATCH_SIZE);
|
||||
expect(call.orderBy).toEqual({ id: "asc" });
|
||||
}
|
||||
// Cursor advanced between pages.
|
||||
expect(findManyCalls[0].cursor).toBeUndefined();
|
||||
expect(findManyCalls[1].cursor).toEqual({ id: pad(24) });
|
||||
|
||||
// Every row now reads under the active key, each in its OWN codec.
|
||||
for (const row of store) {
|
||||
if (row.contentCodec === "binary2") {
|
||||
expect(extractKeyIdFromBytes(Buffer.from(row.contentEncrypted))).toBe(
|
||||
getActiveKeyId(),
|
||||
);
|
||||
} else {
|
||||
expect(
|
||||
Buffer.from(row.contentEncrypted).toString("utf8").startsWith("v2."),
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
// IDEMPOTENT: the second pass rotates nothing.
|
||||
const second = await rotateColumn(client, DOC_COLUMN);
|
||||
expect(second.rotated).toBe(0);
|
||||
expect(second.errors).toBe(0);
|
||||
});
|
||||
|
||||
it("resumes after an interrupted run (already-rotated rows are skipped)", async () => {
|
||||
const rows: DocRow[] = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
rows.push({
|
||||
id: pad(i),
|
||||
contentEncrypted: binaryUnder("v1", Buffer.from(`bin-${i}`)),
|
||||
contentCodec: "binary2",
|
||||
});
|
||||
}
|
||||
const { client, failUpdatesFor } = makeDocClient(rows);
|
||||
|
||||
// First run: the last 20 updates fail (simulated interruption) — they
|
||||
// are counted, never dropped.
|
||||
for (let i = 40; i < 60; i++) failUpdatesFor.add(pad(i));
|
||||
const first = await rotateColumn(client, DOC_COLUMN);
|
||||
expect(first.rotated).toBe(40);
|
||||
expect(first.errors).toBe(20);
|
||||
|
||||
// Second run with the interruption cleared: exactly the remainder
|
||||
// rotates — the 40 already-active rows are skipped.
|
||||
failUpdatesFor.clear();
|
||||
const second = await rotateColumn(client, DOC_COLUMN);
|
||||
expect(second.rotated).toBe(20);
|
||||
expect(second.errors).toBe(0);
|
||||
});
|
||||
|
||||
it("fails closed per row on an unknown codec and leaves the row untouched", async () => {
|
||||
const strange = new Uint8Array(Buffer.from("???strange???"));
|
||||
const { client, store } = makeDocClient([
|
||||
{
|
||||
id: pad(0),
|
||||
contentEncrypted: binaryUnder("v1", Buffer.from("fine")),
|
||||
contentCodec: "binary2",
|
||||
},
|
||||
{ id: pad(1), contentEncrypted: strange, contentCodec: "codec9" },
|
||||
]);
|
||||
|
||||
const result = await rotateColumn(client, DOC_COLUMN);
|
||||
expect(result.rotated).toBe(1);
|
||||
expect(result.errors).toBe(1);
|
||||
expect(store.find((r) => r.id === pad(1))!.contentEncrypted).toBe(strange);
|
||||
});
|
||||
|
||||
it("scanColumn buckets blob rows per codec-aware key id in bounded batches", async () => {
|
||||
const { client, findManyCalls } = makeDocClient([
|
||||
{
|
||||
id: pad(0),
|
||||
contentEncrypted: binaryUnder("v1", Buffer.from("a")),
|
||||
contentCodec: "binary2",
|
||||
},
|
||||
{
|
||||
id: pad(1),
|
||||
contentEncrypted: legacyUnder("v1", "b"),
|
||||
contentCodec: "base64v1",
|
||||
},
|
||||
{
|
||||
id: pad(2),
|
||||
contentEncrypted: binaryUnder("v2", Buffer.from("c")),
|
||||
contentCodec: "binary2",
|
||||
},
|
||||
]);
|
||||
const scan = await scanColumn(client, DOC_COLUMN);
|
||||
expect(scan.total).toBe(3);
|
||||
expect(scan.byKeyId.v1).toBe(2);
|
||||
expect(scan.byKeyId.v2).toBe(1);
|
||||
expect(scan.legacy).toBe(0);
|
||||
for (const call of findManyCalls) {
|
||||
expect(call.take).toBe(BLOB_ROTATION_BATCH_SIZE);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,15 @@ export interface EncryptedColumn {
|
||||
/** Prisma field name (camelCase, as declared in schema.prisma). */
|
||||
readonly field: string;
|
||||
readonly kind: EncryptedColumnKind;
|
||||
/**
|
||||
* Present when the column's ciphertext layout is dispatched by a sibling
|
||||
* codec column (the document vault's `contentCodec`: "base64v1" = the
|
||||
* `encrypt()`-string-as-UTF-8 shape every other Bytes column uses,
|
||||
* "binary2" = the binary `encryptBytes()` layout). A codec-dispatched
|
||||
* column also rotates in bounded id-cursor batches — its rows are
|
||||
* multi-megabyte blobs, so an unbounded `findMany` would balloon memory.
|
||||
*/
|
||||
readonly codecField?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,11 +188,19 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
|
||||
{ model: "Allergy", field: "notesEncrypted", kind: "bytes" },
|
||||
{ model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" },
|
||||
|
||||
// ───── v1.25 (W-DOCS-IN) inbound clinical document (Bytes column) ─────
|
||||
// The raw uploaded doctor report / discharge letter, base64-of-binary →
|
||||
// AES-256-GCM string → UTF-8 bytes. The most sensitive blob in the wave
|
||||
// (a full clinical document); never logged, never in wide-event meta.
|
||||
{ model: "InboundDocument", field: "contentEncrypted", kind: "bytes" },
|
||||
// ───── Inbound clinical document (Bytes column, codec-dispatched) ─────
|
||||
// The raw uploaded document (the most sensitive blob in the corpus; never
|
||||
// logged, never in wide-event meta). Two layouts, recorded per row in
|
||||
// `contentCodec`: "base64v1" (pre-vault rows — base64-of-binary →
|
||||
// AES-256-GCM string → UTF-8 bytes) and "binary2" (vault uploads — the
|
||||
// binary `encryptBytes()` layout). Rotation walks this column in bounded
|
||||
// id-cursor batches and re-encrypts under the row's own codec.
|
||||
{
|
||||
model: "InboundDocument",
|
||||
field: "contentEncrypted",
|
||||
kind: "bytes",
|
||||
codecField: "contentCodec",
|
||||
},
|
||||
// The staged extracted-fact payloads: the FHIR-staged clinical values
|
||||
// (diagnosis text / lab values / medication names / stated codes) and the
|
||||
// verbatim source-span provenance. Both are PHI transcribed from the source
|
||||
|
||||
@@ -30,7 +30,14 @@
|
||||
* protects against dropping a legacy key too early.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import { decrypt, encrypt, extractKeyId, getActiveKeyId } from "@/lib/crypto";
|
||||
import {
|
||||
decrypt,
|
||||
encrypt,
|
||||
extractKeyId,
|
||||
extractKeyIdFromBytes,
|
||||
getActiveKeyId,
|
||||
reencryptBytesToActive,
|
||||
} from "@/lib/crypto";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
encryptedColumnKey,
|
||||
@@ -40,10 +47,21 @@ import {
|
||||
/** Sentinel bucket for legacy (unversioned) ciphertext under `byKeyId`. */
|
||||
export const LEGACY_BUCKET = "legacy";
|
||||
|
||||
/**
|
||||
* Batch size for codec-dispatched blob columns (`codecField` present). The
|
||||
* document vault's rows are up to cap-sized ciphertexts, so the walk is
|
||||
* id-cursor paginated — at most this many blobs are in memory at once.
|
||||
*/
|
||||
export const BLOB_ROTATION_BATCH_SIZE = 25;
|
||||
|
||||
/** Minimal Prisma delegate shape this module needs. */
|
||||
interface ColumnDelegate {
|
||||
findMany: (args: {
|
||||
select: Record<string, true>;
|
||||
orderBy?: Record<string, "asc" | "desc">;
|
||||
take?: number;
|
||||
cursor?: Record<string, unknown>;
|
||||
skip?: number;
|
||||
}) => Promise<Array<Record<string, unknown>>>;
|
||||
update: (args: {
|
||||
where: { id: string };
|
||||
@@ -98,6 +116,78 @@ function shouldRotate(ciphertext: string): boolean {
|
||||
return extractKeyId(ciphertext) !== getActiveKeyId();
|
||||
}
|
||||
|
||||
// ─── Codec-dispatched blob columns (document vault) ─────────────────────────
|
||||
|
||||
/**
|
||||
* The key id a codec-dispatched blob row was written under, or null when
|
||||
* legacy/unparsable. "binary2" parses the binary header; every other codec
|
||||
* value (notably "base64v1") is the `encrypt()`-string-as-UTF-8 shape.
|
||||
*/
|
||||
function blobKeyId(value: Uint8Array, codec: string): string | null {
|
||||
const buf = Buffer.from(value);
|
||||
if (codec === "binary2") return extractKeyIdFromBytes(buf);
|
||||
return extractKeyId(buf.toString("utf8"));
|
||||
}
|
||||
|
||||
/** Re-encrypt one codec-dispatched blob under its OWN codec (never converts). */
|
||||
function reencryptBlob(value: Uint8Array, codec: string): Uint8Array {
|
||||
const buf = Buffer.from(value);
|
||||
if (codec === "binary2") {
|
||||
const rotated = reencryptBytesToActive(buf);
|
||||
const next = new Uint8Array(new ArrayBuffer(rotated.byteLength));
|
||||
next.set(rotated);
|
||||
return next;
|
||||
}
|
||||
if (codec === "base64v1") {
|
||||
const rotated = encrypt(decrypt(buf.toString("utf8")));
|
||||
const encoded = Buffer.from(rotated, "utf8");
|
||||
const next = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
||||
next.set(encoded);
|
||||
return next;
|
||||
}
|
||||
// FAIL-CLOSED: an unknown codec is counted as an error and left untouched.
|
||||
throw new Error(`Unknown content codec '${codec}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a codec-dispatched blob column in bounded id-cursor batches, invoking
|
||||
* `onRow` per non-empty row. At most `BLOB_ROTATION_BATCH_SIZE` blobs are in
|
||||
* memory per step; an interrupted run resumes safely on re-invocation because
|
||||
* processing is idempotent (already-active rows are skipped by the callers).
|
||||
*/
|
||||
async function walkBlobColumn(
|
||||
delegate: ColumnDelegate,
|
||||
col: EncryptedColumn,
|
||||
onRow: (row: {
|
||||
id: string;
|
||||
value: Uint8Array;
|
||||
codec: string;
|
||||
}) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const codecField = col.codecField!;
|
||||
let cursor: string | null = null;
|
||||
for (;;) {
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true, [codecField]: true },
|
||||
orderBy: { id: "asc" },
|
||||
take: BLOB_ROTATION_BATCH_SIZE,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
if (rows.length === 0) break;
|
||||
for (const row of rows) {
|
||||
const value = row[col.field] as Uint8Array | null;
|
||||
if (!value || value.byteLength === 0) continue;
|
||||
await onRow({
|
||||
id: row.id as string,
|
||||
value,
|
||||
codec: String(row[codecField] ?? ""),
|
||||
});
|
||||
}
|
||||
cursor = rows[rows.length - 1]!.id as string;
|
||||
if (rows.length < BLOB_ROTATION_BATCH_SIZE) break;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ColumnScan {
|
||||
model: string;
|
||||
field: string;
|
||||
@@ -116,17 +206,27 @@ export async function scanColumn(
|
||||
col: EncryptedColumn,
|
||||
): Promise<ColumnScan> {
|
||||
const delegate = getDelegate(client, col.model);
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true },
|
||||
});
|
||||
const byKeyId: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
const ciphertext = toCiphertext(row[col.field], col.kind);
|
||||
if (ciphertext == null) continue;
|
||||
total += 1;
|
||||
const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET;
|
||||
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
|
||||
|
||||
if (col.codecField) {
|
||||
// Codec-dispatched blob column: bounded batches, per-row codec.
|
||||
await walkBlobColumn(delegate, col, ({ value, codec }) => {
|
||||
total += 1;
|
||||
const id = blobKeyId(value, codec) ?? LEGACY_BUCKET;
|
||||
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
|
||||
});
|
||||
} else {
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
const ciphertext = toCiphertext(row[col.field], col.kind);
|
||||
if (ciphertext == null) continue;
|
||||
total += 1;
|
||||
const id = extractKeyId(ciphertext) ?? LEGACY_BUCKET;
|
||||
byKeyId[id] = (byKeyId[id] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
model: col.model,
|
||||
@@ -192,16 +292,41 @@ export async function rotateColumn(
|
||||
col: EncryptedColumn,
|
||||
): Promise<RotationResult> {
|
||||
const delegate = getDelegate(client, col.model);
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true },
|
||||
});
|
||||
const result: RotationResult = {
|
||||
model: col.model,
|
||||
field: col.field,
|
||||
scanned: rows.length,
|
||||
scanned: 0,
|
||||
rotated: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
if (col.codecField) {
|
||||
// Codec-dispatched blob column: bounded id-cursor batches (never an
|
||||
// unbounded blob findMany), re-encrypted under each row's OWN codec.
|
||||
// Idempotent — rows already on the active key are skipped, so an
|
||||
// interrupted run resumes cleanly on the next invocation.
|
||||
await walkBlobColumn(delegate, col, async ({ id, value, codec }) => {
|
||||
result.scanned += 1;
|
||||
if (blobKeyId(value, codec) === getActiveKeyId()) return;
|
||||
try {
|
||||
await delegate.update({
|
||||
where: { id },
|
||||
data: { [col.field]: reencryptBlob(value, codec) },
|
||||
});
|
||||
result.rotated += 1;
|
||||
} catch {
|
||||
// FAIL-CLOSED: unknown codec or a no-longer-configured key throws;
|
||||
// count it and leave the row untouched rather than dropping data.
|
||||
result.errors += 1;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const rows = await delegate.findMany({
|
||||
select: { id: true, [col.field]: true },
|
||||
});
|
||||
result.scanned = rows.length;
|
||||
for (const row of rows) {
|
||||
const ciphertext = toCiphertext(row[col.field], col.kind);
|
||||
if (ciphertext == null || !shouldRotate(ciphertext)) continue;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
/**
|
||||
* Document content codec dispatch: new uploads write "binary2", pre-vault
|
||||
* rows keep decoding via the legacy "base64v1" string path, and an unknown
|
||||
* codec value fails closed instead of guessing.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { _resetCryptoCacheForTests } from "@/lib/crypto";
|
||||
import {
|
||||
ACTIVE_DOCUMENT_CODEC,
|
||||
decryptDocumentContent,
|
||||
decryptDocumentFromBytes,
|
||||
encryptDocumentContent,
|
||||
encryptDocumentToBytes,
|
||||
} from "../store";
|
||||
|
||||
const KEY_V1 =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const SAMPLE = Buffer.concat([
|
||||
Buffer.from("%PDF-1.7\n"),
|
||||
Buffer.from([0x00, 0xff, 0x80, 0x7f]),
|
||||
Buffer.alloc(2048, 0xab),
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv("ENCRYPTION_KEYS", "");
|
||||
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
|
||||
vi.stubEnv("ENCRYPTION_KEY", KEY_V1);
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
describe("document content codecs", () => {
|
||||
it("writes new uploads with the binary2 codec and round-trips them", () => {
|
||||
const { content, codec } = encryptDocumentContent(SAMPLE);
|
||||
expect(codec).toBe("binary2");
|
||||
expect(codec).toBe(ACTIVE_DOCUMENT_CODEC);
|
||||
expect(Buffer.from(content).equals(SAMPLE)).toBe(false);
|
||||
expect(decryptDocumentContent(content, codec).equals(SAMPLE)).toBe(true);
|
||||
});
|
||||
|
||||
it("still decodes legacy base64v1 rows byte-identically", () => {
|
||||
// A pre-vault row: base64-of-binary → encrypt() string → UTF-8 bytes.
|
||||
const legacy = encryptDocumentToBytes(SAMPLE);
|
||||
expect(decryptDocumentContent(legacy, "base64v1").equals(SAMPLE)).toBe(
|
||||
true,
|
||||
);
|
||||
// The legacy helper itself stays the exact inverse.
|
||||
expect(decryptDocumentFromBytes(legacy).equals(SAMPLE)).toBe(true);
|
||||
});
|
||||
|
||||
it("binary2 is ~25 % smaller at rest than the base64 detour", () => {
|
||||
const legacy = encryptDocumentToBytes(SAMPLE);
|
||||
const { content } = encryptDocumentContent(SAMPLE);
|
||||
expect(content.byteLength).toBeLessThan(legacy.byteLength * 0.8);
|
||||
});
|
||||
|
||||
it("fails closed on an unknown codec value", () => {
|
||||
const { content } = encryptDocumentContent(SAMPLE);
|
||||
expect(() => decryptDocumentContent(content, "codec9")).toThrow(
|
||||
/unknown document content codec/i,
|
||||
);
|
||||
expect(() => decryptDocumentContent(content, "")).toThrow();
|
||||
});
|
||||
|
||||
it("fails closed when codecs are crossed (no silent fallback)", () => {
|
||||
const legacy = encryptDocumentToBytes(SAMPLE);
|
||||
const { content: binary } = encryptDocumentContent(SAMPLE);
|
||||
// Legacy bytes read as binary2 → header parse fails.
|
||||
expect(() => decryptDocumentContent(legacy, "binary2")).toThrow();
|
||||
// Binary bytes read as base64v1 → string decrypt fails.
|
||||
expect(() => decryptDocumentContent(binary, "base64v1")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Document-vault policy layer: exhaustive accept-matrix coverage.
|
||||
*
|
||||
* Every Class A and Class B format is detected from real magic-byte
|
||||
* fixtures, every deny case is denied, the wire Content-Type never plays a
|
||||
* role (the detector does not even receive it), container ambiguity (ZIP →
|
||||
* OOXML only under an Office extension) is pinned, adversarial polyglots
|
||||
* behave as designed, and the limits resolver clamps to the hard ceiling.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
DOCUMENT_DEFAULT_MAX_FILE_BYTES,
|
||||
DOCUMENT_DEFAULT_QUOTA_BYTES,
|
||||
DOCUMENT_HARD_MAX_FILE_BYTES,
|
||||
detectDocumentType,
|
||||
resolveDocumentLimits,
|
||||
servingClassFor,
|
||||
} from "../upload-policy";
|
||||
|
||||
// ─── Magic-byte fixtures ─────────────────────────────────────────────────────
|
||||
|
||||
const PDF = Buffer.from("%PDF-1.7\n%âãÏÓ\n1 0 obj\n", "latin1");
|
||||
const JPEG = Buffer.concat([
|
||||
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
|
||||
Buffer.alloc(16, 0x11),
|
||||
]);
|
||||
const PNG = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
Buffer.alloc(16, 0x22),
|
||||
]);
|
||||
const WEBP = Buffer.concat([
|
||||
Buffer.from("RIFF"),
|
||||
Buffer.from([0x24, 0x00, 0x00, 0x00]),
|
||||
Buffer.from("WEBPVP8 "),
|
||||
Buffer.alloc(8, 0x33),
|
||||
]);
|
||||
const GIF87 = Buffer.concat([Buffer.from("GIF87a"), Buffer.alloc(16, 0x44)]);
|
||||
const GIF89 = Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(16, 0x44)]);
|
||||
const ZIP = Buffer.concat([
|
||||
Buffer.from([0x50, 0x4b, 0x03, 0x04]),
|
||||
Buffer.alloc(32, 0x55),
|
||||
]);
|
||||
const CFB = Buffer.concat([
|
||||
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
|
||||
Buffer.alloc(32, 0x66),
|
||||
]);
|
||||
const RTF = Buffer.from("{\\rtf1\\ansi Hello}", "latin1");
|
||||
const TIFF_LE = Buffer.concat([
|
||||
Buffer.from([0x49, 0x49, 0x2a, 0x00]),
|
||||
Buffer.alloc(16, 0x77),
|
||||
]);
|
||||
const TIFF_BE = Buffer.concat([
|
||||
Buffer.from([0x4d, 0x4d, 0x00, 0x2a]),
|
||||
Buffer.alloc(16, 0x77),
|
||||
]);
|
||||
|
||||
/** ISO-BMFF ftyp box with the given major + compatible brands. */
|
||||
function ftyp(major: string, compat: string[] = []): Buffer {
|
||||
const brands = Buffer.from([major, "0000", ...compat].join(""), "ascii");
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(8 + brands.byteLength);
|
||||
return Buffer.concat([len, Buffer.from("ftyp"), brands, Buffer.alloc(8)]);
|
||||
}
|
||||
|
||||
const TEXT = Buffer.from("Befund: alles unauffällig.\nZeile 2\n", "utf8");
|
||||
const MZ = Buffer.concat([Buffer.from("MZ"), Buffer.alloc(64, 0x90)]);
|
||||
const ELF = Buffer.concat([
|
||||
Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
|
||||
Buffer.alloc(32, 0),
|
||||
]);
|
||||
const MACHO = Buffer.concat([
|
||||
Buffer.from([0xcf, 0xfa, 0xed, 0xfe]),
|
||||
Buffer.alloc(32, 0),
|
||||
]);
|
||||
|
||||
describe("detectDocumentType — Class A (inline; magic bytes only)", () => {
|
||||
it.each([
|
||||
["report.pdf", PDF, "application/pdf"],
|
||||
["photo.jpg", JPEG, "image/jpeg"],
|
||||
["scan.png", PNG, "image/png"],
|
||||
["scan.webp", WEBP, "image/webp"],
|
||||
["anim87.gif", GIF87, "image/gif"],
|
||||
["anim89.gif", GIF89, "image/gif"],
|
||||
])("%s → %s inline", (name, bytes, mime) => {
|
||||
expect(detectDocumentType(bytes, name)).toEqual({
|
||||
mimeType: mime,
|
||||
servingClass: "inline",
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies by bytes, not extension (JPEG named .dat is still a JPEG)", () => {
|
||||
expect(detectDocumentType(JPEG, "export.dat")).toEqual({
|
||||
mimeType: "image/jpeg",
|
||||
servingClass: "inline",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectDocumentType — Class B (attachment-only)", () => {
|
||||
it.each([
|
||||
[
|
||||
"letter.docx",
|
||||
ZIP,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
],
|
||||
[
|
||||
"values.xlsx",
|
||||
ZIP,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
],
|
||||
[
|
||||
"slides.pptx",
|
||||
ZIP,
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
],
|
||||
["letter.doc", CFB, "application/msword"],
|
||||
["values.xls", CFB, "application/vnd.ms-excel"],
|
||||
["slides.ppt", CFB, "application/vnd.ms-powerpoint"],
|
||||
["note.rtf", RTF, "application/rtf"],
|
||||
["scan.tif", TIFF_LE, "image/tiff"],
|
||||
["scan.tiff", TIFF_BE, "image/tiff"],
|
||||
["befund.txt", TEXT, "text/plain"],
|
||||
["befund.md", TEXT, "text/plain"],
|
||||
["werte.csv", TEXT, "text/csv"],
|
||||
["export.xml", TEXT, "application/xml"],
|
||||
["export.json", TEXT, "application/json"],
|
||||
])("%s → %s attachment", (name, bytes, mime) => {
|
||||
expect(detectDocumentType(bytes, name)).toEqual({
|
||||
mimeType: mime,
|
||||
servingClass: "attachment",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["heic (major brand)", ftyp("heic")],
|
||||
["heix (major brand)", ftyp("heix")],
|
||||
["hevc (major brand)", ftyp("hevc")],
|
||||
["mif1 (major brand)", ftyp("mif1")],
|
||||
["msf1 (major brand)", ftyp("msf1")],
|
||||
["compatible-brand heic", ftyp("isom", ["heic"])],
|
||||
])("HEIC/HEIF %s → image/heic attachment", (_label, bytes) => {
|
||||
expect(detectDocumentType(bytes, "photo.heic")).toEqual({
|
||||
mimeType: "image/heic",
|
||||
servingClass: "attachment",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectDocumentType — deny", () => {
|
||||
it.each([
|
||||
["MZ executable", MZ, "payload.pdf"],
|
||||
["ELF executable", ELF, "scan.png"],
|
||||
["Mach-O executable", MACHO, "report.txt"],
|
||||
])("%s is denied regardless of a friendly extension", (_l, bytes, name) => {
|
||||
expect(detectDocumentType(bytes, name)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
".exe",
|
||||
".dll",
|
||||
".bat",
|
||||
".cmd",
|
||||
".sh",
|
||||
".ps1",
|
||||
".js",
|
||||
".mjs",
|
||||
".jar",
|
||||
".apk",
|
||||
".html",
|
||||
".htm",
|
||||
".xhtml",
|
||||
".svg",
|
||||
".zip",
|
||||
".7z",
|
||||
".rar",
|
||||
".tar",
|
||||
".gz",
|
||||
".iso",
|
||||
".img",
|
||||
".dmg",
|
||||
])("extension %s is denied even with harmless text bytes", (ext) => {
|
||||
expect(detectDocumentType(TEXT, `file${ext}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("denies a ZIP container without an Office extension (incl. no name)", () => {
|
||||
expect(detectDocumentType(ZIP, "archive.zip")).toBeNull();
|
||||
expect(detectDocumentType(ZIP, "archive")).toBeNull();
|
||||
expect(detectDocumentType(ZIP, null)).toBeNull();
|
||||
// CFB without a legacy-Office extension is equally refused.
|
||||
expect(detectDocumentType(CFB, "blob.bin")).toBeNull();
|
||||
});
|
||||
|
||||
it("denies text-extension files smuggling NUL bytes", () => {
|
||||
const binaryish = Buffer.concat([TEXT, Buffer.from([0x00]), TEXT]);
|
||||
expect(detectDocumentType(binaryish, "befund.txt")).toBeNull();
|
||||
});
|
||||
|
||||
it("denies unidentifiable bytes and empty files", () => {
|
||||
expect(detectDocumentType(Buffer.from([1, 2, 3, 4, 5, 6]), "x.bin")).toBe(
|
||||
null,
|
||||
);
|
||||
expect(detectDocumentType(Buffer.alloc(0), "empty.txt")).toBeNull();
|
||||
// Text-like bytes under a non-text extension stay unidentifiable.
|
||||
expect(detectDocumentType(TEXT, "notes.abc")).toBeNull();
|
||||
});
|
||||
|
||||
it("adversarial: SVG content cannot sneak in under an image extension", () => {
|
||||
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>');
|
||||
expect(detectDocumentType(svg, "image.svg")).toBeNull(); // ext deny
|
||||
expect(detectDocumentType(svg, "image.png")).toBeNull(); // no PNG magic, no text ext
|
||||
});
|
||||
|
||||
it("adversarial: HTML under a text extension stays attachment-only", () => {
|
||||
// Accepted as text/plain — but Class B never renders inline, so the
|
||||
// markup can never execute in-origin.
|
||||
const html = Buffer.from("<!doctype html><script>alert(1)</script>");
|
||||
expect(detectDocumentType(html, "page.txt")).toEqual({
|
||||
mimeType: "text/plain",
|
||||
servingClass: "attachment",
|
||||
});
|
||||
expect(detectDocumentType(html, "page.html")).toBeNull();
|
||||
});
|
||||
|
||||
it("adversarial: a PDF with an HTML head is still a PDF (sandboxed inline)", () => {
|
||||
const polyglot = Buffer.concat([
|
||||
Buffer.from("%PDF-1.4\n"),
|
||||
Buffer.from("<script>alert(1)</script>"),
|
||||
]);
|
||||
expect(detectDocumentType(polyglot, "poly.pdf")).toEqual({
|
||||
mimeType: "application/pdf",
|
||||
servingClass: "inline",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("servingClassFor", () => {
|
||||
it("marks exactly the browser-native passive set inline", () => {
|
||||
for (const mime of [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]) {
|
||||
expect(servingClassFor(mime)).toBe("inline");
|
||||
}
|
||||
});
|
||||
|
||||
it("marks every other stored type attachment (incl. unknowns)", () => {
|
||||
for (const mime of [
|
||||
"image/heic",
|
||||
"image/tiff",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"text/plain",
|
||||
"text/csv",
|
||||
"application/rtf",
|
||||
"application/xml",
|
||||
"application/json",
|
||||
"text/html", // must never happen at rest, but even then: attachment
|
||||
"image/svg+xml",
|
||||
"application/octet-stream",
|
||||
]) {
|
||||
expect(servingClassFor(mime)).toBe("attachment");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDocumentLimits", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("falls back to the defaults with no settings row and no override", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
await expect(resolveDocumentLimits("u1")).resolves.toEqual({
|
||||
maxFileBytes: DOCUMENT_DEFAULT_MAX_FILE_BYTES,
|
||||
quotaBytes: DOCUMENT_DEFAULT_QUOTA_BYTES,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps the per-file cap to the hard 100 MiB ceiling", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
documentMaxFileBytes: 999_999_999_999,
|
||||
documentQuotaBytes: BigInt(DOCUMENT_DEFAULT_QUOTA_BYTES),
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
|
||||
const limits = await resolveDocumentLimits("u1");
|
||||
expect(limits.maxFileBytes).toBe(DOCUMENT_HARD_MAX_FILE_BYTES);
|
||||
});
|
||||
|
||||
it("prefers the per-user quota override over the instance default", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
documentMaxFileBytes: 26_214_400,
|
||||
documentQuotaBytes: BigInt(2_000_000_000),
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
documentQuotaBytes: BigInt(50_000_000),
|
||||
} as never);
|
||||
const limits = await resolveDocumentLimits("u1");
|
||||
expect(limits.quotaBytes).toBe(50_000_000);
|
||||
});
|
||||
|
||||
it("uses the instance quota when the user carries no override", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
documentMaxFileBytes: 26_214_400,
|
||||
documentQuotaBytes: BigInt(2_000_000_000),
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
documentQuotaBytes: null,
|
||||
} as never);
|
||||
const limits = await resolveDocumentLimits("u1");
|
||||
expect(limits.quotaBytes).toBe(2_000_000_000);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,16 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
|
||||
import type {
|
||||
ExtractedFact,
|
||||
ExtractedFactStatus,
|
||||
InboundDocument,
|
||||
InboundDocumentStatus,
|
||||
} from "@/generated/prisma/client";
|
||||
import { servingClassFor } from "@/lib/documents/upload-policy";
|
||||
import type {
|
||||
DocumentConditionLinkDto,
|
||||
ExtractedFactDto,
|
||||
FactData,
|
||||
FactProvenance,
|
||||
@@ -26,6 +28,20 @@ import type {
|
||||
InboundFactType,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* The two storage codecs `InboundDocument.contentEncrypted` may carry,
|
||||
* recorded per row in the explicit `contentCodec` column (never sniffed):
|
||||
* - "base64v1" — the legacy string path (base64-of-binary → `encrypt()`
|
||||
* string → UTF-8 bytes). Pre-vault rows only; kept read-compatible.
|
||||
* - "binary2" — the binary AES-256-GCM layout (`encryptBytes()`), no
|
||||
* base64 detour. Every new upload writes this.
|
||||
*/
|
||||
export const DOCUMENT_CONTENT_CODECS = ["base64v1", "binary2"] as const;
|
||||
export type DocumentContentCodec = (typeof DOCUMENT_CONTENT_CODECS)[number];
|
||||
|
||||
/** The codec every NEW upload is written with. */
|
||||
export const ACTIVE_DOCUMENT_CODEC: DocumentContentCodec = "binary2";
|
||||
|
||||
/** Encrypt the raw document bytes into the `Bytes` payload the schema stores. */
|
||||
export function encryptDocumentToBytes(bytes: Buffer): Uint8Array<ArrayBuffer> {
|
||||
const ciphertext = encrypt(bytes.toString("base64"));
|
||||
@@ -49,6 +65,31 @@ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt raw document bytes with the ACTIVE codec (binary2). Returns the
|
||||
* `Bytes` payload plus the codec label the row must persist alongside it.
|
||||
*/
|
||||
export function encryptDocumentContent(bytes: Buffer): {
|
||||
content: Uint8Array<ArrayBuffer>;
|
||||
codec: DocumentContentCodec;
|
||||
} {
|
||||
const encrypted = encryptBytes(bytes);
|
||||
const out = new Uint8Array(new ArrayBuffer(encrypted.byteLength));
|
||||
out.set(encrypted);
|
||||
return { content: out, codec: ACTIVE_DOCUMENT_CODEC };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored document's payload, dispatching on the row's persisted
|
||||
* codec. Fail-closed: an unknown codec value throws — a silent fallback to
|
||||
* either codec would either return garbage or mask data corruption.
|
||||
*/
|
||||
export function decryptDocumentContent(buf: Uint8Array, codec: string): Buffer {
|
||||
if (codec === "base64v1") return decryptDocumentFromBytes(buf);
|
||||
if (codec === "binary2") return decryptBytes(Buffer.from(buf));
|
||||
throw new Error(`Unknown document content codec '${codec}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the
|
||||
* schema stores. The structured clinical values (diagnosis text, lab values,
|
||||
@@ -80,10 +121,19 @@ export function decryptFactProvenance(buf: Uint8Array): FactProvenance {
|
||||
return JSON.parse(decryptFromBytes(buf)) as FactProvenance;
|
||||
}
|
||||
|
||||
/** Map a persisted document row (+ counts) to the list/detail DTO. */
|
||||
/**
|
||||
* The row fields the serialisers read. Deliberately WITHOUT
|
||||
* `contentEncrypted`: the list path must never select the blob column
|
||||
* (hardening — see the list route's `omit`), so the DTO mappers cannot
|
||||
* require it either.
|
||||
*/
|
||||
export type SerialisableDocument = Omit<InboundDocument, "contentEncrypted">;
|
||||
|
||||
/** Map a persisted document row (+ counts + links) to the list/detail DTO. */
|
||||
export function serialiseDocument(
|
||||
doc: InboundDocument,
|
||||
doc: SerialisableDocument,
|
||||
counts: { factCount: number; pendingCount: number },
|
||||
conditionLinks: DocumentConditionLinkDto[] = [],
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id: doc.id,
|
||||
@@ -103,6 +153,8 @@ export function serialiseDocument(
|
||||
errorReason: doc.errorReason,
|
||||
factCount: counts.factCount,
|
||||
pendingCount: counts.pendingCount,
|
||||
conditionLinks,
|
||||
servingClass: servingClassFor(doc.mimeType),
|
||||
createdAt: doc.createdAt.toISOString(),
|
||||
updatedAt: doc.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -123,17 +175,18 @@ export function serialiseFact(fact: ExtractedFact): ExtractedFactDto {
|
||||
};
|
||||
}
|
||||
|
||||
/** Assemble the detail DTO (document + its staged facts). */
|
||||
/** Assemble the detail DTO (document + its staged facts + links). */
|
||||
export function serialiseDocumentDetail(
|
||||
doc: InboundDocument,
|
||||
doc: SerialisableDocument,
|
||||
facts: ExtractedFact[],
|
||||
conditionLinks: DocumentConditionLinkDto[] = [],
|
||||
): InboundDocumentDetailDto {
|
||||
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
|
||||
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
|
||||
// of the document's tally) so the badge matches the list query.
|
||||
const factCount = facts.filter((f) => f.status !== "REJECTED").length;
|
||||
return {
|
||||
...serialiseDocument(doc, { factCount, pendingCount }),
|
||||
...serialiseDocument(doc, { factCount, pendingCount }, conditionLinks),
|
||||
facts: facts.map(serialiseFact),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Document-vault upload policy: the accept matrix, magic-byte sniffers, the
|
||||
* serving-class function, and the admin-tunable limits resolver.
|
||||
*
|
||||
* Three classes, closed deny-list, serving posture as the security boundary:
|
||||
*
|
||||
* - Class A (inline): PDF, JPEG, PNG, WebP, GIF — browser-native passive
|
||||
* formats the serve route renders `Content-Disposition: inline` (PDF
|
||||
* additionally under a `Content-Security-Policy: sandbox` response
|
||||
* header).
|
||||
* - Class B (attachment-only): Office (OOXML + legacy CFB), text/CSV/RTF/
|
||||
* Markdown, TIFF, HEIC/HEIF, XML/JSON — stored verbatim, served ONLY as
|
||||
* `application/octet-stream` + `Content-Disposition: attachment` +
|
||||
* `nosniff`. A misclassified Class B file therefore cannot execute in
|
||||
* the app origin.
|
||||
* - Deny: executables, scripts, HTML/XHTML/SVG (the only classes whose
|
||||
* inline render executes script — zero medical-artefact value), generic
|
||||
* archives / disk images, anything unidentifiable.
|
||||
*
|
||||
* The wire Content-Type is NEVER trusted anywhere — detection is magic-bytes
|
||||
* first, extension only where a container format is ambiguous (ZIP → OOXML,
|
||||
* CFB → legacy Office) or where no magic exists (plain-text formats). We
|
||||
* never parse, rewrite, or "sanitise" stored documents: the original is the
|
||||
* artefact; safety comes from what the serve route will and won't serve
|
||||
* inline.
|
||||
*
|
||||
* The labs OCR path (`src/lib/labs/ocr-upload.ts`) keeps its own 12 MiB cap
|
||||
* and narrow allowlist unchanged; this module only reuses its bounded-read
|
||||
* primitive at the route layer.
|
||||
*/
|
||||
import { detectAvatarMimeType } from "@/lib/avatar";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// ─── Limits (§3.3) ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Default per-file cap: 25 MiB (a ~50-page 300-dpi scan PDF, any phone photo). */
|
||||
export const DOCUMENT_DEFAULT_MAX_FILE_BYTES = 26_214_400;
|
||||
|
||||
/**
|
||||
* Hard server ceiling for the admin-tunable per-file cap: 100 MiB. Single-shot
|
||||
* AES-256-GCM + bounded in-memory reads are load-bearing; past this the
|
||||
* architecture (chunking, streaming crypto) would have to change, so the
|
||||
* setting cannot be pushed there.
|
||||
*/
|
||||
export const DOCUMENT_HARD_MAX_FILE_BYTES = 104_857_600;
|
||||
|
||||
/** Default per-user quota: 1 GiB. */
|
||||
export const DOCUMENT_DEFAULT_QUOTA_BYTES = 1_073_741_824;
|
||||
|
||||
export interface DocumentLimits {
|
||||
/** Per-file upload cap in bytes (admin-tunable, clamped to the ceiling). */
|
||||
maxFileBytes: number;
|
||||
/** Per-user storage quota in bytes (user override ?? instance default). */
|
||||
quotaBytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective limits for a user: `AppSettings.documentMaxFileBytes`
|
||||
* clamped to the hard ceiling, and quota = `User.documentQuotaBytes` ??
|
||||
* `AppSettings.documentQuotaBytes`. Missing rows fall back to the defaults.
|
||||
*/
|
||||
export async function resolveDocumentLimits(
|
||||
userId: string,
|
||||
): Promise<DocumentLimits> {
|
||||
const [settings, user] = await Promise.all([
|
||||
prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { documentMaxFileBytes: true, documentQuotaBytes: true },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { documentQuotaBytes: true },
|
||||
}),
|
||||
]);
|
||||
const rawMax =
|
||||
settings?.documentMaxFileBytes ?? DOCUMENT_DEFAULT_MAX_FILE_BYTES;
|
||||
const maxFileBytes = Math.min(
|
||||
Math.max(rawMax, 1),
|
||||
DOCUMENT_HARD_MAX_FILE_BYTES,
|
||||
);
|
||||
const quotaBytes = Number(
|
||||
user?.documentQuotaBytes ??
|
||||
settings?.documentQuotaBytes ??
|
||||
DOCUMENT_DEFAULT_QUOTA_BYTES,
|
||||
);
|
||||
return { maxFileBytes, quotaBytes };
|
||||
}
|
||||
|
||||
// ─── Accept matrix (§3.1) ───────────────────────────────────────────────────
|
||||
|
||||
export type DocumentServingClass = "inline" | "attachment";
|
||||
|
||||
/** Class A — browser-native passive formats served inline. */
|
||||
const CLASS_A_MIMES: ReadonlySet<string> = new Set([
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The one source of truth the serve route uses: Class A renders inline with
|
||||
* its true Content-Type; EVERYTHING else stored leaves the origin only as an
|
||||
* opaque attachment download.
|
||||
*/
|
||||
export function servingClassFor(mimeType: string): DocumentServingClass {
|
||||
return CLASS_A_MIMES.has(mimeType) ? "inline" : "attachment";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensions the upload UI's `accept` attribute mirrors (Class A + B minus
|
||||
* HEIC — its exclusion makes the iOS picker auto-transcode camera photos to
|
||||
* JPEG so the common path stays inline-previewable). Server-side policy
|
||||
* remains the enforcement; this list is advisory for pickers.
|
||||
*/
|
||||
export const DOCUMENT_ACCEPTED_EXTENSIONS: readonly string[] = [
|
||||
".pdf",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".gif",
|
||||
".docx",
|
||||
".xlsx",
|
||||
".pptx",
|
||||
".doc",
|
||||
".xls",
|
||||
".ppt",
|
||||
".txt",
|
||||
".md",
|
||||
".csv",
|
||||
".rtf",
|
||||
".tif",
|
||||
".tiff",
|
||||
".xml",
|
||||
".json",
|
||||
];
|
||||
|
||||
/**
|
||||
* Extension deny-list: executables and scripts (regardless of content),
|
||||
* HTML/XHTML/SVG (inline render executes script), generic archives and disk
|
||||
* images. A ZIP container is allowed ONLY under an Office extension.
|
||||
*/
|
||||
const DENY_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
".exe",
|
||||
".dll",
|
||||
".bat",
|
||||
".cmd",
|
||||
".sh",
|
||||
".ps1",
|
||||
".js",
|
||||
".mjs",
|
||||
".jar",
|
||||
".apk",
|
||||
".html",
|
||||
".htm",
|
||||
".xhtml",
|
||||
".svg",
|
||||
".zip",
|
||||
".7z",
|
||||
".rar",
|
||||
".tar",
|
||||
".gz",
|
||||
".iso",
|
||||
".img",
|
||||
".dmg",
|
||||
]);
|
||||
|
||||
/** OOXML extensions → stored MIME (ZIP magic + Office extension required). */
|
||||
const OOXML_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
|
||||
[
|
||||
".docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
],
|
||||
[
|
||||
".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
],
|
||||
[
|
||||
".pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
],
|
||||
]);
|
||||
|
||||
/** Legacy Office extensions → stored MIME (CFB magic + extension required). */
|
||||
const CFB_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
|
||||
[".doc", "application/msword"],
|
||||
[".xls", "application/vnd.ms-excel"],
|
||||
[".ppt", "application/vnd.ms-powerpoint"],
|
||||
]);
|
||||
|
||||
/** Text-format extensions → stored MIME (heuristic-gated, no magic bytes). */
|
||||
const TEXT_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
|
||||
[".txt", "text/plain"],
|
||||
[".md", "text/plain"],
|
||||
[".csv", "text/csv"],
|
||||
[".xml", "application/xml"],
|
||||
[".json", "application/json"],
|
||||
]);
|
||||
|
||||
/** Lower-cased extension (with dot) of a filename, or "" when absent. */
|
||||
function extensionOf(filename: string | null | undefined): string {
|
||||
if (!filename) return "";
|
||||
const idx = filename.lastIndexOf(".");
|
||||
if (idx <= 0 || idx === filename.length - 1) return "";
|
||||
return filename.slice(idx).toLowerCase();
|
||||
}
|
||||
|
||||
function startsWith(buffer: Buffer, bytes: readonly number[]): boolean {
|
||||
if (buffer.length < bytes.length) return false;
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (buffer[i] !== bytes[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `%PDF-` */
|
||||
function isPdf(buffer: Buffer): boolean {
|
||||
return startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
|
||||
}
|
||||
|
||||
/** `GIF87a` / `GIF89a` */
|
||||
function isGif(buffer: Buffer): boolean {
|
||||
return (
|
||||
startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
|
||||
startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
|
||||
);
|
||||
}
|
||||
|
||||
/** ZIP local-file header `PK\x03\x04` (OOXML containers; also generic ZIPs). */
|
||||
function isZip(buffer: Buffer): boolean {
|
||||
return startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]);
|
||||
}
|
||||
|
||||
/** Compound File Binary (legacy Office) `D0 CF 11 E0 A1 B1 1A E1`. */
|
||||
function isCfb(buffer: Buffer): boolean {
|
||||
return startsWith(buffer, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
|
||||
}
|
||||
|
||||
/** RTF `{\rtf` */
|
||||
function isRtf(buffer: Buffer): boolean {
|
||||
return startsWith(buffer, [0x7b, 0x5c, 0x72, 0x74, 0x66]);
|
||||
}
|
||||
|
||||
/** TIFF `II*\0` (little-endian) or `MM\0*` (big-endian). */
|
||||
function isTiff(buffer: Buffer): boolean {
|
||||
return (
|
||||
startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) ||
|
||||
startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a])
|
||||
);
|
||||
}
|
||||
|
||||
/** HEIC/HEIF — ISO-BMFF `ftyp` box with a HEIF-family brand. */
|
||||
const HEIF_BRANDS: ReadonlySet<string> = new Set([
|
||||
"heic",
|
||||
"heix",
|
||||
"hevc",
|
||||
"mif1",
|
||||
"msf1",
|
||||
]);
|
||||
|
||||
function isHeif(buffer: Buffer): boolean {
|
||||
if (buffer.length < 12) return false;
|
||||
if (buffer.toString("ascii", 4, 8) !== "ftyp") return false;
|
||||
const major = buffer.toString("ascii", 8, 12).toLowerCase();
|
||||
if (HEIF_BRANDS.has(major)) return true;
|
||||
// Compatible-brand scan inside the ftyp box (bounded by the box length).
|
||||
const boxLen = Math.min(buffer.readUInt32BE(0), buffer.length);
|
||||
for (let off = 16; off + 4 <= boxLen; off += 4) {
|
||||
const brand = buffer.toString("ascii", off, off + 4).toLowerCase();
|
||||
if (HEIF_BRANDS.has(brand)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Executable magic: MZ (PE), ELF, Mach-O (32/64, both endians, fat). */
|
||||
function isExecutable(buffer: Buffer): boolean {
|
||||
if (startsWith(buffer, [0x4d, 0x5a])) return true; // MZ
|
||||
if (startsWith(buffer, [0x7f, 0x45, 0x4c, 0x46])) return true; // ELF
|
||||
const machO = [
|
||||
[0xfe, 0xed, 0xfa, 0xce],
|
||||
[0xfe, 0xed, 0xfa, 0xcf],
|
||||
[0xce, 0xfa, 0xed, 0xfe],
|
||||
[0xcf, 0xfa, 0xed, 0xfe],
|
||||
[0xca, 0xfe, 0xba, 0xbe],
|
||||
] as const;
|
||||
return machO.some((m) => startsWith(buffer, m));
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text heuristic for the extension-gated text formats: no NUL byte in
|
||||
* the first 8 KiB and the sample decodes as UTF-8 or Latin-1 (any byte
|
||||
* sequence is valid Latin-1, so the effective gate is "no NUL" — a binary
|
||||
* payload smuggled under `.txt` almost always carries NULs; if one does not,
|
||||
* it is still stored Class B and can only ever leave as an opaque download).
|
||||
*/
|
||||
function looksLikeText(buffer: Buffer): boolean {
|
||||
if (buffer.length === 0) return false;
|
||||
const sample = buffer.subarray(0, 8192);
|
||||
return !sample.includes(0x00);
|
||||
}
|
||||
|
||||
export interface DetectedDocumentType {
|
||||
/** The MIME type the row stores (sniffed, never the wire Content-Type). */
|
||||
mimeType: string;
|
||||
/** Derived serving class (same as `servingClassFor(mimeType)`). */
|
||||
servingClass: DocumentServingClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an upload from its bytes (+ the claimed filename, used only where
|
||||
* a container/text format needs an extension gate). Returns null for every
|
||||
* deny case: executables, script/markup extensions, generic archives, ZIPs
|
||||
* without an Office extension, and anything unidentifiable.
|
||||
*/
|
||||
export function detectDocumentType(
|
||||
buffer: Buffer,
|
||||
filename: string | null | undefined,
|
||||
): DetectedDocumentType | null {
|
||||
const ext = extensionOf(filename);
|
||||
|
||||
// Hard denies first: content-level executables, then the extension
|
||||
// deny-list (an .exe named payload is refused even if its bytes look like
|
||||
// text; HTML/SVG are refused regardless of content).
|
||||
if (isExecutable(buffer)) return null;
|
||||
if (DENY_EXTENSIONS.has(ext)) return null;
|
||||
|
||||
// ── Class A (magic bytes only — extension is irrelevant) ──
|
||||
if (isPdf(buffer)) {
|
||||
return { mimeType: "application/pdf", servingClass: "inline" };
|
||||
}
|
||||
if (isGif(buffer)) {
|
||||
return { mimeType: "image/gif", servingClass: "inline" };
|
||||
}
|
||||
const image = detectAvatarMimeType(buffer);
|
||||
if (image) {
|
||||
return { mimeType: image, servingClass: "inline" };
|
||||
}
|
||||
|
||||
// ── Class B (magic where it exists; containers need the extension) ──
|
||||
if (isZip(buffer)) {
|
||||
// A ZIP container is acceptable ONLY as an OOXML Office file; a generic
|
||||
// archive (any other extension, incl. none) is denied.
|
||||
const ooxml = OOXML_BY_EXTENSION.get(ext);
|
||||
return ooxml ? { mimeType: ooxml, servingClass: "attachment" } : null;
|
||||
}
|
||||
if (isCfb(buffer)) {
|
||||
const legacy = CFB_BY_EXTENSION.get(ext);
|
||||
return legacy ? { mimeType: legacy, servingClass: "attachment" } : null;
|
||||
}
|
||||
if (isRtf(buffer)) {
|
||||
return { mimeType: "application/rtf", servingClass: "attachment" };
|
||||
}
|
||||
if (isTiff(buffer)) {
|
||||
return { mimeType: "image/tiff", servingClass: "attachment" };
|
||||
}
|
||||
if (isHeif(buffer)) {
|
||||
return { mimeType: "image/heic", servingClass: "attachment" };
|
||||
}
|
||||
|
||||
// ── Text formats (extension + heuristic; no magic bytes exist) ──
|
||||
const text = TEXT_BY_EXTENSION.get(ext);
|
||||
if (text && looksLikeText(buffer)) {
|
||||
return { mimeType: text, servingClass: "attachment" };
|
||||
}
|
||||
|
||||
// Unidentifiable → deny.
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Daily physical purge for soft-deleted document-vault rows.
|
||||
*
|
||||
* Delete in the vault is undo-able: the DELETE route tombstones
|
||||
* (`deletedAt`), the UI's undo toast and the restore endpoint clear the
|
||||
* tombstone, and THIS job hard-deletes tombstones older than the 30-day
|
||||
* grace window — returning the TOAST space the encrypted blob holds (a
|
||||
* tombstoned document keeps counting against the quota until purged, so
|
||||
* "deleted" bytes are never invisible backup weight). Staged facts cascade
|
||||
* via the FK. Restore-after-purge answers 409 at the API.
|
||||
*/
|
||||
import { type Job } from "pg-boss";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { getWorkerPrisma } from "@/lib/jobs/reminder/shared";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export const DOCUMENT_PURGE_QUEUE = "document-tombstone-purge";
|
||||
/** Daily at 04:10 Europe/Berlin — after the med-notes backfill tick (04:05). */
|
||||
export const DOCUMENT_PURGE_CRON = "10 4 * * *";
|
||||
|
||||
/** Days a tombstoned document stays restorable before the purge claims it. */
|
||||
export const DOCUMENT_PURGE_GRACE_DAYS = 30;
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export interface DocumentPurgePayload {
|
||||
triggeredAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete tombstoned `inbound_documents` rows whose `deletedAt` is older
|
||||
* than the grace horizon. Returns the number of rows purged. Rows inside the
|
||||
* grace window and live rows are never touched.
|
||||
*/
|
||||
export async function purgeExpiredDocumentTombstones(
|
||||
prisma: PrismaClient,
|
||||
now: Date = new Date(),
|
||||
): Promise<number> {
|
||||
const cutoff = new Date(now.getTime() - DOCUMENT_PURGE_GRACE_DAYS * DAY_MS);
|
||||
const { count } = await prisma.inboundDocument.deleteMany({
|
||||
where: { deletedAt: { not: null, lt: cutoff } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function handleDocumentPurge(jobs: Job<DocumentPurgePayload>[]) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.document_tombstone_purge", async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const purged = await purgeExpiredDocumentTombstones(prisma);
|
||||
evt.setAction({ name: "documents.vault.purge" });
|
||||
evt.addMeta("document_purge_deleted", purged);
|
||||
} catch (err) {
|
||||
evt.addWarning(`document-tombstone-purge failed: ${err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -124,6 +124,12 @@ import {
|
||||
handleMedicationInventoryExpire,
|
||||
handleIntakeAutoSkip,
|
||||
} from "./medication-maintenance";
|
||||
import {
|
||||
DOCUMENT_PURGE_QUEUE,
|
||||
DOCUMENT_PURGE_CRON,
|
||||
handleDocumentPurge,
|
||||
type DocumentPurgePayload,
|
||||
} from "@/lib/jobs/document-purge";
|
||||
|
||||
const DATA_BACKUP_QUEUE = "data-backup";
|
||||
|
||||
@@ -287,6 +293,11 @@ const allQueues = [
|
||||
// plaintext medication note; without this entry pg-boss never provisions the
|
||||
// queue and both the boot enqueue and the cron silently no-op.
|
||||
MED_NOTES_ENCRYPTION_BACKFILL_QUEUE,
|
||||
// Document vault — daily physical purge for tombstones past the 30-day
|
||||
// undo grace (returns the encrypted blob's TOAST space). Without this entry
|
||||
// the daily schedule silently no-ops and "deleted" documents hold backup
|
||||
// weight forever.
|
||||
DOCUMENT_PURGE_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
@@ -352,6 +363,9 @@ const schedules: ScheduleEntry[] = [
|
||||
// fan out one per-user job per account still holding a plaintext medication
|
||||
// note.
|
||||
[MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON],
|
||||
// Document vault — daily 04:10 Europe/Berlin purge for tombstoned
|
||||
// documents past the 30-day undo grace.
|
||||
[DOCUMENT_PURGE_QUEUE, DOCUMENT_PURGE_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -448,6 +462,14 @@ export async function registerMaintenanceQueues(
|
||||
{ localConcurrency: 1 },
|
||||
handleMcpTokenCleanup,
|
||||
);
|
||||
// Document vault — daily purge of tombstoned documents past the 30-day
|
||||
// undo grace. Single-flight like every other cleanup queue; the underlying
|
||||
// deleteMany is idempotent so a duplicate tick is a no-op.
|
||||
await boss.work<DocumentPurgePayload>(
|
||||
DOCUMENT_PURGE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleDocumentPurge,
|
||||
);
|
||||
await boss.work<PrDetectionPayload>(
|
||||
PR_DETECTION_QUEUE,
|
||||
{ localConcurrency: PR_DETECTION_CONCURRENCY },
|
||||
|
||||
@@ -374,22 +374,21 @@ describe("registry — opt-in marker", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModuleEnabled — inboundDocuments module (disabled in code)", () => {
|
||||
// v1.25.3 — `inboundDocuments` is switched off in code pending a rebuild
|
||||
// (`CODE_DISABLED_MODULE_KEYS`). It is hard-off ahead of both the operator
|
||||
// and the per-user layer: no stored preference or availability blob can
|
||||
// re-surface it. The registry entry + `optIn` marker stay intact.
|
||||
it("is flagged as code-disabled in the registry", () => {
|
||||
expect(isCodeDisabledModule("inboundDocuments")).toBe(true);
|
||||
describe("resolveModuleEnabled — inboundDocuments module (document vault)", () => {
|
||||
// Parked in code from v1.25.3; the document vault re-enabled it. Normal
|
||||
// two-layer opt-in resolution applies again: dark by default, ON only on
|
||||
// an explicit per-user opt-in, and an operator `false` still wins.
|
||||
it("is no longer flagged as code-disabled in the registry", () => {
|
||||
expect(isCodeDisabledModule("inboundDocuments")).toBe(false);
|
||||
});
|
||||
|
||||
it("is OFF when no preference is recorded", () => {
|
||||
it("is OFF when no preference is recorded (opt-in ships dark)", () => {
|
||||
expect(
|
||||
resolveModuleEnabled("inboundDocuments", inputs(), false, ALL_AVAILABLE),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("stays OFF even when the user opted in (the in-code switch wins)", () => {
|
||||
it("is ON when the user opted in", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"inboundDocuments",
|
||||
@@ -397,16 +396,16 @@ describe("resolveModuleEnabled — inboundDocuments module (disabled in code)",
|
||||
false,
|
||||
ALL_AVAILABLE,
|
||||
),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("stays OFF when the user opted in and the operator left it available", () => {
|
||||
it("stays OFF when the operator disabled it, even with a user opt-in", () => {
|
||||
expect(
|
||||
resolveModuleEnabled(
|
||||
"inboundDocuments",
|
||||
inputs({ modulePreferences: { inboundDocuments: true } }),
|
||||
false,
|
||||
operator({ inboundDocuments: true }),
|
||||
operator({ inboundDocuments: false }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -338,12 +338,12 @@ export function isModuleKey(key: string): key is ModuleKey {
|
||||
* migration) stays intact — re-enabling the module is a one-line removal
|
||||
* from this list once the rebuild lands.
|
||||
*
|
||||
* v1.25.3 — `inboundDocuments` is parked here: the inbound-documents surface
|
||||
* is withdrawn from this release and returns later with more depth. Its
|
||||
* registry entry, `optIn` marker, routes, view, and migration 0222 are all
|
||||
* preserved; only the user-reachable switches are removed.
|
||||
* Currently empty. `inboundDocuments` was parked here from v1.25.3 until the
|
||||
* document vault re-enabled it (per-user opt-in + operator availability
|
||||
* resume their normal two-layer resolution; the module still ships dark by
|
||||
* default via `optIn`).
|
||||
*/
|
||||
export const CODE_DISABLED_MODULE_KEYS = ["inboundDocuments"] as const;
|
||||
export const CODE_DISABLED_MODULE_KEYS = [] as const;
|
||||
|
||||
const CODE_DISABLED_MODULE_SET: ReadonlySet<string> = new Set(
|
||||
CODE_DISABLED_MODULE_KEYS,
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
documentBulkSchema,
|
||||
documentUpdateSchema,
|
||||
inboundConfirmSchema,
|
||||
inboundFactEditSchema,
|
||||
@@ -45,7 +46,13 @@ inboundFactEditSchema.meta({
|
||||
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.",
|
||||
"Metadata edit for a stored document: `title` (user label; null clears it), `kind` (category), `documentDate` (user filing date, YYYY-MM-DD; null clears it), `episodeIds` (REPLACE-SET of condition links — the document's links become exactly this set; an empty array unlinks everything; every id must be a live episode of the caller or the whole request answers 404). At least one field required. No `userId` field — narrowed from the session and fed to the Prisma `where` with the row id.",
|
||||
});
|
||||
|
||||
documentBulkSchema.meta({
|
||||
id: "DocumentBulkRequest",
|
||||
description:
|
||||
"One bulk action over up to 100 owner-scoped documents: `setKind` (requires `kind`), `linkEpisode` / `unlinkEpisode` (require `episodeId`), `delete` (tombstone, undo-able for 30 days), `restore` (clear tombstone). Partial failures never abort the batch — see the per-id result array.",
|
||||
});
|
||||
|
||||
const factProvenance = z.object({
|
||||
@@ -72,6 +79,17 @@ const extractedFact = z
|
||||
"One STATED fact transcribed from the document, FHIR-staged (Condition / Observation / MedicationStatement) with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact scored below the confidence floor (it cannot be approved until edited).",
|
||||
});
|
||||
|
||||
const conditionLink = z
|
||||
.object({
|
||||
episodeId: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "DocumentConditionLink",
|
||||
description:
|
||||
"A link to one of the caller's illness/condition episodes. `name` is the episode's user-facing label.",
|
||||
});
|
||||
|
||||
const inboundDocument = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -87,13 +105,15 @@ const inboundDocument = z
|
||||
errorReason: z.string().nullable(),
|
||||
factCount: z.number(),
|
||||
pendingCount: z.number(),
|
||||
conditionLinks: z.array(conditionLink),
|
||||
servingClass: z.enum(["inline", "attachment"]),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
.meta({
|
||||
id: "InboundDocument",
|
||||
description:
|
||||
"A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs).",
|
||||
"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). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. Treat an unknown `kind` value as OTHER when decoding.",
|
||||
});
|
||||
|
||||
const inboundDocumentDetail = inboundDocument
|
||||
@@ -132,6 +152,13 @@ const confirmResponse = z
|
||||
|
||||
const kindValues = [...INBOUND_DOCUMENT_KINDS];
|
||||
|
||||
// Shared by the POST 200 (duplicate) and 201 (created) responses — the
|
||||
// envelope id must be registered exactly once.
|
||||
const inboundDocumentEnvelope = dataEnvelope(
|
||||
inboundDocument,
|
||||
"InboundDocumentEnvelope",
|
||||
);
|
||||
|
||||
export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/documents/inbound": {
|
||||
get: {
|
||||
@@ -151,8 +178,30 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
name: "kind",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: kindValues },
|
||||
description: "Category filter.",
|
||||
style: "form",
|
||||
explode: true,
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { type: "string", enum: kindValues },
|
||||
},
|
||||
description:
|
||||
"Category filter — OR inside the facet. Repeat the parameter (or send a comma-separated value).",
|
||||
},
|
||||
{
|
||||
name: "episodeId",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"Only documents linked to this illness/condition episode.",
|
||||
},
|
||||
{
|
||||
name: "year",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "integer", minimum: 1900, maximum: 9999 },
|
||||
description:
|
||||
"Only documents whose filing date falls in this calendar year (UTC). Takes precedence over from/to.",
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
@@ -219,7 +268,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Store a document (no extraction)",
|
||||
description:
|
||||
"STORE-ONLY upload. Stores the raw document ENCRYPTED at rest with `status: STORED` and runs NO extraction — provider-free, no AI consent / budget / egress. A file is always filable, even with no document-scan provider configured. `multipart/form-data`: a `file` (JPEG/PNG/WebP/PDF, validated by magic-byte MIME sniff, ≤ 12 MiB) plus optional `title`, `kind`, and `documentDate` (YYYY-MM-DD) form fields. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`.",
|
||||
'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` plus optional `title`, `kind`, `documentDate` (YYYY-MM-DD), and repeated `episodeIds` form fields (pre-link to the caller\'s illness/condition episodes). Accepted types (magic-byte sniffed, never the wire Content-Type): PDF/JPEG/PNG/WebP/GIF render inline; Office (docx/xlsx/pptx/doc/xls/ppt), text/CSV/Markdown/RTF, TIFF, HEIC/HEIF, XML/JSON are stored verbatim and served download-only. HEIC is stored as-is but attachment-only — prefer transcoding to JPEG client-side for inline preview parity. Error contract: `413` with `meta.reason = "fileTooLarge"` (+ `maxFileBytes`) or `"quotaExceeded"` (+ `quotaBytes`, `usedBytes`); `415` with `meta.reason = "unsupportedType"`. A same-user duplicate (same bytes) returns 200 + `meta.duplicate: true` with the existing row — not an error. `Idempotency-Key` honoured. Read `GET /api/documents/inbound/usage` for the effective limits before offering an upload. AI extraction is a separate opt-in action — see `POST /api/documents/inbound/{id}/extract`.',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -228,7 +277,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
file: z.string().meta({
|
||||
format: "binary",
|
||||
description:
|
||||
"The document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.",
|
||||
"The document. Validated by magic-byte MIME sniff, not the wire Content-Type.",
|
||||
}),
|
||||
title: z
|
||||
.string()
|
||||
@@ -241,16 +290,110 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Optional user filing date (YYYY-MM-DD). Defaults to the upload day when omitted so the library sort/filter/display stay aligned.",
|
||||
}),
|
||||
episodeIds: z.array(z.string()).optional().meta({
|
||||
description:
|
||||
"Optional condition pre-links (repeat the field per id). Every id must be a live episode of the caller.",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Duplicate — the same bytes are already stored: the existing row, with `meta.duplicate: true` at the envelope level.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: inboundDocumentEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
"201": {
|
||||
description: "The stored document.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(inboundDocument, "InboundDocumentEnvelope"),
|
||||
schema: inboundDocumentEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
"413": {
|
||||
description:
|
||||
"Too large: `meta.reason` is `fileTooLarge` (with `maxFileBytes`) or `quotaExceeded` (with `quotaBytes` + `usedBytes`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description:
|
||||
'Unsupported or unidentifiable type (`meta.reason = "unsupportedType"`). Executables, HTML/SVG, and generic archives are always refused.',
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/usage": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
summary: "Storage usage + effective limits",
|
||||
description:
|
||||
"The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes carrying at least one live document link — the condition-filter chips). Read this before offering an upload.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Usage + limits.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z
|
||||
.object({
|
||||
usedBytes: z.number(),
|
||||
quotaBytes: z.number(),
|
||||
maxFileBytes: z.number(),
|
||||
acceptedExtensions: z.array(z.string()),
|
||||
linkedEpisodes: z.array(
|
||||
z.object({
|
||||
episodeId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({ id: "DocumentUsage" }),
|
||||
"DocumentUsageEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/bulk": {
|
||||
post: {
|
||||
tags: ["Documents"],
|
||||
summary: "Bulk action over documents",
|
||||
description:
|
||||
"Applies one action to up to 100 owner-scoped documents and returns a per-id result array — a partial failure never aborts the batch. `error` is `notFound` (unknown / foreign / tombstoned where liveness is required) or `conflict` (restore blocked by a live duplicate of the same bytes).",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: documentBulkSchema } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Per-id outcomes.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z
|
||||
.object({
|
||||
results: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
ok: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.meta({ id: "DocumentBulkResponse" }),
|
||||
"DocumentBulkEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -348,12 +491,47 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/{id}/restore": {
|
||||
post: {
|
||||
tags: ["Documents"],
|
||||
summary: "Restore a discarded document",
|
||||
description:
|
||||
'Clears the soft-delete tombstone within the 30-day undo grace. 409 when the tombstone is already purged (the undo window is over) or when an identical live document blocks the restore (`meta.reason = "duplicateExists"`). Restoring an already-live document is a no-op success.',
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The restored document + its staged facts.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
inboundDocumentDetail,
|
||||
"InboundRestoreEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"Not restorable: purged past the grace window, or an identical live document exists.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/inbound/{id}/original": {
|
||||
get: {
|
||||
tags: ["Documents"],
|
||||
summary: "View / download the original document",
|
||||
description:
|
||||
"Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Uploads are constrained at the store path to the inline-safe set (JPEG / PNG / WebP / PDF), so the document is always served `Content-Disposition: inline` for in-tab rendering; a non-ASCII filename is carried via RFC 5987 `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext.",
|
||||
"Decrypts and serves the raw uploaded document (the bytes stored encrypted at rest under `InboundDocument.contentEncrypted`). Owner-scoped + gated on the opt-in `inboundDocuments` module. Serving posture follows the document's `servingClass`: inline types (PDF/JPEG/PNG/WebP/GIF) come with their true Content-Type, `Content-Disposition: inline`, `nosniff`, and a `Content-Security-Policy: sandbox` header; attachment types (Office/text/TIFF/HEIC/XML/JSON) ALWAYS come as `application/octet-stream` + `Content-Disposition: attachment` + `nosniff` — never inline. A non-ASCII filename is carried via RFC 5987 `filename*`. Fails closed (500) on a decrypt error — it never returns the ciphertext.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -365,7 +543,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The decrypted original document bytes with the stored MIME type.",
|
||||
"The decrypted original document bytes. Inline types carry the stored MIME type; attachment types are served as application/octet-stream.",
|
||||
content: {
|
||||
"application/pdf": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
@@ -373,6 +551,9 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"image/*": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
"application/octet-stream": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
|
||||
@@ -1,42 +1,61 @@
|
||||
/**
|
||||
* Query keys — clinical documents library (v1.25, W-DOCS-IN): the browsable
|
||||
* document list (search / category filter / sort, keyset-paginated) and a
|
||||
* single document's detail (document + staged facts). Part of the centralized
|
||||
* factory; aggregated in `./index.ts`. Any document write (upload / edit /
|
||||
* extract / confirm / discard) invalidates the `["documents"]` prefix so the
|
||||
* list and the open detail repaint in lockstep.
|
||||
* Query keys — the Dokumente vault: the filtered timeline list (keyset-
|
||||
* paginated), a single document's detail, and the usage/limits read the
|
||||
* upload path pre-flights against. Part of the centralized factory;
|
||||
* aggregated in `./index.ts`. Any document write (upload / edit / link /
|
||||
* delete / restore / bulk) invalidates the `["documents"]` prefix so the
|
||||
* timeline, an open detail sheet, and the quota bar repaint in lockstep.
|
||||
*/
|
||||
import type {
|
||||
DocumentListSort,
|
||||
InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* The parameter set that distinguishes one library list view from another.
|
||||
* Lives in the key so two different filter/sort combinations never share a
|
||||
* cache bucket. `undefined` fields are dropped by TanStack's stable hash, so a
|
||||
* bare `{ sort, order }` and `{ q: undefined, kind: undefined, sort, order }`
|
||||
* resolve to the same key.
|
||||
* The parameter set that distinguishes one vault timeline view from another —
|
||||
* mirrors the URL facets (`?q&kind&episode&year`) one-to-one so a deep link
|
||||
* and an in-page filter change resolve to the same cache bucket. `undefined`
|
||||
* fields are dropped by TanStack's stable hash, so an all-defaults view and
|
||||
* an explicit `{ q: undefined, … }` share a key. Sort is pinned server-side
|
||||
* (filing date, newest first) and is deliberately NOT part of the key.
|
||||
*/
|
||||
export interface DocumentListParams {
|
||||
export interface DocumentVaultFilters {
|
||||
q?: string;
|
||||
kind?: InboundDocumentKindValue;
|
||||
from?: string;
|
||||
to?: string;
|
||||
sort: DocumentListSort;
|
||||
order: "asc" | "desc";
|
||||
/** Type facet — OR inside the facet, sorted before keying by the caller. */
|
||||
kinds?: InboundDocumentKindValue[];
|
||||
/** Only documents linked to this illness/condition episode. */
|
||||
episodeId?: string;
|
||||
/** Only documents whose filing date falls in this calendar year. */
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export const documentKeys = {
|
||||
documents: () => ["documents"] as const,
|
||||
inboundDocuments: () => ["documents", "inbound"] as const,
|
||||
/**
|
||||
* The parameterised library list (keyset-paginated via `useInfiniteQuery`).
|
||||
* The parameterised vault list (keyset-paginated via `useInfiniteQuery`).
|
||||
* The literal `"list"` segment keeps it distinct from a single-document key
|
||||
* (`inboundDocument(id)`); cuid ids never collide with it. Stays under the
|
||||
* `["documents", "inbound"]` prefix so prefix invalidation still reaches it.
|
||||
*/
|
||||
inboundDocumentList: (params: DocumentListParams) =>
|
||||
["documents", "inbound", "list", params] as const,
|
||||
inboundDocumentList: (filters: DocumentVaultFilters) =>
|
||||
["documents", "inbound", "list", filters] as const,
|
||||
inboundDocument: (id: string) => ["documents", "inbound", id] as const,
|
||||
/**
|
||||
* The illness-episode detail's compact document preview (single page,
|
||||
* small limit). Deliberately NOT `inboundDocumentList({ episodeId })` —
|
||||
* that key belongs to the vault's `useInfiniteQuery` and a plain query
|
||||
* under the same key would poison the cache with a different shape.
|
||||
*/
|
||||
inboundDocumentEpisodePreview: (episodeId: string) =>
|
||||
["documents", "inbound", "episode-preview", episodeId] as const,
|
||||
/**
|
||||
* The link-existing-documents picker (searchable single page). Own key
|
||||
* for the same shape-collision reason as the episode preview.
|
||||
*/
|
||||
inboundDocumentPicker: (q: string) =>
|
||||
["documents", "inbound", "picker", q] as const,
|
||||
/**
|
||||
* Storage usage + effective limits (`GET /api/documents/inbound/usage`).
|
||||
* Invalidated through the `["documents"]` prefix after every upload /
|
||||
* delete so the quota bar tracks reality.
|
||||
*/
|
||||
inboundDocumentUsage: () => ["documents", "inbound", "usage"] as const,
|
||||
};
|
||||
@@ -67,6 +67,23 @@ export const adminSettingsSchema = z
|
||||
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
|
||||
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
|
||||
moodLogGlobal: z.boolean().optional(),
|
||||
// Document vault — the two admin-tunable limits. The per-file cap is
|
||||
// bounded by the hard 100 MiB ceiling (single-shot GCM + bounded
|
||||
// in-memory reads are load-bearing past that; the runtime resolver
|
||||
// clamps again as defence-in-depth). The quota accepts up to 1 TiB —
|
||||
// BYTEA rides pg_dump, so anything larger is an operator error.
|
||||
documentMaxFileBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1_048_576)
|
||||
.max(104_857_600)
|
||||
.optional(),
|
||||
documentQuotaBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(10_485_760)
|
||||
.max(1_099_511_627_776)
|
||||
.optional(),
|
||||
// v1.4.25 W7 — server-wide default timezone for new signups
|
||||
// that don't carry a browser-detected zone. The route validates
|
||||
// against Intl.supportedValuesOf at runtime; this schema just
|
||||
|
||||
@@ -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,72 @@ 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[];
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
+33
-4
@@ -301,9 +301,24 @@ export function proxy(request: NextRequest) {
|
||||
// Request ID for log correlation
|
||||
response.headers.set("x-request-id", requestId);
|
||||
|
||||
// The document vault's decrypt-and-serve route is framed SAME-ORIGIN by
|
||||
// the document detail preview (an <iframe> for inline-class PDFs). The
|
||||
// blanket `X-Frame-Options: DENY` + `frame-ancestors 'none'` below would
|
||||
// refuse that embed, and the blanket page CSP would clobber the route's
|
||||
// own `Content-Security-Policy: sandbox` (middleware headers win over
|
||||
// route headers). Scope: exactly the `/original` path — list/detail API
|
||||
// routes keep the full DENY posture. The replacement CSP keeps the
|
||||
// sandbox (the served bytes are user-uploaded and must never script in
|
||||
// the app origin) and narrows framing to 'self' instead of dropping it.
|
||||
const isDocumentServeRoute =
|
||||
/^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname);
|
||||
|
||||
// Security headers
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set(
|
||||
"X-Frame-Options",
|
||||
isDocumentServeRoute ? "SAMEORIGIN" : "DENY",
|
||||
);
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
@@ -390,9 +405,23 @@ export function proxy(request: NextRequest) {
|
||||
// page-load. Avatars now live on the User row and serve from
|
||||
// same-origin `/api/user/avatar/{id}`, so `img-src 'self'` covers
|
||||
// them.
|
||||
const csp = isDev
|
||||
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';`
|
||||
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
||||
const csp = isDocumentServeRoute
|
||||
? // Vault serve route (see the X-Frame-Options note above): the
|
||||
// response is a user-uploaded document, not an app page.
|
||||
// `default-src 'none'` — a served document may load nothing as if
|
||||
// it were a page — and `frame-ancestors 'self'` permits exactly the
|
||||
// same-origin preview iframe. Deliberately NO `sandbox` directive:
|
||||
// Chromium refuses to render PDFs in sandboxed documents (it
|
||||
// force-downloads them), which would kill the inline preview
|
||||
// outright. The load-bearing boundaries for user-uploaded bytes
|
||||
// stay: magic-byte classification at upload, true Content-Type +
|
||||
// `nosniff` at serve (a PDF can never be reinterpreted as HTML),
|
||||
// HTML/SVG denied entirely, and attachment-only posture for every
|
||||
// non-passive format.
|
||||
`default-src 'none'; frame-ancestors 'self';`
|
||||
: isDev
|
||||
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self';`
|
||||
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}${whoopConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
|
||||
// Production-only headers. HSTS carries `preload` so the domain stays
|
||||
|
||||
Reference in new issue
Block a user