mirror of
https://github.com/papra-hq/papra.git
synced 2026-01-06 00:50:41 -06:00
feat(documents): delete documents from the trash (#211)
This commit is contained in:
committed by
GitHub
parent
b13986e1e3
commit
1085bf079c
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user