diff --git a/apps/papra-client/src/locales/en.yml b/apps/papra-client/src/locales/en.yml index 71f827f..b172763 100644 --- a/apps/papra-client/src/locales/en.yml +++ b/apps/papra-client/src/locales/en.yml @@ -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. diff --git a/apps/papra-client/src/modules/demo/demo-api-mock.ts b/apps/papra-client/src/modules/demo/demo-api-mock.ts index 68a4722..78c9378 100644 --- a/apps/papra-client/src/modules/demo/demo-api-mock.ts +++ b/apps/papra-client/src/modules/demo/demo-api-mock.ts @@ -521,6 +521,26 @@ const inMemoryApiMock: Record = { 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 }); diff --git a/apps/papra-client/src/modules/documents/documents.services.ts b/apps/papra-client/src/modules/documents/documents.services.ts index 074d336..7f5ac45 100644 --- a/apps/papra-client/src/modules/documents/documents.services.ts +++ b/apps/papra-client/src/modules/documents/documents.services.ts @@ -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}`, + }); +} diff --git a/apps/papra-client/src/modules/documents/pages/deleted-documents.page.tsx b/apps/papra-client/src/modules/documents/pages/deleted-documents.page.tsx index 170769c..d32ad53 100644 --- a/apps/papra-client/src/modules/documents/pages/deleted-documents.page.tsx +++ b/apps/papra-client/src/modules/documents/pages/deleted-documents.page.tsx @@ -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 ( + + ); +}; + +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 ( + + ); +}; + export const DeletedDocumentsPage: Component = () => { const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 }); const params = useParams(); @@ -77,6 +188,10 @@ export const DeletedDocumentsPage: Component = () => { 0}> +
+ +
+ { { id: 'actions', cell: data => ( -
+
+
), }, diff --git a/apps/papra-client/src/modules/i18n/locales.types.ts b/apps/papra-client/src/modules/i18n/locales.types.ts index 7e6e821..ad73b9a 100644 --- a/apps/papra-client/src/modules/i18n/locales.types.ts +++ b/apps/papra-client/src/modules/i18n/locales.types.ts @@ -1,2 +1,2 @@ // Dynamically generated file. Use "pnpm script:generate-i18n-types" to update. -export type LocaleKeys = 'auth.request-password-reset.title' | 'auth.request-password-reset.description' | 'auth.request-password-reset.requested' | 'auth.request-password-reset.back-to-login' | 'auth.request-password-reset.form.email.label' | 'auth.request-password-reset.form.email.placeholder' | 'auth.request-password-reset.form.email.required' | 'auth.request-password-reset.form.email.invalid' | 'auth.request-password-reset.form.submit' | 'auth.reset-password.title' | 'auth.reset-password.description' | 'auth.reset-password.reset' | 'auth.reset-password.back-to-login' | 'auth.reset-password.form.new-password.label' | 'auth.reset-password.form.new-password.placeholder' | 'auth.reset-password.form.new-password.required' | 'auth.reset-password.form.new-password.min-length' | 'auth.reset-password.form.new-password.max-length' | 'auth.reset-password.form.submit' | 'auth.email-provider.open' | 'auth.login.title' | 'auth.login.description' | 'auth.login.login-with-provider' | 'auth.login.no-account' | 'auth.login.register' | 'auth.login.form.email.label' | 'auth.login.form.email.placeholder' | 'auth.login.form.email.required' | 'auth.login.form.email.invalid' | 'auth.login.form.password.label' | 'auth.login.form.password.placeholder' | 'auth.login.form.password.required' | 'auth.login.form.remember-me.label' | 'auth.login.form.forgot-password.label' | 'auth.login.form.submit' | 'auth.register.title' | 'auth.register.description' | 'auth.register.register-with-email' | 'auth.register.register-with-provider' | 'auth.register.providers.google' | 'auth.register.providers.github' | 'auth.register.have-account' | 'auth.register.login' | 'auth.register.registration-disabled.title' | 'auth.register.registration-disabled.description' | 'auth.register.form.email.label' | 'auth.register.form.email.placeholder' | 'auth.register.form.email.required' | 'auth.register.form.email.invalid' | 'auth.register.form.password.label' | 'auth.register.form.password.placeholder' | 'auth.register.form.password.required' | 'auth.register.form.password.min-length' | 'auth.register.form.password.max-length' | 'auth.register.form.name.label' | 'auth.register.form.name.placeholder' | 'auth.register.form.name.required' | 'auth.register.form.name.max-length' | 'auth.register.form.submit' | 'auth.email-validation-required.title' | 'auth.email-validation-required.description' | 'auth.legal-links.description' | 'auth.legal-links.terms' | 'auth.legal-links.privacy' | 'tags.no-tags.title' | 'tags.no-tags.description' | 'tags.no-tags.create-tag' | 'layout.menu.home' | 'layout.menu.documents' | 'layout.menu.tags' | 'layout.menu.tagging-rules' | 'layout.menu.integrations' | 'layout.menu.deleted-documents' | 'layout.menu.organization-settings' | 'tagging-rules.field.name' | 'tagging-rules.field.content' | 'tagging-rules.operator.equals' | 'tagging-rules.operator.not-equals' | 'tagging-rules.operator.contains' | 'tagging-rules.operator.not-contains' | 'tagging-rules.operator.starts-with' | 'tagging-rules.operator.ends-with' | 'tagging-rules.list.title' | 'tagging-rules.list.description' | 'tagging-rules.list.demo-warning' | 'tagging-rules.list.no-tagging-rules.title' | 'tagging-rules.list.no-tagging-rules.description' | 'tagging-rules.list.no-tagging-rules.create-tagging-rule' | 'tagging-rules.list.card.no-conditions' | 'tagging-rules.list.card.one-condition' | 'tagging-rules.list.card.conditions' | 'tagging-rules.list.card.delete' | 'tagging-rules.list.card.edit' | 'tagging-rules.create.title' | 'tagging-rules.create.success' | 'tagging-rules.create.error' | 'tagging-rules.create.submit' | 'tagging-rules.form.name.label' | 'tagging-rules.form.name.placeholder' | 'tagging-rules.form.name.min-length' | 'tagging-rules.form.name.max-length' | 'tagging-rules.form.description.label' | 'tagging-rules.form.description.placeholder' | 'tagging-rules.form.description.max-length' | 'tagging-rules.form.conditions.label' | 'tagging-rules.form.conditions.description' | 'tagging-rules.form.conditions.add-condition' | 'tagging-rules.form.conditions.no-conditions.title' | 'tagging-rules.form.conditions.no-conditions.description' | 'tagging-rules.form.conditions.no-conditions.confirm' | 'tagging-rules.form.conditions.no-conditions.cancel' | 'tagging-rules.form.conditions.field.label' | 'tagging-rules.form.conditions.operator.label' | 'tagging-rules.form.conditions.value.label' | 'tagging-rules.form.conditions.value.placeholder' | 'tagging-rules.form.conditions.value.min-length' | 'tagging-rules.form.tags.label' | 'tagging-rules.form.tags.description' | 'tagging-rules.form.tags.min-length' | 'tagging-rules.form.tags.add-tag' | 'tagging-rules.form.submit' | 'tagging-rules.update.title' | 'tagging-rules.update.success' | 'tagging-rules.update.error' | 'tagging-rules.update.submit' | 'tagging-rules.update.cancel' | 'demo.popup.description' | 'demo.popup.discord' | 'demo.popup.discord-link-label' | 'demo.popup.reset' | 'demo.popup.hide'; +export type LocaleKeys = 'auth.request-password-reset.title' | 'auth.request-password-reset.description' | 'auth.request-password-reset.requested' | 'auth.request-password-reset.back-to-login' | 'auth.request-password-reset.form.email.label' | 'auth.request-password-reset.form.email.placeholder' | 'auth.request-password-reset.form.email.required' | 'auth.request-password-reset.form.email.invalid' | 'auth.request-password-reset.form.submit' | 'auth.reset-password.title' | 'auth.reset-password.description' | 'auth.reset-password.reset' | 'auth.reset-password.back-to-login' | 'auth.reset-password.form.new-password.label' | 'auth.reset-password.form.new-password.placeholder' | 'auth.reset-password.form.new-password.required' | 'auth.reset-password.form.new-password.min-length' | 'auth.reset-password.form.new-password.max-length' | 'auth.reset-password.form.submit' | 'auth.email-provider.open' | 'auth.login.title' | 'auth.login.description' | 'auth.login.login-with-provider' | 'auth.login.no-account' | 'auth.login.register' | 'auth.login.form.email.label' | 'auth.login.form.email.placeholder' | 'auth.login.form.email.required' | 'auth.login.form.email.invalid' | 'auth.login.form.password.label' | 'auth.login.form.password.placeholder' | 'auth.login.form.password.required' | 'auth.login.form.remember-me.label' | 'auth.login.form.forgot-password.label' | 'auth.login.form.submit' | 'auth.register.title' | 'auth.register.description' | 'auth.register.register-with-email' | 'auth.register.register-with-provider' | 'auth.register.providers.google' | 'auth.register.providers.github' | 'auth.register.have-account' | 'auth.register.login' | 'auth.register.registration-disabled.title' | 'auth.register.registration-disabled.description' | 'auth.register.form.email.label' | 'auth.register.form.email.placeholder' | 'auth.register.form.email.required' | 'auth.register.form.email.invalid' | 'auth.register.form.password.label' | 'auth.register.form.password.placeholder' | 'auth.register.form.password.required' | 'auth.register.form.password.min-length' | 'auth.register.form.password.max-length' | 'auth.register.form.name.label' | 'auth.register.form.name.placeholder' | 'auth.register.form.name.required' | 'auth.register.form.name.max-length' | 'auth.register.form.submit' | 'auth.email-validation-required.title' | 'auth.email-validation-required.description' | 'auth.legal-links.description' | 'auth.legal-links.terms' | 'auth.legal-links.privacy' | 'tags.no-tags.title' | 'tags.no-tags.description' | 'tags.no-tags.create-tag' | 'layout.menu.home' | 'layout.menu.documents' | 'layout.menu.tags' | 'layout.menu.tagging-rules' | 'layout.menu.integrations' | 'layout.menu.deleted-documents' | 'layout.menu.organization-settings' | 'tagging-rules.field.name' | 'tagging-rules.field.content' | 'tagging-rules.operator.equals' | 'tagging-rules.operator.not-equals' | 'tagging-rules.operator.contains' | 'tagging-rules.operator.not-contains' | 'tagging-rules.operator.starts-with' | 'tagging-rules.operator.ends-with' | 'tagging-rules.list.title' | 'tagging-rules.list.description' | 'tagging-rules.list.demo-warning' | 'tagging-rules.list.no-tagging-rules.title' | 'tagging-rules.list.no-tagging-rules.description' | 'tagging-rules.list.no-tagging-rules.create-tagging-rule' | 'tagging-rules.list.card.no-conditions' | 'tagging-rules.list.card.one-condition' | 'tagging-rules.list.card.conditions' | 'tagging-rules.list.card.delete' | 'tagging-rules.list.card.edit' | 'tagging-rules.create.title' | 'tagging-rules.create.success' | 'tagging-rules.create.error' | 'tagging-rules.create.submit' | 'tagging-rules.form.name.label' | 'tagging-rules.form.name.placeholder' | 'tagging-rules.form.name.min-length' | 'tagging-rules.form.name.max-length' | 'tagging-rules.form.description.label' | 'tagging-rules.form.description.placeholder' | 'tagging-rules.form.description.max-length' | 'tagging-rules.form.conditions.label' | 'tagging-rules.form.conditions.description' | 'tagging-rules.form.conditions.add-condition' | 'tagging-rules.form.conditions.no-conditions.title' | 'tagging-rules.form.conditions.no-conditions.description' | 'tagging-rules.form.conditions.no-conditions.confirm' | 'tagging-rules.form.conditions.no-conditions.cancel' | 'tagging-rules.form.conditions.field.label' | 'tagging-rules.form.conditions.operator.label' | 'tagging-rules.form.conditions.value.label' | 'tagging-rules.form.conditions.value.placeholder' | 'tagging-rules.form.conditions.value.min-length' | 'tagging-rules.form.tags.label' | 'tagging-rules.form.tags.description' | 'tagging-rules.form.tags.min-length' | 'tagging-rules.form.tags.add-tag' | 'tagging-rules.form.submit' | 'tagging-rules.update.title' | 'tagging-rules.update.success' | 'tagging-rules.update.error' | 'tagging-rules.update.submit' | 'tagging-rules.update.cancel' | 'demo.popup.description' | 'demo.popup.discord' | 'demo.popup.discord-link-label' | 'demo.popup.reset' | 'demo.popup.hide' | 'trash.delete-all.button' | 'trash.delete-all.confirm.title' | 'trash.delete-all.confirm.description' | 'trash.delete-all.confirm.label' | 'trash.delete-all.confirm.cancel' | 'trash.delete.button' | 'trash.delete.confirm.title' | 'trash.delete.confirm.description' | 'trash.delete.confirm.label' | 'trash.delete.confirm.cancel' | 'trash.deleted.success.title' | 'trash.deleted.success.description'; diff --git a/apps/papra-server/package.json b/apps/papra-server/package.json index 638f04f..27428e6 100644 --- a/apps/papra-server/package.json +++ b/apps/papra-server/package.json @@ -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", diff --git a/apps/papra-server/src/modules/documents/documents.errors.ts b/apps/papra-server/src/modules/documents/documents.errors.ts index 3b52b9c..0e40dc5 100644 --- a/apps/papra-server/src/modules/documents/documents.errors.ts +++ b/apps/papra-server/src/modules/documents/documents.errors.ts @@ -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, +}); diff --git a/apps/papra-server/src/modules/documents/documents.repository.ts b/apps/papra-server/src/modules/documents/documents.repository.ts index 86a34d7..b999c2d 100644 --- a/apps/papra-server/src/modules/documents/documents.repository.ts +++ b/apps/papra-server/src/modules/documents/documents.repository.ts @@ -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), + }; +} diff --git a/apps/papra-server/src/modules/documents/documents.routes.ts b/apps/papra-server/src/modules/documents/documents.routes.ts index 5d39094..fd93e63 100644 --- a/apps/papra-server/src/modules/documents/documents.routes.ts +++ b/apps/papra-server/src/modules/documents/documents.routes.ts @@ -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); + }, + ); +} diff --git a/apps/papra-server/src/modules/documents/documents.usecases.ts b/apps/papra-server/src/modules/documents/documents.usecases.ts index d8f27d7..2fdde9c 100644 --- a/apps/papra-server/src/modules/documents/documents.usecases.ts +++ b/apps/papra-server/src/modules/documents/documents.usecases.ts @@ -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 }))), + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc34593..8619cbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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