mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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
93 lines
3.2 KiB
TypeScript
93 lines
3.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|