mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user