feat(organizations): soft delete organizations with recovery (#542)

This commit is contained in:
Corentin Thomasset
2025-10-11 18:21:55 +02:00
committed by GitHub
parent 60982da847
commit c434d873bc
48 changed files with 3745 additions and 73 deletions

View File

@@ -0,0 +1,6 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
---
Added soft deletion with grace period for organizations

3
.gitignore vendored
View File

@@ -43,4 +43,5 @@ ingestion
.cursorrules
*.traineddata
.eslintcache
.eslintcache
.claude

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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',
};

View File

@@ -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;

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -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ć',
};

View File

@@ -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',
};

View File

@@ -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',
};

View File

@@ -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ă ștergi 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',
};

View File

@@ -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'>;

View File

@@ -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;

View File

@@ -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',
});
}

View File

@@ -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 = {

View File

@@ -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>
);
};

View File

@@ -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) {

View File

@@ -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')}

View File

@@ -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>

View File

@@ -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) } : {}),
};
}

View File

@@ -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: () => {},

View File

@@ -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) => {

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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 );

View File

@@ -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,
];

View File

@@ -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'),

View File

@@ -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,
},
},
});
});

View File

@@ -15,6 +15,7 @@ export function getPublicConfig({ config }: { config: Config }) {
'documents.deletedDocumentsRetentionDays',
'documentsStorage.maxUploadSize',
'intakeEmails.isEnabled',
'organizations.deletedOrganizationsPurgeDaysDelay',
]),
{
auth: {

View File

@@ -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)

View File

@@ -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),

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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);
});
});
});

View File

@@ -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),
};
}

View File

@@ -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);
},
);
}

View File

@@ -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 }),

View File

@@ -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);
});
});
});
});

View File

@@ -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 };
}

View File

@@ -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');
}

View File

@@ -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' }),
};
}

View File

@@ -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;

View File

@@ -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
View File

@@ -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