mirror of
https://github.com/papra-hq/papra.git
synced 2026-02-05 10:47:19 -06:00
feat(organizations): soft delete organizations with recovery (#542)
This commit is contained in:
committed by
GitHub
parent
60982da847
commit
c434d873bc
6
.changeset/chilly-ears-press.md
Normal file
6
.changeset/chilly-ears-press.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@papra/app-client": patch
|
||||
"@papra/app-server": patch
|
||||
---
|
||||
|
||||
Added soft deletion with grace period for organizations
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -43,4 +43,5 @@ ingestion
|
||||
.cursorrules
|
||||
*.traineddata
|
||||
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
.claude
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -102,6 +102,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -102,6 +102,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -102,6 +102,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -102,6 +102,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -102,6 +102,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
|
||||
'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ć',
|
||||
};
|
||||
|
||||
@@ -102,6 +102,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -102,6 +102,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -102,6 +102,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
'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<TranslationsDictionary> = {
|
||||
|
||||
'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',
|
||||
};
|
||||
|
||||
@@ -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<Config, 'auth'>;
|
||||
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'documentsStorage' | 'intakeEmails' | 'organizations'>;
|
||||
|
||||
@@ -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<Dict extends Record<string, string>>({ getDictionary }: { getDictionary: () => Dict }) {
|
||||
const { parse } = createBranchlet();
|
||||
|
||||
return (key: keyof Dict, args?: Record<string, string | number>) => {
|
||||
const translationFromDictionary = getDictionary()[key];
|
||||
|
||||
@@ -37,11 +40,7 @@ export function createTranslator<Dict extends Record<string, string>>({ 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;
|
||||
|
||||
@@ -115,3 +115,21 @@ export async function updateOrganizationMemberRole({ organizationId, memberId, r
|
||||
member: coerceDates(member),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchDeletedOrganizations() {
|
||||
const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<Button variant="ghost" as={A} href="/organizations" class="text-muted-foreground gap-2 ml--4">
|
||||
<div class="i-tabler-arrow-left size-5" />
|
||||
{t('organizations.list.back')}
|
||||
</Button>
|
||||
|
||||
<h2 class="text-xl font-bold">
|
||||
{t('organizations.list.deleted.title')}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
{t('organizations.list.deleted.description', { days: purgeDaysDelay })}
|
||||
</p>
|
||||
|
||||
<Show
|
||||
when={deletedOrgsQuery.data?.organizations && deletedOrgsQuery.data.organizations.length > 0}
|
||||
fallback={(
|
||||
<Alert variant="muted" class="my-4 flex items-center gap-4">
|
||||
<div class="i-tabler-info-circle size-10 text-primary flex-shrink-0 hidden sm:block" />
|
||||
<div>
|
||||
<AlertTitle>{t('organizations.list.deleted.empty')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('organizations.list.deleted.empty-description', { days: purgeDaysDelay })}
|
||||
</AlertDescription>
|
||||
<Button as={A} href="/organizations" variant="outline" class="mt-2 hover:(bg-primary text-primary-foreground) transition-colors" size="sm">
|
||||
<div class="i-tabler-arrow-left size-4 mr-2" />
|
||||
{t('organizations.list.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={deletedOrgsQuery.data?.organizations}>
|
||||
{(organization) => {
|
||||
const daysUntilPurge = organization.scheduledPurgeAt
|
||||
? Math.ceil((organization.scheduledPurgeAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: purgeDaysDelay;
|
||||
|
||||
return (
|
||||
<div class="border rounded-lg p-4 bg-card">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-base truncate">
|
||||
{organization.name}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-muted-foreground flex flex-col sm:flex-row sm:gap-2 flex-wrap">
|
||||
<Show when={organization.deletedAt}>
|
||||
<div class="flex-shrink-0">
|
||||
{t('organizations.list.deleted.deleted-at', {
|
||||
date: formatDate(organization.deletedAt!),
|
||||
})}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={organization.scheduledPurgeAt}>
|
||||
<div class="text-red-500 flex-shrink-0">
|
||||
{t('organizations.list.deleted.purge-at', {
|
||||
date: formatDate(organization.scheduledPurgeAt!),
|
||||
})}
|
||||
{' '}
|
||||
{t('organizations.list.deleted.days-remaining', { daysUntilPurge })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleRestore(organization.id)}
|
||||
disabled={restoreMutation.isPending}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-restore size-4 mr-2" />
|
||||
{t('organizations.list.deleted.restore')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<h2 class="text-xl font-bold mb-2">
|
||||
{t('organizations.list.title')}
|
||||
</h2>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-bold">
|
||||
{t('organizations.list.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as={Button} variant="outline" size="sm">
|
||||
<div class="i-tabler-dots size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem as={A} href="/organizations/deleted" class="cursor-pointer flex items-center gap-2">
|
||||
<div class="i-tabler-trash size-4 text-muted-foreground" />
|
||||
{t('organizations.list.deleted.title')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground mb-6">
|
||||
{t('organizations.list.description')}
|
||||
|
||||
@@ -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<boolean> }>(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<ConfirmModalConfig | undefined>();
|
||||
const [getResolve, setResolve] = createSignal<((isConfirmed: boolean) => void) | undefined>();
|
||||
const [getTypedText, setTypedText] = createSignal<string>('');
|
||||
|
||||
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 (
|
||||
<ConfirmModalContext.Provider value={{ confirm }}>
|
||||
<Dialog open={getIsOpen()} onOpenChange={onOpenChange}>
|
||||
@@ -75,13 +91,27 @@ export const ConfirmModalProvider: ParentComponent = (props) => {
|
||||
{getConfig()?.message && <DialogDescription>{getConfig()?.message}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
|
||||
<Show when={getConfig()?.shouldType}>
|
||||
{getText => (
|
||||
<div class="mt-0">
|
||||
<TextFieldRoot>
|
||||
<TextFieldLabel class="font-semibold">{t('common.confirm-modal.type-to-confirm', { text: getText() })}</TextFieldLabel>
|
||||
<TextField
|
||||
value={getTypedText()}
|
||||
onInput={e => setTypedText(e.currentTarget.value)}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<DialogFooter>
|
||||
<div class="flex gap-2 justify-end flex-col-reverse sm:flex-row">
|
||||
|
||||
<Button onClick={() => handleConfirm({ isConfirmed: false })} variant={getConfig()?.cancelButton?.variant ?? 'secondary'}>
|
||||
{getConfig()?.cancelButton?.text ?? 'Cancel'}
|
||||
</Button>
|
||||
<Button onClick={() => handleConfirm({ isConfirmed: true })} variant={getConfig()?.confirmButton?.variant ?? 'default'}>
|
||||
<Button onClick={() => handleConfirm({ isConfirmed: true })} variant={getConfig()?.confirmButton?.variant ?? 'default'} disabled={!getIsConfirmEnabled()}>
|
||||
{getConfig()?.confirmButton?.text ?? 'Confirm'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -26,5 +26,6 @@ export function coerceDates<T extends Record<string, any>>(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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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[] = [
|
||||
<Navigate href={`/organizations/${getLatestOrganizationId()}`} />
|
||||
</Match>
|
||||
|
||||
<Match when={query.data && query.data.organizations.length > 0}>
|
||||
<Match when={getOrgs().length > 0}>
|
||||
<Navigate href="/organizations" />
|
||||
</Match>
|
||||
|
||||
<Match when={query.data && query.data.organizations.length === 0}>
|
||||
<Match when={getOrgs().length === 0}>
|
||||
<Navigate href="/organizations/first" />
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -89,6 +90,10 @@ export const routes: RouteDefinition[] = [
|
||||
path: '/',
|
||||
component: OrganizationsPage,
|
||||
},
|
||||
{
|
||||
path: '/deleted',
|
||||
component: DeletedOrganizationsPage,
|
||||
},
|
||||
{
|
||||
path: '/:organizationId',
|
||||
component: (props) => {
|
||||
|
||||
@@ -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;
|
||||
2050
apps/papra-server/src/migrations/meta/0010_snapshot.json
Normal file
2050
apps/papra-server/src/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<Config>);
|
||||
|
||||
expect(getPublicConfig({ config })).to.eql({
|
||||
@@ -72,6 +76,9 @@ describe('config models', () => {
|
||||
documentsStorage: {
|
||||
maxUploadSize: 10485760,
|
||||
},
|
||||
organizations: {
|
||||
deletedOrganizationsPurgeDaysDelay: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export function getPublicConfig({ config }: { config: Config }) {
|
||||
'documents.deletedDocumentsRetentionDays',
|
||||
'documentsStorage.maxUploadSize',
|
||||
'intakeEmails.isEnabled',
|
||||
'organizations.deletedOrganizationsPurgeDaysDelay',
|
||||
]),
|
||||
{
|
||||
auth: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
29
pnpm-lock.yaml
generated
29
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user