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:
Marc-André Bombeck committed 2026-10-24 20:51:35 +02:00
1 parent 1ea14e9100
commit 4752115d7d
44 files changed
+2277 -270

No files matched your search

@@ -15,6 +15,7 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -304,6 +305,9 @@ function useDocumentWrites({
onOpenChange(false);
void invalidateKeys(queryClient, [queryKeys.documents()]);
toast.success(t("documents.toast.deleted"), {
description: t("documents.toast.deletedHint", {
days: DOCUMENT_PURGE_GRACE_DAYS,
}),
action: {
label: t("common.undo"),
onClick: () => restore.mutate(id),
@@ -26,6 +26,7 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { RelatedLink } from "@/lib/links/related";
@@ -45,6 +46,9 @@ import { DocumentRecordLinks } from "./document-record-links";
import { DocumentShareSheet } from "./document-share-sheet";
import { DocumentSummaryBlock } from "./document-summary-block";
/** The grace window the delete confirmation names. */
const GRACE = { days: DOCUMENT_PURGE_GRACE_DAYS };
export function DocumentDetailSheet({
documentId,
open,
@@ -359,7 +363,7 @@ export function DocumentDetailSheet({
open={confirmDeleteOpen}
onOpenChange={setConfirmDeleteOpen}
title={t("documents.deleteConfirm.titleOne")}
body={t("documents.deleteConfirm.body")}
body={t("documents.deleteConfirm.body", GRACE)}
confirmLabel={t("documents.deleteConfirm.action")}
onConfirm={() => remove.mutate(doc.id)}
pending={remove.isPending}
+5 -1
View File
@@ -29,6 +29,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
import { useTranslations } from "@/lib/i18n/context";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
@@ -46,6 +47,9 @@ import { useVaultSelection } from "./vault/use-vault-selection";
import { VaultDropOverlay } from "./vault/vault-drop-overlay";
import { VaultPageActions } from "./vault/vault-page-actions";
/** The grace window the delete confirmation names. */
const GRACE = { days: DOCUMENT_PURGE_GRACE_DAYS };
/**
* The vault page. Its state lives in `./vault/`: the URL filters, the
* library (list, usage, uploads, filter choices), the selection and its bulk
@@ -334,7 +338,7 @@ export function DocumentsView() {
})
: t("documents.deleteConfirm.titleOne")
}
body={t("documents.deleteConfirm.body")}
body={t("documents.deleteConfirm.body", GRACE)}
confirmLabel={t("documents.deleteConfirm.action")}
onConfirm={confirmDelete}
slot="documents-delete"
@@ -0,0 +1,186 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { createRef } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* Recently deleted and the vault's way into it, pinned via single-pass static
* renders (the project's component-test convention):
*
* - the page paints a skeleton while the list is out, never the empty copy;
* - each row says what the document was, where it came from, when it was
* deleted and how long it has left, and offers Restore and Delete now;
* - Restore all appears only with more than one document;
* - an empty list says so and leads back to the vault;
* - the vault header links to Recently deleted with the count, and only
* while something is in it.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/documents/deleted",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: { inboundDocuments: true } },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
// Never resolves within the synchronous render: an unseeded query stays
// pending.
apiGet: () => new Promise(() => {}),
apiGetList: () => new Promise(() => {}),
apiPost: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { DeletedDocumentDto } from "@/lib/validations/inbound-documents";
import { VaultPageActions } from "../../vault/vault-page-actions";
import { RecentlyDeletedView } from "../recently-deleted-view";
const DAY = 86_400_000;
function doc(over: Partial<DeletedDocumentDto> = {}): DeletedDocumentDto {
const deletedAt = new Date(Date.now() - 3 * DAY);
return {
id: "doc-1",
kind: "LAB_RESULT",
title: "Blood panel",
filename: "blood.pdf",
mimeType: "application/pdf",
byteSize: 1234,
documentDate: "2024-03-04",
sourceSystem: "PAPERLESS",
deletedAt: deletedAt.toISOString(),
purgeAt: new Date(deletedAt.getTime() + 30 * DAY).toISOString(),
...over,
};
}
function render(node: React.ReactNode, seed?: (qc: QueryClient) => void) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
seed?.(queryClient);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
function seedList(documents: DeletedDocumentDto[], truncated = false) {
return (qc: QueryClient) =>
qc.setQueryData(queryKeys.inboundDocumentsDeleted(), {
data: { documents, graceDays: 30 },
cap: { truncated, limit: 200 },
});
}
describe("<RecentlyDeletedView>", () => {
it("first load: header and a skeleton, never the empty copy", () => {
const html = render(<RecentlyDeletedView />);
expect(html).toContain("Recently deleted");
expect(html).toContain(
"Deleted documents stay here for 30 days, then they are removed for good.",
);
expect(html).toContain('data-slot="documents-trash-loading"');
expect(html).toContain('href="/documents"');
expect(html).not.toContain("Nothing in Recently deleted");
});
it("a row: what it was, where it came from, how long it has left, both ways out", () => {
const html = render(<RecentlyDeletedView />, seedList([doc()]));
expect(html).toContain('data-slot="documents-trash-card"');
expect(html).toContain("Deleted documents (1)");
expect(html).toContain("Blood panel");
expect(html).toContain("Lab result");
expect(html).toContain("Imported from Paperless-ngx");
expect(html).toContain("Removed for good in 27 days.");
expect(html).toContain('data-slot="documents-trash-restore"');
expect(html).toContain('data-slot="documents-trash-purge"');
expect(html).toContain("Delete now");
// One document: nothing to restore "all" of.
expect(html).not.toContain('data-slot="documents-trash-restore-all"');
});
it("falls back to the file name, and says the last day plainly", () => {
const html = render(
<RecentlyDeletedView />,
seedList([
doc({
title: null,
sourceSystem: null,
purgeAt: new Date(Date.now() + 3_600_000).toISOString(),
}),
]),
);
expect(html).toContain("blood.pdf");
expect(html).not.toContain("Imported from");
expect(html).toContain("Removed for good within a day.");
});
it("offers Restore all with more than one document, and the cap notice when cut", () => {
const html = render(
<RecentlyDeletedView />,
seedList([doc(), doc({ id: "doc-2", title: "X-ray" })], true),
);
expect(html).toContain('data-slot="documents-trash-restore-all"');
expect(html).toContain("Restore all");
expect(html).toContain("Deleted documents (2)");
expect(html).toContain('data-slot="list-cap-notice"');
});
it("empty: says so and leads back to the vault", () => {
const html = render(<RecentlyDeletedView />, seedList([]));
expect(html).toContain("Nothing in Recently deleted");
expect(html).not.toContain('data-slot="documents-trash-card"');
});
});
describe("<VaultPageActions> — the way into Recently deleted", () => {
const actions = (inSharedRecord = false) => (
<VaultPageActions
canManageDocuments={!inSharedRecord}
inSharedRecord={inSharedRecord}
hideUpload={false}
uploadInputRef={createRef<HTMLInputElement>()}
/>
);
const seedUsage = (deletedCount: number) => (qc: QueryClient) =>
qc.setQueryData(queryKeys.inboundDocumentUsage(), { deletedCount });
it("links to Recently deleted with the count while something is in it", () => {
const html = render(actions(), seedUsage(3));
expect(html).toContain('data-slot="documents-recently-deleted"');
expect(html).toContain('href="/documents/deleted"');
expect(html).toContain('aria-label="Recently deleted (3)"');
expect(html).toContain("(3)");
});
it("shows nothing while Recently deleted is empty or the usage is out", () => {
expect(render(actions(), seedUsage(0))).not.toContain(
"documents-recently-deleted",
);
expect(render(actions())).not.toContain("documents-recently-deleted");
});
it("shows nothing inside somebody else's record", () => {
expect(render(actions(true), seedUsage(3))).not.toContain(
"documents-recently-deleted",
);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { daysUntilPurge, purgeCountdownKey } from "../trash-utils";
const NOW = new Date("2026-10-24T12:00:00Z");
describe("daysUntilPurge", () => {
it("rounds a part day up: the whole of it is still restorable", () => {
expect(daysUntilPurge("2026-11-20T15:00:00Z", NOW)).toBe(28);
expect(daysUntilPurge("2026-10-25T11:00:00Z", NOW)).toBe(1);
expect(daysUntilPurge("2026-10-24T12:00:01Z", NOW)).toBe(1);
});
it("is zero once the moment has passed, and for an unreadable date", () => {
expect(daysUntilPurge("2026-10-24T12:00:00Z", NOW)).toBe(0);
expect(daysUntilPurge("2026-10-01T00:00:00Z", NOW)).toBe(0);
expect(daysUntilPurge("not a date", NOW)).toBe(0);
});
});
describe("purgeCountdownKey", () => {
it("names the days while there are two or more", () => {
expect(purgeCountdownKey(30)).toEqual({
key: "documents.trash.removesIn",
params: { days: 30 },
});
expect(purgeCountdownKey(2)).toEqual({
key: "documents.trash.removesIn",
params: { days: 2 },
});
});
it("says within a day for the last one, and after the moment passed", () => {
expect(purgeCountdownKey(1)).toEqual({
key: "documents.trash.removesSoon",
});
expect(purgeCountdownKey(0)).toEqual({
key: "documents.trash.removesSoon",
});
});
});
@@ -0,0 +1,270 @@
"use client";
/**
* Recently deleted — the vault's deleted documents, until the nightly purge
* removes them for good.
*
* Deleting in the vault only sets a tombstone; for 30 days the document can
* come back. Before this page the only way back was the undo toast, which is
* gone a few seconds after the delete. Each row says what the document was
* (title, type, filing date, where it came from), when it was deleted and how
* long it has left, and offers Restore and "Delete now". Restoring all of them
* is one action in the card header.
*
* Owner-only, like every route behind it: inside somebody else's record the
* page sends the person back to the vault, which offers no way in either.
*/
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Loader2, RotateCcw, Trash2 } from "lucide-react";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Card, CardHeader } from "@/components/ui/card";
import { ConfirmDialog } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { InlineAction } from "@/components/ui/inline-action";
import { ListCapNotice } from "@/components/ui/list-cap-notice";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
DeletedDocumentDto,
DocumentSourceSystemValue,
} from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "../document-kind-meta";
import { daysUntilPurge, purgeCountdownKey } from "./trash-utils";
import { useRecentlyDeleted } from "./use-recently-deleted";
export function RecentlyDeletedView() {
const { t } = useTranslations();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
const moduleEnabled = user?.modules?.inboundDocuments === true;
const ready = !authLoading && isAuthenticated && moduleEnabled;
// UX redirects only; every route behind the page enforces the same.
useEffect(() => {
if (authLoading) return;
if (!isAuthenticated) router.push("/auth/login");
else if (!moduleEnabled) router.push("/");
else if (inSharedRecord) router.push("/documents");
}, [authLoading, isAuthenticated, moduleEnabled, inSharedRecord, router]);
const { list, restore, restoreAll, purge } = useRecentlyDeleted({
enabled: ready && !inSharedRecord,
});
const [pendingPurge, setPendingPurge] = useState<DeletedDocumentDto | null>(
null,
);
if (!ready || inSharedRecord) {
return (
<div className="flex h-64 items-center justify-center" role="status">
<Loader2
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
aria-hidden
/>
<span className="sr-only">{t("nav.loadingScreen")}</span>
</div>
);
}
const documents = list.data?.data.documents ?? [];
const graceDays = list.data?.data.graceDays ?? DOCUMENT_PURGE_GRACE_DAYS;
return (
<div className="space-y-6">
<PageHeader
title={t("documents.trash.title")}
description={t("documents.trash.subtitle", { days: graceDays })}
backLink={{
href: "/documents",
label: t("documents.trash.back"),
dataSlot: "documents-trash-back",
}}
/>
{list.isPending ? (
<div data-slot="documents-trash-loading" className="space-y-4">
{Array.from({ length: 3 }, (_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
) : list.isError ? (
<QueryErrorCard
error={list.error}
title={t("documents.trash.loadError")}
onRetry={() => void list.refetch()}
/>
) : documents.length === 0 ? (
<EmptyState
icon={<Trash2 className="size-6" aria-hidden />}
title={t("documents.trash.emptyTitle")}
description={t("documents.trash.emptyDescription", {
days: graceDays,
})}
action={
<Button asChild variant="outline" className="min-h-11 sm:min-h-9">
<Link href="/documents">{t("documents.trash.back")}</Link>
</Button>
}
/>
) : (
<Card data-slot="documents-trash-card">
<CardHeader>
<TileHeader
icon={Trash2}
title={t("documents.trash.count", { count: documents.length })}
right={
documents.length > 1 ? (
<InlineAction
size="sm"
data-slot="documents-trash-restore-all"
disabled={restoreAll.isPending}
onClick={() =>
restoreAll.mutate(documents.map((d) => d.id))
}
>
<RotateCcw aria-hidden />
{t("documents.trash.restoreAll")}
</InlineAction>
) : null
}
/>
</CardHeader>
<ul className="divide-border divide-y border-t">
{documents.map((doc) => (
<DeletedRow
key={doc.id}
doc={doc}
busy={
restoreAll.isPending ||
(restore.isPending && restore.variables === doc.id) ||
(purge.isPending && purge.variables === doc.id)
}
onRestore={() => restore.mutate(doc.id)}
onPurge={() => setPendingPurge(doc)}
/>
))}
</ul>
<ListCapNotice cap={list.data?.cap} className="px-4 md:px-6" />
</Card>
)}
<ConfirmDialog
open={pendingPurge !== null}
onOpenChange={(open) => {
if (!open && !purge.isPending) setPendingPurge(null);
}}
title={t("documents.trash.purgeConfirmTitle")}
body={t("documents.trash.purgeConfirmBody")}
confirmLabel={t("documents.trash.purge")}
pending={purge.isPending}
holdOpen
destructive
slot="documents-trash-purge"
onConfirm={() => {
if (!pendingPurge) return;
purge.mutate(pendingPurge.id, {
onSettled: () => setPendingPurge(null),
});
}}
/>
</div>
);
}
/** Product names stay untranslated; only "another system" is prose. */
function sourceName(
system: DocumentSourceSystemValue,
t: (key: string) => string,
): string {
if (system === "PAPERLESS") return "Paperless-ngx";
if (system === "PAPRA") return "Papra";
return t("documents.detail.sourceOther");
}
/** One deleted document: what it was, how long it has left, the two ways out. */
export function DeletedRow({
doc,
busy,
onRestore,
onPurge,
}: {
doc: DeletedDocumentDto;
busy: boolean;
onRestore: () => void;
onPurge: () => void;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const Icon = DOCUMENT_KIND_ICONS[doc.kind];
const title = doc.title ?? doc.filename ?? t("documents.card.untitled");
const about = [
t(`documents.kind.${doc.kind}`),
doc.documentDate ? fmt.dateShortSmartCalendar(doc.documentDate) : null,
doc.sourceSystem
? t("documents.detail.importedFrom", {
system: sourceName(doc.sourceSystem, t),
})
: null,
]
.filter(Boolean)
.join(", ");
const countdown = purgeCountdownKey(daysUntilPurge(doc.purgeAt));
return (
<li
data-slot="documents-trash-row"
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center md:px-6"
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium break-words">{title}</p>
<p className="text-muted-foreground text-xs">{about}</p>
<p className="text-muted-foreground text-xs">
{t("documents.trash.deletedOn", {
date: fmt.dateShortSmart(doc.deletedAt),
})}{" "}
{t(countdown.key, countdown.params)}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="documents-trash-restore"
disabled={busy}
onClick={onRestore}
>
<RotateCcw className="size-4" aria-hidden />
{t("documents.trash.restore")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="documents-trash-purge"
disabled={busy}
onClick={onPurge}
>
{t("documents.trash.purge")}
</Button>
</div>
</li>
);
}
@@ -0,0 +1,31 @@
/**
* How long a document in Recently deleted has left, in whole days, read from
* the server's `purgeAt`.
*
* Rounded up: a document due in 26 days and 3 hours still has 27 days in which
* it can be restored. Zero once the moment has passed; the nightly purge then
* takes it at its next run.
*/
const DAY_MS = 86_400_000;
export function daysUntilPurge(
purgeAt: string,
now: Date = new Date(),
): number {
const left = new Date(purgeAt).getTime() - now.getTime();
if (!Number.isFinite(left) || left <= 0) return 0;
return Math.ceil(left / DAY_MS);
}
/**
* The translation key and parameters for a row's countdown line. The purge
* runs once a night, so the last day is said as "within a day" rather than a
* time the job does not keep.
*/
export function purgeCountdownKey(days: number): {
key: "documents.trash.removesIn" | "documents.trash.removesSoon";
params?: { days: number };
} {
if (days <= 1) return { key: "documents.trash.removesSoon" };
return { key: "documents.trash.removesIn", params: { days } };
}
@@ -0,0 +1,115 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { ApiError, apiGetList, apiPost } from "@/lib/api/api-fetch";
import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import type {
DeletedDocumentListDto,
DocumentBulkResultDto,
} from "@/lib/validations/inbound-documents";
/**
* Recently deleted: the list, and the three ways out of it. Restore brings a
* document back (one at a time, or all through the bulk route); "Delete now"
* removes one for good ahead of the grace window. Every write invalidates the
* `["documents"]` prefix, so the vault, its usage figures and this list repaint
* together.
*/
export function useRecentlyDeleted({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const router = useRouter();
const list = useQuery({
queryKey: queryKeys.inboundDocumentsDeleted(),
queryFn: () =>
apiGetList<DeletedDocumentListDto>("/api/documents/inbound/deleted"),
enabled,
});
const refresh = useCallback(
() => void invalidateKeys(queryClient, [queryKeys.documents()]),
[queryClient],
);
const restore = useMutation({
mutationFn: (id: string) =>
apiPost(`/api/documents/inbound/${id}/restore`, {}),
onSuccess: (_data, id) => {
refresh();
toast.success(t("documents.trash.restored"), {
action: {
label: t("documents.trash.openRestored"),
onClick: () =>
router.push(`/documents?doc=${encodeURIComponent(id)}`),
},
});
},
onError: (error) => {
refresh();
const reason =
error instanceof ApiError && typeof error.meta?.reason === "string"
? error.meta.reason
: "";
if (reason === "purged") {
showErrorToast(t("documents.error.purged"), { cause: error });
} else if (reason === "duplicateExists") {
showErrorToast(t("documents.trash.restoreDuplicate"), {
cause: error,
});
} else {
showErrorToast(t("documents.toast.restoreFailed"), { cause: error });
}
},
});
const restoreAll = useMutation({
mutationFn: async (ids: string[]) => {
const results: DocumentBulkResultDto[] = [];
for (let i = 0; i < ids.length; i += DOCUMENT_BULK_MAX_IDS) {
const page = await apiPost<{ results: DocumentBulkResultDto[] }>(
"/api/documents/inbound/bulk",
{ ids: ids.slice(i, i + DOCUMENT_BULK_MAX_IDS), action: "restore" },
);
results.push(...page.results);
}
return results;
},
onSuccess: (results) => {
const restored = results.filter((r) => r.ok).length;
const failed = results.length - restored;
toastWrittenOutcome(
failed === 0 ? "success" : restored > 0 ? "partial" : "failed",
failed === 0
? t("documents.bulk.restored", { count: restored })
: t("documents.trash.restorePartial", {
failed,
total: results.length,
}),
);
},
onError: (error) =>
showErrorToast(t("documents.toast.restoreFailed"), { cause: error }),
onSettled: refresh,
});
const purge = useMutation({
mutationFn: (id: string) =>
apiPost(`/api/documents/inbound/${id}/purge`, {}),
onSuccess: () =>
toastWrittenOutcome("success", t("documents.trash.purged")),
onError: (error) =>
showErrorToast(t("documents.trash.purgeFailed"), { cause: error }),
onSettled: refresh,
});
return { list, restore, restoreAll, purge };
}
@@ -0,0 +1,21 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
/**
* The vault's usage read (`GET /api/documents/inbound/usage`): limits, usage,
* filter choices, index coverage and the Recently deleted count. One
* definition for every reader, so the shared key never meets a second
* `queryFn` shape.
*/
export function useDocumentUsage(enabled: boolean) {
return useQuery({
queryKey: queryKeys.inboundDocumentUsage(),
enabled,
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
});
}
@@ -5,7 +5,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiPost } from "@/lib/api/api-fetch";
import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants";
import {
DOCUMENT_BULK_MAX_IDS,
DOCUMENT_PURGE_GRACE_DAYS,
} from "@/lib/documents/inbound-constants";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
@@ -155,6 +158,9 @@ export function useVaultBulk({
toast.success(
t("documents.bulk.deleted", { count: okIds.length }),
{
description: t("documents.toast.deletedHint", {
days: DOCUMENT_PURGE_GRACE_DAYS,
}),
action: {
label: t("common.undo"),
onClick: () => restoreBulk(okIds),
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useInfiniteQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
@@ -11,16 +11,14 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
import { queryKeys } from "@/lib/query-keys";
import type {
DocumentUsageDto,
InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { useReindexAll } from "../use-content-index";
import { useDocumentUpload } from "../use-document-upload";
import { usePageFileDrop } from "../use-page-file-drop";
import { buildVaultListApiSearch, hasProcessingDocument } from "../vault-utils";
import { useDocumentUsage } from "./use-document-usage";
import type { VaultFilters } from "./use-vault-filters";
import { useVaultFilterChoices } from "./use-vault-filter-choices";
@@ -51,11 +49,7 @@ export function useVaultLibrary({
timezone: string;
}) {
const { t } = useTranslations();
const usage = useQuery({
queryKey: queryKeys.inboundDocumentUsage(),
enabled: moduleEnabled,
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
});
const usage = useDocumentUsage(moduleEnabled);
const list = useInfiniteQuery({
queryKey: queryKeys.inboundDocumentList(filters),
@@ -2,17 +2,19 @@
import type { RefObject } from "react";
import Link from "next/link";
import { Upload, Wrench } from "lucide-react";
import { Trash2, Upload, Wrench } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { ImportFromSourceButton } from "../sources/import-from-source-button";
import { useDocumentUsage } from "./use-document-usage";
/**
* The page header's actions (rendered only when one of them applies): the display-settings wrench (the caller's own
* settings, so never inside somebody else's record), the source import and
* the upload button, which an empty vault's empty state carries instead.
* settings, so never inside somebody else's record), the way into Recently
* deleted (only while something is in it), the source import and the upload
* button, which an empty vault's empty state carries instead.
*/
export function VaultPageActions({
canManageDocuments,
@@ -26,6 +28,9 @@ export function VaultPageActions({
uploadInputRef: RefObject<HTMLInputElement | null>;
}) {
const { t } = useTranslations();
// The vault's own usage read (shared key): how many sit in Recently
// deleted. The way in shows only above zero.
const deletedCount = useDocumentUsage(true).data?.deletedCount ?? 0;
return (
<div className="flex flex-wrap items-center gap-2">
{/* The wrench left of the primary action, as on the illness
@@ -49,6 +54,31 @@ export function VaultPageActions({
</Link>
</Button>
) : null}
{/* Quiet, like the wrench, and only while there is something to
bring back: deleted documents wait there for 30 days. On a phone
the icon and the count; the words from `sm` up. */}
{canManageDocuments && !inSharedRecord && deletedCount > 0 ? (
<Button
asChild
variant="ghost"
size="sm"
className="min-h-11 min-w-11 gap-1.5 sm:min-h-9 sm:min-w-9"
>
<Link
href="/documents/deleted"
aria-label={t("documents.trash.entryLabel", {
count: deletedCount,
})}
data-slot="documents-recently-deleted"
>
<Trash2 className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.trash.title")}
</span>
<span className="tabular-nums">({deletedCount})</span>
</Link>
</Button>
) : null}
{canManageDocuments ? (
<>
{/* Renders only with a connected Paperless-ngx or Papra. */}