From c434d873bc2da79664f8581bc802131beb95e490 Mon Sep 17 00:00:00 2001 From: Corentin Thomasset Date: Sat, 11 Oct 2025 18:21:55 +0200 Subject: [PATCH] feat(organizations): soft delete organizations with recovery (#542) --- .changeset/chilly-ears-press.md | 6 + .gitignore | 3 +- CLAUDE.md | 6 + CONTRIBUTING.md | 11 + apps/papra-client/package.json | 1 + .../papra-client/src/locales/de.dictionary.ts | 19 +- .../papra-client/src/locales/en.dictionary.ts | 19 +- .../papra-client/src/locales/es.dictionary.ts | 19 +- .../papra-client/src/locales/fr.dictionary.ts | 19 +- .../papra-client/src/locales/it.dictionary.ts | 19 +- .../papra-client/src/locales/pl.dictionary.ts | 19 +- .../src/locales/pt-BR.dictionary.ts | 19 +- .../papra-client/src/locales/pt.dictionary.ts | 19 +- .../papra-client/src/locales/ro.dictionary.ts | 19 +- .../papra-client/src/modules/config/config.ts | 5 +- .../src/modules/i18n/i18n.models.ts | 9 +- .../organizations/organizations.services.ts | 18 + .../organizations/organizations.types.ts | 3 + .../pages/deleted-organizations.page.tsx | 144 ++ .../pages/organizations-settings.page.tsx | 3 +- .../pages/organizations.page.tsx | 24 +- .../src/modules/shared/confirm.tsx | 36 +- .../modules/shared/http/http-client.models.ts | 1 + .../src/modules/tracking/tracking.services.ts | 2 +- apps/papra-client/src/routes.tsx | 9 +- ...011-soft-delete-organizations.migration.ts | 36 + .../src/migrations/meta/0010_snapshot.json | 2050 +++++++++++++++++ .../src/migrations/meta/_journal.json | 7 + .../migrations/migrations.registry.test.ts | 4 +- .../src/migrations/migrations.registry.ts | 3 + .../api-keys/api-keys.repository.test.ts | 3 + .../src/modules/config/config.models.test.ts | 7 + .../src/modules/config/config.models.ts | 1 + .../modules/documents/documents.repository.ts | 31 + .../src/modules/documents/documents.table.ts | 8 +- .../organizations/organizations.config.ts | 6 + .../organizations/organizations.errors.ts | 12 + .../organizations.repository.test.ts | 135 +- .../organizations/organizations.repository.ts | 76 +- .../organizations/organizations.routes.ts | 54 +- .../organizations/organizations.table.ts | 14 +- .../organizations.usecases.test.ts | 659 +++++- .../organizations/organizations.usecases.ts | 145 ++ .../tasks/purge-expired-organizations.task.ts | 41 + .../src/modules/shared/db/columns.helpers.ts | 17 +- .../src/modules/tasks/tasks.config.ts | 26 +- .../src/modules/tasks/tasks.definitions.ts | 2 + pnpm-lock.yaml | 29 +- 48 files changed, 3745 insertions(+), 73 deletions(-) create mode 100644 .changeset/chilly-ears-press.md create mode 100644 apps/papra-client/src/modules/organizations/pages/deleted-organizations.page.tsx create mode 100644 apps/papra-server/src/migrations/list/0011-soft-delete-organizations.migration.ts create mode 100644 apps/papra-server/src/migrations/meta/0010_snapshot.json create mode 100644 apps/papra-server/src/modules/organizations/tasks/purge-expired-organizations.task.ts diff --git a/.changeset/chilly-ears-press.md b/.changeset/chilly-ears-press.md new file mode 100644 index 00000000..9a33ae40 --- /dev/null +++ b/.changeset/chilly-ears-press.md @@ -0,0 +1,6 @@ +--- +"@papra/app-client": patch +"@papra/app-server": patch +--- + +Added soft deletion with grace period for organizations diff --git a/.gitignore b/.gitignore index 5e6ce64a..f25b4c9e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ ingestion .cursorrules *.traineddata -.eslintcache \ No newline at end of file +.eslintcache +.claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index ab54597d..17bb7626 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,12 @@ pnpm dev # localhost:4321 - Fully type-safe with TypeScript - Update `i18n.constants.ts` when adding new languages - Use `pnpm script:sync-i18n-key-order` to sync key order +- **Branchlet/core**: Uses `@branchlet/core` for pluralization and conditional i18n string templates (variant of ICU message format) + - Basic interpolation: `'Hello {{ name }}!'` with `{ name: 'World' }` + - Conditionals: `'{{ count, =0:no items, =1:one item, many items }}'` + - Pluralization with variables: `'{{ count, =0:no items, =1:{count} item, {count} items }}'` + - Range conditions: `'{{ score, [0-50]:bad, [51-75]:good, [76-100]:excellent }}'` + - See [branchlet documentation](https://github.com/CorentinTh/branchlet) for more details ## Contributing Flow diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 06ba6eb0..5260c78a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,17 @@ If you want to update an existing language file, you can do so directly in the c > [!TIP] > You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the TypeScript i18n files, it'll also add the missing keys as comments. +### Using Branchlet for Pluralization and Conditionals + +Papra uses [`@branchlet/core`](https://github.com/CorentinTh/branchlet) for pluralization and conditional i18n string templates (a variant of ICU message format). Here are some common patterns: + +- **Basic interpolation**: `'Hello {{ name }}!'` with `{ name: 'World' }` +- **Conditionals**: `'{{ count, =0:no items, =1:one item, many items }}'` +- **Pluralization with variables**: `'{{ count, =0:no items, =1:{count} item, {count} items }}'` +- **Range conditions**: `'{{ score, [0-50]:bad, [51-75]:good, [76-100]:excellent }}'` + +See the [branchlet documentation](https://github.com/CorentinTh/branchlet) for more details on syntax and advanced usage. + ## Development Setup ### Local Environment Setup diff --git a/apps/papra-client/package.json b/apps/papra-client/package.json index 4170bc97..773e312b 100644 --- a/apps/papra-client/package.json +++ b/apps/papra-client/package.json @@ -28,6 +28,7 @@ "script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts" }, "dependencies": { + "@branchlet/core": "^1.0.0", "@corentinth/chisels": "^1.3.1", "@kobalte/core": "^0.13.10", "@kobalte/utils": "^0.9.1", diff --git a/apps/papra-client/src/locales/de.dictionary.ts b/apps/papra-client/src/locales/de.dictionary.ts index 23fd0dd3..450190b2 100644 --- a/apps/papra-client/src/locales/de.dictionary.ts +++ b/apps/papra-client/src/locales/de.dictionary.ts @@ -102,6 +102,19 @@ export const translations: Partial = { 'organizations.list.title': 'Ihre Organisationen', 'organizations.list.description': 'Organisationen sind eine Möglichkeit, Ihre Dokumente zu gruppieren und den Zugriff darauf zu verwalten. Sie können mehrere Organisationen erstellen und Ihre Teammitglieder zur Zusammenarbeit einladen.', 'organizations.list.create-new': 'Neue Organisation erstellen', + 'organizations.list.back': 'Zurück zu Organisationen', + 'organizations.list.deleted.title': 'Gelöschte Organisationen', + 'organizations.list.deleted.description': 'Gelöschte Organisationen werden für {{ days }} Tage aufbewahrt, bevor sie dauerhaft entfernt werden. Sie können sie während dieser Zeit wiederherstellen.', + 'organizations.list.deleted.empty': 'Keine gelöschten Organisationen', + 'organizations.list.deleted.empty-description': 'Wenn Sie eine Organisation löschen, wird sie hier für {{ days }} Tage angezeigt, bevor sie dauerhaft gelöscht wird.', + 'organizations.list.deleted.restore': 'Wiederherstellen', + 'organizations.list.deleted.restore-success': 'Organisation erfolgreich wiederhergestellt', + 'organizations.list.deleted.restore-confirm.title': 'Organisation wiederherstellen', + 'organizations.list.deleted.restore-confirm.message': 'Sind Sie sicher, dass Sie diese Organisation wiederherstellen möchten? Sie wird wieder in Ihre Liste der aktiven Organisationen verschoben.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Organisation wiederherstellen', + 'organizations.list.deleted.deleted-at': 'Gelöscht {{ date }}', + 'organizations.list.deleted.purge-at': 'Wird dauerhaft gelöscht am {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} Tag, {daysUntilPurge} Tage }} verbleibend)', 'organizations.details.no-documents.title': 'Keine Dokumente', 'organizations.details.no-documents.description': 'Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.', @@ -139,7 +152,7 @@ export const translations: Partial = { 'organization.settings.delete.title': 'Organisation löschen', 'organization.settings.delete.description': 'Das Löschen dieser Organisation entfernt dauerhaft alle damit verbundenen Daten.', 'organization.settings.delete.confirm.title': 'Organisation löschen', - 'organization.settings.delete.confirm.message': 'Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden und alle mit dieser Organisation verbundenen Daten werden dauerhaft entfernt.', + 'organization.settings.delete.confirm.message': 'Sind Sie sicher, dass Sie diese Organisation löschen möchten? Die Organisation wird zum Löschen markiert und nach {{ days }} Tagen endgültig entfernt. Während dieser Zeit können Sie sie aus Ihrer Organisationsliste wiederherstellen. Alle Dokumente und Daten werden nach dieser Frist dauerhaft gelöscht.', 'organization.settings.delete.confirm.confirm-button': 'Organisation löschen', 'organization.settings.delete.confirm.cancel-button': 'Abbrechen', 'organization.settings.delete.success': 'Organisation gelöscht', @@ -642,4 +655,8 @@ export const translations: Partial = { 'subscriptions.usage-warning.message': 'Sie haben {{ percent }}% Ihres Dokumentenspeichers verwendet. Erwägen Sie ein Upgrade Ihres Plans, um mehr Speicherplatz zu erhalten.', 'subscriptions.usage-warning.upgrade-button': 'Plan upgraden', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Geben Sie "{{ text }}" ein zur Bestätigung', }; diff --git a/apps/papra-client/src/locales/en.dictionary.ts b/apps/papra-client/src/locales/en.dictionary.ts index b1762975..7e84bed7 100644 --- a/apps/papra-client/src/locales/en.dictionary.ts +++ b/apps/papra-client/src/locales/en.dictionary.ts @@ -100,6 +100,19 @@ export const translations = { 'organizations.list.title': 'Your organizations', 'organizations.list.description': 'Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.', 'organizations.list.create-new': 'Create new organization', + 'organizations.list.back': 'Back to organizations', + 'organizations.list.deleted.title': 'Deleted organizations', + 'organizations.list.deleted.description': 'Deleted organizations are kept for {{ days }} days before being permanently removed. You can restore them during this period.', + 'organizations.list.deleted.empty': 'No deleted organizations', + 'organizations.list.deleted.empty-description': 'When you delete an organization, it will appear here for {{ days }} days before being permanently deleted.', + 'organizations.list.deleted.restore': 'Restore', + 'organizations.list.deleted.restore-success': 'Organization restored successfully', + 'organizations.list.deleted.restore-confirm.title': 'Restore organization', + 'organizations.list.deleted.restore-confirm.message': 'Are you sure you want to restore this organization? It will be moved back to your active organizations list.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Restore organization', + 'organizations.list.deleted.deleted-at': 'Deleted {{ date }}', + 'organizations.list.deleted.purge-at': 'Will be permanently deleted on {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} day, {daysUntilPurge} days }} remaining)', 'organizations.details.no-documents.title': 'No documents', 'organizations.details.no-documents.description': 'There are no documents in this organization yet. Start by uploading some documents.', @@ -137,7 +150,7 @@ export const translations = { 'organization.settings.delete.title': 'Delete organization', 'organization.settings.delete.description': 'Deleting this organization will permanently remove all data associated with it.', 'organization.settings.delete.confirm.title': 'Delete organization', - 'organization.settings.delete.confirm.message': 'Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.', + 'organization.settings.delete.confirm.message': 'Are you sure you want to delete this organization? The organization will be marked for deletion and permanently removed after {{ days }} days. During this period, you can restore it from your organizations list. All documents and data will be permanently deleted after this delay.', 'organization.settings.delete.confirm.confirm-button': 'Delete organization', 'organization.settings.delete.confirm.cancel-button': 'Cancel', 'organization.settings.delete.success': 'Organization deleted', @@ -640,4 +653,8 @@ export const translations = { 'subscriptions.usage-warning.message': 'You have used {{ percent }}% of your document storage. Consider upgrading your plan to get more space.', 'subscriptions.usage-warning.upgrade-button': 'Upgrade Plan', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Type "{{ text }}" to confirm', } as const; diff --git a/apps/papra-client/src/locales/es.dictionary.ts b/apps/papra-client/src/locales/es.dictionary.ts index 0731b03c..c94b10cf 100644 --- a/apps/papra-client/src/locales/es.dictionary.ts +++ b/apps/papra-client/src/locales/es.dictionary.ts @@ -102,6 +102,19 @@ export const translations: Partial = { 'organizations.list.title': 'Tus organizaciones', 'organizations.list.description': 'Las organizaciones son una manera de agrupar tus documentos y gestionar el acceso a ellos. Puedes crear varias organizaciones e invitar a tus compañeros para colaborar.', 'organizations.list.create-new': 'Crear nueva organización', + 'organizations.list.back': 'Volver a organizaciones', + 'organizations.list.deleted.title': 'Organizaciones eliminadas', + 'organizations.list.deleted.description': 'Las organizaciones eliminadas se conservan durante {{ days }} días antes de ser eliminadas permanentemente. Puedes restaurarlas durante este período.', + 'organizations.list.deleted.empty': 'No hay organizaciones eliminadas', + 'organizations.list.deleted.empty-description': 'Cuando elimines una organización, aparecerá aquí durante {{ days }} días antes de ser eliminada permanentemente.', + 'organizations.list.deleted.restore': 'Restaurar', + 'organizations.list.deleted.restore-success': 'Organización restaurada exitosamente', + 'organizations.list.deleted.restore-confirm.title': 'Restaurar organización', + 'organizations.list.deleted.restore-confirm.message': '¿Estás seguro de que quieres restaurar esta organización? Se moverá de vuelta a tu lista de organizaciones activas.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurar organización', + 'organizations.list.deleted.deleted-at': 'Eliminada el {{ date }}', + 'organizations.list.deleted.purge-at': 'Se eliminará permanentemente el {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} día, {daysUntilPurge} días }} restante{{ daysUntilPurge, >1:s}})', 'organizations.details.no-documents.title': 'Sin documentos', 'organizations.details.no-documents.description': 'Aún no hay documentos en esta organización. Comienza subiendo algunos documentos.', @@ -139,7 +152,7 @@ export const translations: Partial = { 'organization.settings.delete.title': 'Eliminar organización', 'organization.settings.delete.description': 'Eliminar esta organización eliminará permanentemente todos los datos asociados a ella.', 'organization.settings.delete.confirm.title': 'Eliminar organización', - 'organization.settings.delete.confirm.message': '¿Estás seguro de que deseas eliminar esta organización? Esta acción no se puede deshacer, y todos los datos asociados se eliminarán permanentemente.', + 'organization.settings.delete.confirm.message': '¿Estás seguro de que deseas eliminar esta organización? La organización se marcará para eliminación y se eliminará permanentemente después de {{ days }} días. Durante este período, puedes restaurarla desde tu lista de organizaciones. Todos los documentos y datos se eliminarán permanentemente después de este plazo.', 'organization.settings.delete.confirm.confirm-button': 'Eliminar organización', 'organization.settings.delete.confirm.cancel-button': 'Cancelar', 'organization.settings.delete.success': 'Organización eliminada', @@ -642,4 +655,8 @@ export const translations: Partial = { 'subscriptions.usage-warning.message': 'Ha utilizado el {{ percent }}% de su almacenamiento de documentos. Considere actualizar su plan para obtener más espacio.', 'subscriptions.usage-warning.upgrade-button': 'Actualizar plan', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Escriba "{{ text }}" para confirmar', }; diff --git a/apps/papra-client/src/locales/fr.dictionary.ts b/apps/papra-client/src/locales/fr.dictionary.ts index 3540df20..5ed57a6e 100644 --- a/apps/papra-client/src/locales/fr.dictionary.ts +++ b/apps/papra-client/src/locales/fr.dictionary.ts @@ -102,6 +102,19 @@ export const translations: Partial = { 'organizations.list.title': 'Vos organisations', 'organizations.list.description': 'Les organisations sont un moyen de grouper vos documents et de gérer l\'accès à eux. Vous pouvez créer plusieurs organisations et inviter vos membres de l\'équipe à collaborer.', 'organizations.list.create-new': 'Créer une nouvelle organisation', + 'organizations.list.back': 'Retour aux organisations', + 'organizations.list.deleted.title': 'Organisations supprimées', + 'organizations.list.deleted.description': 'Les organisations supprimées sont conservées pendant {{ days }} jours avant d\'être définitivement supprimées. Vous pouvez les restaurer pendant cette période.', + 'organizations.list.deleted.empty': 'Aucune organisation supprimée', + 'organizations.list.deleted.empty-description': 'Lorsque vous supprimez une organisation, elle apparaîtra ici pendant {{ days }} jours avant d\'être définitivement supprimée.', + 'organizations.list.deleted.restore': 'Restaurer', + 'organizations.list.deleted.restore-success': 'Organisation restaurée avec succès', + 'organizations.list.deleted.restore-confirm.title': 'Restaurer l\'organisation', + 'organizations.list.deleted.restore-confirm.message': 'Êtes-vous sûr de vouloir restaurer cette organisation ? Elle sera remise dans votre liste d\'organisations actives.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurer l\'organisation', + 'organizations.list.deleted.deleted-at': 'Supprimée le {{ date }}', + 'organizations.list.deleted.purge-at': 'Sera définitivement supprimée le {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} jour, {daysUntilPurge} jours }} restant{{ daysUntilPurge, >1:s}})', 'organizations.details.no-documents.title': 'Aucun document', 'organizations.details.no-documents.description': 'Il n\'y a pas de documents dans cette organisation. Commencez par télécharger des documents.', @@ -139,7 +152,7 @@ export const translations: Partial = { 'organization.settings.delete.title': 'Supprimer l\'organisation', 'organization.settings.delete.description': 'Supprimer cette organisation supprimera définitivement toutes les données associées à elle.', 'organization.settings.delete.confirm.title': 'Supprimer l\'organisation', - 'organization.settings.delete.confirm.message': 'Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible, et toutes les données associées à cette organisation seront supprimées définitivement.', + 'organization.settings.delete.confirm.message': 'Êtes-vous sûr de vouloir supprimer cette organisation ? L\'organisation sera marquée pour suppression et définitivement supprimée après {{ days }} jours. Pendant cette période, vous pouvez la restaurer depuis votre liste d\'organisations. Tous les documents et données seront définitivement supprimés après ce délai.', 'organization.settings.delete.confirm.confirm-button': 'Supprimer l\'organisation', 'organization.settings.delete.confirm.cancel-button': 'Annuler', 'organization.settings.delete.success': 'Organisation supprimée', @@ -642,4 +655,8 @@ export const translations: Partial = { 'subscriptions.usage-warning.message': 'Vous avez utilisé {{ percent }}% de votre stockage de documents. Envisagez de mettre à niveau votre plan pour obtenir plus d\'espace.', 'subscriptions.usage-warning.upgrade-button': 'Mettre à niveau', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Saisissez "{{ text }}" pour confirmer', }; diff --git a/apps/papra-client/src/locales/it.dictionary.ts b/apps/papra-client/src/locales/it.dictionary.ts index f7997fb4..05f88719 100644 --- a/apps/papra-client/src/locales/it.dictionary.ts +++ b/apps/papra-client/src/locales/it.dictionary.ts @@ -102,6 +102,19 @@ export const translations: Partial = { 'organizations.list.title': 'Le tue organizzazioni', 'organizations.list.description': 'Le organizzazioni sono un modo per raggruppare i tuoi documenti e gestire l\'accesso. Puoi creare più organizzazioni e invitare i tuoi collaboratori.', 'organizations.list.create-new': 'Crea una nuova organizzazione', + 'organizations.list.back': 'Torna alle organizzazioni', + 'organizations.list.deleted.title': 'Organizzazioni eliminate', + 'organizations.list.deleted.description': 'Le organizzazioni eliminate vengono conservate per {{ days }} giorni prima di essere rimosse definitivamente. Puoi ripristinarle durante questo periodo.', + 'organizations.list.deleted.empty': 'Nessuna organizzazione eliminata', + 'organizations.list.deleted.empty-description': 'Quando elimini un\'organizzazione, apparirà qui per {{ days }} giorni prima di essere eliminata definitivamente.', + 'organizations.list.deleted.restore': 'Ripristina', + 'organizations.list.deleted.restore-success': 'Organizzazione ripristinata con successo', + 'organizations.list.deleted.restore-confirm.title': 'Ripristina organizzazione', + 'organizations.list.deleted.restore-confirm.message': 'Sei sicuro di voler ripristinare questa organizzazione? Verrà rimossa nella tua lista di organizzazioni attive.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Ripristina organizzazione', + 'organizations.list.deleted.deleted-at': 'Eliminata il {{ date }}', + 'organizations.list.deleted.purge-at': 'Sarà eliminata definitivamente il {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} giorno, {daysUntilPurge} giorni }} rimanent{{ daysUntilPurge, =1:e, i}})', 'organizations.details.no-documents.title': 'Nessun documento', 'organizations.details.no-documents.description': 'Non ci sono ancora documenti in questa organizzazione. Inizia caricando dei documenti.', @@ -139,7 +152,7 @@ export const translations: Partial = { 'organization.settings.delete.title': 'Elimina organizzazione', 'organization.settings.delete.description': 'Eliminando questa organizzazione rimuoverai definitivamente tutti i dati associati.', 'organization.settings.delete.confirm.title': 'Elimina organizzazione', - 'organization.settings.delete.confirm.message': 'Sei sicuro di voler eliminare questa organizzazione? Questa azione non può essere annullata e tutti i dati associati saranno rimossi in modo permanente.', + 'organization.settings.delete.confirm.message': 'Sei sicuro di voler eliminare questa organizzazione? L\'organizzazione verrà contrassegnata per l\'eliminazione e rimossa definitivamente dopo {{ days }} giorni. Durante questo periodo, puoi ripristinarla dalla tua lista di organizzazioni. Tutti i documenti e i dati verranno eliminati definitivamente dopo questo periodo.', 'organization.settings.delete.confirm.confirm-button': 'Elimina organizzazione', 'organization.settings.delete.confirm.cancel-button': 'Annulla', 'organization.settings.delete.success': 'Organizzazione eliminata', @@ -642,4 +655,8 @@ export const translations: Partial = { 'subscriptions.usage-warning.message': 'Hai utilizzato il {{ percent }}% dello spazio di archiviazione dei documenti. Considera l\'aggiornamento del piano per ottenere più spazio.', 'subscriptions.usage-warning.upgrade-button': 'Aggiorna piano', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Digita "{{ text }}" per confermare', }; diff --git a/apps/papra-client/src/locales/pl.dictionary.ts b/apps/papra-client/src/locales/pl.dictionary.ts index 4475d70e..0c2c47d8 100644 --- a/apps/papra-client/src/locales/pl.dictionary.ts +++ b/apps/papra-client/src/locales/pl.dictionary.ts @@ -102,6 +102,19 @@ export const translations: Partial = { 'organizations.list.title': 'Twoje organizacje', 'organizations.list.description': 'Organizacje to sposób grupowania dokumentów i zarządzania dostępem do nich. Możesz tworzyć wiele organizacji i zapraszać członków zespołu do współpracy.', 'organizations.list.create-new': 'Utwórz nową organizację', + 'organizations.list.back': 'Powrót do organizacji', + 'organizations.list.deleted.title': 'Usunięte organizacje', + 'organizations.list.deleted.description': 'Usunięte organizacje są przechowywane przez {{ days }} dni przed trwałym usunięciem. Możesz je przywrócić w tym okresie.', + 'organizations.list.deleted.empty': 'Brak usuniętych organizacji', + 'organizations.list.deleted.empty-description': 'Kiedy usuniesz organizację, pojawi się tutaj na {{ days }} dni przed trwałym usunięciem.', + 'organizations.list.deleted.restore': 'Przywróć', + 'organizations.list.deleted.restore-success': 'Organizacja została pomyślnie przywrócona', + 'organizations.list.deleted.restore-confirm.title': 'Przywróć organizację', + 'organizations.list.deleted.restore-confirm.message': 'Czy na pewno chcesz przywrócić tę organizację? Zostanie przeniesiona z powrotem do listy aktywnych organizacji.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Przywróć organizację', + 'organizations.list.deleted.deleted-at': 'Usunięto {{ date }}', + 'organizations.list.deleted.purge-at': 'Zostanie trwale usunięta {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} dzień, {daysUntilPurge} dni }} pozostał{{ daysUntilPurge, =1:o, o}})', 'organizations.details.no-documents.title': 'Brak dokumentów', 'organizations.details.no-documents.description': 'W tej organizacji nie ma jeszcze żadnych dokumentów. Zacznij od przesłania kilku dokumentów.', @@ -139,7 +152,7 @@ export const translations: Partial = { 'organization.settings.delete.title': 'Usuń organizację', 'organization.settings.delete.description': 'Usunięcie tej organizacji spowoduje trwałe usunięcie wszystkich danych z nią związanych.', 'organization.settings.delete.confirm.title': 'Usuń organizację', - 'organization.settings.delete.confirm.message': 'Czy na pewno chcesz usunąć tę organizację? Ta operacja jest nieodwracalna, a wszystkie dane związane z tą organizacją zostaną trwale usunięte.', + 'organization.settings.delete.confirm.message': 'Czy na pewno chcesz usunąć tę organizację? Organizacja zostanie oznaczona do usunięcia i trwale usunięta po {{ days}} dniach. W tym okresie możesz ją przywrócić z listy organizacji. Wszystkie dokumenty i dane zostaną trwale usunięte po upływie tego terminu.', 'organization.settings.delete.confirm.confirm-button': 'Usuń organizację', 'organization.settings.delete.confirm.cancel-button': 'Anuluj', 'organization.settings.delete.success': 'Organizacja została usunięta', @@ -642,4 +655,8 @@ export const translations: Partial = { 'subscriptions.usage-warning.message': 'Wykorzystano {{ percent }}% miejsca na dokumenty. Rozważ aktualizację planu, aby uzyskać więcej miejsca.', 'subscriptions.usage-warning.upgrade-button': 'Ulepsz plan', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Wpisz "{{ text }}", aby potwierdzić', }; diff --git a/apps/papra-client/src/locales/pt-BR.dictionary.ts b/apps/papra-client/src/locales/pt-BR.dictionary.ts index a7248424..be01ecf1 100644 --- a/apps/papra-client/src/locales/pt-BR.dictionary.ts +++ b/apps/papra-client/src/locales/pt-BR.dictionary.ts @@ -102,6 +102,19 @@ export const translations: Partial = { 'organizations.list.title': 'Suas organizações', 'organizations.list.description': 'Organizações são uma forma de agrupar seus documentos e gerenciar o acesso a eles. Você pode criar várias organizações e convidar membros da sua equipe para colaborar.', 'organizations.list.create-new': 'Criar nova organização', + 'organizations.list.back': 'Voltar às organizações', + 'organizations.list.deleted.title': 'Organizações excluídas', + 'organizations.list.deleted.description': 'As organizações excluídas são mantidas por {{ days }} dias antes de serem removidas permanentemente. Você pode restaurá-las durante este período.', + 'organizations.list.deleted.empty': 'Nenhuma organização excluída', + 'organizations.list.deleted.empty-description': 'Quando você excluir uma organização, ela aparecerá aqui por {{ days }} dias antes de ser excluída permanentemente.', + 'organizations.list.deleted.restore': 'Restaurar', + 'organizations.list.deleted.restore-success': 'Organização restaurada com sucesso', + 'organizations.list.deleted.restore-confirm.title': 'Restaurar organização', + 'organizations.list.deleted.restore-confirm.message': 'Tem certeza de que deseja restaurar esta organização? Ela será movida de volta para sua lista de organizações ativas.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurar organização', + 'organizations.list.deleted.deleted-at': 'Excluída em {{ date }}', + 'organizations.list.deleted.purge-at': 'Será excluída permanentemente em {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} dia, {daysUntilPurge} dias }} restante{{ daysUntilPurge, >1:s}})', 'organizations.details.no-documents.title': 'Nenhum documento', 'organizations.details.no-documents.description': 'Ainda não há documentos nesta organização. Comece enviando documentos.', @@ -139,7 +152,7 @@ export const translations: Partial = { 'organization.settings.delete.title': 'Excluir organização', 'organization.settings.delete.description': 'A exclusão desta organização removerá permanentemente todos seus dados associados.', 'organization.settings.delete.confirm.title': 'Excluir organização', - 'organization.settings.delete.confirm.message': 'Tem certeza de que deseja excluir esta organização? Esta ação não pode ser desfeita e todos os dados associados serão permanentemente removidos.', + 'organization.settings.delete.confirm.message': 'Tem certeza de que deseja excluir esta organização? A organização será marcada para exclusão e removida permanentemente após {{ days }} dias. Durante este período, você pode restaurá-la da sua lista de organizações. Todos os documentos e dados serão excluídos permanentemente após este prazo.', 'organization.settings.delete.confirm.confirm-button': 'Excluir organização', 'organization.settings.delete.confirm.cancel-button': 'Cancelar', 'organization.settings.delete.success': 'Organização excluída', @@ -642,4 +655,8 @@ export const translations: Partial = { 'subscriptions.usage-warning.message': 'Você usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar seu plano para obter mais espaço.', 'subscriptions.usage-warning.upgrade-button': 'Atualizar plano', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Digite "{{ text }}" para confirmar', }; diff --git a/apps/papra-client/src/locales/pt.dictionary.ts b/apps/papra-client/src/locales/pt.dictionary.ts index 6834d522..ee975053 100644 --- a/apps/papra-client/src/locales/pt.dictionary.ts +++ b/apps/papra-client/src/locales/pt.dictionary.ts @@ -102,6 +102,19 @@ export const translations: Partial = { 'organizations.list.title': 'As suas organizações', 'organizations.list.description': 'As organizações são uma forma de agrupar os seus documentos e gerir o acesso aos mesmos. Pode criar várias organizações e convidar os membros da sua equipa para colaborar.', 'organizations.list.create-new': 'Criar nova organização', + 'organizations.list.back': 'Voltar às organizações', + 'organizations.list.deleted.title': 'Organizações eliminadas', + 'organizations.list.deleted.description': 'As organizações eliminadas são mantidas durante {{ days }} dias antes de serem removidas permanentemente. Pode restaurá-las durante este período.', + 'organizations.list.deleted.empty': 'Nenhuma organização eliminada', + 'organizations.list.deleted.empty-description': 'Quando eliminar uma organização, ela aparecerá aqui durante {{ days }} dias antes de ser eliminada permanentemente.', + 'organizations.list.deleted.restore': 'Restaurar', + 'organizations.list.deleted.restore-success': 'Organização restaurada com sucesso', + 'organizations.list.deleted.restore-confirm.title': 'Restaurar organização', + 'organizations.list.deleted.restore-confirm.message': 'Tem a certeza de que quer restaurar esta organização? Ela será movida de volta para a sua lista de organizações ativas.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurar organização', + 'organizations.list.deleted.deleted-at': 'Eliminada em {{ date }}', + 'organizations.list.deleted.purge-at': 'Será eliminada permanentemente em {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} dia, {daysUntilPurge} dias }} restante{{ daysUntilPurge, >1:s}})', 'organizations.details.no-documents.title': 'Sem documentos', 'organizations.details.no-documents.description': 'Não há documentos nesta organização ainda. Comece por carregar alguns documentos.', @@ -139,7 +152,7 @@ export const translations: Partial = { 'organization.settings.delete.title': 'Eliminar organização', 'organization.settings.delete.description': 'Eliminar esta organização removerá permanentemente todos os dados associados à mesma.', 'organization.settings.delete.confirm.title': 'Eliminar organização', - 'organization.settings.delete.confirm.message': 'Tem a certeza de que quer eliminar esta organização? Esta ação não pode ser desfeita e todos os dados associados a esta organização serão permanentemente removidos.', + 'organization.settings.delete.confirm.message': 'Tem a certeza de que pretende eliminar esta organização? A organização será marcada para eliminação e permanentemente removida após {{ days }} dias. Durante este período, pode restaurá-la a partir da sua lista de organizações. Todos os documentos e dados serão permanentemente eliminados após este prazo.', 'organization.settings.delete.confirm.confirm-button': 'Eliminar organização', 'organization.settings.delete.confirm.cancel-button': 'Cancelar', 'organization.settings.delete.success': 'Organização eliminada', @@ -642,4 +655,8 @@ export const translations: Partial = { 'subscriptions.usage-warning.message': 'Usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar o seu plano para obter mais espaço.', 'subscriptions.usage-warning.upgrade-button': 'Atualizar plano', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Digite "{{ text }}" para confirmar', }; diff --git a/apps/papra-client/src/locales/ro.dictionary.ts b/apps/papra-client/src/locales/ro.dictionary.ts index 78699e60..a39a4cdc 100644 --- a/apps/papra-client/src/locales/ro.dictionary.ts +++ b/apps/papra-client/src/locales/ro.dictionary.ts @@ -102,6 +102,19 @@ export const translations: Partial = { 'organizations.list.title': 'Organizațiile tale', 'organizations.list.description': 'Organizațiile sunt o modalitate de a grupa documentele și de a gestiona accesul la acestea. Poți crea multiple organizații și invita membrii echipei tale să colaboreze.', 'organizations.list.create-new': 'Creează o organizație nouă', + 'organizations.list.back': 'Înapoi la organizații', + 'organizations.list.deleted.title': 'Organizații șterse', + 'organizations.list.deleted.description': 'Organizațiile șterse sunt păstrate {{ days }} zile înainte de a fi eliminate definitiv. Le poți restaura în această perioadă.', + 'organizations.list.deleted.empty': 'Nu există organizații șterse', + 'organizations.list.deleted.empty-description': 'Când ștergi o organizație, va apărea aici pentru {{ days }} zile înainte de a fi ștearsă definitiv.', + 'organizations.list.deleted.restore': 'Restaurează', + 'organizations.list.deleted.restore-success': 'Organizația a fost restaurată cu succes', + 'organizations.list.deleted.restore-confirm.title': 'Restaurează organizația', + 'organizations.list.deleted.restore-confirm.message': 'Ești sigur că vrei să restaurezi această organizație? Va fi mutată înapoi în lista organizațiilor active.', + 'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurează organizația', + 'organizations.list.deleted.deleted-at': 'Ștearsă {{ date }}', + 'organizations.list.deleted.purge-at': 'Va fi ștearsă definitiv {{ date }}', + 'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} zi, {daysUntilPurge} zile }} rămas{{ daysUntilPurge, =1:ă, e}})', 'organizations.details.no-documents.title': 'Niciun document', 'organizations.details.no-documents.description': 'Încă nu există documente în această organizație. Încarcă niște documente pentru a începe.', @@ -139,7 +152,7 @@ export const translations: Partial = { 'organization.settings.delete.title': 'Șterge organizația', 'organization.settings.delete.description': 'Ștergerea acestei organizații va elimina definitiv toate datele asociate cu aceasta.', 'organization.settings.delete.confirm.title': 'Șterge organizatia', - 'organization.settings.delete.confirm.message': 'Ești sigur că vrei să ștergi această organizație? Aceasta operatie nu poate fi anulată si toate datele asociate cu aceasta vor fi eliminate definitiv.', + 'organization.settings.delete.confirm.message': 'Sigur doriți să ștergeți această organizație? Organizația va fi marcată pentru ștergere și eliminată definitiv după {{ days }} zile. În această perioadă, o puteți restaura din lista dvs. de organizații. Toate documentele și datele vor fi șterse definitiv după această perioadă.', 'organization.settings.delete.confirm.confirm-button': 'Șterge organizație', 'organization.settings.delete.confirm.cancel-button': 'Anulează', 'organization.settings.delete.success': 'Organizație ștearsă cu succes', @@ -642,4 +655,8 @@ export const translations: Partial = { 'subscriptions.usage-warning.message': 'Ai folosit {{ percent }}% din spațiul de stocare pentru documente. Ia în considerare actualizarea planului pentru a obține mai mult spațiu.', 'subscriptions.usage-warning.upgrade-button': 'Actualizează planul', + + // Common / Shared + + 'common.confirm-modal.type-to-confirm': 'Tastează "{{ text }}" pentru confirmare', }; diff --git a/apps/papra-client/src/modules/config/config.ts b/apps/papra-client/src/modules/config/config.ts index 76721725..07ffa24c 100644 --- a/apps/papra-client/src/modules/config/config.ts +++ b/apps/papra-client/src/modules/config/config.ts @@ -29,6 +29,9 @@ export const buildTimeConfig = { documents: { deletedDocumentsRetentionDays: asNumber(import.meta.env.VITE_DOCUMENTS_DELETED_DOCUMENTS_RETENTION_DAYS, 30), }, + organizations: { + deletedOrganizationsPurgeDaysDelay: asNumber(import.meta.env.VITE_ORGANIZATIONS_DELETED_PURGE_DAYS_DELAY, 30), + }, posthog: { apiKey: asString(import.meta.env.VITE_POSTHOG_API_KEY), host: asString(import.meta.env.VITE_POSTHOG_HOST), @@ -44,4 +47,4 @@ export const buildTimeConfig = { } as const; export type Config = typeof buildTimeConfig; -export type RuntimePublicConfig = Pick; +export type RuntimePublicConfig = Pick; diff --git a/apps/papra-client/src/modules/i18n/i18n.models.ts b/apps/papra-client/src/modules/i18n/i18n.models.ts index 22e03919..205c2da1 100644 --- a/apps/papra-client/src/modules/i18n/i18n.models.ts +++ b/apps/papra-client/src/modules/i18n/i18n.models.ts @@ -1,5 +1,6 @@ import type { JSX } from 'solid-js'; import type { Locale } from './i18n.provider'; +import { createBranchlet } from '@branchlet/core'; // This tries to get the most preferred language compatible with the supported languages // It tries to find a supported language by comparing both region and language, if not, then just language @@ -29,6 +30,8 @@ export function findMatchingLocale({ } export function createTranslator>({ getDictionary }: { getDictionary: () => Dict }) { + const { parse } = createBranchlet(); + return (key: keyof Dict, args?: Record) => { const translationFromDictionary = getDictionary()[key]; @@ -37,11 +40,7 @@ export function createTranslator>({ getDicti } if (args && translationFromDictionary) { - return Object.entries(args) - .reduce( - (acc, [key, value]) => acc.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), String(value)), - String(translationFromDictionary), - ); + return parse(translationFromDictionary, args); } return translationFromDictionary; diff --git a/apps/papra-client/src/modules/organizations/organizations.services.ts b/apps/papra-client/src/modules/organizations/organizations.services.ts index ba11c29f..11ad707d 100644 --- a/apps/papra-client/src/modules/organizations/organizations.services.ts +++ b/apps/papra-client/src/modules/organizations/organizations.services.ts @@ -115,3 +115,21 @@ export async function updateOrganizationMemberRole({ organizationId, memberId, r member: coerceDates(member), }; } + +export async function fetchDeletedOrganizations() { + const { organizations } = await apiClient<{ organizations: AsDto[] }>({ + path: '/api/organizations/deleted', + method: 'GET', + }); + + return { + organizations: organizations.map(coerceDates), + }; +} + +export async function restoreOrganization({ organizationId }: { organizationId: string }) { + await apiClient({ + path: `/api/organizations/${organizationId}/restore`, + method: 'POST', + }); +} diff --git a/apps/papra-client/src/modules/organizations/organizations.types.ts b/apps/papra-client/src/modules/organizations/organizations.types.ts index 71344471..0e37d871 100644 --- a/apps/papra-client/src/modules/organizations/organizations.types.ts +++ b/apps/papra-client/src/modules/organizations/organizations.types.ts @@ -6,6 +6,9 @@ export type Organization = { name: string; createdAt: Date; updatedAt: Date; + deletedAt?: Date | null; + deletedBy?: string | null; + scheduledPurgeAt?: Date | null; }; export type OrganizationMember = { diff --git a/apps/papra-client/src/modules/organizations/pages/deleted-organizations.page.tsx b/apps/papra-client/src/modules/organizations/pages/deleted-organizations.page.tsx new file mode 100644 index 00000000..f6e37108 --- /dev/null +++ b/apps/papra-client/src/modules/organizations/pages/deleted-organizations.page.tsx @@ -0,0 +1,144 @@ +import type { Component } from 'solid-js'; +import { A } from '@solidjs/router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/solid-query'; +import { For, Show } from 'solid-js'; +import { useConfig } from '@/modules/config/config.provider'; +import { useI18n } from '@/modules/i18n/i18n.provider'; +import { useConfirmModal } from '@/modules/shared/confirm'; +import { Alert, AlertDescription, AlertTitle } from '@/modules/ui/components/alert'; +import { Button } from '@/modules/ui/components/button'; +import { createToast } from '@/modules/ui/components/sonner'; +import { fetchDeletedOrganizations, restoreOrganization } from '../organizations.services'; + +export const DeletedOrganizationsPage: Component = () => { + const { t } = useI18n(); + const queryClient = useQueryClient(); + const { confirm } = useConfirmModal(); + const { config } = useConfig(); + + const purgeDaysDelay = config.organizations.deletedOrganizationsPurgeDaysDelay; + + const deletedOrgsQuery = useQuery(() => ({ + queryKey: ['organizations', 'deleted'], + queryFn: fetchDeletedOrganizations, + })); + + const restoreMutation = useMutation(() => ({ + mutationFn: restoreOrganization, + onSuccess: async () => { + createToast({ + message: t('organizations.list.deleted.restore-success'), + type: 'success', + }); + await queryClient.invalidateQueries({ queryKey: ['organizations'] }); + }, + })); + + const handleRestore = async (organizationId: string) => { + const confirmed = await confirm({ + title: t('organizations.list.deleted.restore-confirm.title'), + message: t('organizations.list.deleted.restore-confirm.message'), + confirmButton: { + text: t('organizations.list.deleted.restore-confirm.confirm-button'), + }, + }); + + if (!confirmed) { + return; + } + restoreMutation.mutate({ organizationId }); + }; + + const formatDate = (date: Date) => { + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date); + }; + + return ( +
+ + +

+ {t('organizations.list.deleted.title')} +

+

+ {t('organizations.list.deleted.description', { days: purgeDaysDelay })} +

+ + 0} + fallback={( + + + ); +}; diff --git a/apps/papra-client/src/modules/organizations/pages/organizations-settings.page.tsx b/apps/papra-client/src/modules/organizations/pages/organizations-settings.page.tsx index d76a9c5e..a26b976b 100644 --- a/apps/papra-client/src/modules/organizations/pages/organizations-settings.page.tsx +++ b/apps/papra-client/src/modules/organizations/pages/organizations-settings.page.tsx @@ -29,7 +29,7 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props const handleDelete = async () => { const confirmed = await confirm({ title: t('organization.settings.delete.confirm.title'), - message: t('organization.settings.delete.confirm.message'), + message: t('organization.settings.delete.confirm.message', { days: 30 }), confirmButton: { text: t('organization.settings.delete.confirm.confirm-button'), variant: 'destructive', @@ -37,6 +37,7 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props cancelButton: { text: t('organization.settings.delete.confirm.cancel-button'), }, + shouldType: props.organization.name, }); if (confirmed) { diff --git a/apps/papra-client/src/modules/organizations/pages/organizations.page.tsx b/apps/papra-client/src/modules/organizations/pages/organizations.page.tsx index 02cb13a3..660949f4 100644 --- a/apps/papra-client/src/modules/organizations/pages/organizations.page.tsx +++ b/apps/papra-client/src/modules/organizations/pages/organizations.page.tsx @@ -3,6 +3,8 @@ import { A, useNavigate } from '@solidjs/router'; import { useQuery } from '@tanstack/solid-query'; import { createEffect, For, on } from 'solid-js'; import { useI18n } from '@/modules/i18n/i18n.provider'; +import { Button } from '@/modules/ui/components/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu'; import { fetchOrganizations } from '../organizations.services'; export const OrganizationsPage: Component = () => { @@ -25,9 +27,25 @@ export const OrganizationsPage: Component = () => { return (
-

- {t('organizations.list.title')} -

+
+
+

+ {t('organizations.list.title')} +

+
+ + + +
+ + + +
+ {t('organizations.list.deleted.title')} + + + +

{t('organizations.list.description')} diff --git a/apps/papra-client/src/modules/shared/confirm.tsx b/apps/papra-client/src/modules/shared/confirm.tsx index bdcb9e99..0ae1eaa6 100644 --- a/apps/papra-client/src/modules/shared/confirm.tsx +++ b/apps/papra-client/src/modules/shared/confirm.tsx @@ -1,7 +1,9 @@ import type { JSX, ParentComponent } from 'solid-js'; -import { createContext, createSignal, useContext } from 'solid-js'; +import { createContext, createSignal, Show, useContext } from 'solid-js'; +import { useI18n } from '@/modules/i18n/i18n.provider'; import { Button } from '../ui/components/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/components/dialog'; +import { TextField, TextFieldLabel, TextFieldRoot } from '../ui/components/textfield'; type ConfirmModalConfig = { title: JSX.Element | string; @@ -14,6 +16,7 @@ type ConfirmModalConfig = { text?: string; variant?: 'default' | 'secondary'; }; + shouldType?: string; }; const ConfirmModalContext = createContext<{ confirm: (config: ConfirmModalConfig) => Promise }>(undefined); @@ -29,14 +32,17 @@ export function useConfirmModal() { } export const ConfirmModalProvider: ParentComponent = (props) => { + const { t } = useI18n(); const [getIsOpen, setIsOpen] = createSignal(false); const [getConfig, setConfig] = createSignal(); const [getResolve, setResolve] = createSignal<((isConfirmed: boolean) => void) | undefined>(); + const [getTypedText, setTypedText] = createSignal(''); - const confirm = ({ title, message, confirmButton, cancelButton }: ConfirmModalConfig) => { + const confirm = ({ title, message, confirmButton, cancelButton, shouldType }: ConfirmModalConfig) => { setConfig({ title, message, + shouldType, confirmButton: { text: confirmButton?.text, variant: confirmButton?.variant ?? 'default', @@ -66,6 +72,16 @@ export const ConfirmModalProvider: ParentComponent = (props) => { setIsOpen(false); } + const getIsConfirmEnabled = () => { + const { shouldType } = getConfig() ?? {}; + + if (shouldType === undefined) { + return true; + } + + return getTypedText().trim().toLowerCase() === shouldType.trim().toLowerCase(); + }; + return (

@@ -75,13 +91,27 @@ export const ConfirmModalProvider: ParentComponent = (props) => { {getConfig()?.message && {getConfig()?.message}} + + {getText => ( +
+ + {t('common.confirm-modal.type-to-confirm', { text: getText() })} + setTypedText(e.currentTarget.value)} + /> + +
+ )} +
+
-
diff --git a/apps/papra-client/src/modules/shared/http/http-client.models.ts b/apps/papra-client/src/modules/shared/http/http-client.models.ts index 92ee3d90..4a765f5f 100644 --- a/apps/papra-client/src/modules/shared/http/http-client.models.ts +++ b/apps/papra-client/src/modules/shared/http/http-client.models.ts @@ -26,5 +26,6 @@ export function coerceDates>(obj: T): CoerceDates< ...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}), ...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}), ...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}), + ...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: toDate(obj.scheduledPurgeAt) } : {}), }; } diff --git a/apps/papra-client/src/modules/tracking/tracking.services.ts b/apps/papra-client/src/modules/tracking/tracking.services.ts index b5243c43..951ae873 100644 --- a/apps/papra-client/src/modules/tracking/tracking.services.ts +++ b/apps/papra-client/src/modules/tracking/tracking.services.ts @@ -19,7 +19,7 @@ const dummyTrackingServices: TrackingServices = { capture: ({ event, ...args }) => { if (isDev) { // eslint-disable-next-line no-console - console.log(`[dev] captured event ${event}`, args); + console.log(`[dev] captured event ${event}`, ...(Object.keys(args).length ? [args] : [])); } }, reset: () => {}, diff --git a/apps/papra-client/src/routes.tsx b/apps/papra-client/src/routes.tsx index 0b62820a..8a877cb6 100644 --- a/apps/papra-client/src/routes.tsx +++ b/apps/papra-client/src/routes.tsx @@ -18,6 +18,7 @@ import { InvitationsPage } from './modules/invitations/pages/invitations.page'; import { fetchOrganizations } from './modules/organizations/organizations.services'; import { CreateFirstOrganizationPage } from './modules/organizations/pages/create-first-organization.page'; import { CreateOrganizationPage } from './modules/organizations/pages/create-organization.page'; +import { DeletedOrganizationsPage } from './modules/organizations/pages/deleted-organizations.page'; import { InvitationsListPage } from './modules/organizations/pages/invitations-list.page'; import { InviteMemberPage } from './modules/organizations/pages/invite-member.page'; import { MembersPage } from './modules/organizations/pages/members.page'; @@ -66,11 +67,11 @@ export const routes: RouteDefinition[] = [ - 0}> + 0}> - + @@ -89,6 +90,10 @@ export const routes: RouteDefinition[] = [ path: '/', component: OrganizationsPage, }, + { + path: '/deleted', + component: DeletedOrganizationsPage, + }, { path: '/:organizationId', component: (props) => { diff --git a/apps/papra-server/src/migrations/list/0011-soft-delete-organizations.migration.ts b/apps/papra-server/src/migrations/list/0011-soft-delete-organizations.migration.ts new file mode 100644 index 00000000..251c53aa --- /dev/null +++ b/apps/papra-server/src/migrations/list/0011-soft-delete-organizations.migration.ts @@ -0,0 +1,36 @@ +import type { BatchItem } from 'drizzle-orm/batch'; +import type { Migration } from '../migrations.types'; +import { sql } from 'drizzle-orm'; + +export const softDeleteOrganizationsMigration = { + name: 'soft-delete-organizations', + + up: async ({ db }) => { + const tableInfo = await db.run(sql`PRAGMA table_info(organizations)`); + const existingColumns = tableInfo.rows.map(row => row.name); + const hasColumn = (columnName: string) => existingColumns.includes(columnName); + + const statements = [ + ...(hasColumn('deleted_by') ? [] : [(sql`ALTER TABLE "organizations" ADD "deleted_by" text REFERENCES users(id);`)]), + ...(hasColumn('deleted_at') ? [] : [(sql`ALTER TABLE "organizations" ADD "deleted_at" integer;`)]), + ...(hasColumn('scheduled_purge_at') ? [] : [(sql`ALTER TABLE "organizations" ADD "scheduled_purge_at" integer;`)]), + + sql`CREATE INDEX IF NOT EXISTS "organizations_deleted_at_purge_at_index" ON "organizations" ("deleted_at","scheduled_purge_at");`, + sql`CREATE INDEX IF NOT EXISTS "organizations_deleted_by_deleted_at_index" ON "organizations" ("deleted_by","deleted_at");`, + ]; + + await db.batch(statements.map(statement => db.run(statement) as BatchItem<'sqlite'>) as [BatchItem<'sqlite'>, ...BatchItem<'sqlite'>[]]); + }, + + down: async ({ db }) => { + await db.batch([ + db.run(sql`DROP INDEX IF EXISTS "organizations_deleted_at_purge_at_index";`), + db.run(sql`DROP INDEX IF EXISTS "organizations_deleted_by_deleted_at_index";`), + + db.run(sql`ALTER TABLE "organizations" DROP COLUMN "deleted_by";`), + db.run(sql`ALTER TABLE "organizations" DROP COLUMN "deleted_at";`), + db.run(sql`ALTER TABLE "organizations" DROP COLUMN "scheduled_purge_at";`), + + ]); + }, +} satisfies Migration; diff --git a/apps/papra-server/src/migrations/meta/0010_snapshot.json b/apps/papra-server/src/migrations/meta/0010_snapshot.json new file mode 100644 index 00000000..811acc13 --- /dev/null +++ b/apps/papra-server/src/migrations/meta/0010_snapshot.json @@ -0,0 +1,2050 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1c4a2eb0-efbe-486d-a4ea-44768fbdfeff", + "prevId": "6f0abb3a-800c-4968-a528-95f3f254c1ef", + "tables": { + "document_activity_log": { + "name": "document_activity_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_data": { + "name": "event_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "document_activity_log_document_id_documents_id_fk": { + "name": "document_activity_log_document_id_documents_id_fk", + "tableFrom": "document_activity_log", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "document_activity_log_user_id_users_id_fk": { + "name": "document_activity_log_user_id_users_id_fk", + "tableFrom": "document_activity_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "document_activity_log_tag_id_tags_id_fk": { + "name": "document_activity_log_tag_id_tags_id_fk", + "tableFrom": "document_activity_log", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_size": { + "name": "original_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "original_storage_key": { + "name": "original_storage_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_sha256_hash": { + "name": "original_sha256_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "file_encryption_key_wrapped": { + "name": "file_encryption_key_wrapped", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_encryption_kek_version": { + "name": "file_encryption_kek_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_encryption_algorithm": { + "name": "file_encryption_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "documents_organization_id_is_deleted_created_at_index": { + "name": "documents_organization_id_is_deleted_created_at_index", + "columns": [ + "organization_id", + "is_deleted", + "created_at" + ], + "isUnique": false + }, + "documents_organization_id_is_deleted_index": { + "name": "documents_organization_id_is_deleted_index", + "columns": [ + "organization_id", + "is_deleted" + ], + "isUnique": false + }, + "documents_organization_id_original_sha256_hash_unique": { + "name": "documents_organization_id_original_sha256_hash_unique", + "columns": [ + "organization_id", + "original_sha256_hash" + ], + "isUnique": true + }, + "documents_original_sha256_hash_index": { + "name": "documents_original_sha256_hash_index", + "columns": [ + "original_sha256_hash" + ], + "isUnique": false + }, + "documents_organization_id_size_index": { + "name": "documents_organization_id_size_index", + "columns": [ + "organization_id", + "original_size" + ], + "isUnique": false + }, + "documents_file_encryption_kek_version_index": { + "name": "documents_file_encryption_kek_version_index", + "columns": [ + "file_encryption_kek_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_organization_id_organizations_id_fk": { + "name": "documents_organization_id_organizations_id_fk", + "tableFrom": "documents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "documents_created_by_users_id_fk": { + "name": "documents_created_by_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "documents_deleted_by_users_id_fk": { + "name": "documents_deleted_by_users_id_fk", + "tableFrom": "documents", + "tableTo": "users", + "columnsFrom": [ + "deleted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_invitations": { + "name": "organization_invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_invitations_organization_email_unique": { + "name": "organization_invitations_organization_email_unique", + "columns": [ + "organization_id", + "email" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organization_invitations_organization_id_organizations_id_fk": { + "name": "organization_invitations_organization_id_organizations_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "organization_invitations_inviter_id_users_id_fk": { + "name": "organization_invitations_inviter_id_users_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "users", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_user_organization_unique": { + "name": "organization_members_user_organization_unique", + "columns": [ + "organization_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_purge_at": { + "name": "scheduled_purge_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "organizations_deleted_at_purge_at_index": { + "name": "organizations_deleted_at_purge_at_index", + "columns": [ + "deleted_at", + "scheduled_purge_at" + ], + "isUnique": false + }, + "organizations_deleted_by_deleted_at_index": { + "name": "organizations_deleted_by_deleted_at_index", + "columns": [ + "deleted_by", + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organizations_deleted_by_users_id_fk": { + "name": "organizations_deleted_by_users_id_fk", + "tableFrom": "organizations", + "tableTo": "users", + "columnsFrom": [ + "deleted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_roles": { + "name": "user_roles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_roles_role_index": { + "name": "user_roles_role_index", + "columns": [ + "role" + ], + "isUnique": false + }, + "user_roles_user_id_role_unique_index": { + "name": "user_roles_user_id_role_unique_index", + "columns": [ + "user_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_roles_user_id_users_id_fk": { + "name": "user_roles_user_id_users_id_fk", + "tableFrom": "user_roles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents_tags": { + "name": "documents_tags", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "documents_tags_document_id_documents_id_fk": { + "name": "documents_tags_document_id_documents_id_fk", + "tableFrom": "documents_tags", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "documents_tags_tag_id_tags_id_fk": { + "name": "documents_tags_tag_id_tags_id_fk", + "tableFrom": "documents_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": { + "documents_tags_pkey": { + "columns": [ + "document_id", + "tag_id" + ], + "name": "documents_tags_pkey" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tags_organization_id_name_unique": { + "name": "tags_organization_id_name_unique", + "columns": [ + "organization_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "tags_organization_id_organizations_id_fk": { + "name": "tags_organization_id_organizations_id_fk", + "tableFrom": "tags", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_organization_count": { + "name": "max_organization_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_index": { + "name": "users_email_index", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key_organizations": { + "name": "api_key_organizations", + "columns": { + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_member_id": { + "name": "organization_member_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_key_organizations_api_key_id_api_keys_id_fk": { + "name": "api_key_organizations_api_key_id_api_keys_id_fk", + "tableFrom": "api_key_organizations", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "api_key_organizations_organization_member_id_organization_members_id_fk": { + "name": "api_key_organizations_organization_member_id_organization_members_id_fk", + "tableFrom": "api_key_organizations", + "tableTo": "organization_members", + "columnsFrom": [ + "organization_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "all_organizations": { + "name": "all_organizations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "key_hash_index": { + "name": "key_hash_index", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_accounts": { + "name": "auth_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "auth_accounts_user_id_users_id_fk": { + "name": "auth_accounts_user_id_users_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "auth_sessions_token_index": { + "name": "auth_sessions_token_index", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "auth_sessions_user_id_users_id_fk": { + "name": "auth_sessions_user_id_users_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "auth_sessions_active_organization_id_organizations_id_fk": { + "name": "auth_sessions_active_organization_id_organizations_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_verifications": { + "name": "auth_verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "auth_verifications_identifier_index": { + "name": "auth_verifications_identifier_index", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "intake_emails": { + "name": "intake_emails", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_address": { + "name": "email_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "allowed_origins": { + "name": "allowed_origins", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "intake_emails_email_address_unique": { + "name": "intake_emails_email_address_unique", + "columns": [ + "email_address" + ], + "isUnique": true + } + }, + "foreignKeys": { + "intake_emails_organization_id_organizations_id_fk": { + "name": "intake_emails_organization_id_organizations_id_fk", + "tableFrom": "intake_emails", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_subscriptions": { + "name": "organization_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "customer_id": { + "name": "customer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seats_count": { + "name": "seats_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "organization_subscriptions_organization_id_organizations_id_fk": { + "name": "organization_subscriptions_organization_id_organizations_id_fk", + "tableFrom": "organization_subscriptions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagging_rule_actions": { + "name": "tagging_rule_actions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagging_rule_id": { + "name": "tagging_rule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tagging_rule_actions_tagging_rule_id_tagging_rules_id_fk": { + "name": "tagging_rule_actions_tagging_rule_id_tagging_rules_id_fk", + "tableFrom": "tagging_rule_actions", + "tableTo": "tagging_rules", + "columnsFrom": [ + "tagging_rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "tagging_rule_actions_tag_id_tags_id_fk": { + "name": "tagging_rule_actions_tag_id_tags_id_fk", + "tableFrom": "tagging_rule_actions", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagging_rule_conditions": { + "name": "tagging_rule_conditions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagging_rule_id": { + "name": "tagging_rule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operator": { + "name": "operator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_case_sensitive": { + "name": "is_case_sensitive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "tagging_rule_conditions_tagging_rule_id_tagging_rules_id_fk": { + "name": "tagging_rule_conditions_tagging_rule_id_tagging_rules_id_fk", + "tableFrom": "tagging_rule_conditions", + "tableTo": "tagging_rules", + "columnsFrom": [ + "tagging_rule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tagging_rules": { + "name": "tagging_rules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "tagging_rules_organization_id_organizations_id_fk": { + "name": "tagging_rules_organization_id_organizations_id_fk", + "tableFrom": "tagging_rules", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_deliveries": { + "name": "webhook_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_payload": { + "name": "request_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_payload": { + "name": "response_payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhook_events": { + "name": "webhook_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "webhook_events_webhook_id_event_name_unique": { + "name": "webhook_events_webhook_id_event_name_unique", + "columns": [ + "webhook_id", + "event_name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "webhook_events_webhook_id_webhooks_id_fk": { + "name": "webhook_events_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_events", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "webhooks": { + "name": "webhooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_created_by_users_id_fk": { + "name": "webhooks_created_by_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "webhooks_organization_id_organizations_id_fk": { + "name": "webhooks_organization_id_organizations_id_fk", + "tableFrom": "webhooks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/papra-server/src/migrations/meta/_journal.json b/apps/papra-server/src/migrations/meta/_journal.json index 80f6f14b..e0613896 100644 --- a/apps/papra-server/src/migrations/meta/_journal.json +++ b/apps/papra-server/src/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1756332955747, "tag": "0009_document-file-encryption", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1760016118956, + "tag": "0010_soft-delete-organizations", + "breakpoints": true } ] } diff --git a/apps/papra-server/src/migrations/migrations.registry.test.ts b/apps/papra-server/src/migrations/migrations.registry.test.ts index a943ec45..2eceb211 100644 --- a/apps/papra-server/src/migrations/migrations.registry.test.ts +++ b/apps/papra-server/src/migrations/migrations.registry.test.ts @@ -83,6 +83,8 @@ describe('migrations registry', () => { CREATE INDEX migrations_run_at_index ON migrations (run_at); CREATE UNIQUE INDEX "organization_invitations_organization_email_unique" ON "organization_invitations" ("organization_id","email"); CREATE UNIQUE INDEX "organization_members_user_organization_unique" ON "organization_members" ("organization_id","user_id"); + CREATE INDEX "organizations_deleted_at_purge_at_index" ON "organizations" ("deleted_at","scheduled_purge_at"); + CREATE INDEX "organizations_deleted_by_deleted_at_index" ON "organizations" ("deleted_by","deleted_at"); CREATE UNIQUE INDEX "tags_organization_id_name_unique" ON "tags" ("organization_id","name"); CREATE INDEX "user_roles_role_index" ON "user_roles" ("role"); CREATE UNIQUE INDEX "user_roles_user_id_role_unique_index" ON "user_roles" ("user_id","role"); @@ -108,7 +110,7 @@ describe('migrations registry', () => { CREATE TABLE "organization_invitations" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "email" text NOT NULL, "role" text NOT NULL, "status" text NOT NULL DEFAULT 'pending', "expires_at" integer NOT NULL, "inviter_id" text NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("inviter_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade ); CREATE TABLE "organization_members" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "organization_id" text NOT NULL, "user_id" text NOT NULL, "role" text NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("user_id") REFERENCES "users"("id") ON UPDATE cascade ON DELETE cascade ); CREATE TABLE "organization_subscriptions" ( "id" text PRIMARY KEY NOT NULL, "customer_id" text NOT NULL, "organization_id" text NOT NULL, "plan_id" text NOT NULL, "status" text NOT NULL, "seats_count" integer NOT NULL, "current_period_end" integer NOT NULL, "current_period_start" integer NOT NULL, "cancel_at_period_end" integer DEFAULT false NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON UPDATE cascade ON DELETE cascade ); - CREATE TABLE "organizations" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "customer_id" text ); + CREATE TABLE "organizations" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "name" text NOT NULL, "customer_id" text , "deleted_by" text REFERENCES users(id), "deleted_at" integer, "scheduled_purge_at" integer); CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE "tagging_rule_actions" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "tagging_rule_id" text NOT NULL, "tag_id" text NOT NULL, FOREIGN KEY ("tagging_rule_id") REFERENCES "tagging_rules"("id") ON UPDATE cascade ON DELETE cascade, FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON UPDATE cascade ON DELETE cascade ); CREATE TABLE "tagging_rule_conditions" ( "id" text PRIMARY KEY NOT NULL, "created_at" integer NOT NULL, "updated_at" integer NOT NULL, "tagging_rule_id" text NOT NULL, "field" text NOT NULL, "operator" text NOT NULL, "value" text NOT NULL, "is_case_sensitive" integer DEFAULT false NOT NULL, FOREIGN KEY ("tagging_rule_id") REFERENCES "tagging_rules"("id") ON UPDATE cascade ON DELETE cascade ); diff --git a/apps/papra-server/src/migrations/migrations.registry.ts b/apps/papra-server/src/migrations/migrations.registry.ts index 5665ad26..de92b174 100644 --- a/apps/papra-server/src/migrations/migrations.registry.ts +++ b/apps/papra-server/src/migrations/migrations.registry.ts @@ -12,6 +12,8 @@ import { dropLegacyMigrationsMigration } from './list/0009-drop-legacy-migration import { documentFileEncryptionMigration } from './list/0010-document-file-encryption.migration'; +import { softDeleteOrganizationsMigration } from './list/0011-soft-delete-organizations.migration'; + export const migrations: Migration[] = [ initialSchemaSetupMigration, documentsFtsMigration, @@ -23,4 +25,5 @@ export const migrations: Migration[] = [ documentActivityLogOnDeleteSetNullMigration, dropLegacyMigrationsMigration, documentFileEncryptionMigration, + softDeleteOrganizationsMigration, ]; diff --git a/apps/papra-server/src/modules/api-keys/api-keys.repository.test.ts b/apps/papra-server/src/modules/api-keys/api-keys.repository.test.ts index 07ef66ae..7b45c149 100644 --- a/apps/papra-server/src/modules/api-keys/api-keys.repository.test.ts +++ b/apps/papra-server/src/modules/api-keys/api-keys.repository.test.ts @@ -44,6 +44,9 @@ describe('api-keys repository', () => { customerId: null, createdAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-02'), + deletedAt: null, + deletedBy: null, + scheduledPurgeAt: null, }], createdAt: new Date('2021-03-01'), updatedAt: new Date('2021-03-02'), diff --git a/apps/papra-server/src/modules/config/config.models.test.ts b/apps/papra-server/src/modules/config/config.models.test.ts index c2300f99..f5872b43 100644 --- a/apps/papra-server/src/modules/config/config.models.test.ts +++ b/apps/papra-server/src/modules/config/config.models.test.ts @@ -15,6 +15,7 @@ describe('config models', () => { - documents.deletedExpirationDelayInDays The delay in days before a deleted document is permanently deleted - intakeEmails.isEnabled Whether intake emails are enabled - auth.providers.email.isEnabled Whether email/password authentication is enabled + - organizations.deletedOrganizationsPurgeDaysDelay The delay in days before a soft-deleted organization is permanently purged Any other config should not be exposed.`, () => { const config = overrideConfig({ @@ -41,6 +42,9 @@ describe('config models', () => { intakeEmails: { isEnabled: true, }, + organizations: { + deletedOrganizationsPurgeDaysDelay: 30, + }, } as DeepPartial); expect(getPublicConfig({ config })).to.eql({ @@ -72,6 +76,9 @@ describe('config models', () => { documentsStorage: { maxUploadSize: 10485760, }, + organizations: { + deletedOrganizationsPurgeDaysDelay: 30, + }, }, }); }); diff --git a/apps/papra-server/src/modules/config/config.models.ts b/apps/papra-server/src/modules/config/config.models.ts index 986e897d..d2c3c6b1 100644 --- a/apps/papra-server/src/modules/config/config.models.ts +++ b/apps/papra-server/src/modules/config/config.models.ts @@ -15,6 +15,7 @@ export function getPublicConfig({ config }: { config: Config }) { 'documents.deletedDocumentsRetentionDays', 'documentsStorage.maxUploadSize', 'intakeEmails.isEnabled', + 'organizations.deletedOrganizationsPurgeDaysDelay', ]), { auth: { diff --git a/apps/papra-server/src/modules/documents/documents.repository.ts b/apps/papra-server/src/modules/documents/documents.repository.ts index 610d7fe2..15e796d9 100644 --- a/apps/papra-server/src/modules/documents/documents.repository.ts +++ b/apps/papra-server/src/modules/documents/documents.repository.ts @@ -4,6 +4,7 @@ import { injectArguments, safely } from '@corentinth/chisels'; import { subDays } from 'date-fns'; import { and, count, desc, eq, getTableColumns, lt, sql, sum } from 'drizzle-orm'; import { omit } from 'lodash-es'; +import { createIterator } from '../app/database/database.usecases'; import { createOrganizationNotFoundError } from '../organizations/organizations.errors'; import { isUniqueConstraintError } from '../shared/db/constraints.models'; import { withPagination } from '../shared/db/pagination'; @@ -32,6 +33,9 @@ export function createDocumentsRepository({ db }: { db: Database }) { getOrganizationStats, getOrganizationDocumentBySha256Hash, getAllOrganizationTrashDocuments, + getAllOrganizationDocuments, + getOrganizationDocumentsQuery, + getAllOrganizationDocumentsIterator, updateDocument, }, { db }, @@ -367,6 +371,33 @@ async function getAllOrganizationTrashDocuments({ organizationId, db }: { organi }; } +async function getAllOrganizationDocuments({ organizationId, db }: { organizationId: string; db: Database }) { + const documents = await db.select({ + id: documentsTable.id, + originalStorageKey: documentsTable.originalStorageKey, + }).from(documentsTable).where( + eq(documentsTable.organizationId, organizationId), + ); + + return { + documents, + }; +} + +function getOrganizationDocumentsQuery({ organizationId, db }: { organizationId: string; db: Database }) { + return db.select({ + id: documentsTable.id, + originalStorageKey: documentsTable.originalStorageKey, + }).from(documentsTable).where( + eq(documentsTable.organizationId, organizationId), + ); +} + +function getAllOrganizationDocumentsIterator({ organizationId, db, batchSize = 100 }: { organizationId: string; db: Database; batchSize?: number }) { + const query = getOrganizationDocumentsQuery({ organizationId, db }).$dynamic(); + return createIterator({ query, batchSize }) as AsyncGenerator<{ id: string; originalStorageKey: string }>; +} + async function updateDocument({ documentId, organizationId, name, content, db }: { documentId: string; organizationId: string; name?: string; content?: string; db: Database }) { const [document] = await db .update(documentsTable) diff --git a/apps/papra-server/src/modules/documents/documents.table.ts b/apps/papra-server/src/modules/documents/documents.table.ts index 9c38895d..e59beb68 100644 --- a/apps/papra-server/src/modules/documents/documents.table.ts +++ b/apps/papra-server/src/modules/documents/documents.table.ts @@ -1,17 +1,15 @@ import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { organizationsTable } from '../organizations/organizations.table'; -import { createPrimaryKeyField, createSoftDeleteColumns, createTimestampColumns } from '../shared/db/columns.helpers'; +import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers'; import { usersTable } from '../users/users.table'; import { generateDocumentId } from './documents.models'; export const documentsTable = sqliteTable('documents', { ...createPrimaryKeyField({ idGenerator: generateDocumentId }), ...createTimestampColumns(), - ...createSoftDeleteColumns(), organizationId: text('organization_id').notNull().references(() => organizationsTable.id, { onDelete: 'cascade', onUpdate: 'cascade' }), createdBy: text('created_by').references(() => usersTable.id, { onDelete: 'set null', onUpdate: 'cascade' }), - deletedBy: text('deleted_by').references(() => usersTable.id, { onDelete: 'set null', onUpdate: 'cascade' }), originalName: text('original_name').notNull(), originalSize: integer('original_size').notNull().default(0), @@ -25,6 +23,10 @@ export const documentsTable = sqliteTable('documents', { fileEncryptionKeyWrapped: text('file_encryption_key_wrapped'), // The wrapped encryption key fileEncryptionKekVersion: text('file_encryption_kek_version'), // The key encryption key version used to encrypt the file encryption key fileEncryptionAlgorithm: text('file_encryption_algorithm'), + + deletedAt: integer('deleted_at', { mode: 'timestamp_ms' }), + deletedBy: text('deleted_by').references(() => usersTable.id, { onDelete: 'set null', onUpdate: 'cascade' }), + isDeleted: integer('is_deleted', { mode: 'boolean' }).notNull().default(false), }, table => [ // To select paginated documents by organization index('documents_organization_id_is_deleted_created_at_index').on(table.organizationId, table.isDeleted, table.createdAt), diff --git a/apps/papra-server/src/modules/organizations/organizations.config.ts b/apps/papra-server/src/modules/organizations/organizations.config.ts index 65da2ed4..fa96faf2 100644 --- a/apps/papra-server/src/modules/organizations/organizations.config.ts +++ b/apps/papra-server/src/modules/organizations/organizations.config.ts @@ -20,4 +20,10 @@ export const organizationsConfig = { default: 30, env: 'MAX_USER_ORGANIZATIONS_INVITATIONS_PER_DAY', }, + deletedOrganizationsPurgeDaysDelay: { + doc: 'The number of days before a soft-deleted organization is permanently purged', + schema: z.coerce.number().int().positive(), + default: 30, + env: 'ORGANIZATIONS_DELETED_PURGE_DAYS_DELAY', + }, } as const satisfies ConfigDefinition; diff --git a/apps/papra-server/src/modules/organizations/organizations.errors.ts b/apps/papra-server/src/modules/organizations/organizations.errors.ts index 78f4c378..438555eb 100644 --- a/apps/papra-server/src/modules/organizations/organizations.errors.ts +++ b/apps/papra-server/src/modules/organizations/organizations.errors.ts @@ -53,3 +53,15 @@ export const createMaxOrganizationMembersCountReachedError = createErrorFactory( code: 'organization.max_members_count_reached', statusCode: 403, }); + +export const createOrganizationNotDeletedError = createErrorFactory({ + message: 'Organization not deleted.', + code: 'organization.not_deleted', + statusCode: 403, +}); + +export const createOnlyPreviousOwnerCanRestoreError = createErrorFactory({ + message: 'Only the previous owner can restore this organization.', + code: 'organization.only_previous_owner_can_restore', + statusCode: 403, +}); diff --git a/apps/papra-server/src/modules/organizations/organizations.repository.test.ts b/apps/papra-server/src/modules/organizations/organizations.repository.test.ts index 29f2877d..c105ea45 100644 --- a/apps/papra-server/src/modules/organizations/organizations.repository.test.ts +++ b/apps/papra-server/src/modules/organizations/organizations.repository.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { createInMemoryDatabase } from '../app/database/database.test-utils'; import { createOrganizationsRepository } from './organizations.repository'; -import { organizationInvitationsTable } from './organizations.table'; +import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table'; describe('organizations repository', () => { describe('updateExpiredPendingInvitationsStatus', () => { @@ -73,4 +73,137 @@ describe('organizations repository', () => { ]); }); }); + + describe('deleteAllMembersFromOrganization', () => { + test('deletes all members from the specified organization', async () => { + const { db } = await createInMemoryDatabase({ + users: [ + { id: 'user_1', email: 'user1@test.com' }, + { id: 'user_2', email: 'user2@test.com' }, + { id: 'user_3', email: 'user3@test.com' }, + ], + organizations: [ + { id: 'org_1', name: 'Org 1' }, + { id: 'org_2', name: 'Org 2' }, + ], + organizationMembers: [ + { id: 'member_1', organizationId: 'org_1', userId: 'user_1', role: 'owner' }, + { id: 'member_2', organizationId: 'org_1', userId: 'user_2', role: 'member' }, + { id: 'member_3', organizationId: 'org_2', userId: 'user_3', role: 'owner' }, + ], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + + await organizationsRepository.deleteAllMembersFromOrganization({ organizationId: 'org_1' }); + + const remainingMembers = await db.select().from(organizationMembersTable); + + expect(remainingMembers).to.have.lengthOf(1); + expect(remainingMembers[0]?.organizationId).to.equal('org_2'); + }); + }); + + describe('deleteAllOrganizationInvitations', () => { + test('deletes all invitations for the specified organization', async () => { + const { db } = await createInMemoryDatabase({ + users: [{ id: 'user_1', email: 'user1@test.com' }], + organizations: [ + { id: 'org_1', name: 'Org 1' }, + { id: 'org_2', name: 'Org 2' }, + ], + organizationInvitations: [ + { + id: 'invite_1', + organizationId: 'org_1', + email: 'invite1@test.com', + role: 'member', + inviterId: 'user_1', + status: 'pending', + expiresAt: new Date('2025-12-31'), + }, + { + id: 'invite_2', + organizationId: 'org_1', + email: 'invite2@test.com', + role: 'admin', + inviterId: 'user_1', + status: 'pending', + expiresAt: new Date('2025-12-31'), + }, + { + id: 'invite_3', + organizationId: 'org_2', + email: 'invite3@test.com', + role: 'member', + inviterId: 'user_1', + status: 'pending', + expiresAt: new Date('2025-12-31'), + }, + ], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + + await organizationsRepository.deleteAllOrganizationInvitations({ organizationId: 'org_1' }); + + const remainingInvitations = await db.select().from(organizationInvitationsTable); + + expect(remainingInvitations).to.have.lengthOf(1); + expect(remainingInvitations[0]?.organizationId).to.equal('org_2'); + }); + }); + + describe('softDeleteOrganization', () => { + test('marks organization as deleted with deletedAt, deletedBy, and scheduledPurgeAt', async () => { + const { db } = await createInMemoryDatabase({ + users: [{ id: 'user_1', email: 'user1@test.com' }], + organizations: [ + { id: 'org_1', name: 'Org to Delete' }, + ], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + + const now = new Date('2025-05-15T10:00:00Z'); + const expectedPurgeDate = new Date('2025-06-14T10:00:00Z'); // 30 days later + + await organizationsRepository.softDeleteOrganization({ + organizationId: 'org_1', + deletedBy: 'user_1', + now, + purgeDaysDelay: 30, + }); + + const [organization] = await db.select().from(organizationsTable); + + expect(organization?.deletedAt).to.eql(now); + expect(organization?.deletedBy).to.equal('user_1'); + expect(organization?.scheduledPurgeAt).to.eql(expectedPurgeDate); + }); + + test('uses default purge delay of 30 days when not specified', async () => { + const { db } = await createInMemoryDatabase({ + users: [{ id: 'user_1', email: 'user1@test.com' }], + organizations: [ + { id: 'org_1', name: 'Org to Delete' }, + ], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + + const now = new Date('2025-05-15T10:00:00Z'); + const expectedPurgeDate = new Date('2025-06-14T10:00:00Z'); // 30 days later by default + + await organizationsRepository.softDeleteOrganization({ + organizationId: 'org_1', + deletedBy: 'user_1', + now, + }); + + const [organization] = await db.select().from(organizationsTable); + + expect(organization?.scheduledPurgeAt).to.eql(expectedPurgeDate); + }); + }); }); diff --git a/apps/papra-server/src/modules/organizations/organizations.repository.ts b/apps/papra-server/src/modules/organizations/organizations.repository.ts index 800de034..e8499866 100644 --- a/apps/papra-server/src/modules/organizations/organizations.repository.ts +++ b/apps/papra-server/src/modules/organizations/organizations.repository.ts @@ -2,7 +2,7 @@ import type { Database } from '../app/database/database.types'; import type { DbInsertableOrganization, OrganizationInvitationStatus, OrganizationRole } from './organizations.types'; import { injectArguments } from '@corentinth/chisels'; import { addDays, startOfDay } from 'date-fns'; -import { and, count, eq, getTableColumns, gte, lte } from 'drizzle-orm'; +import { and, count, eq, getTableColumns, gte, isNotNull, isNull, lte } from 'drizzle-orm'; import { omit } from 'lodash-es'; import { omitUndefined } from '../shared/utils'; import { usersTable } from '../users/users.table'; @@ -43,6 +43,12 @@ export function createOrganizationsRepository({ db }: { db: Database }) { getOrganizationInvitations, updateExpiredPendingInvitationsStatus, getOrganizationPendingInvitationsCount, + deleteAllMembersFromOrganization, + deleteAllOrganizationInvitations, + softDeleteOrganization, + restoreOrganization, + getUserDeletedOrganizations, + getExpiredSoftDeletedOrganizations, }, { db }, ); @@ -67,7 +73,10 @@ async function getUserOrganizations({ userId, db }: { userId: string; db: Databa }) .from(organizationsTable) .leftJoin(organizationMembersTable, eq(organizationsTable.id, organizationMembersTable.organizationId)) - .where(eq(organizationMembersTable.userId, userId)); + .where(and( + eq(organizationMembersTable.userId, userId), + isNull(organizationsTable.deletedAt), + )); return { organizations: organizations.map(({ organization }) => organization), @@ -469,3 +478,66 @@ async function getOrganizationPendingInvitationsCount({ organizationId, db }: { pendingInvitationsCount, }; } + +async function deleteAllMembersFromOrganization({ organizationId, db }: { organizationId: string; db: Database }) { + await db + .delete(organizationMembersTable) + .where(eq(organizationMembersTable.organizationId, organizationId)); +} + +async function deleteAllOrganizationInvitations({ organizationId, db }: { organizationId: string; db: Database }) { + await db + .delete(organizationInvitationsTable) + .where(eq(organizationInvitationsTable.organizationId, organizationId)); +} + +async function softDeleteOrganization({ organizationId, deletedBy, db, now = new Date(), purgeDaysDelay = 30 }: { organizationId: string; deletedBy: string; db: Database; now?: Date; purgeDaysDelay?: number }) { + await db + .update(organizationsTable) + .set({ + deletedAt: now, + deletedBy, + scheduledPurgeAt: addDays(now, purgeDaysDelay), + }) + .where(eq(organizationsTable.id, organizationId)); +} + +async function restoreOrganization({ organizationId, db }: { organizationId: string; db: Database }) { + await db + .update(organizationsTable) + .set({ + deletedAt: null, + deletedBy: null, + scheduledPurgeAt: null, + }) + .where(eq(organizationsTable.id, organizationId)); +} + +async function getUserDeletedOrganizations({ userId, db, now = new Date() }: { userId: string; db: Database; now?: Date }) { + const organizations = await db + .select() + .from(organizationsTable) + .where(and( + eq(organizationsTable.deletedBy, userId), + isNotNull(organizationsTable.deletedAt), + gte(organizationsTable.scheduledPurgeAt, now), + )); + + return { + organizations, + }; +} + +async function getExpiredSoftDeletedOrganizations({ db, now = new Date() }: { db: Database; now?: Date }) { + const organizations = await db + .select({ id: organizationsTable.id }) + .from(organizationsTable) + .where(and( + isNotNull(organizationsTable.deletedAt), + lte(organizationsTable.scheduledPurgeAt, now), + )); + + return { + organizationIds: organizations.map(org => org.id), + }; +} diff --git a/apps/papra-server/src/modules/organizations/organizations.routes.ts b/apps/papra-server/src/modules/organizations/organizations.routes.ts index e6020a22..8bdd13fd 100644 --- a/apps/papra-server/src/modules/organizations/organizations.routes.ts +++ b/apps/papra-server/src/modules/organizations/organizations.routes.ts @@ -10,20 +10,22 @@ import { createUsersRepository } from '../users/users.repository'; import { memberIdSchema, organizationIdSchema } from './organization.schemas'; import { ORGANIZATION_ROLES } from './organizations.constants'; import { createOrganizationsRepository } from './organizations.repository'; -import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, inviteMemberToOrganization, removeMemberFromOrganization, updateOrganizationMemberRole } from './organizations.usecases'; +import { checkIfUserCanCreateNewOrganization, createOrganization, ensureUserIsInOrganization, inviteMemberToOrganization, removeMemberFromOrganization, restoreOrganization, softDeleteOrganization, updateOrganizationMemberRole } from './organizations.usecases'; export function registerOrganizationsRoutes(context: RouteDefinitionContext) { setupGetOrganizationsRoute(context); + setupGetDeletedOrganizationsRoute(context); setupCreateOrganizationRoute(context); setupGetOrganizationRoute(context); setupUpdateOrganizationRoute(context); - setupDeleteOrganizationRoute(context); + setupSoftDeleteOrganizationRoute(context); setupGetOrganizationMembersRoute(context); setupRemoveOrganizationMemberRoute(context); setupUpdateOrganizationMemberRoute(context); setupInviteOrganizationMemberRoute(context); setupGetMembershipRoute(context); setupGetOrganizationInvitationsRoute(context); + setupRestoreOrganizationRoute(context); } function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) { @@ -44,6 +46,24 @@ function setupGetOrganizationsRoute({ app, db }: RouteDefinitionContext) { ); } +function setupGetDeletedOrganizationsRoute({ app, db }: RouteDefinitionContext) { + app.get( + '/api/organizations/deleted', + requireAuthentication({ apiKeyPermissions: ['organizations:read'] }), + async (context) => { + const { userId } = getUser({ context }); + + const organizationsRepository = createOrganizationsRepository({ db }); + + const { organizations } = await organizationsRepository.getUserDeletedOrganizations({ userId }); + + return context.json({ + organizations, + }); + }, + ); +} + function setupCreateOrganizationRoute({ app, db, config }: RouteDefinitionContext) { app.post( '/api/organizations', @@ -119,7 +139,7 @@ function setupUpdateOrganizationRoute({ app, db }: RouteDefinitionContext) { ); } -function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) { +function setupSoftDeleteOrganizationRoute({ app, db, config }: RouteDefinitionContext) { app.delete( '/api/organizations/:organizationId', requireAuthentication({ apiKeyPermissions: ['organizations:delete'] }), @@ -132,11 +152,9 @@ function setupDeleteOrganizationRoute({ app, db }: RouteDefinitionContext) { const organizationsRepository = createOrganizationsRepository({ db }); - // No Promise.all as we want to ensure consistency in error handling await ensureUserIsInOrganization({ userId, organizationId, organizationsRepository }); - await ensureUserIsOwnerOfOrganization({ userId, organizationId, organizationsRepository }); - await organizationsRepository.deleteOrganization({ organizationId }); + await softDeleteOrganization({ organizationId, deletedBy: userId, organizationsRepository, config }); return context.body(null, 204); }, @@ -305,3 +323,27 @@ function setupGetOrganizationInvitationsRoute({ app, db }: RouteDefinitionContex }, ); } + +function setupRestoreOrganizationRoute({ app, db }: RouteDefinitionContext) { + app.post( + '/api/organizations/:organizationId/restore', + requireAuthentication(), + validateParams(z.object({ + organizationId: organizationIdSchema, + })), + async (context) => { + const { userId } = getUser({ context }); + const { organizationId } = context.req.valid('param'); + + const organizationsRepository = createOrganizationsRepository({ db }); + + await restoreOrganization({ + organizationId, + restoredBy: userId, + organizationsRepository, + }); + + return context.body(null, 204); + }, + ); +} diff --git a/apps/papra-server/src/modules/organizations/organizations.table.ts b/apps/papra-server/src/modules/organizations/organizations.table.ts index 16b4f0ac..5234e8d5 100644 --- a/apps/papra-server/src/modules/organizations/organizations.table.ts +++ b/apps/papra-server/src/modules/organizations/organizations.table.ts @@ -1,6 +1,6 @@ import type { NonEmptyArray } from '../shared/types'; import type { OrganizationInvitationStatus, OrganizationRole } from './organizations.types'; -import { integer, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core'; +import { index, integer, sqliteTable, text, unique } from 'drizzle-orm/sqlite-core'; import { createPrimaryKeyField, createTimestampColumns } from '../shared/db/columns.helpers'; import { usersTable } from '../users/users.table'; import { ORGANIZATION_ID_PREFIX, ORGANIZATION_INVITATION_ID_PREFIX, ORGANIZATION_INVITATION_STATUS, ORGANIZATION_INVITATION_STATUS_LIST, ORGANIZATION_MEMBER_ID_PREFIX, ORGANIZATION_ROLES_LIST } from './organizations.constants'; @@ -11,7 +11,17 @@ export const organizationsTable = sqliteTable('organizations', { name: text('name').notNull(), customerId: text('customer_id'), -}); + + deletedAt: integer('deleted_at', { mode: 'timestamp_ms' }), + deletedBy: text('deleted_by').references(() => usersTable.id, { onDelete: 'set null', onUpdate: 'cascade' }), + // When the organization is soft-deleted, we schedule a purge date some days in the future for hard deletion + scheduledPurgeAt: integer('scheduled_purge_at', { mode: 'timestamp_ms' }), +}, t => [ + // Used to list organizations to purge + index('organizations_deleted_at_purge_at_index').on(t.deletedAt, t.scheduledPurgeAt), + // For a user to list their deleted organizations for possible restoration + index('organizations_deleted_by_deleted_at_index').on(t.deletedBy, t.deletedAt), +]); export const organizationMembersTable = sqliteTable('organization_members', { ...createPrimaryKeyField({ prefix: ORGANIZATION_MEMBER_ID_PREFIX }), diff --git a/apps/papra-server/src/modules/organizations/organizations.usecases.test.ts b/apps/papra-server/src/modules/organizations/organizations.usecases.test.ts index b243528b..afeebe33 100644 --- a/apps/papra-server/src/modules/organizations/organizations.usecases.test.ts +++ b/apps/papra-server/src/modules/organizations/organizations.usecases.test.ts @@ -1,3 +1,4 @@ +import type { DocumentStorageService } from '../documents/storage/documents.storage.services'; import type { EmailsServices } from '../emails/emails.services'; import type { PlansRepository } from '../plans/plans.repository'; import type { SubscriptionsServices } from '../subscriptions/subscriptions.services'; @@ -14,7 +15,7 @@ import { ORGANIZATION_ROLES } from './organizations.constants'; import { createMaxOrganizationMembersCountReachedError, createOrganizationDocumentStorageLimitReachedError, createOrganizationInvitationAlreadyExistsError, createOrganizationNotFoundError, createUserAlreadyInOrganizationError, createUserMaxOrganizationCountReachedError, createUserNotInOrganizationError, createUserNotOrganizationOwnerError, createUserOrganizationInvitationLimitReachedError } from './organizations.errors'; import { createOrganizationsRepository } from './organizations.repository'; import { organizationInvitationsTable, organizationMembersTable, organizationsTable } from './organizations.table'; -import { checkIfOrganizationCanCreateNewDocument, checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, inviteMemberToOrganization, removeMemberFromOrganization } from './organizations.usecases'; +import { checkIfOrganizationCanCreateNewDocument, checkIfUserCanCreateNewOrganization, ensureUserIsInOrganization, ensureUserIsOwnerOfOrganization, getOrCreateOrganizationCustomerId, inviteMemberToOrganization, purgeExpiredSoftDeletedOrganization, purgeExpiredSoftDeletedOrganizations, removeMemberFromOrganization, softDeleteOrganization } from './organizations.usecases'; describe('organizations usecases', () => { describe('ensureUserIsInOrganization', () => { @@ -1025,4 +1026,660 @@ describe('organizations usecases', () => { ]); }); }); + + describe('softDeleteOrganization', () => { + describe('when an organization owner wants to delete their organization, the organization is soft-deleted to allow for recovery within a grace period', () => { + test('owner can soft-delete organization with all metadata set correctly', async () => { + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [{ id: 'organization-1', name: 'Test Org' }], + organizationMembers: [ + { organizationId: 'organization-1', userId: 'usr_1', role: ORGANIZATION_ROLES.OWNER }, + ], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + const config = overrideConfig(); + + await softDeleteOrganization({ + organizationId: 'organization-1', + deletedBy: 'usr_1', + organizationsRepository, + config, + now: new Date('2025-10-05'), + }); + + const [organization] = await db.select().from(organizationsTable); + expect(organization?.deletedAt).to.eql(new Date('2025-10-05')); + expect(organization?.deletedBy).to.eql('usr_1'); + expect(organization?.scheduledPurgeAt).to.eql(new Date('2025-11-04')); + }); + + test('only owner can delete organization, admins and members cannot', async () => { + const { db } = await createInMemoryDatabase({ + users: [ + { id: 'usr_1', email: 'owner@example.com' }, + { id: 'admin-user', email: 'admin@example.com' }, + ], + organizations: [{ id: 'organization-1', name: 'Test Org' }], + organizationMembers: [ + { organizationId: 'organization-1', userId: 'usr_1', role: ORGANIZATION_ROLES.OWNER }, + { organizationId: 'organization-1', userId: 'admin-user', role: ORGANIZATION_ROLES.ADMIN }, + ], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + const config = overrideConfig(); + + await expect( + softDeleteOrganization({ + organizationId: 'organization-1', + deletedBy: 'admin-user', + organizationsRepository, + config, + }), + ).rejects.toThrow(createUserNotOrganizationOwnerError()); + }); + + test('soft deletion removes all members and invitations from the organization', async () => { + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [{ id: 'organization-1', name: 'Test Org' }], + organizationMembers: [ + { id: 'member-1', organizationId: 'organization-1', userId: 'usr_1', role: ORGANIZATION_ROLES.OWNER }, + ], + organizationInvitations: [ + { + organizationId: 'organization-1', + email: 'invited@example.com', + role: ORGANIZATION_ROLES.MEMBER, + inviterId: 'usr_1', + status: 'pending', + expiresAt: new Date('2025-12-31'), + }, + ], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + const config = overrideConfig(); + + await softDeleteOrganization({ + organizationId: 'organization-1', + deletedBy: 'usr_1', + organizationsRepository, + config, + }); + + const remainingMembers = await db.select().from(organizationMembersTable); + const remainingInvitations = await db.select().from(organizationInvitationsTable); + + expect(remainingMembers).toHaveLength(0); + expect(remainingInvitations).toHaveLength(0); + }); + + test('attempting to delete a non-existent organization throws an error', async () => { + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + const config = overrideConfig(); + + await expect( + softDeleteOrganization({ + organizationId: 'non-existent-org', + deletedBy: 'usr_1', + organizationsRepository, + config, + }), + ).rejects.toThrow(createOrganizationNotFoundError()); + }); + + test('soft deletion only affects the target organization, not other organizations', async () => { + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [ + { id: 'organization-1', name: 'Org to Delete' }, + { id: 'organization-2', name: 'Other Org' }, + ], + organizationMembers: [ + { organizationId: 'organization-1', userId: 'usr_1', role: ORGANIZATION_ROLES.OWNER }, + { organizationId: 'organization-2', userId: 'usr_1', role: ORGANIZATION_ROLES.OWNER }, + ], + }); + + const organizationsRepository = createOrganizationsRepository({ db }); + const config = overrideConfig(); + + await softDeleteOrganization({ + organizationId: 'organization-1', + deletedBy: 'usr_1', + organizationsRepository, + config, + now: new Date('2025-10-05'), + }); + + const members = await db.select().from(organizationMembersTable); + const [org1, org2] = await db.select().from(organizationsTable).orderBy(organizationsTable.id); + + // Only organization-2 member remains + expect(members).toHaveLength(1); + expect(members[0]?.organizationId).toBe('organization-2'); + + expect(org1?.deletedAt).to.eql(new Date('2025-10-05')); + expect(org2?.deletedAt).to.eql(null); + }); + }); + }); + + describe('purgeExpiredSoftDeletedOrganization', () => { + describe('when a deleted organization reaches its scheduled purge date, it should be permanently deleted along with all its documents from storage', () => { + test('successfully purges organization and deletes all documents from storage', async () => { + const { logger, getLogs } = createTestLogger(); + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [{ + id: 'organization-1', + name: 'Expired Org', + deletedAt: new Date('2025-10-05'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-11-04'), + }], + documents: [ + { + id: 'doc-1', + organizationId: 'organization-1', + originalStorageKey: 'org-1/doc-1.pdf', + originalName: 'doc-1.pdf', + name: 'doc-1.pdf', + mimeType: 'application/pdf', + originalSize: 1024, + originalSha256Hash: 'hash1', + }, + { + id: 'doc-2', + organizationId: 'organization-1', + originalStorageKey: 'org-1/doc-2.txt', + originalName: 'doc-2.txt', + name: 'doc-2.txt', + mimeType: 'text/plain', + originalSize: 512, + originalSha256Hash: 'hash2', + isDeleted: true, + deletedAt: new Date('2025-10-10'), + deletedBy: 'usr_1', + }, + ], + }); + + const documentsRepository = createDocumentsRepository({ db }); + const organizationsRepository = createOrganizationsRepository({ db }); + + const deletedFiles: string[] = []; + const documentsStorageService = { + deleteFile: async ({ storageKey }: { storageKey: string }) => { + deletedFiles.push(storageKey); + }, + } as DocumentStorageService; + + await purgeExpiredSoftDeletedOrganization({ + organizationId: 'organization-1', + documentsRepository, + organizationsRepository, + documentsStorageService, + logger, + }); + + // Verify files were deleted from storage (order may vary due to async processing) + expect(deletedFiles.toSorted()).to.eql(['org-1/doc-1.pdf', 'org-1/doc-2.txt'].toSorted()); + + // Verify organization was deleted from database + const orgs = await db.select().from(organizationsTable); + expect(orgs).to.eql([]); + + expect(getLogs({ excludeTimestampMs: true })).to.eql([ + { + level: 'info', + message: 'Starting purge of organization', + namespace: 'test', + data: { + organizationId: 'organization-1', + }, + }, + { + level: 'debug', + message: 'Deleted document file from storage', + namespace: 'test', + data: { + documentId: 'doc-2', + organizationId: 'organization-1', + storageKey: 'org-1/doc-2.txt', + }, + }, + { + level: 'debug', + message: 'Deleted document file from storage', + namespace: 'test', + data: { + documentId: 'doc-1', + organizationId: 'organization-1', + storageKey: 'org-1/doc-1.pdf', + }, + }, + { + level: 'info', + message: 'Finished deleting document files from storage', + namespace: 'test', + data: { + deletedCount: 2, + failedCount: 0, + organizationId: 'organization-1', + }, + }, + { + level: 'info', + message: 'Successfully purged organization', + namespace: 'test', + data: { + organizationId: 'organization-1', + }, + }, + ]); + }); + + test('handles storage deletion errors gracefully and continues purging', async () => { + const { logger, getLogs } = createTestLogger(); + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [{ + id: 'organization-1', + name: 'Expired Org', + deletedAt: new Date('2025-10-05'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-11-04'), + }], + documents: [ + { + id: 'doc-1', + organizationId: 'organization-1', + originalStorageKey: 'org-1/missing-file.pdf', + originalName: 'missing-file.pdf', + name: 'missing-file.pdf', + mimeType: 'application/pdf', + originalSize: 1024, + originalSha256Hash: 'hash1', + }, + { + id: 'doc-2', + organizationId: 'organization-1', + originalStorageKey: 'org-1/doc-2.txt', + originalName: 'doc-2.txt', + name: 'doc-2.txt', + mimeType: 'text/plain', + originalSize: 512, + originalSha256Hash: 'hash2', + }, + ], + }); + + const documentsRepository = createDocumentsRepository({ db }); + const organizationsRepository = createOrganizationsRepository({ db }); + + const deletedFiles: string[] = []; + const documentsStorageService = { + deleteFile: async ({ storageKey }: { storageKey: string }) => { + if (storageKey === 'org-1/missing-file.pdf') { + throw new Error('File not found in storage'); + } + deletedFiles.push(storageKey); + }, + } as DocumentStorageService; + + await purgeExpiredSoftDeletedOrganization({ + organizationId: 'organization-1', + documentsRepository, + organizationsRepository, + documentsStorageService, + logger, + }); + + // Verify only the successful file was deleted + expect(deletedFiles).to.eql(['org-1/doc-2.txt']); + + // Verify organization was still deleted despite storage errors + const orgs = await db.select().from(organizationsTable); + expect(orgs).to.eql([]); + + // Verify error was logged + const logs = getLogs({ excludeTimestampMs: true }); + expect(logs).toContainEqual(expect.objectContaining({ + level: 'error', + message: 'Failed to delete document file from storage', + })); + expect(logs).toContainEqual(expect.objectContaining({ + level: 'info', + message: 'Finished deleting document files from storage', + data: { organizationId: 'organization-1', deletedCount: 1, failedCount: 1 }, + })); + }); + + test('purges organization even when it has no documents', async () => { + const { logger } = createTestLogger(); + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [{ + id: 'organization-1', + name: 'Empty Org', + deletedAt: new Date('2025-10-05'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-11-04'), + }], + }); + + const documentsRepository = createDocumentsRepository({ db }); + const organizationsRepository = createOrganizationsRepository({ db }); + + const deletedFiles: string[] = []; + const documentsStorageService = { + deleteFile: async ({ storageKey }: { storageKey: string }) => { + deletedFiles.push(storageKey); + }, + } as DocumentStorageService; + + await purgeExpiredSoftDeletedOrganization({ + organizationId: 'organization-1', + documentsRepository, + organizationsRepository, + documentsStorageService, + logger, + }); + + // No files should have been deleted + expect(deletedFiles).to.eql([]); + + // Organization should still be deleted + const orgs = await db.select().from(organizationsTable); + expect(orgs).to.eql([]); + }); + + test('processes documents in batches for large organizations', async () => { + const { logger } = createTestLogger(); + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [{ + id: 'organization-1', + name: 'Large Org', + deletedAt: new Date('2025-10-05'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-11-04'), + }], + documents: Array.from({ length: 250 }, (_, i) => ({ + id: `doc-${i}`, + organizationId: 'organization-1', + originalStorageKey: `org-1/doc-${i}.pdf`, + originalName: `doc-${i}.pdf`, + name: `doc-${i}.pdf`, + mimeType: 'application/pdf', + originalSize: 1024, + originalSha256Hash: `hash${i}`, + })), + }); + + const documentsRepository = createDocumentsRepository({ db }); + const organizationsRepository = createOrganizationsRepository({ db }); + + const deletedFiles: string[] = []; + const documentsStorageService = { + deleteFile: async ({ storageKey }: { storageKey: string }) => { + deletedFiles.push(storageKey); + }, + } as DocumentStorageService; + + await purgeExpiredSoftDeletedOrganization({ + organizationId: 'organization-1', + documentsRepository, + organizationsRepository, + documentsStorageService, + logger, + batchSize: 100, + }); + + // All 250 files should have been deleted + expect(deletedFiles.length).to.eql(250); + + // Organization should be deleted + const orgs = await db.select().from(organizationsTable); + expect(orgs).to.eql([]); + }); + }); + }); + + describe('purgeExpiredSoftDeletedOrganizations', () => { + describe('batch purges all expired organizations past their scheduled purge date', () => { + test('purges multiple expired organizations', async () => { + const { logger, getLogs } = createTestLogger(); + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [ + { + id: 'organization-1', + name: 'Expired Org 1', + deletedAt: new Date('2025-10-01'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-10-31'), + }, + { + id: 'organization-2', + name: 'Expired Org 2', + deletedAt: new Date('2025-09-15'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-10-15'), + }, + { + id: 'organization-3', + name: 'Not Yet Expired', + deletedAt: new Date('2025-11-01'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-12-01'), + }, + ], + documents: [ + { + id: 'doc-1', + organizationId: 'organization-1', + originalStorageKey: 'org-1/doc-1.pdf', + originalName: 'doc-1.pdf', + name: 'doc-1.pdf', + mimeType: 'application/pdf', + originalSize: 1024, + originalSha256Hash: 'hash1', + }, + { + id: 'doc-2', + organizationId: 'organization-2', + originalStorageKey: 'org-2/doc-2.pdf', + originalName: 'doc-2.pdf', + name: 'doc-2.pdf', + mimeType: 'application/pdf', + originalSize: 1024, + originalSha256Hash: 'hash2', + }, + { + id: 'doc-3', + organizationId: 'organization-3', + originalStorageKey: 'org-3/doc-3.pdf', + originalName: 'doc-3.pdf', + name: 'doc-3.pdf', + mimeType: 'application/pdf', + originalSize: 1024, + originalSha256Hash: 'hash3', + }, + ], + }); + + const documentsRepository = createDocumentsRepository({ db }); + const organizationsRepository = createOrganizationsRepository({ db }); + + const deletedFiles: string[] = []; + const documentsStorageService = { + deleteFile: async ({ storageKey }: { storageKey: string }) => { + deletedFiles.push(storageKey); + }, + } as DocumentStorageService; + + const { purgedOrganizationCount } = await purgeExpiredSoftDeletedOrganizations({ + organizationsRepository, + documentsRepository, + documentsStorageService, + logger, + now: new Date('2025-11-05'), + }); + + // Only expired organizations should be purged + expect(purgedOrganizationCount).to.eql(2); + + // Only files from expired organizations should be deleted + expect(deletedFiles.toSorted()).to.eql(['org-1/doc-1.pdf', 'org-2/doc-2.pdf'].toSorted()); + + // Only the not-yet-expired organization should remain + const orgs = await db.select().from(organizationsTable); + expect(orgs.length).to.eql(1); + expect(orgs[0]?.id).to.eql('organization-3'); + + // Verify logs + const logs = getLogs({ excludeTimestampMs: true }); + expect(logs).toContainEqual(expect.objectContaining({ + level: 'info', + message: 'Found expired soft-deleted organizations to purge', + data: { organizationCount: 2 }, + })); + }); + + test('handles errors during individual organization purge and continues with others', async () => { + const { logger, getLogs } = createTestLogger(); + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [ + { + id: 'organization-1', + name: 'Will Fail', + deletedAt: new Date('2025-10-01'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-10-31'), + }, + { + id: 'organization-2', + name: 'Will Succeed', + deletedAt: new Date('2025-10-01'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-10-31'), + }, + ], + documents: [ + { + id: 'doc-1', + organizationId: 'organization-1', + originalStorageKey: 'org-1/doc-1.pdf', + originalName: 'doc-1.pdf', + name: 'doc-1.pdf', + mimeType: 'application/pdf', + originalSize: 1024, + originalSha256Hash: 'hash1', + }, + { + id: 'doc-2', + organizationId: 'organization-2', + originalStorageKey: 'org-2/doc-2.pdf', + originalName: 'doc-2.pdf', + name: 'doc-2.pdf', + mimeType: 'application/pdf', + originalSize: 1024, + originalSha256Hash: 'hash2', + }, + ], + }); + + const documentsRepository = createDocumentsRepository({ db }); + const organizationsRepository = createOrganizationsRepository({ db }); + + const deletedFiles: string[] = []; + const documentsStorageService = { + deleteFile: async ({ storageKey }: { storageKey: string }) => { + if (storageKey.startsWith('org-1/')) { + throw new Error('Storage service error'); + } + deletedFiles.push(storageKey); + }, + } as DocumentStorageService; + + const { purgedOrganizationCount } = await purgeExpiredSoftDeletedOrganizations({ + organizationsRepository, + documentsRepository, + documentsStorageService, + logger, + now: new Date('2025-11-05'), + }); + + // Both organizations should be purged even though org-1 had storage deletion errors + // The singular purge function catches file deletion errors but continues + // and still deletes the organization record from the database + expect(purgedOrganizationCount).to.eql(2); + + // Only successful file should be deleted + expect(deletedFiles).to.eql(['org-2/doc-2.pdf']); + + // Both organizations should be deleted from database despite storage errors + const orgs = await db.select().from(organizationsTable); + expect(orgs).to.eql([]); + + // Verify file deletion error was logged (but not organization purge failure) + const logs = getLogs({ excludeTimestampMs: true }); + expect(logs).toContainEqual(expect.objectContaining({ + level: 'error', + message: 'Failed to delete document file from storage', + })); + }); + + test('returns zero count when no organizations need purging', async () => { + const { logger } = createTestLogger(); + const { db } = await createInMemoryDatabase({ + users: [{ id: 'usr_1', email: 'owner@example.com' }], + organizations: [ + { + id: 'organization-1', + name: 'Not Yet Expired', + deletedAt: new Date('2025-11-01'), + deletedBy: 'usr_1', + scheduledPurgeAt: new Date('2025-12-01'), + }, + ], + }); + + const documentsRepository = createDocumentsRepository({ db }); + const organizationsRepository = createOrganizationsRepository({ db }); + + const deletedFiles: string[] = []; + const documentsStorageService = { + deleteFile: async ({ storageKey }: { storageKey: string }) => { + deletedFiles.push(storageKey); + }, + } as DocumentStorageService; + + const { purgedOrganizationCount } = await purgeExpiredSoftDeletedOrganizations({ + organizationsRepository, + documentsRepository, + documentsStorageService, + logger, + now: new Date('2025-11-05'), + }); + + expect(purgedOrganizationCount).to.eql(0); + expect(deletedFiles).to.eql([]); + + // Organization should remain + const orgs = await db.select().from(organizationsTable); + expect(orgs.length).to.eql(1); + }); + }); + }); }); diff --git a/apps/papra-server/src/modules/organizations/organizations.usecases.ts b/apps/papra-server/src/modules/organizations/organizations.usecases.ts index d5a61b73..35f21f5b 100644 --- a/apps/papra-server/src/modules/organizations/organizations.usecases.ts +++ b/apps/papra-server/src/modules/organizations/organizations.usecases.ts @@ -1,5 +1,6 @@ import type { Config } from '../config/config.types'; import type { DocumentsRepository } from '../documents/documents.repository'; +import type { DocumentStorageService } from '../documents/storage/documents.storage.services'; import type { EmailsServices } from '../emails/emails.services'; import type { PlansRepository } from '../plans/plans.repository'; import type { Logger } from '../shared/logger/logger'; @@ -19,8 +20,10 @@ import { isDefined } from '../shared/utils'; import { ORGANIZATION_INVITATION_STATUS, ORGANIZATION_ROLES } from './organizations.constants'; import { createMaxOrganizationMembersCountReachedError, + createOnlyPreviousOwnerCanRestoreError, createOrganizationDocumentStorageLimitReachedError, createOrganizationInvitationAlreadyExistsError, + createOrganizationNotDeletedError, createOrganizationNotFoundError, createUserAlreadyInOrganizationError, createUserMaxOrganizationCountReachedError, @@ -471,3 +474,145 @@ export async function getOrganizationStorageLimits({ maxFileSize, }; } + +export async function softDeleteOrganization({ + organizationId, + deletedBy, + organizationsRepository, + config, + now = new Date(), +}: { + organizationId: string; + deletedBy: string; + organizationsRepository: OrganizationsRepository; + config: Config; + now?: Date; +}) { + await ensureUserIsOwnerOfOrganization({ userId: deletedBy, organizationId, organizationsRepository }); + + await organizationsRepository.deleteAllMembersFromOrganization({ organizationId }); + await organizationsRepository.deleteAllOrganizationInvitations({ organizationId }); + await organizationsRepository.softDeleteOrganization({ + organizationId, + deletedBy, + now, + purgeDaysDelay: config.organizations.deletedOrganizationsPurgeDaysDelay, + }); +} + +export async function restoreOrganization({ + organizationId, + restoredBy, + organizationsRepository, + now = new Date(), +}: { + organizationId: string; + restoredBy: string; + organizationsRepository: OrganizationsRepository; + now?: Date; +}) { + const { organization } = await organizationsRepository.getOrganizationById({ organizationId }); + + if (!organization) { + throw createOrganizationNotFoundError(); + } + + if (!organization.deletedAt) { + throw createOrganizationNotDeletedError(); + } + + if (organization.scheduledPurgeAt && organization.scheduledPurgeAt < now) { + throw createOrganizationNotFoundError(); + } + + if (organization.deletedBy !== restoredBy) { + throw createOnlyPreviousOwnerCanRestoreError(); + } + + await organizationsRepository.restoreOrganization({ organizationId }); + await organizationsRepository.addUserToOrganization({ + userId: restoredBy, + organizationId, + role: ORGANIZATION_ROLES.OWNER, + }); +} + +export async function purgeExpiredSoftDeletedOrganization({ + organizationId, + documentsRepository, + organizationsRepository, + documentsStorageService, + logger = createLogger({ namespace: 'organizations.purge' }), + batchSize = 100, +}: { + organizationId: string; + documentsRepository: DocumentsRepository; + organizationsRepository: OrganizationsRepository; + documentsStorageService: DocumentStorageService; + logger?: Logger; + batchSize?: number; +}) { + logger.info({ organizationId }, 'Starting purge of organization'); + + // Process documents in batches using an iterator to avoid loading all into memory + const documentsIterator = documentsRepository.getAllOrganizationDocumentsIterator({ organizationId, batchSize }); + + let deletedCount = 0; + let failedCount = 0; + + for await (const document of documentsIterator) { + try { + await documentsStorageService.deleteFile({ storageKey: document.originalStorageKey }); + logger.debug({ organizationId, documentId: document.id, storageKey: document.originalStorageKey }, 'Deleted document file from storage'); + deletedCount++; + } catch (error) { + // Log but don't fail the entire purge if a single file deletion fails + logger.error({ organizationId, documentId: document.id, storageKey: document.originalStorageKey, error }, 'Failed to delete document file from storage'); + failedCount++; + } + } + + logger.info({ organizationId, deletedCount, failedCount }, 'Finished deleting document files from storage'); + + // Hard delete the organization (cascade will handle all related records) + await organizationsRepository.deleteOrganization({ organizationId }); + + logger.info({ organizationId }, 'Successfully purged organization'); +} + +export async function purgeExpiredSoftDeletedOrganizations({ + organizationsRepository, + documentsRepository, + documentsStorageService, + logger = createLogger({ namespace: 'organizations.purge' }), + now = new Date(), +}: { + organizationsRepository: OrganizationsRepository; + documentsRepository: DocumentsRepository; + documentsStorageService: DocumentStorageService; + logger?: Logger; + now?: Date; +}) { + const { organizationIds } = await organizationsRepository.getExpiredSoftDeletedOrganizations({ now }); + + logger.info({ organizationCount: organizationIds.length }, 'Found expired soft-deleted organizations to purge'); + + let purgedCount = 0; + + for (const organizationId of organizationIds) { + try { + await purgeExpiredSoftDeletedOrganization({ + organizationId, + documentsRepository, + organizationsRepository, + documentsStorageService, + logger, + }); + purgedCount++; + } catch (error) { + logger.error({ organizationId, error }, 'Failed to purge organization'); + } + } + + return { purgedOrganizationCount: purgedCount, totalOrganizationCount: organizationIds.length }; +} diff --git a/apps/papra-server/src/modules/organizations/tasks/purge-expired-organizations.task.ts b/apps/papra-server/src/modules/organizations/tasks/purge-expired-organizations.task.ts new file mode 100644 index 00000000..53d53d59 --- /dev/null +++ b/apps/papra-server/src/modules/organizations/tasks/purge-expired-organizations.task.ts @@ -0,0 +1,41 @@ +import type { Database } from '../../app/database/database.types'; +import type { Config } from '../../config/config.types'; +import type { DocumentStorageService } from '../../documents/storage/documents.storage.services'; +import type { TaskServices } from '../../tasks/tasks.services'; +import { createDocumentsRepository } from '../../documents/documents.repository'; +import { createLogger } from '../../shared/logger/logger'; +import { createOrganizationsRepository } from '../organizations.repository'; +import { purgeExpiredSoftDeletedOrganizations } from '../organizations.usecases'; + +const logger = createLogger({ namespace: 'organizations:tasks:purgeExpiredOrganizations' }); + +export async function registerPurgeExpiredOrganizationsTask({ taskServices, db, config, documentsStorageService }: { taskServices: TaskServices; db: Database; config: Config; documentsStorageService: DocumentStorageService }) { + const taskName = 'purge-expired-organizations'; + const { cron, runOnStartup } = config.tasks.purgeExpiredOrganizations; + + taskServices.registerTask({ + taskName, + handler: async () => { + const organizationsRepository = createOrganizationsRepository({ db }); + const documentsRepository = createDocumentsRepository({ db }); + + const { purgedOrganizationCount, totalOrganizationCount } = await purgeExpiredSoftDeletedOrganizations({ + organizationsRepository, + documentsRepository, + documentsStorageService, + logger, + }); + + logger.info({ purgedOrganizationCount, totalOrganizationCount }, 'Purged expired soft-deleted organizations'); + }, + }); + + await taskServices.schedulePeriodicJob({ + scheduleId: `periodic-${taskName}`, + taskName, + cron, + immediate: runOnStartup, + }); + + logger.info({ taskName, cron, runOnStartup }, 'Purge expired organizations task registered'); +} diff --git a/apps/papra-server/src/modules/shared/db/columns.helpers.ts b/apps/papra-server/src/modules/shared/db/columns.helpers.ts index 57396463..ff83b17d 100644 --- a/apps/papra-server/src/modules/shared/db/columns.helpers.ts +++ b/apps/papra-server/src/modules/shared/db/columns.helpers.ts @@ -1,9 +1,7 @@ import { integer, text } from 'drizzle-orm/sqlite-core'; import { generateId } from '../random/ids'; -export { createCreatedAtField, createPrimaryKeyField, createSoftDeleteColumns, createTimestampColumns, createUpdatedAtField }; - -function createPrimaryKeyField({ +export function createPrimaryKeyField({ prefix, idGenerator = () => generateId({ prefix }), }: { prefix?: string; idGenerator?: () => string } = {}) { @@ -14,7 +12,7 @@ function createPrimaryKeyField({ }; } -function createCreatedAtField() { +export function createCreatedAtField() { return { createdAt: integer('created_at', { mode: 'timestamp_ms' }) .notNull() @@ -22,7 +20,7 @@ function createCreatedAtField() { }; } -function createUpdatedAtField() { +export function createUpdatedAtField() { return { updatedAt: integer('updated_at', { mode: 'timestamp_ms' }) .notNull() @@ -30,16 +28,9 @@ function createUpdatedAtField() { }; } -function createTimestampColumns() { +export function createTimestampColumns() { return { ...createCreatedAtField(), ...createUpdatedAtField(), }; } - -function createSoftDeleteColumns() { - return { - isDeleted: integer('is_deleted', { mode: 'boolean' }).default(false).notNull(), - deletedAt: integer('deleted_at', { mode: 'timestamp_ms' }), - }; -} diff --git a/apps/papra-server/src/modules/tasks/tasks.config.ts b/apps/papra-server/src/modules/tasks/tasks.config.ts index f8508496..03df7f04 100644 --- a/apps/papra-server/src/modules/tasks/tasks.config.ts +++ b/apps/papra-server/src/modules/tasks/tasks.config.ts @@ -49,12 +49,6 @@ export const tasksConfig = { }, }, hardDeleteExpiredDocuments: { - enabled: { - doc: 'Whether the task to hard delete expired "soft deleted" documents is enabled', - schema: booleanishSchema, - default: true, - env: 'DOCUMENTS_HARD_DELETE_EXPIRED_DOCUMENTS_ENABLED', - }, cron: { doc: 'The cron schedule for the task to hard delete expired "soft deleted" documents', schema: z.string(), @@ -69,12 +63,6 @@ export const tasksConfig = { }, }, expireInvitations: { - enabled: { - doc: 'Whether the task to expire invitations is enabled', - schema: booleanishSchema, - default: true, - env: 'ORGANIZATIONS_EXPIRE_INVITATIONS_ENABLED', - }, cron: { doc: 'The cron schedule for the task to expire invitations', schema: z.string(), @@ -88,4 +76,18 @@ export const tasksConfig = { env: 'ORGANIZATIONS_EXPIRE_INVITATIONS_RUN_ON_STARTUP', }, }, + purgeExpiredOrganizations: { + cron: { + doc: 'The cron schedule for the task to purge expired soft-deleted organizations', + schema: z.string(), + default: '0 1 * * *', + env: 'ORGANIZATIONS_PURGE_EXPIRED_ORGANIZATIONS_CRON', + }, + runOnStartup: { + doc: 'Whether the task to purge expired soft-deleted organizations should run on startup', + schema: booleanishSchema, + default: true, + env: 'ORGANIZATIONS_PURGE_EXPIRED_ORGANIZATIONS_RUN_ON_STARTUP', + }, + }, } as const satisfies ConfigDefinition; diff --git a/apps/papra-server/src/modules/tasks/tasks.definitions.ts b/apps/papra-server/src/modules/tasks/tasks.definitions.ts index 82e6a5c7..0f01bd77 100644 --- a/apps/papra-server/src/modules/tasks/tasks.definitions.ts +++ b/apps/papra-server/src/modules/tasks/tasks.definitions.ts @@ -5,9 +5,11 @@ import type { TaskServices } from './tasks.services'; import { registerExtractDocumentFileContentTask } from '../documents/tasks/extract-document-file-content.task'; import { registerHardDeleteExpiredDocumentsTask } from '../documents/tasks/hard-delete-expired-documents.task'; import { registerExpireInvitationsTask } from '../organizations/tasks/expire-invitations.task'; +import { registerPurgeExpiredOrganizationsTask } from '../organizations/tasks/purge-expired-organizations.task'; export async function registerTaskDefinitions({ taskServices, db, config, documentsStorageService }: { taskServices: TaskServices; db: Database; config: Config; documentsStorageService: DocumentStorageService }) { await registerHardDeleteExpiredDocumentsTask({ taskServices, db, config, documentsStorageService }); await registerExpireInvitationsTask({ taskServices, db, config }); + await registerPurgeExpiredOrganizationsTask({ taskServices, db, config, documentsStorageService }); await registerExtractDocumentFileContentTask({ taskServices, db, documentsStorageService }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 114e6ca8..9be4ce0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: apps/papra-client: dependencies: + '@branchlet/core': + specifier: ^1.0.0 + version: 1.0.0 '@corentinth/chisels': specifier: ^1.3.1 version: 1.3.1 @@ -1073,6 +1076,10 @@ packages: '@better-fetch/fetch@1.1.18': resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@branchlet/core@1.0.0': + resolution: {integrity: sha512-qEFl0VeaIfdtVxHzIGeTu1HW7rpt4haMCV81YYBoFYTJZUBb0YZ2BN3Z6L2klwQ/6XxGJO61Cm5J6RMOKkvFmA==} + engines: {node: '>=22.0.0'} + '@cadence-mq/core@0.2.1': resolution: {integrity: sha512-Cu/jqR7mNhMZ1U4Boiudy2nePyf4PtqBUFGhUcsCQPJfymKcrDm4xjp8A/2tKZr5JSgkN/7L0/+mHZ27GVSryQ==} @@ -4654,8 +4661,8 @@ packages: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} - detect-libc@2.1.0: - resolution: {integrity: sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} deterministic-object-hash@2.0.2: @@ -7215,6 +7222,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + seroval-plugins@1.3.1: resolution: {integrity: sha512-dOlUoiI3fgZbQIcj6By+l865pzeWdP3XCSLdI3xlKnjCk5983yLWPsXytFOUI0BUZKG9qwqbj78n9yVcVwUqaQ==} engines: {node: '>=10'} @@ -9506,6 +9518,8 @@ snapshots: '@better-fetch/fetch@1.1.18': {} + '@branchlet/core@1.0.0': {} + '@cadence-mq/core@0.2.1': dependencies: '@corentinth/chisels': 1.3.1 @@ -10750,14 +10764,14 @@ snapshots: '@mapbox/node-pre-gyp@1.0.11': dependencies: - detect-libc: 2.1.0 + detect-libc: 2.1.2 https-proxy-agent: 5.0.1 make-dir: 3.1.0 node-fetch: 2.7.0 nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.7.2 + semver: 7.7.3 tar: 6.2.1 transitivePeerDependencies: - encoding @@ -13306,7 +13320,7 @@ snapshots: detect-libc@2.0.3: {} - detect-libc@2.1.0: + detect-libc@2.1.2: optional: true deterministic-object-hash@2.0.2: @@ -16752,6 +16766,9 @@ snapshots: semver@7.7.2: {} + semver@7.7.3: + optional: true + seroval-plugins@1.3.1(seroval@1.3.1): dependencies: seroval: 1.3.1 @@ -17783,7 +17800,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.19(@types/node@24.0.10)) + '@vitest/mocker': 3.2.4(vite@5.4.19(@types/node@22.16.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4