feat(documents): delete documents from the trash (#211)

This commit is contained in:
Corentin Thomasset
2025-04-10 22:24:47 +02:00
committed by GitHub
parent b13986e1e3
commit 1085bf079c
11 changed files with 301 additions and 7 deletions

View File

@@ -182,3 +182,23 @@ demo:
discord-link-label: Discord server
reset: Reset demo data
hide: Hide
trash:
delete-all:
button: Delete all
confirm:
title: Permanently delete all documents?
description: Are you sure you want to permanently delete all documents from the trash? This action cannot be undone.
label: Delete
cancel: Cancel
delete:
button: Delete
confirm:
title: Permanently delete document?
description: Are you sure you want to permanently delete this document from the trash? This action cannot be undone.
label: Delete
cancel: Cancel
deleted:
success:
title: Document deleted
description: The document has been permanently deleted.

View File

@@ -521,6 +521,26 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
return { taggingRule };
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/trash',
method: 'DELETE',
handler: async ({ params: { organizationId } }) => {
const documents = await findMany(documentStorage, document => document.organizationId === organizationId && Boolean(document.deletedAt));
await Promise.all(documents.map(document => documentStorage.removeItem(`${organizationId}:${document.id}`)));
},
}),
...defineHandler({
path: '/api/organizations/:organizationId/documents/trash/:documentId',
method: 'DELETE',
handler: async ({ params: { organizationId, documentId } }) => {
const key = `${organizationId}:${documentId}`;
await documentStorage.removeItem(key);
},
}),
};
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });

View File

@@ -194,3 +194,17 @@ export async function getOrganizationDocumentsStats({ organizationId }: { organi
return { organizationStats };
}
export async function deleteAllTrashDocuments({ organizationId }: { organizationId: string }) {
await apiClient({
method: 'DELETE',
path: `/api/organizations/${organizationId}/documents/trash`,
});
}
export async function deleteTrashDocument({ documentId, organizationId }: { documentId: string; organizationId: string }) {
await apiClient({
method: 'DELETE',
path: `/api/organizations/${organizationId}/documents/trash/${documentId}`,
});
}

View File

@@ -1,14 +1,18 @@
import type { Document } from '../documents.types';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { queryClient } from '@/modules/shared/query/query-client';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { createToast } from '@/modules/ui/components/sonner';
import { useParams } from '@solidjs/router';
import { createQuery, keepPreviousData } from '@tanstack/solid-query';
import { createMutation, createQuery, keepPreviousData } from '@tanstack/solid-query';
import { type Component, createSignal, Show, Suspense } from 'solid-js';
import { DocumentsPaginatedList } from '../components/documents-list.component';
import { useRestoreDocument } from '../documents.composables';
import { fetchOrganizationDeletedDocuments } from '../documents.services';
import { deleteAllTrashDocuments, deleteTrashDocument, fetchOrganizationDeletedDocuments } from '../documents.services';
const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
const { getIsRestoring, restore } = useRestoreDocument();
@@ -32,6 +36,113 @@ const RestoreDocumentButton: Component<{ document: Document }> = (props) => {
);
};
const PermanentlyDeleteTrashDocumentButton: Component<{ document: Document; organizationId: string }> = (props) => {
const { confirm } = useConfirmModal();
const { t } = useI18n();
const deleteMutation = createMutation(() => ({
mutationFn: async () => {
await deleteTrashDocument({ documentId: props.document.id, organizationId: props.organizationId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations', props.organizationId, 'documents', 'deleted'] });
createToast({
message: t('trash.deleted.success.title'),
description: t('trash.deleted.success.description'),
});
},
}));
const handleClick = async () => {
if (!await confirm({
title: t('trash.delete.confirm.title'),
message: t('trash.delete.confirm.description'),
confirmButton: {
text: t('trash.delete.confirm.label'),
variant: 'destructive',
},
cancelButton: {
text: t('trash.delete.confirm.cancel'),
},
})) {
return;
}
deleteMutation.mutate();
};
return (
<Button
variant="outline"
size="sm"
onClick={handleClick}
isLoading={deleteMutation.isPending}
class="text-red-500 hover:text-red-600"
>
{deleteMutation.isPending
? (<>Deleting...</>)
: (
<>
<div class="i-tabler-trash size-4 mr-2" />
{t('trash.delete.button')}
</>
)}
</Button>
);
};
const DeleteAllTrashDocumentsButton: Component<{ organizationId: string }> = (props) => {
const { confirm } = useConfirmModal();
const { t } = useI18n();
const deleteAllMutation = createMutation(() => ({
mutationFn: async () => {
await deleteAllTrashDocuments({ organizationId: props.organizationId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['organizations', props.organizationId, 'documents', 'deleted'] });
},
}));
const handleClick = async () => {
if (!await confirm({
title: t('trash.delete-all.confirm.title'),
message: t('trash.delete-all.confirm.description'),
confirmButton: {
text: t('trash.delete-all.confirm.label'),
variant: 'destructive',
},
cancelButton: {
text: t('trash.delete-all.confirm.cancel'),
},
})) {
return;
}
deleteAllMutation.mutate();
};
return (
<Button
variant="outline"
size="sm"
onClick={handleClick}
isLoading={deleteAllMutation.isPending}
class="text-red-500 hover:text-red-600"
>
{deleteAllMutation.isPending
? (<>Deleting...</>)
: (
<>
<div class="i-tabler-trash size-4 mr-2" />
{t('trash.delete-all.button')}
</>
)}
</Button>
);
};
export const DeletedDocumentsPage: Component = () => {
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
const params = useParams();
@@ -77,6 +188,10 @@ export const DeletedDocumentsPage: Component = () => {
</Show>
<Show when={query.data && query.data?.documents.length > 0}>
<div class="flex items-center justify-end gap-2">
<DeleteAllTrashDocumentsButton organizationId={params.organizationId} />
</div>
<DocumentsPaginatedList
documents={query.data?.documents ?? []}
documentsCount={query.data?.documentsCount ?? 0}
@@ -96,8 +211,9 @@ export const DeletedDocumentsPage: Component = () => {
{
id: 'actions',
cell: data => (
<div class="flex items-center justify-end">
<div class="flex items-center justify-end gap-2">
<RestoreDocumentButton document={data.row.original} />
<PermanentlyDeleteTrashDocumentButton document={data.row.original} organizationId={params.organizationId} />
</div>
),
},

File diff suppressed because one or more lines are too long

View File

@@ -50,6 +50,7 @@
"hono": "^4.6.15",
"lodash-es": "^4.17.21",
"node-cron": "^3.0.3",
"p-limit": "^6.2.0",
"posthog-node": "^4.11.1",
"resend": "^4.1.2",
"stripe": "^17.7.0",

View File

@@ -17,3 +17,9 @@ export const createDocumentAlreadyExistsError = createErrorFactory({
code: 'document.already_exists',
statusCode: 409,
});
export const createDocumentNotDeletedError = createErrorFactory({
message: 'Document is not deleted, cannot delete.',
code: 'document.not_deleted',
statusCode: 400,
});

View File

@@ -28,6 +28,7 @@ export function createDocumentsRepository({ db }: { db: Database }) {
getExpiredDeletedDocuments,
getOrganizationStats,
getOrganizationDocumentBySha256Hash,
getAllOrganizationTrashDocumentIds,
},
{ db },
);
@@ -303,3 +304,18 @@ async function getOrganizationStats({ organizationId, db }: { organizationId: st
documentsSize: Number(documentsSize ?? 0),
};
}
async function getAllOrganizationTrashDocumentIds({ organizationId, db }: { organizationId: string; db: Database }) {
const documents = await db.select({
id: documentsTable.id,
}).from(documentsTable).where(
and(
eq(documentsTable.organizationId, organizationId),
eq(documentsTable.isDeleted, true),
),
);
return {
documentIds: documents.map(document => document.id),
};
}

View File

@@ -14,7 +14,7 @@ import { createTagsRepository } from '../tags/tags.repository';
import { createDocumentIsNotDeletedError } from './documents.errors';
import { isDocumentSizeLimitEnabled } from './documents.models';
import { createDocumentsRepository } from './documents.repository';
import { createDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
import { createDocument, deleteAllTrashDocuments, deleteTrashDocument, ensureDocumentExists, getDocumentOrThrow } from './documents.usecases';
import { createDocumentStorageService } from './storage/documents.storage.services';
export function registerDocumentsPrivateRoutes(context: RouteDefinitionContext) {
@@ -25,6 +25,8 @@ export function registerDocumentsPrivateRoutes(context: RouteDefinitionContext)
setupGetDeletedDocumentsRoute(context);
setupGetOrganizationDocumentsStatsRoute(context);
setupGetDocumentRoute(context);
setupDeleteTrashDocumentRoute(context);
setupDeleteAllTrashDocumentsRoute(context);
setupDeleteDocumentRoute(context);
setupGetDocumentFileRoute(context);
}
@@ -369,3 +371,54 @@ function setupGetOrganizationDocumentsStatsRoute({ app, db }: RouteDefinitionCon
},
);
}
function setupDeleteTrashDocumentRoute({ app, config, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/trash/:documentId',
validateParams(z.object({
organizationId: z.string().regex(organizationIdRegex),
documentId: z.string(),
})),
async (context) => {
const { userId } = getUser({ context });
const { organizationId, documentId } = context.req.valid('param');
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const documentsStorageService = await createDocumentStorageService({ config });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await deleteTrashDocument({ documentId, organizationId, documentsRepository, documentsStorageService });
return context.json({
success: true,
});
},
);
}
function setupDeleteAllTrashDocumentsRoute({ app, config, db }: RouteDefinitionContext) {
app.delete(
'/api/organizations/:organizationId/documents/trash',
validateParams(z.object({
organizationId: z.string().regex(organizationIdRegex),
})),
async (context) => {
const { userId } = getUser({ context });
const { organizationId } = context.req.valid('param');
const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
const documentsStorageService = await createDocumentStorageService({ config });
await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository });
await deleteAllTrashDocuments({ organizationId, documentsRepository, documentsStorageService });
return context.body(null, 204);
},
);
}

View File

@@ -9,10 +9,11 @@ import type { DocumentsRepository } from './documents.repository';
import type { DocumentStorageService } from './storage/documents.storage.services';
import { safely } from '@corentinth/chisels';
import { extractTextFromFile } from '@papra/lecture';
import pLimit from 'p-limit';
import { checkIfOrganizationCanCreateNewDocument } from '../organizations/organizations.usecases';
import { createLogger } from '../shared/logger/logger';
import { applyTaggingRules } from '../tagging-rules/tagging-rules.usecases';
import { createDocumentAlreadyExistsError, createDocumentNotFoundError } from './documents.errors';
import { createDocumentAlreadyExistsError, createDocumentNotDeletedError, createDocumentNotFoundError } from './documents.errors';
import { buildOriginalDocumentKey, generateDocumentId as generateDocumentIdImpl } from './documents.models';
import { getFileSha256Hash } from './documents.services';
@@ -172,7 +173,7 @@ export async function hardDeleteDocument({
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
}) {
await Promise.all([
await Promise.allSettled([
documentsRepository.hardDeleteDocument({ documentId }),
documentsStorageService.deleteFile({ storageKey: documentId }),
]);
@@ -210,3 +211,47 @@ export async function deleteExpiredDocuments({
deletedDocumentsCount: documentIds.length,
};
}
export async function deleteTrashDocument({
documentId,
organizationId,
documentsRepository,
documentsStorageService,
}: {
documentId: string;
organizationId: string;
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
}) {
const { document } = await documentsRepository.getDocumentById({ documentId, organizationId });
if (!document) {
throw createDocumentNotFoundError();
}
if (!document.isDeleted) {
throw createDocumentNotDeletedError();
}
await hardDeleteDocument({ documentId, documentsRepository, documentsStorageService });
}
export async function deleteAllTrashDocuments({
organizationId,
documentsRepository,
documentsStorageService,
}: {
organizationId: string;
documentsRepository: DocumentsRepository;
documentsStorageService: DocumentStorageService;
}) {
const { documentIds } = await documentsRepository.getAllOrganizationTrashDocumentIds({ organizationId });
// TODO: refactor to use batching and transaction
const limit = pLimit(10);
await Promise.all(
documentIds.map(documentId => limit(() => hardDeleteDocument({ documentId, documentsRepository, documentsStorageService }))),
);
}

3
pnpm-lock.yaml generated
View File

@@ -274,6 +274,9 @@ importers:
node-cron:
specifier: ^3.0.3
version: 3.0.3
p-limit:
specifier: ^6.2.0
version: 6.2.0
posthog-node:
specifier: ^4.11.1
version: 4.11.1