mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(documents): add Recently deleted for deleted documents
Deleting a document only stamps a tombstone and the purge removes it
after 30 days, but the only way back was the undo toast, gone a few
seconds after the delete. Recently deleted lists the deleted documents
of the record with type, filing date, where an import came from, when
each was deleted and how long it has left, and offers Restore, Restore
all and Delete now.
GET /api/documents/inbound/deleted lists them (owner-only, capped at 200
with the list-cap meta). POST /api/documents/inbound/{id}/purge removes
one deleted document for good through the same function the nightly
purge runs, so its import keys and aliases still move into the ledger
and an importer re-sending it is still answered "deleted"; a live
document answers 409 documents.inbound.notDeleted. The usage read gains
deletedCount, 0 under a grant. The restore route and bulk restore
share one restore, which now also clears ledger entries for the
document's own keys.
The vault header shows a quiet "Recently deleted (n)" link only while
something is in it, and the delete toasts and confirmations say that
deleted documents stay there for 30 days. The purge's queue facts and
grace window move to document-purge-queue.ts so the worker registry
still loads none of the work.
Refs #1275
This commit is contained in:
1 parent
1ea14e9100
commit
4752115d7d
44 files changed
+2277
-270
No files matched your search
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Document vault: remove one deleted document for good, now.
|
||||
*
|
||||
* "Delete now" in Recently deleted. The daily job purges a deleted document
|
||||
* once the grace window has passed; this does the same for one document
|
||||
* ahead of time, through the same function (`purgeDocumentTombstones` in
|
||||
* `src/lib/documents/tombstones.ts`): the row and everything that cascades
|
||||
* from it go, and an imported document's source keys are carried into the
|
||||
* import ledger in the same transaction, so a re-run of the importer does not
|
||||
* bring it back.
|
||||
*
|
||||
* Only a deleted document can be purged: a live one answers 409
|
||||
* `documents.inbound.notDeleted`, so no single request takes a document from
|
||||
* the vault straight out of the database. Owner-only, like delete and restore.
|
||||
*/
|
||||
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 { purgeDocumentTombstoneNow } from "@/lib/documents/tombstones";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import type { DocumentPurgeDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Shares the write bucket of the other owner-scoped document mutations. */
|
||||
const WRITE_LIMIT_PER_HOUR = 240;
|
||||
const WRITE_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
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 },
|
||||
select: { id: true, deletedAt: true },
|
||||
});
|
||||
if (!row) {
|
||||
return apiError("Document not found", 404, {
|
||||
errorCode: "documents.inbound.notFound",
|
||||
});
|
||||
}
|
||||
if (row.deletedAt === null) {
|
||||
return apiError("Only a deleted document can be removed for good.", 409, {
|
||||
errorCode: "documents.inbound.notDeleted",
|
||||
});
|
||||
}
|
||||
|
||||
const purged = await purgeDocumentTombstoneNow(user.id, row.id);
|
||||
if (!purged) {
|
||||
// Lost a race between the read above and the purge: a restore brought
|
||||
// it back (409, as above), or the daily purge took it first, which is
|
||||
// what was asked for.
|
||||
const now = await prisma.inboundDocument.findFirst({
|
||||
where: { id: row.id, userId: user.id },
|
||||
select: { deletedAt: true },
|
||||
});
|
||||
if (now && now.deletedAt === null) {
|
||||
return apiError(
|
||||
"Only a deleted document can be removed for good.",
|
||||
409,
|
||||
{ errorCode: "documents.inbound.notDeleted" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("documents.inbound.purge", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId: row.id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.trash.purge" },
|
||||
meta: { documentId: row.id, raced: !purged },
|
||||
});
|
||||
|
||||
const body: DocumentPurgeDto = { id: row.id, purged: true };
|
||||
return apiSuccess(body);
|
||||
},
|
||||
);
|
||||
@@ -8,6 +8,8 @@
|
||||
* 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).
|
||||
* The restore itself is the shared one (`src/lib/documents/tombstones.ts`):
|
||||
* it also clears any import-ledger entry for the document's source keys.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -20,11 +22,10 @@ import {
|
||||
loadDocumentEncounterLinks,
|
||||
} from "@/lib/documents/links";
|
||||
import { serialiseDocumentDetail } from "@/lib/documents/store";
|
||||
import { restoreDocumentTombstone } from "@/lib/documents/tombstones";
|
||||
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 }> };
|
||||
|
||||
@@ -34,18 +35,6 @@ export const dynamic = "force-dynamic";
|
||||
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();
|
||||
@@ -68,80 +57,26 @@ export const POST = apiHandler(
|
||||
}
|
||||
|
||||
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.
|
||||
const outcome = await restoreDocumentTombstone(user.id, id);
|
||||
if (outcome.kind === "gone") {
|
||||
// Purged (past the 30-day grace, or deleted now from Recently deleted)
|
||||
// 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, visitLinks] = await Promise.all([
|
||||
loadConditionLinks(user.id, [row.id]),
|
||||
loadDocumentEncounterLinks(user.id, [row.id]),
|
||||
]);
|
||||
return apiSuccess(
|
||||
serialiseDocumentDetail(
|
||||
row,
|
||||
row.facts,
|
||||
links.get(row.id) ?? [],
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
visitLinks.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 (outcome.kind === "duplicate") {
|
||||
return apiError("An identical document is already stored.", 409, {
|
||||
errorCode: "documents.inbound.restoreDuplicate",
|
||||
reason: "duplicateExists",
|
||||
...(outcome.existingId ? { existingId: outcome.existingId } : {}),
|
||||
});
|
||||
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 },
|
||||
where: { id, userId: user.id },
|
||||
omit: { contentEncrypted: true },
|
||||
include: { facts: { orderBy: { createdAt: "asc" } } },
|
||||
});
|
||||
@@ -150,15 +85,19 @@ export const POST = apiHandler(
|
||||
loadDocumentEncounterLinks(user.id, [restored.id]),
|
||||
]);
|
||||
|
||||
await auditLog("documents.inbound.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { documentId: restored.id },
|
||||
});
|
||||
// Restoring an already-live document is a no-op success (the undo toast
|
||||
// may fire after a concurrent restore); only a real restore is audited.
|
||||
if (outcome.kind === "restored") {
|
||||
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 },
|
||||
meta: { documentId: restored.id, alreadyLive: outcome.kind === "live" },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
|
||||
@@ -17,6 +17,7 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { restoreDocumentTombstone } from "@/lib/documents/tombstones";
|
||||
import { linkTargets, unlinkTargets } from "@/lib/links";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
@@ -95,7 +96,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// 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 },
|
||||
select: { id: true, deletedAt: true },
|
||||
});
|
||||
const byId = new Map(owned.map((d) => [d.id, d]));
|
||||
|
||||
@@ -182,39 +183,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
break;
|
||||
}
|
||||
case "restore": {
|
||||
if (row.deletedAt === null) {
|
||||
// Already live — restoring twice is a no-op success.
|
||||
break;
|
||||
// The shared restore: the duplicate check, the status the staged
|
||||
// facts imply, and the import-ledger cleanup. Already live is a
|
||||
// no-op success.
|
||||
const outcome = await restoreDocumentTombstone(user.id, id);
|
||||
if (outcome.kind === "gone") {
|
||||
results.push({ id, ok: false, error: "notFound" });
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (outcome.kind === "duplicate") {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Document vault: Recently deleted.
|
||||
*
|
||||
* GET lists the caller's deleted documents that the purge has not removed yet,
|
||||
* newest deletion first, each with the moment it becomes due for the purge.
|
||||
* Restoring one is `POST /api/documents/inbound/{id}/restore` (or the bulk
|
||||
* route's `restore`); removing one for good ahead of time is
|
||||
* `POST /api/documents/inbound/{id}/purge`.
|
||||
*
|
||||
* Owner-only, like the restore and delete beside it: a deleted document is the
|
||||
* owner's decision in progress, and nothing a delegate may do (restore, delete
|
||||
* now) is a verb a grant admits. A bare `requireAuth()` therefore refuses the
|
||||
* request while it acts inside somebody else's record, and refuses a narrow
|
||||
* token.
|
||||
*
|
||||
* Metadata only. The encrypted blob is never selected; a deleted document's
|
||||
* original, thumbnail and AI surfaces stay closed until it is restored.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiListSuccess } from "@/lib/api/list-cap";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
|
||||
import { documentPurgeAt } from "@/lib/documents/tombstones";
|
||||
import { listPage } from "@/lib/http/list-query";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import {
|
||||
toDocumentSourceSystem,
|
||||
type DeletedDocumentListDto,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Rows per answer. Thirty days of deletions rarely come near it; a person who
|
||||
* cleared out a large import gets the newest ones and the list-cap notice.
|
||||
*/
|
||||
const DELETED_DOCUMENTS_LIMIT = 200;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { rows, cap } = await listPage(DELETED_DOCUMENTS_LIMIT, (take) =>
|
||||
prisma.inboundDocument.findMany({
|
||||
where: { userId: user.id, deletedAt: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
title: true,
|
||||
filename: true,
|
||||
mimeType: true,
|
||||
byteSize: true,
|
||||
documentDate: true,
|
||||
sourceSystem: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
orderBy: [{ deletedAt: "desc" }, { id: "asc" }],
|
||||
take,
|
||||
}),
|
||||
);
|
||||
|
||||
const payload: DeletedDocumentListDto = {
|
||||
graceDays: DOCUMENT_PURGE_GRACE_DAYS,
|
||||
documents: rows.map((row) => {
|
||||
// Selected under `deletedAt: { not: null }`.
|
||||
const deletedAt = row.deletedAt as Date;
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as InboundDocumentKindValue,
|
||||
title: row.title,
|
||||
filename: row.filename,
|
||||
mimeType: row.mimeType,
|
||||
byteSize: row.byteSize,
|
||||
documentDate: row.documentDate ? dateOnlyKey(row.documentDate) : null,
|
||||
sourceSystem: toDocumentSourceSystem(row.sourceSystem),
|
||||
deletedAt: deletedAt.toISOString(),
|
||||
purgeAt: documentPurgeAt(deletedAt).toISOString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
annotate({
|
||||
action: { name: "documents.trash.list" },
|
||||
meta: { count: rows.length, truncated: cap.truncated },
|
||||
});
|
||||
|
||||
return apiListSuccess(payload, cap);
|
||||
});
|
||||
@@ -57,6 +57,7 @@ export const GET = apiHandler(async () => {
|
||||
capability,
|
||||
indexedCount,
|
||||
totalCount,
|
||||
deletedCount,
|
||||
] = await Promise.all([
|
||||
resolveDocumentLimits(user.id),
|
||||
prisma.$queryRaw<Array<{ used: bigint }>>`
|
||||
@@ -93,6 +94,15 @@ export const GET = apiHandler(async () => {
|
||||
prisma.inboundDocument.count({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
}),
|
||||
// How many sit in Recently deleted — the vault's way in to it shows only
|
||||
// when there is something there. Recently deleted is the owner's own
|
||||
// (restore and delete-now are owner-only writes), so a delegate gets 0
|
||||
// and the vault offers them no way in.
|
||||
grantId === null
|
||||
? prisma.inboundDocument.count({
|
||||
where: { userId: user.id, deletedAt: { not: null } },
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
const usedBytes = Number(rows[0]?.used ?? 0);
|
||||
|
||||
@@ -121,6 +131,7 @@ export const GET = apiHandler(async () => {
|
||||
indexedCount,
|
||||
totalCount,
|
||||
},
|
||||
deletedCount,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
Reference in new issue
Block a user