mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(documents): restore, bulk, usage endpoints and class-aware serving
POST [id]/restore clears the soft-delete tombstone inside the 30-day undo grace; it answers 409 once the tombstone is purged (the undo window is over) and 409 with meta.reason=duplicateExists when an identical live document would collide with the live-dedupe index. Restoring an already-live row is a no-op success, so a late undo toast never errors. POST /bulk applies one action (setKind / linkEpisode / unlinkEpisode / delete / restore) to up to 100 owner-scoped documents and reports per-id outcomes — a partial failure never aborts the batch. GET /usage returns usedBytes (tombstone-inclusive), the effective quota, the per-file cap, and the accepted-extension list for picker accept attributes. The serve route now derives its posture from the serving class: inline types keep their true Content-Type and gain Content-Security-Policy: sandbox as defence-in-depth for the PDF viewer context; attachment types ALWAYS go out as application/octet-stream + Content-Disposition: attachment. nosniff and private,no-store on both; RFC 5987 filename handling unchanged; decrypt dispatches on the row codec and stays fail-closed. Header matrix pinned per class in the route tests. Tasks: W1-T8, W1-T9, W1-T10.
This commit is contained in:
1 parent
b76873ee1f
commit
d9bbdb037a
5 files changed
+545
-25
No files matched your search
@@ -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,66 @@ 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, CSP sandbox",
|
||||
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");
|
||||
expect(res.headers.get("Content-Security-Policy")).toBe("sandbox");
|
||||
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");
|
||||
// No render context → no sandbox header needed on a pure download.
|
||||
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,23 +1,31 @@
|
||||
/**
|
||||
* 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`, plus `Content-Security-Policy:
|
||||
* sandbox` as defence-in-depth for the PDF viewer context.
|
||||
* - 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.
|
||||
*/
|
||||
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 +48,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 +133,7 @@ export const GET = apiHandler(
|
||||
filename: true,
|
||||
mimeType: true,
|
||||
contentEncrypted: true,
|
||||
contentCodec: true,
|
||||
},
|
||||
});
|
||||
if (!document) {
|
||||
@@ -119,10 +144,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 +161,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 +180,33 @@ 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",
|
||||
};
|
||||
if (servingClass === "inline") {
|
||||
// Defence-in-depth for the in-origin render context (notably the PDF
|
||||
// viewer): no scripts, no forms, no top-navigation from the response.
|
||||
headers["Content-Security-Policy"] = "sandbox";
|
||||
}
|
||||
|
||||
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) ?? [],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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] = 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}
|
||||
`,
|
||||
]);
|
||||
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],
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
Reference in new issue
Block a user