Browse Source

Teams (#920)

* Started with teams.

* Fixes and tests.

* Update tests.

* More fixes.

* More test fixes.

* Consistent command usage.

* Started with frontend.

* More progress.

* More UI

* Fix tests.

* More tests and texts.

* Fix tests.
pull/921/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
49d61485e7
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .gitignore
  2. 28
      backend/i18n/frontend_en.json
  3. 22
      backend/i18n/frontend_it.json
  4. 22
      backend/i18n/frontend_nl.json
  5. 22
      backend/i18n/frontend_zh.json
  6. 15
      backend/i18n/source/backend_en.json
  7. 28
      backend/i18n/source/frontend_en.json
  8. 4
      backend/src/Squidex.Domain.Apps.Core.Model/AssignedPlan.cs
  9. 18
      backend/src/Squidex.Domain.Apps.Core.Model/Contributors.cs
  10. 2
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
  12. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs
  13. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs
  14. 49
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs
  15. 27
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs
  16. 38
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamEntity.cs
  17. 61
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamRepository.cs
  18. 45
      backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs
  19. 45
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
  20. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs
  21. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddRole.cs
  22. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs
  23. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs
  24. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs
  25. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs
  26. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureAssetScripts.cs
  27. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs
  28. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteApp.cs
  29. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteRole.cs
  30. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs
  31. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs
  32. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs
  33. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs
  34. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs
  35. 5
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/TransferToTeam.cs
  36. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs
  37. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateAppSettings.cs
  38. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs
  39. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs
  40. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateRole.cs
  41. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs
  42. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs
  43. 30
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/_AppCommand.cs
  44. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs
  45. 30
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs
  46. 87
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs
  47. 36
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardApp.cs
  48. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs
  49. 10
      backend/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
  50. 20
      backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs
  51. 3
      backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs
  52. 96
      backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs
  53. 65
      backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
  54. 43
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppLimitsPlan.cs
  55. 107
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs
  56. 36
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppLimitsPlan.cs
  57. 26
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlansProvider.cs
  58. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs
  59. 107
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs
  60. 3
      backend/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs
  61. 57
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
  62. 30
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
  63. 16
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetCommand.cs
  64. 16
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetFolderCommand.cs
  65. 5
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.State.cs
  66. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs
  67. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.State.cs
  68. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs
  69. 12
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs
  70. 82
      backend/src/Squidex.Domain.Apps.Entities/Billing/ConfigPlansProvider.cs
  71. 40
      backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs
  72. 13
      backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs
  73. 22
      backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingPlans.cs
  74. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs
  75. 22
      backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs
  76. 38
      backend/src/Squidex.Domain.Apps.Entities/Billing/Plan.cs
  77. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/PlanChangedResult.cs
  78. 315
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs
  79. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs
  80. 2
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs
  81. 2
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs
  82. 35
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs
  83. 2
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs
  84. 19
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/_ContentCommand.cs
  85. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
  86. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
  87. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
  88. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  89. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
  90. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs
  91. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
  92. 14
      backend/src/Squidex.Domain.Apps.Entities/Context.cs
  93. 2
      backend/src/Squidex.Domain.Apps.Entities/History/HistoryEvent.cs
  94. 65
      backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
  95. 2
      backend/src/Squidex.Domain.Apps.Entities/History/IHistoryService.cs
  96. 82
      backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs
  97. 10
      backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs
  98. 8
      backend/src/Squidex.Domain.Apps.Entities/ITeamCommand.cs
  99. 131
      backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs
  100. 90
      backend/src/Squidex.Domain.Apps.Entities/Invitation/InviteUserCommandMiddleware.cs

1
.gitignore

@ -3,6 +3,7 @@
*.suo *.suo
*.user *.user
*.vs *.vs
*.received.txt
.angular .angular
.awCache .awCache

28
backend/i18n/frontend_en.json

@ -6,12 +6,14 @@
"api.pageTitle": "API", "api.pageTitle": "API",
"api.title": "API", "api.title": "API",
"apps.allApps": "All Apps", "apps.allApps": "All Apps",
"apps.allTeams": "All Teams",
"apps.appLoadFailed": "Failed to load app. Please reload.", "apps.appLoadFailed": "Failed to load app. Please reload.",
"apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.", "apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.",
"apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.", "apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.",
"apps.appNameWarning": "The app name cannot be changed later.", "apps.appNameWarning": "The app name cannot be changed later.",
"apps.appsButtonCreate": "Apps Overview", "apps.appsButtonCreate": "Create App",
"apps.appsButtonFallbackTitle": "Apps Overview", "apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Apps and Teams",
"apps.archiveFailed": "Failed to archive app.", "apps.archiveFailed": "Failed to archive app.",
"apps.create": "Create App", "apps.create": "Create App",
"apps.createBlankApp": "New App", "apps.createBlankApp": "New App",
@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Remove image", "apps.removeImage": "Remove image",
"apps.removeImageFailed": "Failed to remove app image. Please reload.", "apps.removeImageFailed": "Failed to remove app image. Please reload.",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",
"apps.transferWarning": "Teams are used to share subscriptions.",
"apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.", "apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.",
"apps.updateFailed": "Failed to update app. Please reload.", "apps.updateFailed": "Failed to update app. Please reload.",
"apps.updateSettingsFailed": "Failed to update UI settings. Please reload.", "apps.updateSettingsFailed": "Failed to update UI settings. Please reload.",
@ -378,6 +384,7 @@
"common.tagAddSchema": ", to add schema", "common.tagAddSchema": ", to add schema",
"common.tags": "Tags", "common.tags": "Tags",
"common.tagsAll": "All tags", "common.tagsAll": "All tags",
"common.teams": "Teams",
"common.templates": "Templates", "common.templates": "Templates",
"common.time": "Time", "common.time": "Time",
"common.to": "To", "common.to": "To",
@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "API Documentation", "dashboard.apiDocumentationCard": "API Documentation",
"dashboard.apiPerformanceCard": "API Performance (ms): {summary}ms avg", "dashboard.apiPerformanceCard": "API Performance (ms): {summary}ms avg",
"dashboard.apiPerformanceChart": "API Performance Chart", "dashboard.apiPerformanceChart": "API Performance Chart",
"dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "Assets Size (MB", "dashboard.assetSizeCard": "Assets Size (MB",
"dashboard.assetSizeLabel": "Total Size", "dashboard.assetSizeLabel": "Total Size",
"dashboard.assetSizeLimitLabel": "Total limit", "dashboard.assetSizeLimitLabel": "Total limit",
@ -564,7 +572,8 @@
"dashboard.trafficHeader": "Traffic (MB)", "dashboard.trafficHeader": "Traffic (MB)",
"dashboard.trafficLimitLabel": "Monthly limit", "dashboard.trafficLimitLabel": "Monthly limit",
"dashboard.trafficSummaryCard": "API Traffic Summary", "dashboard.trafficSummaryCard": "API Traffic Summary",
"dashboard.welcomeText": "Welcome to **{app}** dashboard.", "dashboard.welcomeText": "Welcome to App **{app}**.",
"dashboard.welcomeTextTeam": "Welcome to Team **{team}**.",
"dashboard.welcomeTitle": "Hi {user}", "dashboard.welcomeTitle": "Hi {user}",
"eventConsumers.count": "Count", "eventConsumers.count": "Count",
"eventConsumers.loadFailed": "Failed to load event consumers. Please reload.", "eventConsumers.loadFailed": "Failed to load event consumers. Please reload.",
@ -603,6 +612,7 @@
"news.title": "New Features", "news.title": "New Features",
"notifications.empty": "No notifications yet", "notifications.empty": "No notifications yet",
"notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.", "notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.",
"plans.allApps": "The subscription is shared between all apps of this team. Check the dashboard for the assigned apps.",
"plans.billingPortal": "Billing Portal", "plans.billingPortal": "Billing Portal",
"plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.", "plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.",
"plans.change": "Change", "plans.change": "Change",
@ -613,6 +623,7 @@
"plans.includedStorage": "Storage", "plans.includedStorage": "Storage",
"plans.includedTraffic": "Traffic", "plans.includedTraffic": "Traffic",
"plans.loadFailed": "Failed to load plans. Please reload.", "plans.loadFailed": "Failed to load plans. Please reload.",
"plans.managedByTeam": "App has been assigned to a team and therefore the subscription is managed by the team.",
"plans.noPlanConfigured": "No plan configured, this app has unlimited usage.", "plans.noPlanConfigured": "No plan configured, this app has unlimited usage.",
"plans.notPlanOwner": "You have not created the subscription, therefore you cannot change the plan.", "plans.notPlanOwner": "You have not created the subscription, therefore you cannot change the plan.",
"plans.perMonth": "Per Month", "plans.perMonth": "Per Month",
@ -979,6 +990,17 @@
"start.loginHint": "The login button will open a new popup. Once you are logged in successful we will redirect you to the Squidex management portal.", "start.loginHint": "The login button will open a new popup. Once you are logged in successful we will redirect you to the Squidex management portal.",
"start.madeBy": "Proudly made by", "start.madeBy": "Proudly made by",
"start.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2021", "start.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2021",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",
"teams.leaveConfirmTitle": "Leave team.",
"teams.leaveFailed": "Failed to leavew team. Please reload.",
"teams.loadFailed": "Failed to load teams. Please reload.",
"teams.teamLoadFailed": "Failed to load team. Please reload.",
"teams.teamNameHint": "You can use all characters here.",
"teams.teamNameWarning": "The team name is only used as display name and can be changed later.",
"teams.updateFailed": "Failed to update team. Please reload.",
"templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.", "templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.",
"templates.loadFailed": "Failed to load templates. Please reload.", "templates.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates", "templates.refreshTooltip": "Refresh Templates",

22
backend/i18n/frontend_it.json

@ -6,11 +6,13 @@
"api.pageTitle": "API", "api.pageTitle": "API",
"api.title": "API", "api.title": "API",
"apps.allApps": "Tutte le Apps", "apps.allApps": "Tutte le Apps",
"apps.allTeams": "All Teams",
"apps.appLoadFailed": "Non è stato possibile caricare l'App. Per favore ricarica.", "apps.appLoadFailed": "Non è stato possibile caricare l'App. Per favore ricarica.",
"apps.appNameHint": "Puoi utilizzare solo lettere, numeri e trattini e non più di 40 caratteri.", "apps.appNameHint": "Puoi utilizzare solo lettere, numeri e trattini e non più di 40 caratteri.",
"apps.appNameValidationMessage": "Il nome può contenere lettere minuscole (a-z), numeri e trattini all'interno.", "apps.appNameValidationMessage": "Il nome può contenere lettere minuscole (a-z), numeri e trattini all'interno.",
"apps.appNameWarning": "Il nome della app non potrà essere cambiato in un secondo momento.", "apps.appNameWarning": "Il nome della app non potrà essere cambiato in un secondo momento.",
"apps.appsButtonCreate": "Nuova App", "apps.appsButtonCreate": "Nuova App",
"apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Lista App", "apps.appsButtonFallbackTitle": "Lista App",
"apps.archiveFailed": "Failed to archive app.", "apps.archiveFailed": "Failed to archive app.",
"apps.create": "Crea un'App", "apps.create": "Crea un'App",
@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Rimuovi l'immagine", "apps.removeImage": "Rimuovi l'immagine",
"apps.removeImageFailed": "Non è stato possibile rimuovere l'immagine dell'app. Per favore ricarica.", "apps.removeImageFailed": "Non è stato possibile rimuovere l'immagine dell'app. Per favore ricarica.",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",
"apps.transferWarning": "Teams are used to share subscriptions.",
"apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.", "apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.",
"apps.updateFailed": "Non è stato possibile aggiornare l'app. Per favore ricarica.", "apps.updateFailed": "Non è stato possibile aggiornare l'app. Per favore ricarica.",
"apps.updateSettingsFailed": "Failed to update UI settings. Please reload.", "apps.updateSettingsFailed": "Failed to update UI settings. Please reload.",
@ -378,6 +384,7 @@
"common.tagAddSchema": ", aggiungi schema", "common.tagAddSchema": ", aggiungi schema",
"common.tags": "Tag", "common.tags": "Tag",
"common.tagsAll": "Tutti i tag", "common.tagsAll": "Tutti i tag",
"common.teams": "Teams",
"common.templates": "Templates", "common.templates": "Templates",
"common.time": "Ora", "common.time": "Ora",
"common.to": "To", "common.to": "To",
@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "Documentazione delle API", "dashboard.apiDocumentationCard": "Documentazione delle API",
"dashboard.apiPerformanceCard": "Performance(ms) delle API: {summary}ms avg", "dashboard.apiPerformanceCard": "Performance(ms) delle API: {summary}ms avg",
"dashboard.apiPerformanceChart": "Diagramma delle Performance delle API", "dashboard.apiPerformanceChart": "Diagramma delle Performance delle API",
"dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "Dimensione delle risorse (MB", "dashboard.assetSizeCard": "Dimensione delle risorse (MB",
"dashboard.assetSizeLabel": "Dimensione totale", "dashboard.assetSizeLabel": "Dimensione totale",
"dashboard.assetSizeLimitLabel": "Limite totale", "dashboard.assetSizeLimitLabel": "Limite totale",
@ -565,6 +573,7 @@
"dashboard.trafficLimitLabel": "Limite mensile", "dashboard.trafficLimitLabel": "Limite mensile",
"dashboard.trafficSummaryCard": "Riepilogo del traffico delle API", "dashboard.trafficSummaryCard": "Riepilogo del traffico delle API",
"dashboard.welcomeText": "Benvenuto sulla dashboard **{app}**.", "dashboard.welcomeText": "Benvenuto sulla dashboard **{app}**.",
"dashboard.welcomeTextTeam": "Welcome to Team **{team}**.",
"dashboard.welcomeTitle": "Ciao {user}", "dashboard.welcomeTitle": "Ciao {user}",
"eventConsumers.count": "Conteggio", "eventConsumers.count": "Conteggio",
"eventConsumers.loadFailed": "Non è stato possibile caricare event consumers. Per favore ricarica.", "eventConsumers.loadFailed": "Non è stato possibile caricare event consumers. Per favore ricarica.",
@ -603,6 +612,7 @@
"news.title": "Nuove funzionalità", "news.title": "Nuove funzionalità",
"notifications.empty": "No notifications yet", "notifications.empty": "No notifications yet",
"notifo.subscripeTooltip": "Fai clic su questo pulsante per iscriverti a tutte le modifiche e ricevere le notifiche push.", "notifo.subscripeTooltip": "Fai clic su questo pulsante per iscriverti a tutte le modifiche e ricevere le notifiche push.",
"plans.allApps": "The subscription is shared between all apps of this team. Check the dashboard for the assigned apps.",
"plans.billingPortal": "Portale di fatturazione", "plans.billingPortal": "Portale di fatturazione",
"plans.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.", "plans.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.",
"plans.change": "Cambia", "plans.change": "Cambia",
@ -613,6 +623,7 @@
"plans.includedStorage": "Spazio disco", "plans.includedStorage": "Spazio disco",
"plans.includedTraffic": "Traffico", "plans.includedTraffic": "Traffico",
"plans.loadFailed": "Non è stato possibile caricare i piani. Per favore ricarica.", "plans.loadFailed": "Non è stato possibile caricare i piani. Per favore ricarica.",
"plans.managedByTeam": "App has been assigned to a team and therefore the subscription is managed by the team.",
"plans.noPlanConfigured": "Nessun piano è stato impostato, quest'app ha un uso illimitato dello spazio su disco.", "plans.noPlanConfigured": "Nessun piano è stato impostato, quest'app ha un uso illimitato dello spazio su disco.",
"plans.notPlanOwner": "Non hai creato nessun abbonamento, pertanto non è possibile cambiare il piano.", "plans.notPlanOwner": "Non hai creato nessun abbonamento, pertanto non è possibile cambiare il piano.",
"plans.perMonth": "Al mese", "plans.perMonth": "Al mese",
@ -979,6 +990,17 @@
"start.loginHint": "Il pulsante per accedere aprirà un popup. Una volta effettuato l'accesso sarai indirizzato al portale per la gestione di Squidex.", "start.loginHint": "Il pulsante per accedere aprirà un popup. Una volta effettuato l'accesso sarai indirizzato al portale per la gestione di Squidex.",
"start.madeBy": "Realizzato con orgoglio da", "start.madeBy": "Realizzato con orgoglio da",
"start.madeByCopyright": "Sebastian Stehle e Collaboratori, 2016-2020", "start.madeByCopyright": "Sebastian Stehle e Collaboratori, 2016-2020",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",
"teams.leaveConfirmTitle": "Leave team.",
"teams.leaveFailed": "Failed to leavew team. Please reload.",
"teams.loadFailed": "Failed to load teams. Please reload.",
"teams.teamLoadFailed": "Failed to load team. Please reload.",
"teams.teamNameHint": "You can use all characters here.",
"teams.teamNameWarning": "The team name is only used as display name and can be changed later.",
"teams.updateFailed": "Failed to update team. Please reload.",
"templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.", "templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.",
"templates.loadFailed": "Failed to load templates. Please reload.", "templates.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates", "templates.refreshTooltip": "Refresh Templates",

22
backend/i18n/frontend_nl.json

@ -6,11 +6,13 @@
"api.pageTitle": "API", "api.pageTitle": "API",
"api.title": "API", "api.title": "API",
"apps.allApps": "Alle apps", "apps.allApps": "Alle apps",
"apps.allTeams": "All Teams",
"apps.appLoadFailed": "Kan app niet laden. Laad opnieuw.", "apps.appLoadFailed": "Kan app niet laden. Laad opnieuw.",
"apps.appNameHint": "Je kunt alleen letters, cijfers en streepjes gebruiken en niet meer dan 40 tekens.", "apps.appNameHint": "Je kunt alleen letters, cijfers en streepjes gebruiken en niet meer dan 40 tekens.",
"apps.appNameValidationMessage": "Naam mag kleine letters (a-z), cijfers en streepjes tussen bevatten.", "apps.appNameValidationMessage": "Naam mag kleine letters (a-z), cijfers en streepjes tussen bevatten.",
"apps.appNameWarning": "De app-naam kan later niet worden gewijzigd.", "apps.appNameWarning": "De app-naam kan later niet worden gewijzigd.",
"apps.appsButtonCreate": "Apps-overzicht", "apps.appsButtonCreate": "Apps-overzicht",
"apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Apps-overzicht", "apps.appsButtonFallbackTitle": "Apps-overzicht",
"apps.archiveFailed": "Failed to archive app.", "apps.archiveFailed": "Failed to archive app.",
"apps.create": "App maken", "apps.create": "App maken",
@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Afbeelding verwijderen", "apps.removeImage": "Afbeelding verwijderen",
"apps.removeImageFailed": "Verwijderen van app-afbeelding is mislukt. Laad opnieuw.", "apps.removeImageFailed": "Verwijderen van app-afbeelding is mislukt. Laad opnieuw.",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",
"apps.transferWarning": "Teams are used to share subscriptions.",
"apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.", "apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.",
"apps.updateFailed": "Update app mislukt. Laad opnieuw.", "apps.updateFailed": "Update app mislukt. Laad opnieuw.",
"apps.updateSettingsFailed": "Failed to update UI settings. Please reload.", "apps.updateSettingsFailed": "Failed to update UI settings. Please reload.",
@ -378,6 +384,7 @@
"common.tagAddSchema": ", om schema toe te voegen", "common.tagAddSchema": ", om schema toe te voegen",
"common.tags": "Tags", "common.tags": "Tags",
"common.tagsAll": "Alle tags", "common.tagsAll": "Alle tags",
"common.teams": "Teams",
"common.templates": "Templates", "common.templates": "Templates",
"common.time": "Tijd", "common.time": "Tijd",
"common.to": "Naar", "common.to": "Naar",
@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "API-documentatie", "dashboard.apiDocumentationCard": "API-documentatie",
"dashboard.apiPerformanceCard": "API-prestaties (ms): {summary} ms gem.", "dashboard.apiPerformanceCard": "API-prestaties (ms): {summary} ms gem.",
"dashboard.apiPerformanceChart": "API-prestatiegrafiek", "dashboard.apiPerformanceChart": "API-prestatiegrafiek",
"dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "Grootte van bestand (MB", "dashboard.assetSizeCard": "Grootte van bestand (MB",
"dashboard.assetSizeLabel": "Totale grootte", "dashboard.assetSizeLabel": "Totale grootte",
"dashboard.assetSizeLimitLabel": "Totale limiet", "dashboard.assetSizeLimitLabel": "Totale limiet",
@ -565,6 +573,7 @@
"dashboard.trafficLimitLabel": "Maandelijks limiet", "dashboard.trafficLimitLabel": "Maandelijks limiet",
"dashboard.trafficSummaryCard": "API Verkeer Samenvatting", "dashboard.trafficSummaryCard": "API Verkeer Samenvatting",
"dashboard.welcomeText": "Welkom bij **{app}** dashboard.", "dashboard.welcomeText": "Welkom bij **{app}** dashboard.",
"dashboard.welcomeTextTeam": "Welcome to Team **{team}**.",
"dashboard.welcomeTitle": "Hallo {user}", "dashboard.welcomeTitle": "Hallo {user}",
"eventConsumers.count": "Tellen", "eventConsumers.count": "Tellen",
"eventConsumers.loadFailed": "Kan gebeurtenisgebruikers niet laden. Laad opnieuw.", "eventConsumers.loadFailed": "Kan gebeurtenisgebruikers niet laden. Laad opnieuw.",
@ -603,6 +612,7 @@
"news.title": "Nieuwe functies", "news.title": "Nieuwe functies",
"notifications.empty": "Nog geen meldingen", "notifications.empty": "Nog geen meldingen",
"notifo.subscripeTooltip": "Klik op deze knop om je te abonneren op alle wijzigingen en om pushmeldingen te ontvangen.", "notifo.subscripeTooltip": "Klik op deze knop om je te abonneren op alle wijzigingen en om pushmeldingen te ontvangen.",
"plans.allApps": "The subscription is shared between all apps of this team. Check the dashboard for the assigned apps.",
"plans.billingPortal": "Factureringsportal", "plans.billingPortal": "Factureringsportal",
"plans.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.", "plans.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.",
"plans.change": "Wijzigen", "plans.change": "Wijzigen",
@ -613,6 +623,7 @@
"plans.includedStorage": "Opslag", "plans.includedStorage": "Opslag",
"plans.includedTraffic": "Verkeer", "plans.includedTraffic": "Verkeer",
"plans.loadFailed": "Laden van plannen is mislukt. Laad opnieuw.", "plans.loadFailed": "Laden van plannen is mislukt. Laad opnieuw.",
"plans.managedByTeam": "App has been assigned to a team and therefore the subscription is managed by the team.",
"plans.noPlanConfigured": "Geen plan geconfigureerd, deze app heeft onbeperkt gebruik.", "plans.noPlanConfigured": "Geen plan geconfigureerd, deze app heeft onbeperkt gebruik.",
"plans.notPlanOwner": "Je hebt geen abonnement aangemaakt. Daarom kun je het plan niet wijzigen.", "plans.notPlanOwner": "Je hebt geen abonnement aangemaakt. Daarom kun je het plan niet wijzigen.",
"plans.perMonth": "Per maand", "plans.perMonth": "Per maand",
@ -979,6 +990,17 @@
"start.loginHint": "De login-knop opent een nieuwe pop-up. Zodra je succesvol bent ingelogd, zullen we je doorverwijzen naar het Squidex beheerportaal.", "start.loginHint": "De login-knop opent een nieuwe pop-up. Zodra je succesvol bent ingelogd, zullen we je doorverwijzen naar het Squidex beheerportaal.",
"start.madeBy": "Met trots gemaakt door", "start.madeBy": "Met trots gemaakt door",
"start.madeByCopyright": "Sebastian Stehle en medewerkers, 2016-2020", "start.madeByCopyright": "Sebastian Stehle en medewerkers, 2016-2020",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",
"teams.leaveConfirmTitle": "Leave team.",
"teams.leaveFailed": "Failed to leavew team. Please reload.",
"teams.loadFailed": "Failed to load teams. Please reload.",
"teams.teamLoadFailed": "Failed to load team. Please reload.",
"teams.teamNameHint": "You can use all characters here.",
"teams.teamNameWarning": "The team name is only used as display name and can be changed later.",
"teams.updateFailed": "Failed to update team. Please reload.",
"templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.", "templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.",
"templates.loadFailed": "Failed to load templates. Please reload.", "templates.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates", "templates.refreshTooltip": "Refresh Templates",

22
backend/i18n/frontend_zh.json

@ -6,11 +6,13 @@
"api.pageTitle": "API", "api.pageTitle": "API",
"api.title": "API", "api.title": "API",
"apps.allApps": "所有应用程序", "apps.allApps": "所有应用程序",
"apps.allTeams": "All Teams",
"apps.appLoadFailed": "加载应用失败。请重新加载。", "apps.appLoadFailed": "加载应用失败。请重新加载。",
"apps.appNameHint": "您只能使用字母、数字和破折号,并且不能超过 40 个字符。", "apps.appNameHint": "您只能使用字母、数字和破折号,并且不能超过 40 个字符。",
"apps.appNameValidationMessage": "名称可以包含小写字母 (a-z)、数字和破折号。", "apps.appNameValidationMessage": "名称可以包含小写字母 (a-z)、数字和破折号。",
"apps.appNameWarning": "以后不能更改应用名称。", "apps.appNameWarning": "以后不能更改应用名称。",
"apps.appsButtonCreate": "应用概览", "apps.appsButtonCreate": "应用概览",
"apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "应用概览", "apps.appsButtonFallbackTitle": "应用概览",
"apps.archiveFailed": "Failed to archive app.", "apps.archiveFailed": "Failed to archive app.",
"apps.create": "创建应用程序", "apps.create": "创建应用程序",
@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "更新界面设置失败。请重新加载。", "apps.loadSettingsFailed": "更新界面设置失败。请重新加载。",
"apps.removeImage": "删除图片", "apps.removeImage": "删除图片",
"apps.removeImageFailed": "删除应用图片失败。请重新加载。", "apps.removeImageFailed": "删除应用图片失败。请重新加载。",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",
"apps.transferWarning": "Teams are used to share subscriptions.",
"apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.", "apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.",
"apps.updateFailed": "更新应用失败。请重新加载。", "apps.updateFailed": "更新应用失败。请重新加载。",
"apps.updateSettingsFailed": "更新界面设置失败。请重新加载。", "apps.updateSettingsFailed": "更新界面设置失败。请重新加载。",
@ -378,6 +384,7 @@
"common.tagAddSchema": ", 添加Schemas", "common.tagAddSchema": ", 添加Schemas",
"common.tags": "标签", "common.tags": "标签",
"common.tagsAll": "所有标签", "common.tagsAll": "所有标签",
"common.teams": "Teams",
"common.templates": "Templates", "common.templates": "Templates",
"common.time": "时间", "common.time": "时间",
"common.to": "To", "common.to": "To",
@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "API 文档", "dashboard.apiDocumentationCard": "API 文档",
"dashboard.apiPerformanceCard": "API 性能 (ms): {summary}ms avg", "dashboard.apiPerformanceCard": "API 性能 (ms): {summary}ms avg",
"dashboard.apiPerformanceChart": "API 性能图表", "dashboard.apiPerformanceChart": "API 性能图表",
"dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "资源大小 (MB", "dashboard.assetSizeCard": "资源大小 (MB",
"dashboard.assetSizeLabel": "总大小", "dashboard.assetSizeLabel": "总大小",
"dashboard.assetSizeLimitLabel": "总限制", "dashboard.assetSizeLimitLabel": "总限制",
@ -565,6 +573,7 @@
"dashboard.trafficLimitLabel": "每月限制", "dashboard.trafficLimitLabel": "每月限制",
"dashboard.trafficSummaryCard": "API 流量汇总", "dashboard.trafficSummaryCard": "API 流量汇总",
"dashboard.welcomeText": "欢迎使用 **{app}** 仪表板。", "dashboard.welcomeText": "欢迎使用 **{app}** 仪表板。",
"dashboard.welcomeTextTeam": "Welcome to Team **{team}**.",
"dashboard.welcomeTitle": "Hi {user}", "dashboard.welcomeTitle": "Hi {user}",
"eventConsumers.count": "计数", "eventConsumers.count": "计数",
"eventConsumers.loadFailed": "加载事件消费者失败。请重新加载。", "eventConsumers.loadFailed": "加载事件消费者失败。请重新加载。",
@ -603,6 +612,7 @@
"news.title": "新功能", "news.title": "新功能",
"notifications.empty": "No notifications yet", "notifications.empty": "No notifications yet",
"notifo.subscripeTooltip": "单击此按钮可订阅所有更改并接收推送通知。", "notifo.subscripeTooltip": "单击此按钮可订阅所有更改并接收推送通知。",
"plans.allApps": "The subscription is shared between all apps of this team. Check the dashboard for the assigned apps.",
"plans.billingPortal": "计费门户", "plans.billingPortal": "计费门户",
"plans.billingPortalHint": "前往账单门户查看付款历史和订阅概览。", "plans.billingPortalHint": "前往账单门户查看付款历史和订阅概览。",
"plans.change": "改变", "plans.change": "改变",
@ -613,6 +623,7 @@
"plans.includedStorage": "存储", "plans.includedStorage": "存储",
"plans.includedTraffic": "交通", "plans.includedTraffic": "交通",
"plans.loadFailed": "加载计划失败。请重新加载。", "plans.loadFailed": "加载计划失败。请重新加载。",
"plans.managedByTeam": "App has been assigned to a team and therefore the subscription is managed by the team.",
"plans.noPlanConfigured": "未配置计划,此应用无限制使用。", "plans.noPlanConfigured": "未配置计划,此应用无限制使用。",
"plans.notPlanOwner": "您尚未创建订阅。因此您无法更改计划。", "plans.notPlanOwner": "您尚未创建订阅。因此您无法更改计划。",
"plans.perMonth": "每月", "plans.perMonth": "每月",
@ -979,6 +990,17 @@
"start.loginHint": "登录按钮将打开一个新的弹出窗口。一旦您登录成功,我们会将您重定向到 Squidex 管理门户。", "start.loginHint": "登录按钮将打开一个新的弹出窗口。一旦您登录成功,我们会将您重定向到 Squidex 管理门户。",
"start.madeBy": "自豪地制作", "start.madeBy": "自豪地制作",
"start.madeByCopyright": "Sebastian Stehle 和贡献者,2016-2021", "start.madeByCopyright": "Sebastian Stehle 和贡献者,2016-2021",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",
"teams.leaveConfirmTitle": "Leave team.",
"teams.leaveFailed": "Failed to leavew team. Please reload.",
"teams.loadFailed": "Failed to load teams. Please reload.",
"teams.teamLoadFailed": "Failed to load team. Please reload.",
"teams.teamNameHint": "You can use all characters here.",
"teams.teamNameWarning": "The team name is only used as display name and can be changed later.",
"teams.updateFailed": "Failed to update team. Please reload.",
"templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.", "templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.",
"templates.loadFailed": "Failed to load templates. Please reload.", "templates.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates", "templates.refreshTooltip": "Refresh Templates",

15
backend/i18n/source/backend_en.json

@ -19,6 +19,7 @@
"apps.maximumTotalReached": "You cannot create more apps. Please contact the support to remove this restriction from your account.", "apps.maximumTotalReached": "You cannot create more apps. Please contact the support to remove this restriction from your account.",
"apps.nameAlreadyExists": "An app with the same name already exists.", "apps.nameAlreadyExists": "An app with the same name already exists.",
"apps.notImage": "File is not an image", "apps.notImage": "File is not an image",
"apps.plans.assignedToTeam": "Plan is managed by the team.",
"apps.plans.notFound": "A plan with this id does not exist.", "apps.plans.notFound": "A plan with this id does not exist.",
"apps.plans.notPlanOwner": "Plan can only changed from the user who configured the plan initially.", "apps.plans.notPlanOwner": "Plan can only changed from the user who configured the plan initially.",
"apps.roles.defaultRoleNotRemovable": "Cannot delete a default role.", "apps.roles.defaultRoleNotRemovable": "Cannot delete a default role.",
@ -26,6 +27,8 @@
"apps.roles.nameAlreadyExists": "A role with the same name already exists.", "apps.roles.nameAlreadyExists": "A role with the same name already exists.",
"apps.roles.usedRoleByClientsNotRemovable": "Cannot remove a role when a client is assigned.", "apps.roles.usedRoleByClientsNotRemovable": "Cannot remove a role when a client is assigned.",
"apps.roles.usedRoleByContributorsNotRemovable": "Cannot remove a role when a contributor is assigned.", "apps.roles.usedRoleByContributorsNotRemovable": "Cannot remove a role when a contributor is assigned.",
"apps.transfer.planAssigned": "Subscription must be cancelled first before the app can be transfered.",
"apps.transfer.teamNotFound": "The team does not exist.",
"assets.folderNotFound": "Asset folder does not exist.", "assets.folderNotFound": "Asset folder does not exist.",
"assets.folderRecursion": "Cannot add folder to its own child.", "assets.folderRecursion": "Cannot add folder to its own child.",
"assets.maxSizeReached": "You have reached your max asset size.", "assets.maxSizeReached": "You have reached your max asset size.",
@ -208,11 +211,14 @@
"exceptions.domainObjectDeleted": "Entity ({id}) has been deleted.", "exceptions.domainObjectDeleted": "Entity ({id}) has been deleted.",
"exceptions.domainObjectNotFound": "Entity ({id}) does not exist.", "exceptions.domainObjectNotFound": "Entity ({id}) does not exist.",
"exceptions.domainObjectVersion": "Entity ({id}) requested version {expectedVersion}, but found {currentVersion}.", "exceptions.domainObjectVersion": "Entity ({id}) requested version {expectedVersion}, but found {currentVersion}.",
"history.apps.assetScriptsConfigured": "updated asset scripts",
"history.apps.clientAdded": "added client {[Id]} to app", "history.apps.clientAdded": "added client {[Id]} to app",
"history.apps.clientRevoked": "revoked client {[Id]}", "history.apps.clientRevoked": "revoked client {[Id]}",
"history.apps.clientUpdated": "updated client {[Id]}", "history.apps.clientUpdated": "updated client {[Id]}",
"history.apps.contributoreAssigned": "assigned {user:[Contributor]} as {[Role]}", "history.apps.contributoreAssigned": "assigned {user:[Contributor]} as {[Role]}",
"history.apps.contributoreRemoved": "removed {user:[Contributor]} from app", "history.apps.contributoreRemoved": "removed {user:[Contributor]} from app",
"history.apps.imageRemoved": "removed app image",
"history.apps.imageUploaded": "uploaded a new app image",
"history.apps.languagedAdded": "added language {[Language]}", "history.apps.languagedAdded": "added language {[Language]}",
"history.apps.languagedRemoved": "removed language {[Language]}", "history.apps.languagedRemoved": "removed language {[Language]}",
"history.apps.languagedSetToMaster": "changed master language to {[Language]}", "history.apps.languagedSetToMaster": "changed master language to {[Language]}",
@ -223,6 +229,8 @@
"history.apps.roleDeleted": "deleted role {[Name]}", "history.apps.roleDeleted": "deleted role {[Name]}",
"history.apps.roleUpdated": "updated role {[Name]}", "history.apps.roleUpdated": "updated role {[Name]}",
"history.apps.settingsUpdated": "updated UI settings", "history.apps.settingsUpdated": "updated UI settings",
"history.apps.transfered": "updated app to client",
"history.apps.updated": "updated general settings",
"history.assets.replaced": "replaced asset.", "history.assets.replaced": "replaced asset.",
"history.assets.updated": "updated asset.", "history.assets.updated": "updated asset.",
"history.assets.uploaded": "uploaded asset.", "history.assets.uploaded": "uploaded asset.",
@ -248,6 +256,11 @@
"history.schemas.unpublished": "unpublished schema {[Name]}.", "history.schemas.unpublished": "unpublished schema {[Name]}.",
"history.schemas.updated": "updated schema {[Name]}.", "history.schemas.updated": "updated schema {[Name]}.",
"history.statusChanged": "changed status of {[Schema]} content to {[Status]}.", "history.statusChanged": "changed status of {[Schema]} content to {[Status]}.",
"history.teams.contributoreAssigned": "assigned {user:[Contributor]} as {[Role]}",
"history.teams.contributoreRemoved": "removed {user:[Contributor]} from team",
"history.teams.planChanged": "changed plan to {[Plan]}",
"history.teams.planReset": "resetted plan",
"history.teams.updated": "updated general settings",
"login.githubPrivateEmail": "Your email address is set to private in Github. Please set it to public to use Github login.", "login.githubPrivateEmail": "Your email address is set to private in Github. Please set it to public to use Github login.",
"rules.ruleAlreadyRunning": "Another rule is already running.", "rules.ruleAlreadyRunning": "Another rule is already running.",
"schemas.dateTimeCalculatedDefaultAndDefaultError": "Calculated default value and default value cannot be used together.", "schemas.dateTimeCalculatedDefaultAndDefaultError": "Calculated default value and default value cannot be used together.",
@ -293,6 +306,8 @@
"setup.ruleHttps.failure": " You are not accessing the site over https. If this warning is not correct then Squidex cannot detect https mode, because your instance is behind a reverse proxy such as nginx. Ensure that http headers are forwarded properly, via the <code>X-Forwarded-*</code> headers.", "setup.ruleHttps.failure": " You are not accessing the site over https. If this warning is not correct then Squidex cannot detect https mode, because your instance is behind a reverse proxy such as nginx. Ensure that http headers are forwarded properly, via the <code>X-Forwarded-*</code> headers.",
"setup.ruleHttps.success": "Congratulations, you are accessing your Squidex installation over a secure connection (https).", "setup.ruleHttps.success": "Congratulations, you are accessing your Squidex installation over a secure connection (https).",
"setup.rules.headline": "System Checklist", "setup.rules.headline": "System Checklist",
"setup.ruleTeamCreation.warningAdmins": "With your setup, only admins can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=false</code> as environment variable.",
"setup.ruleTeamCreation.warningAll": "With your setup, every user can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=true</code> as environment variable.",
"setup.ruleUrl.failure": "You should access Squidex only over one canonical URL and configure this URL with the <code>URLS__BASEURL</code> environment variable. The current base URL <code>{actual}</code> does not match to the base url <code>{configured}</code>. This variable must point to the public URL under which your Squidex instance is available.", "setup.ruleUrl.failure": "You should access Squidex only over one canonical URL and configure this URL with the <code>URLS__BASEURL</code> environment variable. The current base URL <code>{actual}</code> does not match to the base url <code>{configured}</code>. This variable must point to the public URL under which your Squidex instance is available.",
"setup.ruleUrl.success": "Congratulations, the <code>URLS__BASEURL</code> environment variable is configured properly. This variable must point to the public URL under which your Squidex instance is available.", "setup.ruleUrl.success": "Congratulations, the <code>URLS__BASEURL</code> environment variable is configured properly. This variable must point to the public URL under which your Squidex instance is available.",
"setup.title": "Installation", "setup.title": "Installation",

28
backend/i18n/source/frontend_en.json

@ -6,12 +6,14 @@
"api.pageTitle": "API", "api.pageTitle": "API",
"api.title": "API", "api.title": "API",
"apps.allApps": "All Apps", "apps.allApps": "All Apps",
"apps.allTeams": "All Teams",
"apps.appLoadFailed": "Failed to load app. Please reload.", "apps.appLoadFailed": "Failed to load app. Please reload.",
"apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.", "apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.",
"apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.", "apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.",
"apps.appNameWarning": "The app name cannot be changed later.", "apps.appNameWarning": "The app name cannot be changed later.",
"apps.appsButtonCreate": "Apps Overview", "apps.appsButtonCreate": "Create App",
"apps.appsButtonFallbackTitle": "Apps Overview", "apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Apps and Teams",
"apps.archiveFailed": "Failed to archive app.", "apps.archiveFailed": "Failed to archive app.",
"apps.create": "Create App", "apps.create": "Create App",
"apps.createBlankApp": "New App", "apps.createBlankApp": "New App",
@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.", "apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Remove image", "apps.removeImage": "Remove image",
"apps.removeImageFailed": "Failed to remove app image. Please reload.", "apps.removeImageFailed": "Failed to remove app image. Please reload.",
"apps.transfer": "Transfer",
"apps.transferFailed": "Failed to transfer the app. Please reload.",
"apps.transferTitle": "Transfer to team",
"apps.transferWarning": "Teams are used to share subscriptions.",
"apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.", "apps.updateAssetScriptsFailed": "Failed to update asset scripts. Please reload.",
"apps.updateFailed": "Failed to update app. Please reload.", "apps.updateFailed": "Failed to update app. Please reload.",
"apps.updateSettingsFailed": "Failed to update UI settings. Please reload.", "apps.updateSettingsFailed": "Failed to update UI settings. Please reload.",
@ -378,6 +384,7 @@
"common.tagAddSchema": ", to add schema", "common.tagAddSchema": ", to add schema",
"common.tags": "Tags", "common.tags": "Tags",
"common.tagsAll": "All tags", "common.tagsAll": "All tags",
"common.teams": "Teams",
"common.templates": "Templates", "common.templates": "Templates",
"common.time": "Time", "common.time": "Time",
"common.to": "To", "common.to": "To",
@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "API Documentation", "dashboard.apiDocumentationCard": "API Documentation",
"dashboard.apiPerformanceCard": "API Performance (ms): {summary}ms avg", "dashboard.apiPerformanceCard": "API Performance (ms): {summary}ms avg",
"dashboard.apiPerformanceChart": "API Performance Chart", "dashboard.apiPerformanceChart": "API Performance Chart",
"dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "Assets Size (MB", "dashboard.assetSizeCard": "Assets Size (MB",
"dashboard.assetSizeLabel": "Total Size", "dashboard.assetSizeLabel": "Total Size",
"dashboard.assetSizeLimitLabel": "Total limit", "dashboard.assetSizeLimitLabel": "Total limit",
@ -564,7 +572,8 @@
"dashboard.trafficHeader": "Traffic (MB)", "dashboard.trafficHeader": "Traffic (MB)",
"dashboard.trafficLimitLabel": "Monthly limit", "dashboard.trafficLimitLabel": "Monthly limit",
"dashboard.trafficSummaryCard": "API Traffic Summary", "dashboard.trafficSummaryCard": "API Traffic Summary",
"dashboard.welcomeText": "Welcome to **{app}** dashboard.", "dashboard.welcomeText": "Welcome to App **{app}**.",
"dashboard.welcomeTextTeam": "Welcome to Team **{team}**.",
"dashboard.welcomeTitle": "Hi {user}", "dashboard.welcomeTitle": "Hi {user}",
"eventConsumers.count": "Count", "eventConsumers.count": "Count",
"eventConsumers.loadFailed": "Failed to load event consumers. Please reload.", "eventConsumers.loadFailed": "Failed to load event consumers. Please reload.",
@ -603,6 +612,7 @@
"news.title": "New Features", "news.title": "New Features",
"notifications.empty": "No notifications yet", "notifications.empty": "No notifications yet",
"notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.", "notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.",
"plans.allApps": "The subscription is shared between all apps of this team. Check the dashboard for the assigned apps.",
"plans.billingPortal": "Billing Portal", "plans.billingPortal": "Billing Portal",
"plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.", "plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.",
"plans.change": "Change", "plans.change": "Change",
@ -613,6 +623,7 @@
"plans.includedStorage": "Storage", "plans.includedStorage": "Storage",
"plans.includedTraffic": "Traffic", "plans.includedTraffic": "Traffic",
"plans.loadFailed": "Failed to load plans. Please reload.", "plans.loadFailed": "Failed to load plans. Please reload.",
"plans.managedByTeam": "App has been assigned to a team and therefore the subscription is managed by the team.",
"plans.noPlanConfigured": "No plan configured, this app has unlimited usage.", "plans.noPlanConfigured": "No plan configured, this app has unlimited usage.",
"plans.notPlanOwner": "You have not created the subscription, therefore you cannot change the plan.", "plans.notPlanOwner": "You have not created the subscription, therefore you cannot change the plan.",
"plans.perMonth": "Per Month", "plans.perMonth": "Per Month",
@ -979,6 +990,17 @@
"start.loginHint": "The login button will open a new popup. Once you are logged in successful we will redirect you to the Squidex management portal.", "start.loginHint": "The login button will open a new popup. Once you are logged in successful we will redirect you to the Squidex management portal.",
"start.madeBy": "Proudly made by", "start.madeBy": "Proudly made by",
"start.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2021", "start.madeByCopyright": "Sebastian Stehle and Contributors, 2016-2021",
"teams.create": "Create",
"teams.createFailed": "Failed to create team. Please reload.",
"teams.leave": "Leave team",
"teams.leaveConfirmText": "Do you really want to leave this team?",
"teams.leaveConfirmTitle": "Leave team.",
"teams.leaveFailed": "Failed to leavew team. Please reload.",
"teams.loadFailed": "Failed to load teams. Please reload.",
"teams.teamLoadFailed": "Failed to load team. Please reload.",
"teams.teamNameHint": "You can use all characters here.",
"teams.teamNameWarning": "The team name is only used as display name and can be changed later.",
"teams.updateFailed": "Failed to update team. Please reload.",
"templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.", "templates.cliHint": "Download the CLI at https://github.com/squidex/squidex-samples to use the templates.",
"templates.loadFailed": "Failed to load templates. Please reload.", "templates.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates", "templates.refreshTooltip": "Refresh Templates",

4
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs → backend/src/Squidex.Domain.Apps.Core.Model/AssignedPlan.cs

@ -9,9 +9,9 @@ using Squidex.Infrastructure;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter #pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Core.Apps namespace Squidex.Domain.Apps.Core
{ {
public sealed record AppPlan(RefToken Owner, string PlanId) public sealed record AssignedPlan(RefToken Owner, string PlanId)
{ {
public RefToken Owner { get; } = Guard.NotNull(Owner); public RefToken Owner { get; } = Guard.NotNull(Owner);

18
backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributors.cs → backend/src/Squidex.Domain.Apps.Core.Model/Contributors.cs

@ -9,23 +9,23 @@ using System.Diagnostics.Contracts;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Collections;
namespace Squidex.Domain.Apps.Core.Apps namespace Squidex.Domain.Apps.Core
{ {
public sealed class AppContributors : ReadonlyDictionary<string, string> public sealed class Contributors : ReadonlyDictionary<string, string>
{ {
public static readonly AppContributors Empty = new AppContributors(); public static readonly Contributors Empty = new Contributors();
private AppContributors() private Contributors()
{ {
} }
public AppContributors(IDictionary<string, string> inner) public Contributors(IDictionary<string, string> inner)
: base(inner) : base(inner)
{ {
} }
[Pure] [Pure]
public AppContributors Assign(string contributorId, string role) public Contributors Assign(string contributorId, string role)
{ {
Guard.NotNullOrEmpty(contributorId); Guard.NotNullOrEmpty(contributorId);
Guard.NotNullOrEmpty(role); Guard.NotNullOrEmpty(role);
@ -35,11 +35,11 @@ namespace Squidex.Domain.Apps.Core.Apps
return this; return this;
} }
return new AppContributors(updated); return new Contributors(updated);
} }
[Pure] [Pure]
public AppContributors Remove(string contributorId) public Contributors Remove(string contributorId)
{ {
Guard.NotNullOrEmpty(contributorId); Guard.NotNullOrEmpty(contributorId);
@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Core.Apps
return this; return this;
} }
return new AppContributors(updated); return new Contributors(updated);
} }
} }
} }

2
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs

@ -214,7 +214,7 @@ namespace Squidex.Domain.Apps.Core {
} }
/// <summary> /// <summary>
/// Looks up a localized string similar to The id of the parent folder. Empty for files without parent.. /// Looks up a localized string similar to The ID of the parent folder. Empty for files without parent..
/// </summary> /// </summary>
public static string AssetParentId { public static string AssetParentId {
get { get {

2
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx

@ -169,7 +169,7 @@
<value>The mime type.</value> <value>The mime type.</value>
</data> </data>
<data name="AssetParentId" xml:space="preserve"> <data name="AssetParentId" xml:space="preserve">
<value>The id of the parent folder. Empty for files without parent.</value> <value>The ID of the parent folder. Empty for files without parent.</value>
</data> </data>
<data name="AssetParentPath" xml:space="preserve"> <data name="AssetParentPath" xml:space="preserve">
<value>The full path in the folder hierarchy as array of folder infos.</value> <value>The full path in the folder hierarchy as array of folder infos.</value>

1
backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Messaging.Subscriptions; using Squidex.Messaging.Subscriptions;

5
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs

@ -23,6 +23,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
[BsonElement("_ui")] [BsonElement("_ui")]
public string[] IndexedUserIds { get; set; } public string[] IndexedUserIds { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("_ti")]
public DomainId? IndexedTeamId { get; set; }
[BsonRequired] [BsonRequired]
[BsonElement("_dl")] [BsonElement("_dl")]
public bool IndexedDeleted { get; set; } public bool IndexedDeleted { get; set; }
@ -44,6 +48,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
IndexedUserIds = users.ToArray(); IndexedUserIds = users.ToArray();
IndexedCreated = Document.Created; IndexedCreated = Document.Created;
IndexedDeleted = Document.IsDeleted; IndexedDeleted = Document.IsDeleted;
IndexedTeamId = Document.TeamId;
IndexedName = Document.Name; IndexedName = Document.Name;
} }
} }

49
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs

@ -10,7 +10,6 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.DomainObject; using Squidex.Domain.Apps.Entities.Apps.DomainObject;
using Squidex.Domain.Apps.Entities.Apps.Repositories; using Squidex.Domain.Apps.Entities.Apps.Repositories;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Apps namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
@ -32,7 +31,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
.Ascending(x => x.IndexedName)), .Ascending(x => x.IndexedName)),
new CreateIndexModel<MongoAppEntity>( new CreateIndexModel<MongoAppEntity>(
Index Index
.Ascending(x => x.IndexedUserIds)) .Ascending(x => x.IndexedUserIds)),
new CreateIndexModel<MongoAppEntity>(
Index
.Ascending(x => x.IndexedTeamId))
}, ct); }, ct);
} }
@ -42,53 +44,26 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
return Collection.DeleteManyAsync(Filter.Eq(x => x.DocumentId, app.Id), ct); return Collection.DeleteManyAsync(Filter.Eq(x => x.DocumentId, app.Id), ct);
} }
public async Task<Dictionary<string, DomainId>> QueryIdsAsync(string contributorId, public async Task<List<IAppEntity>> QueryAllAsync(string contributorId, IEnumerable<string> names,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryIdsAsync"))
{
var find = Collection.Find(x => x.IndexedUserIds.Contains(contributorId) && !x.IndexedDeleted);
return await QueryAsync(find, ct);
}
}
public async Task<Dictionary<string, DomainId>> QueryIdsAsync(IEnumerable<string> names,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryAsync")) using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryAllAsync"))
{
var find = Collection.Find(x => names.Contains(x.IndexedName) && !x.IndexedDeleted);
return await QueryAsync(find, ct);
}
}
private static async Task<Dictionary<string, DomainId>> QueryAsync(IFindFluent<MongoAppEntity, MongoAppEntity> find,
CancellationToken ct)
{
var entities = await find.SortBy(x => x.IndexedCreated).Only(x => x.DocumentId, x => x.IndexedName).ToListAsync(ct);
var result = new Dictionary<string, DomainId>();
foreach (var entity in entities)
{ {
var indexedId = DomainId.Create(entity["_id"].AsString); var entities =
var indexedName = entity["_an"].AsString; await Collection.Find(x => (x.IndexedUserIds.Contains(contributorId) || names.Contains(x.IndexedName)) && !x.IndexedDeleted)
.ToListAsync(ct);
result[indexedName] = indexedId; return entities.Select(x => (IAppEntity)x.Document).ToList();
} }
return result;
} }
public async Task<List<IAppEntity>> QueryAllAsync(string contributorId, IEnumerable<string> names, public async Task<List<IAppEntity>> QueryAllAsync(DomainId teamId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryAllAsync")) using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryAllAsync"))
{ {
var entities = var entities =
await Collection.Find(x => (x.IndexedUserIds.Contains(contributorId) || names.Contains(x.IndexedName)) && !x.IndexedDeleted) await Collection.Find(x => x.IndexedTeamId == teamId)
.ToListAsync(ct); .ToListAsync(ct);
return entities.Select(x => (IAppEntity)x.Document).ToList(); return entities.Select(x => (IAppEntity)x.Document).ToList();

27
backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs

@ -23,6 +23,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
{ {
cm.AutoMap(); cm.AutoMap();
cm.MapProperty(x => x.OwnerId)
.SetElementName("AppId");
cm.MapProperty(x => x.EventType) cm.MapProperty(x => x.EventType)
.SetElementName("Message"); .SetElementName("Message");
}); });
@ -45,13 +48,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
{ {
new CreateIndexModel<HistoryEvent>( new CreateIndexModel<HistoryEvent>(
Index Index
.Ascending(x => x.AppId) .Ascending(x => x.OwnerId)
.Ascending(x => x.Channel) .Ascending(x => x.Channel)
.Descending(x => x.Created) .Descending(x => x.Created)
.Descending(x => x.Version)), .Descending(x => x.Version)),
new CreateIndexModel<HistoryEvent>( new CreateIndexModel<HistoryEvent>(
Index Index
.Ascending(x => x.AppId) .Ascending(x => x.OwnerId)
.Descending(x => x.Created) .Descending(x => x.Created)
.Descending(x => x.Version)) .Descending(x => x.Version))
}, ct); }, ct);
@ -60,22 +63,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
async Task IDeleter.DeleteAppAsync(IAppEntity app, async Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct) CancellationToken ct)
{ {
await Collection.DeleteManyAsync(Filter.Eq(x => x.AppId, app.Id), ct); await Collection.DeleteManyAsync(Filter.Eq(x => x.OwnerId, app.Id), ct);
} }
public async Task<IReadOnlyList<HistoryEvent>> QueryByChannelAsync(DomainId appId, string channelPrefix, int count, public async Task<IReadOnlyList<HistoryEvent>> QueryByChannelAsync(DomainId ownerId, string channelPrefix, int count,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (!string.IsNullOrWhiteSpace(channelPrefix)) var find =
{ !string.IsNullOrWhiteSpace(channelPrefix) ?
return await Collection.Find(x => x.AppId == appId && x.Channel == channelPrefix) Collection.Find(x => x.OwnerId == ownerId && x.Channel == channelPrefix) :
.SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count).ToListAsync(ct); Collection.Find(x => x.OwnerId == ownerId);
}
else return await find.SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count).ToListAsync(ct);
{
return await Collection.Find(x => x.AppId == appId)
.SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count).ToListAsync(ct);
}
} }
public Task InsertManyAsync(IEnumerable<HistoryEvent> historyEvents, public Task InsertManyAsync(IEnumerable<HistoryEvent> historyEvents,

38
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamEntity.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Bson.Serialization.Attributes;
using NodaTime;
using Squidex.Domain.Apps.Entities.Teams.DomainObject;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Teams
{
public sealed class MongoTeamEntity : MongoState<TeamDomainObject.State>
{
[BsonRequired]
[BsonElement("_ui")]
public string[] IndexedUserIds { get; set; }
[BsonIgnoreIfDefault]
[BsonElement("_ct")]
public Instant IndexedCreated { get; set; }
public override void Prepare()
{
var users = new HashSet<string>
{
Document.CreatedBy.Identifier
};
users.AddRange(Document.Contributors.Keys);
IndexedUserIds = users.ToArray();
}
}
}

61
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamRepository.cs

@ -0,0 +1,61 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MongoDB.Driver;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Domain.Apps.Entities.Teams.DomainObject;
using Squidex.Domain.Apps.Entities.Teams.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Teams
{
public sealed class MongoTeamRepository : MongoSnapshotStoreBase<TeamDomainObject.State, MongoTeamEntity>, ITeamRepository
{
public MongoTeamRepository(IMongoDatabase database)
: base(database)
{
}
protected override Task SetupCollectionAsync(IMongoCollection<MongoTeamEntity> collection,
CancellationToken ct)
{
return collection.Indexes.CreateManyAsync(new[]
{
new CreateIndexModel<MongoTeamEntity>(
Index
.Ascending(x => x.IndexedUserIds))
}, ct);
}
public async Task<List<ITeamEntity>> QueryAllAsync(string contributorId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoTeamRepository/QueryAllAsync"))
{
var entities =
await Collection.Find(x => x.IndexedUserIds.Contains(contributorId))
.ToListAsync(ct);
return entities.Select(x => (ITeamEntity)x.Document).ToList();
}
}
public async Task<ITeamEntity?> FindAsync(DomainId id,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoTeamRepository/FindAsync"))
{
var entity =
await Collection.Find(x => x.DocumentId == id)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
}
}

45
backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -12,6 +12,8 @@ using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Indexes; using Squidex.Domain.Apps.Entities.Rules.Indexes;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Indexes; using Squidex.Domain.Apps.Entities.Schemas.Indexes;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Domain.Apps.Entities.Teams.Indexes;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
@ -23,14 +25,16 @@ namespace Squidex.Domain.Apps.Entities
private readonly IAppsIndex indexForApps; private readonly IAppsIndex indexForApps;
private readonly IRulesIndex indexForRules; private readonly IRulesIndex indexForRules;
private readonly ISchemasIndex indexForSchemas; private readonly ISchemasIndex indexForSchemas;
private readonly ITeamsIndex indexForTeams;
public AppProvider(IAppsIndex indexForApps, IRulesIndex indexForRules, ISchemasIndex indexForSchemas, public AppProvider(IAppsIndex indexForApps, IRulesIndex indexForRules, ISchemasIndex indexForSchemas, ITeamsIndex indexForTeams,
ILocalCache localCache) ILocalCache localCache)
{ {
this.localCache = localCache; this.localCache = localCache;
this.indexForApps = indexForApps; this.indexForApps = indexForApps;
this.indexForRules = indexForRules; this.indexForRules = indexForRules;
this.indexForSchemas = indexForSchemas; this.indexForSchemas = indexForSchemas;
this.indexForTeams = indexForTeams;
} }
public async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id, bool canCache = false, public async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id, bool canCache = false,
@ -89,6 +93,19 @@ namespace Squidex.Domain.Apps.Entities
return app; return app;
} }
public async Task<ITeamEntity?> GetTeamAsync(DomainId teamId,
CancellationToken ct = default)
{
var cacheKey = TeamCacheKey(teamId);
var team = await GetOrCreate(cacheKey, () =>
{
return indexForTeams.GetTeamAsync(teamId, ct);
});
return team;
}
public async Task<ISchemaEntity?> GetSchemaAsync(DomainId appId, string name, bool canCache = false, public async Task<ISchemaEntity?> GetSchemaAsync(DomainId appId, string name, bool canCache = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@ -136,6 +153,27 @@ namespace Squidex.Domain.Apps.Entities
return apps?.ToList() ?? new List<IAppEntity>(); return apps?.ToList() ?? new List<IAppEntity>();
} }
public async Task<List<IAppEntity>> GetTeamAppsAsync(DomainId teamId,
CancellationToken ct = default)
{
var apps = await GetOrCreate($"GetTeamApps({teamId})", () =>
{
return indexForApps.GetAppsForTeamAsync(teamId, ct)!;
});
return apps?.ToList() ?? new List<IAppEntity>();
}
public async Task<List<ITeamEntity>> GetUserTeamsAsync(string userId, CancellationToken ct = default)
{
var teams = await GetOrCreate($"GetUserTeams({userId})", () =>
{
return indexForTeams.GetTeamsAsync(userId, ct)!;
});
return teams?.ToList() ?? new List<ITeamEntity>();
}
public async Task<List<ISchemaEntity>> GetSchemasAsync(DomainId appId, public async Task<List<ISchemaEntity>> GetSchemasAsync(DomainId appId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@ -207,6 +245,11 @@ namespace Squidex.Domain.Apps.Entities
return $"APPS_NAME_{appName}"; return $"APPS_NAME_{appName}";
} }
private static string TeamCacheKey(DomainId teamId)
{
return $"TEAMS_ID{teamId}";
}
private static string SchemaCacheKey(DomainId appId, DomainId id) private static string SchemaCacheKey(DomainId appId, DomainId id)
{ {
return $"SCHEMAS_ID_{appId}_{id}"; return $"SCHEMAS_ID_{appId}_{id}";

45
backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs

@ -1,4 +1,4 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -13,7 +13,7 @@ using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Apps namespace Squidex.Domain.Apps.Entities.Apps
{ {
public class AppHistoryEventsCreator : HistoryEventsCreatorBase public sealed class AppHistoryEventsCreator : HistoryEventsCreatorBase
{ {
public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry) public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry)
: base(typeNameRegistry) : base(typeNameRegistry)
@ -62,6 +62,21 @@ namespace Squidex.Domain.Apps.Entities.Apps
AddEventMessage<AppRoleUpdated>( AddEventMessage<AppRoleUpdated>(
"history.apps.roleUpdated"); "history.apps.roleUpdated");
AddEventMessage<AppAssetsScriptsConfigured>(
"history.apps.assetScriptsConfigured");
AddEventMessage<AppUpdated>(
"history.apps.updated");
AddEventMessage<AppTransfered>(
"history.apps.transfered");
AddEventMessage<AppImageUploaded>(
"history.apps.imageUploaded");
AddEventMessage<AppImageRemoved>(
"history.apps.imageRemoved");
} }
private HistoryEvent? CreateEvent(IEvent @event) private HistoryEvent? CreateEvent(IEvent @event)
@ -97,13 +112,28 @@ namespace Squidex.Domain.Apps.Entities.Apps
case AppPlanReset e: case AppPlanReset e:
return CreatePlansEvent(e); return CreatePlansEvent(e);
case AppSettingsUpdated e: case AppSettingsUpdated e:
return CreateAppSettingsEvent(e); return CreateAssetScriptsEvent(e);
case AppAssetsScriptsConfigured e:
return CreateGeneralEvent(e);
case AppUpdated e:
return CreateGeneralEvent(e);
case AppTransfered e:
return CreateGeneralEvent(e);
case AppImageUploaded e:
return CreateGeneralEvent(e);
case AppImageRemoved e:
return CreateGeneralEvent(e);
} }
return null; return null;
} }
private HistoryEvent CreateAppSettingsEvent(AppSettingsUpdated e) private HistoryEvent CreateGeneralEvent(IEvent e)
{
return ForEvent(e, "general");
}
private HistoryEvent CreateAppSettingsEvent(IEvent e)
{ {
return ForEvent(e, "settings.appSettings"); return ForEvent(e, "settings.appSettings");
} }
@ -133,9 +163,14 @@ namespace Squidex.Domain.Apps.Entities.Apps
return ForEvent(e, "settings.plan").Param("Plan", plan); return ForEvent(e, "settings.plan").Param("Plan", plan);
} }
private HistoryEvent CreateAssetScriptsEvent(IEvent e)
{
return ForEvent(e, "settings.assetScripts");
}
protected override Task<HistoryEvent?> CreateEventCoreAsync(Envelope<IEvent> @event) protected override Task<HistoryEvent?> CreateEventCoreAsync(Envelope<IEvent> @event)
{ {
return Task.FromResult(CreateEvent(@event.Payload)); return Task.FromResult(CreateEvent(@event.Payload));
} }
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs

@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class AddLanguage : AppUpdateCommand public sealed class AddLanguage : AppCommand
{ {
public Language Language { get; set; } public Language Language { get; set; }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddRole.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class AddRole : AppUpdateCommand public sealed class AddRole : AppCommand
{ {
public string Name { get; set; } public string Name { get; set; }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs

@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class AddWorkflow : AppUpdateCommand public sealed class AddWorkflow : AppCommand
{ {
public DomainId WorkflowId { get; set; } public DomainId WorkflowId { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs

@ -9,7 +9,7 @@ using Roles = Squidex.Domain.Apps.Core.Apps.Role;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class AssignContributor : AppUpdateCommand public sealed class AssignContributor : AppCommand
{ {
public string ContributorId { get; set; } public string ContributorId { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs

@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class AttachClient : AppUpdateCommand public sealed class AttachClient : AppCommand
{ {
public string Id { get; set; } public string Id { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class ChangePlan : AppUpdateCommand public sealed class ChangePlan : AppCommand
{ {
public bool FromCallback { get; set; } public bool FromCallback { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureAssetScripts.cs

@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Assets;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class ConfigureAssetScripts : AppUpdateCommand public sealed class ConfigureAssetScripts : AppCommand
{ {
public AssetScripts? Scripts { get; set; } public AssetScripts? Scripts { get; set; }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs

@ -10,7 +10,7 @@ using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class CreateApp : AppCommand, IAggregateCommand public sealed class CreateApp : AppCommandBase, IAggregateCommand
{ {
public DomainId AppId { get; set; } public DomainId AppId { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteApp.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class DeleteApp : AppUpdateCommand public sealed class DeleteApp : AppCommand
{ {
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteRole.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class DeleteRole : AppUpdateCommand public sealed class DeleteRole : AppCommand
{ {
public string Name { get; set; } public string Name { get; set; }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs

@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class DeleteWorkflow : AppUpdateCommand public sealed class DeleteWorkflow : AppCommand
{ {
public DomainId WorkflowId { get; set; } public DomainId WorkflowId { get; set; }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class RemoveAppImage : AppUpdateCommand public sealed class RemoveAppImage : AppCommand
{ {
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class RemoveContributor : AppUpdateCommand public sealed class RemoveContributor : AppCommand
{ {
public string ContributorId { get; set; } public string ContributorId { get; set; }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs

@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class RemoveLanguage : AppUpdateCommand public sealed class RemoveLanguage : AppCommand
{ {
public Language Language { get; set; } public Language Language { get; set; }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class RevokeClient : AppUpdateCommand public sealed class RevokeClient : AppCommand
{ {
public string Id { get; set; } public string Id { get; set; }
} }

5
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppCommand.cs → backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/TransferToTeam.cs

@ -6,12 +6,11 @@
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public abstract class AppCommand : SquidexCommand, IAggregateCommand public sealed class TransferToTeam : AppCommand
{ {
public abstract DomainId AggregateId { get; } public DomainId? TeamId { get; set; }
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class UpdateApp : AppUpdateCommand public sealed class UpdateApp : AppCommand
{ {
public string? Label { get; set; } public string? Label { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateAppSettings.cs

@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Apps;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class UpdateAppSettings : AppUpdateCommand public sealed class UpdateAppSettings : AppCommand
{ {
public AppSettings Settings { get; set; } public AppSettings Settings { get; set; }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class UpdateClient : AppUpdateCommand public sealed class UpdateClient : AppCommand
{ {
public string Id { get; set; } public string Id { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs

@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class UpdateLanguage : AppUpdateCommand public sealed class UpdateLanguage : AppCommand
{ {
public Language Language { get; set; } public Language Language { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateRole.cs

@ -9,7 +9,7 @@ using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class UpdateRole : AppUpdateCommand public sealed class UpdateRole : AppCommand
{ {
public string Name { get; set; } public string Name { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs

@ -10,7 +10,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class UpdateWorkflow : AppUpdateCommand public sealed class UpdateWorkflow : AppCommand
{ {
public DomainId WorkflowId { get; set; } public DomainId WorkflowId { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs

@ -9,7 +9,7 @@ using Squidex.Assets;
namespace Squidex.Domain.Apps.Entities.Apps.Commands namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public sealed class UploadAppImage : AppUpdateCommand public sealed class UploadAppImage : AppCommand
{ {
public AssetFile File { get; set; } public AssetFile File { get; set; }
} }

30
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/_AppCommand.cs

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
public abstract class AppCommand : AppCommandBase, IAppCommand
{
public NamedId<DomainId> AppId { get; set; }
public override DomainId AggregateId
{
get => AppId.Id;
}
}
// This command is needed as marker for middlewares.
public abstract class AppCommandBase : SquidexCommand, IAggregateCommand
{
public abstract DomainId AggregateId { get; }
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs

@ -13,7 +13,7 @@ using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Apps.DomainObject namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{ {
public sealed class AppCommandMiddleware : AggregateCommandMiddleware<AppCommand, AppDomainObject> public sealed class AppCommandMiddleware : AggregateCommandMiddleware<AppCommandBase, AppDomainObject>
{ {
private readonly IAppImageStore appImageStore; private readonly IAppImageStore appImageStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetThumbnailGenerator;

30
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
@ -29,17 +30,19 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
public string Description { get; set; } public string Description { get; set; }
public Roles Roles { get; set; } = Roles.Empty; public DomainId? TeamId { get; set; }
public AppImage? Image { get; set; } public Contributors Contributors { get; set; } = Contributors.Empty;
public AppPlan? Plan { get; set; } public Roles Roles { get; set; } = Roles.Empty;
public AssignedPlan? Plan { get; set; }
public AppClients Clients { get; set; } = AppClients.Empty; public AppClients Clients { get; set; } = AppClients.Empty;
public AppSettings Settings { get; set; } = AppSettings.Empty; public AppImage? Image { get; set; }
public AppContributors Contributors { get; set; } = AppContributors.Empty; public AppSettings Settings { get; set; } = AppSettings.Empty;
public AssetScripts AssetScripts { get; set; } = new AssetScripts(); public AssetScripts AssetScripts { get; set; } = new AssetScripts();
@ -64,26 +67,30 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Id = e.AppId.Id; Id = e.AppId.Id;
SimpleMapper.Map(e, this); SimpleMapper.Map(e, this);
return true; return true;
} }
case AppUpdated e when Is.Change(Label, e.Label) || Is.Change(Description, e.Description): case AppUpdated e when Is.Change(Label, e.Label) || Is.Change(Description, e.Description):
{ {
SimpleMapper.Map(e, this); SimpleMapper.Map(e, this);
return true;
}
case AppTransfered e when Is.Change(TeamId, e.TeamId):
{
SimpleMapper.Map(e, this);
return true; return true;
} }
case AppSettingsUpdated e when Is.Change(Settings, e.Settings): case AppSettingsUpdated e when Is.Change(Settings, e.Settings):
return UpdateSettings(e.Settings); return UpdateSettings(e.Settings);
case AppPlanChanged e when Is.Change(Plan?.PlanId, e.PlanId):
return UpdatePlan(e.ToPlan());
case AppAssetsScriptsConfigured e when Is.Change(e.Scripts, AssetScripts): case AppAssetsScriptsConfigured e when Is.Change(e.Scripts, AssetScripts):
return UpdateAssetScripts(e.Scripts); return UpdateAssetScripts(e.Scripts);
case AppPlanChanged e when Is.Change(Plan?.PlanId, e.PlanId):
return UpdatePlan(e.ToPlan());
case AppPlanReset e when Plan != null: case AppPlanReset e when Plan != null:
return UpdatePlan(null); return UpdatePlan(null);
@ -150,7 +157,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Plan = null; Plan = null;
IsDeleted = true; IsDeleted = true;
return true; return true;
} }
} }
@ -158,7 +164,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return false; return false;
} }
private bool UpdateContributors<T>(T @event, Func<T, AppContributors, AppContributors> update) private bool UpdateContributors<T>(T @event, Func<T, Contributors, Contributors> update)
{ {
var previous = Contributors; var previous = Contributors;
@ -224,7 +230,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return true; return true;
} }
private bool UpdatePlan(AppPlan? plan) private bool UpdatePlan(AssignedPlan? plan)
{ {
Plan = plan; Plan = plan;

87
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs

@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards; using Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards;
using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -42,12 +42,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
protected override bool CanAcceptCreation(ICommand command) protected override bool CanAcceptCreation(ICommand command)
{ {
return command is CreateApp; return command is AppCommandBase;
} }
protected override bool CanAccept(ICommand command) protected override bool CanAccept(ICommand command)
{ {
return command is AppUpdateCommand update && Equals(update?.AppId?.Id, Snapshot.Id); return command is AppCommand update && Equals(update?.AppId?.Id, Snapshot.Id);
} }
public override Task<CommandResult> ExecuteAsync(IAggregateCommand command, public override Task<CommandResult> ExecuteAsync(IAggregateCommand command,
@ -75,6 +75,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return Snapshot; return Snapshot;
}, ct); }, ct);
case TransferToTeam transfer:
return UpdateReturnAsync(transfer, async (c, ct) =>
{
await GuardApp.CanTransfer(c, Snapshot, AppProvider(), ct);
Transfer(c);
return Snapshot;
}, ct);
case UpdateAppSettings updateSettings: case UpdateAppSettings updateSettings:
return UpdateReturn(updateSettings, c => return UpdateReturn(updateSettings, c =>
{ {
@ -258,7 +268,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
case DeleteApp delete: case DeleteApp delete:
return UpdateAsync(delete, async (c, ct) => return UpdateAsync(delete, async (c, ct) =>
{ {
await Billing().UnsubscribeAsync(c.Actor.Identifier, Snapshot.NamedId(), default); await BillingManager().UnsubscribeAsync(c.Actor.Identifier, Snapshot.NamedId(), default);
DeleteApp(c); DeleteApp(c);
}, ct); }, ct);
@ -279,7 +289,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
var result = await UpdateReturnAsync(changePlan, async (c, ct) => var result = await UpdateReturnAsync(changePlan, async (c, ct) =>
{ {
GuardApp.CanChangePlan(c, Snapshot, Plans()); GuardApp.CanChangePlan(c, Snapshot, BillingPlans());
if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal)) if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal))
{ {
@ -290,7 +300,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
if (!c.FromCallback) if (!c.FromCallback)
{ {
var redirectUri = await Billing().MustRedirectToPortalAsync(userId, Snapshot.NamedId(), c.PlanId, ct); var redirectUri = await BillingManager().MustRedirectToPortalAsync(userId, Snapshot.NamedId(), c.PlanId, ct);
if (redirectUri != null) if (redirectUri != null)
{ {
@ -310,11 +320,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
if (result.Payload is PlanChangedResult { Unsubscribed: true, RedirectUri: null }) if (result.Payload is PlanChangedResult { Unsubscribed: true, RedirectUri: null })
{ {
await Billing().UnsubscribeAsync(userId, Snapshot.NamedId(), default); await BillingManager().UnsubscribeAsync(userId, Snapshot.NamedId(), default);
} }
else if (result.Payload is PlanChangedResult { RedirectUri: null }) else if (result.Payload is PlanChangedResult { RedirectUri: null })
{ {
await Billing().SubscribeAsync(userId, Snapshot.NamedId(), changePlan.PlanId, default); await BillingManager().SubscribeAsync(userId, Snapshot.NamedId(), changePlan.PlanId, default);
} }
return result; return result;
@ -324,23 +334,25 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{ {
var appId = NamedId.Of(command.AppId, command.Name); var appId = NamedId.Of(command.AppId, command.Name);
var events = new List<AppEvent> void RaiseInitial<T>(T @event) where T : AppEvent
{ {
CreateInitalEvent(command.Name) Raise(command, @event, appId);
}; }
RaiseInitial(new AppCreated());
if (command.Actor.IsUser) var actor = command.Actor;
if (actor.IsUser)
{ {
events.Add(CreateInitialOwner(command.Actor)); RaiseInitial(new AppContributorAssigned { ContributorId = actor.Identifier, Role = Role.Owner });
} }
events.Add(CreateInitialSettings()); var settings = serviceProvider.GetService<InitialSettings>()?.Settings;
foreach (var @event in events) if (settings != null)
{ {
@event.AppId = appId; RaiseInitial(new AppSettingsUpdated { Settings = settings });
Raise(command, @event);
} }
} }
@ -359,6 +371,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Raise(command, new AppUpdated()); Raise(command, new AppUpdated());
} }
private void Transfer(TransferToTeam command)
{
Raise(command, new AppTransfered());
}
private void UpdateSettings(UpdateAppSettings command) private void UpdateSettings(UpdateAppSettings command)
{ {
Raise(command, new AppSettingsUpdated()); Raise(command, new AppSettingsUpdated());
@ -454,38 +471,28 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Raise(command, new AppDeleted()); Raise(command, new AppDeleted());
} }
private void Raise<T, TEvent>(T command, TEvent @event) where T : class where TEvent : AppEvent private void Raise<T, TEvent>(T command, TEvent @event, NamedId<DomainId>? id = null) where T : class where TEvent : AppEvent
{ {
SimpleMapper.Map(command, @event); SimpleMapper.Map(command, @event);
@event.AppId ??= Snapshot.NamedId(); @event.AppId ??= id ?? Snapshot.NamedId();
RaiseEvent(Envelope.Create(@event)); RaiseEvent(Envelope.Create(@event));
} }
private static AppCreated CreateInitalEvent(string name) private IAppProvider AppProvider()
{
return new AppCreated { Name = name };
}
private static AppContributorAssigned CreateInitialOwner(RefToken actor)
{
return new AppContributorAssigned { ContributorId = actor.Identifier, Role = Role.Owner };
}
private AppSettingsUpdated CreateInitialSettings()
{ {
return new AppSettingsUpdated { Settings = serviceProvider.GetRequiredService<InitialSettings>().Settings }; return serviceProvider.GetRequiredService<IAppProvider>();
} }
private IAppPlansProvider Plans() private IBillingPlans BillingPlans()
{ {
return serviceProvider.GetRequiredService<IAppPlansProvider>(); return serviceProvider.GetRequiredService<IBillingPlans>();
} }
private IAppPlanBillingManager Billing() private IBillingManager BillingManager()
{ {
return serviceProvider.GetRequiredService<IAppPlanBillingManager>(); return serviceProvider.GetRequiredService<IBillingManager>();
} }
private IUserResolver Users() private IUserResolver Users()
@ -493,14 +500,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return serviceProvider.GetRequiredService<IUserResolver>(); return serviceProvider.GetRequiredService<IUserResolver>();
} }
private IAppLimitsPlan GetFreePlan() private Plan GetFreePlan()
{ {
return Plans().GetFreePlan(); return BillingPlans().GetFreePlan();
} }
private IAppLimitsPlan GetPlan() private Plan GetPlan()
{ {
return Plans().GetPlanForApp(Snapshot).Plan; return BillingPlans().GetActualPlan(Snapshot.Plan?.PlanId).Plan;
} }
} }
} }

36
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardApp.cs

@ -6,7 +6,7 @@
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
@ -123,7 +123,32 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
}); });
} }
public static void CanChangePlan(ChangePlan command, IAppEntity app, IAppPlansProvider appPlans) public static Task CanTransfer(TransferToTeam command, IAppEntity app, IAppProvider appProvider, CancellationToken ct)
{
Guard.NotNull(command);
return Validate.It(async e =>
{
if (command.TeamId == null)
{
return;
}
var team = await appProvider.GetTeamAsync(command.TeamId.Value, ct);
if (team == null || !team.Contributors.ContainsKey(command.Actor.Identifier))
{
e(T.Get("apps.transfer.teamNotFound"));
}
if (app.Plan != null)
{
e(T.Get("apps.transfer.planAssigned"));
}
});
}
public static void CanChangePlan(ChangePlan command, IAppEntity app, IBillingPlans billingPlans)
{ {
Guard.NotNull(command); Guard.NotNull(command);
@ -135,11 +160,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
return; return;
} }
if (appPlans.GetPlan(command.PlanId) == null) if (billingPlans.GetPlan(command.PlanId) == null)
{ {
e(T.Get("apps.plans.notFound"), nameof(command.PlanId)); e(T.Get("apps.plans.notFound"), nameof(command.PlanId));
} }
if (app.TeamId != null)
{
e(T.Get("apps.plans.assignedToTeam"));
}
var plan = app.Plan; var plan = app.Plan;
if (!string.IsNullOrWhiteSpace(command.PlanId) && plan != null && !plan.Owner.Equals(command.Actor)) if (!string.IsNullOrWhiteSpace(command.PlanId) && plan != null && !plan.Owner.Equals(command.Actor))

4
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs

@ -7,7 +7,7 @@
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
@ -17,7 +17,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
{ {
public static class GuardAppContributors public static class GuardAppContributors
{ {
public static Task CanAssign(AssignContributor command, IAppEntity app, IUserResolver users, IAppLimitsPlan plan) public static Task CanAssign(AssignContributor command, IAppEntity app, IUserResolver users, Plan plan)
{ {
Guard.NotNull(command); Guard.NotNull(command);

10
backend/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs

@ -5,9 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps namespace Squidex.Domain.Apps.Entities.Apps
{ {
@ -23,9 +25,13 @@ namespace Squidex.Domain.Apps.Entities.Apps
string? Description { get; } string? Description { get; }
DomainId? TeamId { get; }
Roles Roles { get; } Roles Roles { get; }
AppPlan? Plan { get; } AssignedPlan? Plan { get; }
Contributors Contributors { get; }
AppImage? Image { get; } AppImage? Image { get; }
@ -33,8 +39,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
AppSettings Settings { get; } AppSettings Settings { get; }
AppContributors Contributors { get; }
AssetScripts AssetScripts { get; } AssetScripts AssetScripts { get; }
LanguagesConfig Languages { get; } LanguagesConfig Languages { get; }

20
backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs

@ -74,6 +74,22 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
} }
} }
public async Task<List<IAppEntity>> GetAppsForTeamAsync(DomainId teamId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("AppsIndex/GetAppsForTeamAsync"))
{
var apps = await appRepository.QueryAllAsync(teamId, ct);
foreach (var app in apps.Where(IsValid))
{
await CacheItAsync(app);
}
return apps.Where(IsValid).ToList();
}
}
public async Task<IAppEntity?> GetAppAsync(string name, bool canCache = false, public async Task<IAppEntity?> GetAppAsync(string name, bool canCache = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
@ -165,7 +181,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
case DeleteApp delete: case DeleteApp delete:
await OnDeleteAsync(delete); await OnDeleteAsync(delete);
break; break;
case AppUpdateCommand update: case AppCommand update:
await OnUpdateAsync(update); await OnUpdateAsync(update);
break; break;
} }
@ -195,7 +211,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
await InvalidateItAsync(delete.AppId.Id, delete.AppId.Name); await InvalidateItAsync(delete.AppId.Id, delete.AppId.Name);
} }
private async Task OnUpdateAsync(AppUpdateCommand update) private async Task OnUpdateAsync(AppCommand update)
{ {
await InvalidateItAsync(update.AppId.Id, update.AppId.Name); await InvalidateItAsync(update.AppId.Id, update.AppId.Name);
} }

3
backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs

@ -15,6 +15,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
Task<List<IAppEntity>> GetAppsForUserAsync(string userId, PermissionSet permissions, Task<List<IAppEntity>> GetAppsForUserAsync(string userId, PermissionSet permissions,
CancellationToken ct = default); CancellationToken ct = default);
Task<List<IAppEntity>> GetAppsForTeamAsync(DomainId teamId,
CancellationToken ct = default);
Task<IAppEntity?> GetAppAsync(string name, bool canCache = false, Task<IAppEntity?> GetAppAsync(string name, bool canCache = false,
CancellationToken ct = default); CancellationToken ct = default);

96
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs

@ -1,96 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class InvitationEventConsumer : IEventConsumer
{
private static readonly Duration MaxAge = Duration.FromDays(2);
private readonly INotificationSender emailSender;
private readonly IUserResolver userResolver;
private readonly ILogger<InvitationEventConsumer> log;
public string Name
{
get => "NotificationEmailSender";
}
public string EventsFilter
{
get { return "^app-"; }
}
public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver,
ILogger<InvitationEventConsumer> log)
{
this.emailSender = emailSender;
this.userResolver = userResolver;
this.log = log;
}
public async Task On(Envelope<IEvent> @event)
{
if (!emailSender.IsActive)
{
return;
}
if (@event.Headers.EventStreamNumber() <= 1)
{
return;
}
var now = SystemClock.Instance.GetCurrentInstant();
var timestamp = @event.Headers.Timestamp();
if (now - timestamp > MaxAge)
{
return;
}
if (@event.Payload is AppContributorAssigned appContributorAssigned)
{
if (!appContributorAssigned.Actor.IsUser || !appContributorAssigned.IsAdded)
{
return;
}
var assignerId = appContributorAssigned.Actor.Identifier;
var assigneeId = appContributorAssigned.ContributorId;
var assigner = await userResolver.FindByIdAsync(assignerId);
if (assigner == null)
{
log.LogWarning("Failed to invite user: Assigner {assignerId} not found.", assignerId);
return;
}
var assignee = await userResolver.FindByIdAsync(appContributorAssigned.ContributorId);
if (assignee == null)
{
log.LogWarning("Failed to invite user: Assignee {assigneeId} not found.", assigneeId);
return;
}
var appName = appContributorAssigned.AppId.Name;
await emailSender.SendInviteAsync(assigner, assignee, appName);
}
}
}
}

65
backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs

@ -1,65 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{
public sealed class InviteUserCommandMiddleware : ICommandMiddleware
{
private readonly IUserResolver userResolver;
public InviteUserCommandMiddleware(IUserResolver userResolver)
{
this.userResolver = userResolver;
}
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is AssignContributor assignContributor && ShouldResolve(assignContributor))
{
IUser? user;
var created = false;
if (assignContributor.Invite)
{
(user, created) = await userResolver.CreateUserIfNotExistsAsync(assignContributor.ContributorId, true, ct);
}
else
{
user = await userResolver.FindByIdOrEmailAsync(assignContributor.ContributorId, ct);
}
if (user != null)
{
assignContributor.ContributorId = user.Id;
}
await next(context, ct);
if (created && context.PlainResult is IAppEntity app)
{
context.Complete(new InvitedResult { App = app });
}
}
else
{
await next(context, ct);
}
}
private static bool ShouldResolve(AssignContributor assignContributor)
{
return assignContributor.ContributorId.IsEmail();
}
}
}

43
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppLimitsPlan.cs

@ -1,43 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public sealed class ConfigAppLimitsPlan : IAppLimitsPlan
{
public string Id { get; set; }
public string Name { get; set; }
public string Costs { get; set; }
public string? ConfirmText { get; set; }
public string? YearlyCosts { get; set; }
public string? YearlyId { get; set; }
public string? YearlyConfirmText { get; set; }
public long BlockingApiCalls { get; set; }
public long MaxApiCalls { get; set; }
public long MaxApiBytes { get; set; }
public long MaxAssetSize { get; set; }
public int MaxContributors { get; set; }
public bool IsFree { get; set; }
public ConfigAppLimitsPlan Clone()
{
return (ConfigAppLimitsPlan)MemberwiseClone();
}
}
}

107
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs

@ -1,107 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public sealed class ConfigAppPlansProvider : IAppPlansProvider
{
private static readonly ConfigAppLimitsPlan Infinite = new ConfigAppLimitsPlan
{
Id = "infinite",
Name = "Infinite",
MaxApiCalls = -1,
MaxAssetSize = -1,
MaxContributors = -1,
BlockingApiCalls = -1
};
private readonly Dictionary<string, ConfigAppLimitsPlan> plansById = new Dictionary<string, ConfigAppLimitsPlan>(StringComparer.OrdinalIgnoreCase);
private readonly List<ConfigAppLimitsPlan> plansList = new List<ConfigAppLimitsPlan>();
private readonly ConfigAppLimitsPlan freePlan;
public ConfigAppPlansProvider(IEnumerable<ConfigAppLimitsPlan> config)
{
foreach (var plan in config.OrderBy(x => x.MaxApiCalls).Select(x => x.Clone()))
{
plansList.Add(plan);
plansById[plan.Id] = plan;
if (!string.IsNullOrWhiteSpace(plan.YearlyId) && !string.IsNullOrWhiteSpace(plan.YearlyCosts))
{
plansById[plan.YearlyId] = plan;
}
}
freePlan = plansList.Find(x => x.IsFree) ?? Infinite;
}
public IEnumerable<IAppLimitsPlan> GetAvailablePlans()
{
return plansList;
}
public bool IsConfiguredPlan(string? planId)
{
return planId != null && plansById.ContainsKey(planId);
}
public IAppLimitsPlan? GetPlan(string? planId)
{
return plansById.GetValueOrDefault(planId ?? string.Empty);
}
public IAppLimitsPlan GetFreePlan()
{
return freePlan;
}
public IAppLimitsPlan? GetPlanUpgradeForApp(IAppEntity app)
{
Guard.NotNull(app);
return GetPlanUpgrade(app.Plan?.PlanId);
}
public IAppLimitsPlan? GetPlanUpgrade(string? planId)
{
var plan = GetPlanCore(planId);
var nextPlanIndex = plansList.IndexOf(plan);
if (nextPlanIndex >= 0 && nextPlanIndex < plansList.Count - 1)
{
return plansList[nextPlanIndex + 1];
}
return null;
}
public (IAppLimitsPlan Plan, string PlanId) GetPlanForApp(IAppEntity app)
{
Guard.NotNull(app);
var planId = app.Plan?.PlanId;
var plan = GetPlanCore(planId);
if (plan.YearlyId != null && plan.YearlyId == planId)
{
return (plan, plan.YearlyId);
}
else
{
return (plan, plan.Id);
}
}
private ConfigAppLimitsPlan GetPlanCore(string? planId)
{
return plansById.GetValueOrDefault(planId ?? string.Empty) ?? freePlan;
}
}
}

36
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppLimitsPlan.cs

@ -1,36 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public interface IAppLimitsPlan
{
string Id { get; }
string Name { get; }
string Costs { get; }
string? ConfirmText { get; }
string? YearlyCosts { get; }
string? YearlyId { get; }
string? YearlyConfirmText { get; }
long BlockingApiCalls { get; }
long MaxApiCalls { get; }
long MaxApiBytes { get; }
long MaxAssetSize { get; }
int MaxContributors { get; }
}
}

26
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlansProvider.cs

@ -1,26 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public interface IAppPlansProvider
{
IEnumerable<IAppLimitsPlan> GetAvailablePlans();
bool IsConfiguredPlan(string? planId);
IAppLimitsPlan? GetPlanUpgradeForApp(IAppEntity app);
IAppLimitsPlan? GetPlanUpgrade(string? planId);
IAppLimitsPlan? GetPlan(string? planId);
IAppLimitsPlan GetFreePlan();
(IAppLimitsPlan Plan, string PlanId) GetPlanForApp(IAppEntity app);
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs

@ -54,8 +54,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
if (context.IsCompleted && user != null) if (context.IsCompleted && user != null)
{ {
var newApps = totalApps + 1; var newAppsCount = totalApps + 1;
var newAppsValue = newApps.ToString(CultureInfo.InvariantCulture); var newAppsValue = newAppsCount.ToString(CultureInfo.InvariantCulture);
// Always update the user and therefore do nto pass cancellation token. // Always update the user and therefore do nto pass cancellation token.
await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.TotalApps, newAppsValue, true, default); await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.TotalApps, newAppsValue, true, default);

107
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs

@ -1,107 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.UsageTracking;
using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Apps.Plans
{
public class UsageGate
{
private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IAppPlansProvider appPlansProvider;
private readonly IApiUsageTracker apiUsageTracker;
private readonly IMessageBus messaging;
public UsageGate(IAppPlansProvider appPlansProvider, IApiUsageTracker apiUsageTracker, IMessageBus messaging)
{
this.appPlansProvider = appPlansProvider;
this.apiUsageTracker = apiUsageTracker;
this.messaging = messaging;
}
public virtual async Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime today,
CancellationToken ct = default)
{
Guard.NotNull(app);
var (plan, _) = appPlansProvider.GetPlanForApp(app);
var appId = app.Id;
var blocking = false;
var blockLimit = plan.MaxApiCalls;
if (blockLimit > 0 || plan.BlockingApiCalls > 0)
{
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, null, ct);
if (IsOver10Percent(blockLimit, usage) && IsAboutToBeLocked(today, blockLimit, usage) && !HasNotifiedBefore(app.Id))
{
var notification = new UsageTrackingCheck
{
AppId = appId,
AppName = app.Name,
Usage = usage,
UsageLimit = blockLimit,
Users = GetUsers(app)
};
await messaging.PublishAsync(notification, ct: ct);
TrackNotified(appId);
}
blocking = plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls;
}
if (!blocking)
{
if (clientId != null && app.Clients.TryGetValue(clientId, out var client) && client.ApiCallsLimit > 0)
{
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, clientId, ct);
blocking = usage >= client.ApiCallsLimit;
}
}
return blocking;
}
private bool HasNotifiedBefore(DomainId appId)
{
return memoryCache.Get<bool>(appId);
}
private bool TrackNotified(DomainId appId)
{
return memoryCache.Set(appId, true, TimeSpan.FromHours(1));
}
private static string[] GetUsers(IAppEntity app)
{
return app.Contributors.Where(x => x.Value == Role.Owner).Select(x => x.Key).ToArray();
}
private static bool IsOver10Percent(long limit, long usage)
{
return usage > limit * 0.1;
}
private static bool IsAboutToBeLocked(DateTime today, long limit, long usage)
{
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
var forecasted = ((float)usage / today.Day) * daysInMonth;
return forecasted > limit;
}
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs

@ -14,6 +14,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Repositories
Task<List<IAppEntity>> QueryAllAsync(string contributorId, IEnumerable<string> names, Task<List<IAppEntity>> QueryAllAsync(string contributorId, IEnumerable<string> names,
CancellationToken ct = default); CancellationToken ct = default);
Task<List<IAppEntity>> QueryAllAsync(DomainId teamId,
CancellationToken ct = default);
Task<IAppEntity?> FindAsync(DomainId id, Task<IAppEntity?> FindAsync(DomainId id,
CancellationToken ct = default); CancellationToken ct = default);

57
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs

@ -7,23 +7,19 @@
using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure; using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.UsageTracking;
#pragma warning disable CS0649 #pragma warning disable CS0649
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public partial class AssetUsageTracker : IAssetUsageTracker, IDeleter public partial class AssetUsageTracker : IDeleter
{ {
private const string CounterTotalCount = "TotalAssets";
private const string CounterTotalSize = "TotalSize";
private static readonly DateTime SummaryDate;
private readonly IAssetLoader assetLoader; private readonly IAssetLoader assetLoader;
private readonly ISnapshotStore<State> store; private readonly ISnapshotStore<State> store;
private readonly ITagService tagService; private readonly ITagService tagService;
private readonly IUsageTracker usageTracker; private readonly IAppUsageGate appUsageGate;
[CollectionName("Index_TagHistory")] [CollectionName("Index_TagHistory")]
public sealed class State public sealed class State
@ -31,13 +27,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
public HashSet<string>? Tags { get; set; } public HashSet<string>? Tags { get; set; }
} }
public AssetUsageTracker(IUsageTracker usageTracker, IAssetLoader assetLoader, ITagService tagService, public AssetUsageTracker(IAppUsageGate appUsageGate, IAssetLoader assetLoader, ITagService tagService,
ISnapshotStore<State> store) ISnapshotStore<State> store)
{ {
this.appUsageGate = appUsageGate;
this.assetLoader = assetLoader; this.assetLoader = assetLoader;
this.tagService = tagService; this.tagService = tagService;
this.store = store; this.store = store;
this.usageTracker = usageTracker;
ClearCache(); ClearCache();
} }
@ -45,48 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Task IDeleter.DeleteAppAsync(IAppEntity app, Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct) CancellationToken ct)
{ {
var key = GetKey(app.Id); return appUsageGate.DeleteAssetUsageAsync(app.Id, ct);
return usageTracker.DeleteAsync(key, ct);
}
public async Task<long> GetTotalSizeAsync(DomainId appId)
{
var key = GetKey(appId);
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null);
return counters.GetInt64(CounterTotalSize);
}
public async Task<IReadOnlyList<AssetStats>> QueryAsync(DomainId appId, DateTime fromDate, DateTime toDate)
{
var enriched = new List<AssetStats>();
var usages = await usageTracker.QueryAsync(GetKey(appId), fromDate, toDate);
if (usages.TryGetValue(usageTracker.FallbackCategory, out var byCategory1))
{
AddCounters(enriched, byCategory1);
}
else if (usages.TryGetValue("Default", out var byCategory2))
{
// Fallback for older versions where default was uses as tracking category.
AddCounters(enriched, byCategory2);
}
return enriched;
}
private static void AddCounters(List<AssetStats> enriched, List<(DateTime, Counters)> details)
{
foreach (var (date, counters) in details)
{
var totalCount = counters.GetInt64(CounterTotalCount);
var totalSize = counters.GetInt64(CounterTotalSize);
enriched.Add(new AssetStats(date, totalCount, totalSize));
}
} }
} }
} }

30
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs

@ -12,7 +12,6 @@ using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.UsageTracking;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
@ -58,8 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
await store.ClearAsync(); await store.ClearAsync();
// Use a well defined prefix query for the deletion to improve performance. await appUsageGate.DeleteAssetsUsageAsync();
await usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_Assets");
} }
public async Task On(IEnumerable<Envelope<IEvent>> events) public async Task On(IEnumerable<Envelope<IEvent>> events)
@ -187,13 +185,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
switch (@event.Payload) switch (@event.Payload)
{ {
case AssetCreated assetCreated: case AssetCreated assetCreated:
return UpdateSizeAsync(assetCreated.AppId.Id, GetDate(@event), assetCreated.FileSize, 1); return appUsageGate.TrackAssetAsync(assetCreated.AppId.Id, GetDate(@event), assetCreated.FileSize, 1);
case AssetUpdated assetUpdated: case AssetUpdated assetUpdated:
return UpdateSizeAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0); return appUsageGate.TrackAssetAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0);
case AssetDeleted assetDeleted: case AssetDeleted assetDeleted:
return UpdateSizeAsync(assetDeleted.AppId.Id, GetDate(@event), -assetDeleted.DeletedSize, -1); return appUsageGate.TrackAssetAsync(assetDeleted.AppId.Id, GetDate(@event), -assetDeleted.DeletedSize, -1);
} }
return Task.CompletedTask; return Task.CompletedTask;
@ -203,25 +201,5 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
return @event.Headers.Timestamp().ToDateTimeUtc().Date; return @event.Headers.Timestamp().ToDateTimeUtc().Date;
} }
private Task UpdateSizeAsync(DomainId appId, DateTime date, long size, long count)
{
var counters = new Counters
{
[CounterTotalSize] = size,
[CounterTotalCount] = count
};
var appKey = GetKey(appId);
return Task.WhenAll(
usageTracker.TrackAsync(date, appKey, null, counters),
usageTracker.TrackAsync(SummaryDate, appKey, null, counters));
}
private static string GetKey(DomainId appId)
{
return $"{appId}_Assets";
}
} }
} }

16
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs → backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetCommand.cs

@ -8,19 +8,27 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Assets.Commands namespace Squidex.Domain.Apps.Entities.Assets.Commands
{ {
public abstract class AssetCommand : SquidexCommand, IAppCommand, IAggregateCommand public abstract class AssetCommand : AssetCommandBase
{ {
public NamedId<DomainId> AppId { get; set; }
public DomainId AssetId { get; set; } public DomainId AssetId { get; set; }
public bool DoNotScript { get; set; } public bool DoNotScript { get; set; }
public DomainId AggregateId public override DomainId AggregateId
{ {
get => DomainId.Combine(AppId, AssetId); get => DomainId.Combine(AppId, AssetId);
} }
} }
// This command is needed as marker for middlewares.
public abstract class AssetCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
{
public NamedId<DomainId> AppId { get; set; }
public abstract DomainId AggregateId { get; }
}
} }

16
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs → backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetFolderCommand.cs

@ -8,17 +8,25 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Assets.Commands namespace Squidex.Domain.Apps.Entities.Assets.Commands
{ {
public abstract class AssetFolderCommand : SquidexCommand, IAppCommand, IAggregateCommand public abstract class AssetFolderCommand : AssetFolderCommandBase
{ {
public NamedId<DomainId> AppId { get; set; }
public DomainId AssetFolderId { get; set; } public DomainId AssetFolderId { get; set; }
public DomainId AggregateId public override DomainId AggregateId
{ {
get => DomainId.Combine(AppId, AssetFolderId); get => DomainId.Combine(AppId, AssetFolderId);
} }
} }
// This command is needed as marker for middlewares.
public abstract class AssetFolderCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
{
public NamedId<DomainId> AppId { get; set; }
public abstract DomainId AggregateId { get; }
}
} }

5
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.State.cs

@ -77,7 +77,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
TotalSize += e.FileSize; TotalSize += e.FileSize;
EnsureProperties(); EnsureProperties();
return true; return true;
} }
@ -88,7 +87,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
TotalSize += e.FileSize; TotalSize += e.FileSize;
EnsureProperties(); EnsureProperties();
return true; return true;
} }
@ -132,7 +130,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
} }
EnsureProperties(); EnsureProperties();
return hasChanged; return hasChanged;
} }
@ -141,14 +138,12 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
ParentId = e.ParentId; ParentId = e.ParentId;
EnsureProperties(); EnsureProperties();
return true; return true;
} }
case AssetDeleted: case AssetDeleted:
{ {
IsDeleted = true; IsDeleted = true;
return true; return true;
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs

@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
protected override bool CanAcceptCreation(ICommand command) protected override bool CanAcceptCreation(ICommand command)
{ {
return command is AssetCommand; return command is AssetCommandBase;
} }
protected override bool CanAccept(ICommand command) protected override bool CanAccept(ICommand command)

4
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.State.cs

@ -41,28 +41,24 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
Id = e.AssetFolderId; Id = e.AssetFolderId;
SimpleMapper.Map(e, this); SimpleMapper.Map(e, this);
return true; return true;
} }
case AssetFolderRenamed e when Is.OptionalChange(FolderName, e.FolderName): case AssetFolderRenamed e when Is.OptionalChange(FolderName, e.FolderName):
{ {
FolderName = e.FolderName; FolderName = e.FolderName;
return true; return true;
} }
case AssetFolderMoved e when Is.Change(ParentId, e.ParentId): case AssetFolderMoved e when Is.Change(ParentId, e.ParentId):
{ {
ParentId = e.ParentId; ParentId = e.ParentId;
return true; return true;
} }
case AssetFolderDeleted: case AssetFolderDeleted:
{ {
IsDeleted = true; IsDeleted = true;
return true; return true;
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs

@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
protected override bool CanAcceptCreation(ICommand command) protected override bool CanAcceptCreation(ICommand command)
{ {
return command is AssetFolderCommand; return command is AssetFolderCommandBase;
} }
protected override bool CanAccept(ICommand command) protected override bool CanAccept(ICommand command)

12
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs

@ -11,8 +11,16 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
public interface IAssetUsageTracker public interface IAssetUsageTracker
{ {
Task<IReadOnlyList<AssetStats>> QueryAsync(DomainId appId, DateTime fromDate, DateTime toDate); Task<IReadOnlyList<AssetStats>> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate,
CancellationToken ct = default);
Task<long> GetTotalSizeAsync(DomainId appId); Task<IReadOnlyList<AssetStats>> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate,
CancellationToken ct = default);
Task<long> GetTotalSizeByAppAsync(DomainId appId,
CancellationToken ct = default);
Task<long> GetTotalSizeByTeamAsync(DomainId teamId,
CancellationToken ct = default);
} }
} }

82
backend/src/Squidex.Domain.Apps.Entities/Billing/ConfigPlansProvider.cs

@ -0,0 +1,82 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Billing
{
public sealed class ConfigPlansProvider : IBillingPlans
{
private static readonly Plan Infinite = new Plan
{
Id = "infinite",
Name = "Infinite",
MaxApiCalls = -1,
MaxAssetSize = -1,
MaxContributors = -1,
BlockingApiCalls = -1
};
private readonly Dictionary<string, Plan> plansById = new Dictionary<string, Plan>(StringComparer.OrdinalIgnoreCase);
private readonly List<Plan> plans = new List<Plan>();
private readonly Plan freePlan;
public ConfigPlansProvider(IEnumerable<Plan> config)
{
plans.AddRange(config.OrderBy(x => x.MaxApiCalls));
foreach (var plan in config.OrderBy(x => x.MaxApiCalls))
{
plansById[plan.Id] = plan;
if (!string.IsNullOrWhiteSpace(plan.YearlyId) && !string.IsNullOrWhiteSpace(plan.YearlyCosts))
{
plansById[plan.YearlyId] = plan;
}
}
freePlan = config.FirstOrDefault(x => x.IsFree) ?? Infinite;
}
public IEnumerable<Plan> GetAvailablePlans()
{
return plans;
}
public bool IsConfiguredPlan(string? planId)
{
return planId != null && plansById.ContainsKey(planId);
}
public Plan? GetPlan(string? planId)
{
return plansById.GetValueOrDefault(planId ?? string.Empty);
}
public Plan GetFreePlan()
{
return freePlan;
}
public (Plan Plan, string PlanId) GetActualPlan(string? planId)
{
if (planId == null || !plansById.TryGetValue(planId, out var plan))
{
var result = GetFreePlan();
return (result, result.Id);
}
if (plan.YearlyId != null && plan.YearlyId == planId)
{
return (plan, plan.YearlyId);
}
else
{
return (plan, plan.Id);
}
}
}
}

40
backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs

@ -0,0 +1,40 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Billing
{
public interface IAppUsageGate
{
Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date,
CancellationToken ct = default);
Task TrackRequestAsync(IAppEntity app, string? clientId, DateTime date, double costs, long elapsedMs, long bytes,
CancellationToken ct = default);
Task TrackAssetAsync(DomainId appId, DateTime date, long fileSize, long count,
CancellationToken ct = default);
Task DeleteAssetUsageAsync(DomainId appId,
CancellationToken ct = default);
Task DeleteAssetsUsageAsync(
CancellationToken ct = default);
Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(IAppEntity app,
CancellationToken ct = default);
Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(DomainId appId,
CancellationToken ct = default);
Task<(Plan Plan, string PlanId)> GetPlanForTeamAsync(ITeamEntity team,
CancellationToken ct = default);
}
}

13
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlanBillingManager.cs → backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs

@ -7,21 +7,30 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Plans namespace Squidex.Domain.Apps.Entities.Billing
{ {
public interface IAppPlanBillingManager public interface IBillingManager
{ {
bool HasPortal { get; } bool HasPortal { get; }
Task<Uri?> MustRedirectToPortalAsync(string userId, NamedId<DomainId> appId, string? planId, Task<Uri?> MustRedirectToPortalAsync(string userId, NamedId<DomainId> appId, string? planId,
CancellationToken ct = default); CancellationToken ct = default);
Task<Uri?> MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId,
CancellationToken ct = default);
Task SubscribeAsync(string userId, NamedId<DomainId> appId, string planId, Task SubscribeAsync(string userId, NamedId<DomainId> appId, string planId,
CancellationToken ct = default); CancellationToken ct = default);
Task SubscribeAsync(string userId, DomainId teamId, string planId,
CancellationToken ct = default);
Task UnsubscribeAsync(string userId, NamedId<DomainId> appId, Task UnsubscribeAsync(string userId, NamedId<DomainId> appId,
CancellationToken ct = default); CancellationToken ct = default);
Task UnsubscribeAsync(string userId, DomainId teamId,
CancellationToken ct = default);
Task<string> GetPortalLinkAsync(string userId, Task<string> GetPortalLinkAsync(string userId,
CancellationToken ct = default); CancellationToken ct = default);
} }

22
backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingPlans.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Billing
{
public interface IBillingPlans
{
IEnumerable<Plan> GetAvailablePlans();
bool IsConfiguredPlan(string? planId);
Plan? GetPlan(string? planId);
Plan GetFreePlan();
(Plan Plan, string PlanId) GetActualPlan(string? planId);
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/Messages.cs → backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs

@ -9,7 +9,7 @@ using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Apps.Plans namespace Squidex.Domain.Apps.Entities.Billing
{ {
public sealed record UsageTrackingCheck public sealed record UsageTrackingCheck
{ {

22
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/NoopAppPlanBillingManager.cs → backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs

@ -7,9 +7,9 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Plans namespace Squidex.Domain.Apps.Entities.Billing
{ {
public sealed class NoopAppPlanBillingManager : IAppPlanBillingManager public sealed class NoopBillingManager : IBillingManager
{ {
public bool HasPortal public bool HasPortal
{ {
@ -28,16 +28,34 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
return Task.FromResult<Uri?>(null); return Task.FromResult<Uri?>(null);
} }
public Task<Uri?> MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId,
CancellationToken ct = default)
{
return Task.FromResult<Uri?>(null);
}
public Task SubscribeAsync(string userId, NamedId<DomainId> appId, string planId, public Task SubscribeAsync(string userId, NamedId<DomainId> appId, string planId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SubscribeAsync(string userId, DomainId teamId, string planId,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task UnsubscribeAsync(string userId, NamedId<DomainId> appId, public Task UnsubscribeAsync(string userId, NamedId<DomainId> appId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task UnsubscribeAsync(string userId, DomainId teamId,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
} }
} }

38
backend/src/Squidex.Domain.Apps.Entities/Billing/Plan.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Billing
{
public sealed record Plan
{
public string Id { get; init; }
public string Name { get; init; }
public string Costs { get; init; }
public string? ConfirmText { get; init; }
public string? YearlyCosts { get; init; }
public string? YearlyId { get; init; }
public string? YearlyConfirmText { get; init; }
public long BlockingApiCalls { get; init; }
public long MaxApiCalls { get; init; }
public long MaxApiBytes { get; init; }
public long MaxAssetSize { get; init; }
public long MaxContributors { get; init; }
public bool IsFree { get; init; }
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/PlanChangedResult.cs → backend/src/Squidex.Domain.Apps.Entities/Billing/PlanChangedResult.cs

@ -7,7 +7,7 @@
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter #pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Entities.Apps.Plans namespace Squidex.Domain.Apps.Entities.Billing
{ {
public sealed record PlanChangedResult(string PlanId, bool Unsubscribed = false, Uri? RedirectUri = null) public sealed record PlanChangedResult(string PlanId, bool Unsubscribed = false, Uri? RedirectUri = null)
{ {

315
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs

@ -0,0 +1,315 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure;
using Squidex.Infrastructure.UsageTracking;
using Squidex.Messaging;
namespace Squidex.Domain.Apps.Entities.Billing
{
public sealed class UsageGate : IAppUsageGate, IAssetUsageTracker
{
private const string CounterTotalCount = "TotalAssets";
private const string CounterTotalSize = "TotalSize";
private static readonly DateTime SummaryDate = default;
private readonly IBillingPlans billingPlans;
private readonly IAppProvider appProvider;
private readonly IApiUsageTracker apiUsageTracker;
private readonly IMemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IMessageBus messaging;
private readonly IUsageTracker usageTracker;
public UsageGate(
IAppProvider appProvider,
IApiUsageTracker apiUsageTracker,
IBillingPlans billingPlans,
IMessageBus messaging,
IUsageTracker usageTracker)
{
this.appProvider = appProvider;
this.apiUsageTracker = apiUsageTracker;
this.billingPlans = billingPlans;
this.messaging = messaging;
this.usageTracker = usageTracker;
}
public Task DeleteAssetUsageAsync(DomainId appId,
CancellationToken ct = default)
{
// Do not delete the team, as this is only called when an app is deleted.
return usageTracker.DeleteAsync(AppAssetsKey(appId), ct);
}
public Task DeleteAssetsUsageAsync(
CancellationToken ct = default)
{
// Use a well defined prefix query for the deletion to improve performance.
return usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_Assets", ct);
}
public Task<long> GetTotalSizeByAppAsync(DomainId appId,
CancellationToken ct = default)
{
return GetTotalSizeAsync(AppAssetsKey(appId), ct);
}
public Task<long> GetTotalSizeByTeamAsync(DomainId teamId,
CancellationToken ct = default)
{
return GetTotalSizeAsync(TeamAssetsKey(teamId), ct);
}
private async Task<long> GetTotalSizeAsync(string key,
CancellationToken ct)
{
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null, ct);
return counters.GetInt64(CounterTotalSize);
}
public Task<IReadOnlyList<AssetStats>> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate,
CancellationToken ct = default)
{
return QueryAsync(AppAssetsKey(appId), fromDate, toDate, ct);
}
public Task<IReadOnlyList<AssetStats>> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate,
CancellationToken ct = default)
{
return QueryAsync(TeamAssetsKey(teamId), fromDate, toDate, ct);
}
private async Task<IReadOnlyList<AssetStats>> QueryAsync(string key, DateTime fromDate, DateTime toDate,
CancellationToken ct)
{
var enriched = new List<AssetStats>();
var usages = await usageTracker.QueryAsync(key, fromDate, toDate, ct);
if (usages.TryGetValue(usageTracker.FallbackCategory, out var byCategory1))
{
AddCounters(enriched, byCategory1);
}
return enriched;
}
private static void AddCounters(List<AssetStats> enriched, List<(DateTime, Counters)> details)
{
foreach (var (date, counters) in details)
{
var totalCount = counters.GetInt64(CounterTotalCount);
var totalSize = counters.GetInt64(CounterTotalSize);
enriched.Add(new AssetStats(date, totalCount, totalSize));
}
}
public async Task TrackRequestAsync(IAppEntity app, string? clientId, DateTime date, double costs, long elapsedMs, long bytes,
CancellationToken ct = default)
{
var appId = app.Id.ToString();
if (app.TeamId != null)
{
await apiUsageTracker.TrackAsync(date, app.TeamId.ToString()!, app.Name, costs, elapsedMs, bytes, ct);
}
await apiUsageTracker.TrackAsync(date, appId, clientId, costs, elapsedMs, bytes, ct);
}
public async Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date,
CancellationToken ct = default)
{
Guard.NotNull(app);
// Resolve the plan from either the app or the assigned team.
var (plan, _, teamId) = await GetPlanForAppAsync(app, ct);
var appId = app.Id;
var blocking = false;
var blockLimit = plan.MaxApiCalls;
var referenceId = teamId ?? app.Id;
if (blockLimit > 0 || plan.BlockingApiCalls > 0)
{
var usage = await apiUsageTracker.GetMonthCallsAsync(referenceId.ToString(), date, null, ct);
if (IsOver10Percent(blockLimit, usage) && IsAboutToBeLocked(date, blockLimit, usage) && !HasNotifiedBefore(appId))
{
var notification = new UsageTrackingCheck
{
AppId = appId,
AppName = app.Name,
Usage = usage,
UsageLimit = blockLimit,
Users = GetUsers(app)
};
await messaging.PublishAsync(notification, ct: ct);
TrackNotified(appId);
}
blocking = plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls;
}
if (!blocking)
{
if (clientId != null && app.Clients.TryGetValue(clientId, out var client) && client.ApiCallsLimit > 0)
{
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), date, clientId, ct);
blocking = usage >= client.ApiCallsLimit;
}
}
return blocking;
}
private bool HasNotifiedBefore(DomainId appId)
{
return memoryCache.Get<bool>(appId);
}
private bool TrackNotified(DomainId appId)
{
return memoryCache.Set(appId, true, TimeSpan.FromHours(1));
}
private static string[] GetUsers(IAppEntity app)
{
return app.Contributors.Where(x => x.Value == Role.Owner).Select(x => x.Key).ToArray();
}
private static bool IsOver10Percent(long limit, long usage)
{
return usage > limit * 0.1;
}
private static bool IsAboutToBeLocked(DateTime today, long limit, long usage)
{
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
var forecasted = ((float)usage / today.Day) * daysInMonth;
return forecasted > limit;
}
public async Task TrackAssetAsync(DomainId appId, DateTime date, long fileSize, long count,
CancellationToken ct = default)
{
var counters = new Counters
{
[CounterTotalSize] = fileSize,
[CounterTotalCount] = count
};
var appKey = AppAssetsKey(appId);
var tasks = new List<Task>
{
usageTracker.TrackAsync(date, appKey, null, counters, ct),
usageTracker.TrackAsync(SummaryDate, appKey, null, counters, ct)
};
var (_, _, teamId) = await GetPlanForAppAsync(appId, ct);
if (teamId != null)
{
var teamKey = TeamAssetsKey(teamId.Value);
tasks.Add(usageTracker.TrackAsync(date, teamKey, null, counters, ct));
tasks.Add(usageTracker.TrackAsync(SummaryDate, teamKey, null, counters, ct));
}
await Task.WhenAll(tasks);
}
public Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(IAppEntity app,
CancellationToken ct = default)
{
Guard.NotNull(app);
return memoryCache.GetOrCreateAsync(app, async x =>
{
x.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await GetPlanCoreAsync(app, ct);
});
}
public Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(DomainId appId,
CancellationToken ct = default)
{
return memoryCache.GetOrCreateAsync(appId, async x =>
{
x.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await GetPlanCoreAsync(appId, ct);
});
}
private async Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanCoreAsync(DomainId appId,
CancellationToken ct)
{
var app = await appProvider.GetAppAsync(appId, true, ct);
if (app == null)
{
var freePlan = billingPlans.GetFreePlan();
return (freePlan, freePlan.Id, null);
}
return await GetPlanCoreAsync(app, ct);
}
private async Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanCoreAsync(IAppEntity app,
CancellationToken ct)
{
if (app.TeamId != null)
{
var team = await appProvider.GetTeamAsync(app.TeamId.Value, ct);
var (plan, planId) = billingPlans.GetActualPlan(team?.Plan?.PlanId ?? app.Plan?.PlanId);
return (plan, planId, team?.Id);
}
else
{
var (plan, planId) = billingPlans.GetActualPlan(app.Plan?.PlanId);
return (plan, planId, null);
}
}
public Task<(Plan Plan, string PlanId)> GetPlanForTeamAsync(ITeamEntity team,
CancellationToken ct = default)
{
var (plan, planId) = billingPlans.GetActualPlan(team?.Plan?.PlanId);
return Task.FromResult((plan, planId));
}
private static string AppAssetsKey(DomainId appId)
{
return $"{appId}_Assets";
}
private static string TeamAssetsKey(DomainId appId)
{
return $"{appId}_TeamAssets";
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierWorker.cs → backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs

@ -13,7 +13,7 @@ using Squidex.Infrastructure.Tasks;
using Squidex.Messaging; using Squidex.Messaging;
using Squidex.Shared.Users; using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps.Plans namespace Squidex.Domain.Apps.Entities.Billing
{ {
public sealed class UsageNotifierWorker : IMessageHandler<UsageTrackingCheck> public sealed class UsageNotifierWorker : IMessageHandler<UsageTrackingCheck>
{ {

2
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Comments.Commands namespace Squidex.Domain.Apps.Entities.Comments.Commands
{ {
public abstract class CommentTextCommand : CommentsCommand public abstract class CommentTextCommand : CommentCommand
{ {
public string Text { get; set; } public string Text { get; set; }

2
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Comments.Commands namespace Squidex.Domain.Apps.Entities.Comments.Commands
{ {
public sealed class DeleteComment : CommentsCommand public sealed class DeleteComment : CommentCommand
{ {
} }
} }

35
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs → backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs

@ -8,21 +8,42 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Comments.Commands namespace Squidex.Domain.Apps.Entities.Comments.Commands
{ {
public abstract class CommentsCommand : SquidexCommand, IAppCommand, IAggregateCommand public abstract class CommentCommand : CommentsCommand
{ {
public static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.NewGuid(), "none"); public DomainId CommentId { get; set; }
}
public NamedId<DomainId> AppId { get; set; } public abstract class CommentsCommand : CommentsCommandBase
{
public static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.NewGuid(), "none");
public DomainId CommentsId { get; set; } public DomainId CommentsId { get; set; }
public DomainId CommentId { get; set; } public override DomainId AggregateId
DomainId IAggregateCommand.AggregateId
{ {
get => AppId.Id != default ? DomainId.Combine(AppId.Id, CommentsId) : CommentsId; get
{
if (AppId.Id == default)
{
return CommentsId;
}
else
{
return DomainId.Combine(AppId, CommentsId);
}
}
} }
} }
// This command is needed as marker for middlewares.
public abstract class CommentsCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
{
public NamedId<DomainId> AppId { get; set; }
public abstract DomainId AggregateId { get; }
}
} }

2
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs

@ -12,7 +12,7 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject namespace Squidex.Domain.Apps.Entities.Comments.DomainObject
{ {
public sealed class CommentsCommandMiddleware : AggregateCommandMiddleware<CommentsCommand, CommentsStream> public sealed class CommentsCommandMiddleware : AggregateCommandMiddleware<CommentsCommandBase, CommentsStream>
{ {
private static readonly Regex MentionRegex = new Regex(@"@(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, TimeSpan.FromMilliseconds(100)); private static readonly Regex MentionRegex = new Regex(@"@(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, TimeSpan.FromMilliseconds(100));
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;

19
backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/_ContentCommand.cs

@ -8,21 +8,28 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Contents.Commands namespace Squidex.Domain.Apps.Entities.Contents.Commands
{ {
public abstract class ContentCommand : SquidexCommand, IAppCommand, ISchemaCommand, IAggregateCommand public abstract class ContentCommand : ContentCommandBase
{ {
public NamedId<DomainId> AppId { get; set; }
public NamedId<DomainId> SchemaId { get; set; }
public DomainId ContentId { get; set; } public DomainId ContentId { get; set; }
public bool DoNotScript { get; set; } public bool DoNotScript { get; set; }
public DomainId AggregateId public override DomainId AggregateId
{ {
get => DomainId.Combine(AppId, ContentId); get => DomainId.Combine(AppId, ContentId);
} }
} }
public abstract class ContentCommandBase : SquidexCommand, IAppCommand, ISchemaCommand, IAggregateCommand
{
public NamedId<DomainId> AppId { get; set; }
public NamedId<DomainId> SchemaId { get; set; }
public abstract DomainId AggregateId { get; }
}
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs

@ -42,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
protected override bool CanAcceptCreation(ICommand command) protected override bool CanAcceptCreation(ICommand command)
{ {
return command is CreateContent or UpsertContent; return command is ContentCommandBase;
} }
protected override bool CanRecreate() protected override bool CanRecreate()

3
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs

@ -13,7 +13,6 @@ using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Messaging.Subscriptions;
using Squidex.Shared; using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets
@ -53,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets
new QueryArgument(Scalars.NonNullString) new QueryArgument(Scalars.NonNullString)
{ {
Name = "id", Name = "id",
Description = "The id of the asset (usually GUID).", Description = "The ID of the asset (usually GUID).",
DefaultValue = null DefaultValue = null
} }
}; };

2
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs

@ -444,7 +444,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
new QueryArgument(Scalars.NonNullString) new QueryArgument(Scalars.NonNullString)
{ {
Name = "id", Name = "id",
Description = "The id of the content (usually GUID).", Description = "The ID of the content (usually GUID).",
DefaultValue = null DefaultValue = null
}, },
new QueryArgument(Scalars.Int) new QueryArgument(Scalars.Int)

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs

@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
if (!HasPermission(context, schema, PermissionIds.AppContentsRead)) if (!HasPermission(context, schema, PermissionIds.AppContentsRead))
{ {
q = q with { CreatedBy = context.User.Token() }; q = q with { CreatedBy = context.UserPrincipal.Token() };
} }
q = await queryParser.ParseAsync(context, q, schema); q = await queryParser.ParseAsync(context, q, schema);

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs

@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
} }
else else
{ {
content.NextStatuses = await contentWorkflow.GetNextAsync(content, editingStatus, context.User); content.NextStatuses = await contentWorkflow.GetNextAsync(content, editingStatus, context.UserPrincipal);
} }
} }
@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{ {
var editingStatus = content.NewStatus ?? content.Status; var editingStatus = content.NewStatus ?? content.Status;
content.CanUpdate = await contentWorkflow.CanUpdateAsync(content, editingStatus, context.User); content.CanUpdate = await contentWorkflow.CanUpdateAsync(content, editingStatus, context.UserPrincipal);
} }
private async Task EnrichColorAsync(ContentEntity content, ContentEntity result, Dictionary<(DomainId, Status), StatusInfo> cache) private async Task EnrichColorAsync(ContentEntity content, ContentEntity result, Dictionary<(DomainId, Status), StatusInfo> cache)

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs

@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
AppName = schema.AppId.Name, AppName = schema.AppId.Name,
SchemaId = schema.Id, SchemaId = schema.Id,
SchemaName = schema.SchemaDef.Name, SchemaName = schema.SchemaDef.Name,
User = context.User User = context.UserPrincipal
}; };
var preScript = schema.SchemaDef.Scripts.QueryPre; var preScript = schema.SchemaDef.Scripts.QueryPre;

2
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs

@ -362,7 +362,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
var ids = var ids =
events events
.Select(x => x.Payload).OfType<ContentEvent>() .Select(x => x.Payload).OfType<ContentEvent>()
.Select(x => DomainId.Combine(x.AppId.Id, x.ContentId)) .Select(x => DomainId.Combine(x.AppId, x.ContentId))
.ToHashSet(); .ToHashSet();
return textIndexerState.GetAsync(ids); return textIndexerState.GetAsync(ids);

14
backend/src/Squidex.Domain.Apps.Entities/Context.cs

@ -25,11 +25,11 @@ namespace Squidex.Domain.Apps.Entities
public ClaimsPermissions UserPermissions { get; } public ClaimsPermissions UserPermissions { get; }
public ClaimsPrincipal User { get; } public ClaimsPrincipal UserPrincipal { get; }
public IAppEntity App { get; set; } public IAppEntity App { get; set; }
public bool IsFrontendClient => User.IsInClient(DefaultClients.Frontend); public bool IsFrontendClient => UserPrincipal.IsInClient(DefaultClients.Frontend);
public Context(ClaimsPrincipal user, IAppEntity app) public Context(ClaimsPrincipal user, IAppEntity app)
: this(app, user, user.Claims.Permissions(), EmptyHeaders) : this(app, user, user.Claims.Permissions(), EmptyHeaders)
@ -37,11 +37,15 @@ namespace Squidex.Domain.Apps.Entities
Guard.NotNull(user); Guard.NotNull(user);
} }
private Context(IAppEntity app, ClaimsPrincipal user, ClaimsPermissions userPermissions, IReadOnlyDictionary<string, string> headers) private Context(
IAppEntity app,
ClaimsPrincipal userPrincipal,
ClaimsPermissions userPermissions,
IReadOnlyDictionary<string, string> headers)
{ {
App = app; App = app;
User = user; UserPrincipal = userPrincipal;
UserPermissions = userPermissions; UserPermissions = userPermissions;
Headers = headers; Headers = headers;
@ -84,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities
{ {
if (headers != null) if (headers != null)
{ {
return new Context(context.App!, context.User, context.UserPermissions, headers); return new Context(context.App!, context.UserPrincipal, context.UserPermissions, headers);
} }
return context; return context;

2
backend/src/Squidex.Domain.Apps.Entities/History/HistoryEvent.cs

@ -14,7 +14,7 @@ namespace Squidex.Domain.Apps.Entities.History
{ {
public DomainId Id { get; set; } = DomainId.NewGuid(); public DomainId Id { get; set; } = DomainId.NewGuid();
public DomainId AppId { get; set; } public DomainId OwnerId { get; set; }
public Instant Created { get; set; } public Instant Created { get; set; }

65
backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs

@ -7,6 +7,7 @@
using Squidex.Domain.Apps.Entities.History.Repositories; using Squidex.Domain.Apps.Entities.History.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Teams;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -34,9 +35,12 @@ namespace Squidex.Domain.Apps.Entities.History
get => GetType().Name; get => GetType().Name;
} }
public HistoryService(IHistoryEventRepository repository, IEnumerable<IHistoryEventsCreator> creators, NotifoService notifo) public HistoryService(IHistoryEventRepository repository, IEnumerable<IHistoryEventsCreator> creators,
NotifoService notifo)
{ {
this.creators = creators.ToList(); this.creators = creators.ToList();
this.repository = repository;
this.notifo = notifo;
foreach (var creator in this.creators) foreach (var creator in this.creators)
{ {
@ -46,9 +50,6 @@ namespace Squidex.Domain.Apps.Entities.History
} }
} }
this.repository = repository;
this.notifo = notifo;
} }
public Task ClearAsync() public Task ClearAsync()
@ -58,32 +59,35 @@ namespace Squidex.Domain.Apps.Entities.History
public async Task On(IEnumerable<Envelope<IEvent>> events) public async Task On(IEnumerable<Envelope<IEvent>> events)
{ {
var targets = new List<(Envelope<AppEvent> Event, HistoryEvent? HistoryEvent)>(); var targets = new List<(Envelope<IEvent> Event, HistoryEvent? HistoryEvent)>();
foreach (var @event in events) foreach (var @event in events)
{ {
if (@event.Payload is AppEvent) switch (@event.Payload)
{ {
var appEvent = @event.To<AppEvent>(); case AppEvent appEvent:
{
var historyEvent = await CreateEvent(appEvent.AppId.Id, appEvent.Actor, @event);
HistoryEvent? historyEvent = null; if (historyEvent != null)
{
targets.Add((@event, historyEvent));
}
foreach (var creator in creators) break;
{ }
historyEvent = await creator.CreateEventAsync(@event);
if (historyEvent != null) case TeamEvent teamEvent:
{ {
historyEvent.Actor = appEvent.Payload.Actor; var historyEvent = await CreateEvent(teamEvent.TeamId, teamEvent.Actor, @event);
historyEvent.AppId = appEvent.Payload.AppId.Id;
historyEvent.Created = @event.Headers.Timestamp(); if (historyEvent != null)
historyEvent.Version = @event.Headers.EventStreamNumber(); {
targets.Add((@event, historyEvent));
}
break; break;
} }
}
targets.Add((appEvent, historyEvent));
} }
} }
@ -95,10 +99,29 @@ namespace Squidex.Domain.Apps.Entities.History
} }
} }
public async Task<IReadOnlyList<ParsedHistoryEvent>> QueryByChannelAsync(DomainId appId, string channelPrefix, int count, private async Task<HistoryEvent?> CreateEvent(DomainId ownerId, RefToken actor, Envelope<IEvent> @event)
{
foreach (var creator in creators)
{
var historyEvent = await creator.CreateEventAsync(@event);
if (historyEvent != null)
{
historyEvent.Actor = actor;
historyEvent.OwnerId = ownerId;
historyEvent.Created = @event.Headers.Timestamp();
historyEvent.Version = @event.Headers.EventStreamNumber();
return historyEvent;
}
}
return null;
}
public async Task<IReadOnlyList<ParsedHistoryEvent>> QueryByChannelAsync(DomainId ownerId, string channelPrefix, int count,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var items = await repository.QueryByChannelAsync(appId, channelPrefix, count, ct); var items = await repository.QueryByChannelAsync(ownerId, channelPrefix, count, ct);
return items.Select(x => new ParsedHistoryEvent(x, texts)).ToList(); return items.Select(x => new ParsedHistoryEvent(x, texts)).ToList();
} }

2
backend/src/Squidex.Domain.Apps.Entities/History/IHistoryService.cs

@ -11,7 +11,7 @@ namespace Squidex.Domain.Apps.Entities.History
{ {
public interface IHistoryService public interface IHistoryService
{ {
Task<IReadOnlyList<ParsedHistoryEvent>> QueryByChannelAsync(DomainId appId, string channelPrefix, int count, Task<IReadOnlyList<ParsedHistoryEvent>> QueryByChannelAsync(DomainId ownerId, string channelPrefix, int count,
CancellationToken ct = default); CancellationToken ct = default);
} }
} }

82
backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs

@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Apps;
using Squidex.Domain.Apps.Events.Comments; using Squidex.Domain.Apps.Events.Comments;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
using Squidex.Domain.Apps.Events.Teams;
using Squidex.Domain.Users; using Squidex.Domain.Users;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -141,7 +142,7 @@ namespace Squidex.Domain.Apps.Entities.History
} }
} }
public async Task HandleEventsAsync(IEnumerable<(Envelope<AppEvent> AppEvent, HistoryEvent? HistoryEvent)> events) public async Task HandleEventsAsync(IEnumerable<(Envelope<IEvent> AppEvent, HistoryEvent? HistoryEvent)> events)
{ {
Guard.NotNull(events); Guard.NotNull(events);
@ -176,12 +177,20 @@ namespace Squidex.Domain.Apps.Entities.History
{ {
switch (@event.AppEvent.Payload) switch (@event.AppEvent.Payload)
{ {
case AppContributorAssigned contributorAssigned: case AppContributorAssigned assigned:
await AssignContributorAsync(client, contributorAssigned); await AssignContributorAsync(client, assigned.ContributorId, GetAppPrefix(assigned));
break; break;
case AppContributorRemoved contributorRemoved: case AppContributorRemoved removed:
await RemoveContributorAsync(client, contributorRemoved); await RemoveContributorAsync(client, removed.ContributorId, GetAppPrefix(removed));
break;
case TeamContributorAssigned assigned:
await AssignContributorAsync(client, assigned.ContributorId, GetTeamPrefix(assigned));
break;
case TeamContributorRemoved removed:
await RemoveContributorAsync(client, removed.ContributorId, GetTeamPrefix(removed));
break; break;
} }
} }
@ -196,10 +205,8 @@ namespace Squidex.Domain.Apps.Entities.History
} }
} }
private async Task AssignContributorAsync(INotifoClient actualClient, AppContributorAssigned contributorAssigned) private async Task AssignContributorAsync(INotifoClient actualClient, string userId, string prefix)
{ {
var userId = contributorAssigned.ContributorId;
var user = await userResolver.FindByIdAsync(userId); var user = await userResolver.FindByIdAsync(userId);
if (user != null) if (user != null)
@ -211,7 +218,7 @@ namespace Squidex.Domain.Apps.Entities.History
{ {
var request = new AddAllowedTopicDto var request = new AddAllowedTopicDto
{ {
Prefix = GetAppPrefix(contributorAssigned) Prefix = prefix
}; };
await actualClient.Users.PostAllowedTopicAsync(options.AppId, userId, request); await actualClient.Users.PostAllowedTopicAsync(options.AppId, userId, request);
@ -222,14 +229,10 @@ namespace Squidex.Domain.Apps.Entities.History
} }
} }
private async Task RemoveContributorAsync(INotifoClient actualClient, AppContributorRemoved contributorRemoved) private async Task RemoveContributorAsync(INotifoClient actualClient, string userId, string prefix)
{ {
var userId = contributorRemoved.ContributorId;
try try
{ {
var prefix = GetAppPrefix(contributorRemoved);
await actualClient.Users.DeleteAllowedTopicAsync(options.ApiKey, userId, prefix); await actualClient.Users.DeleteAllowedTopicAsync(options.ApiKey, userId, prefix);
} }
catch (NotifoException ex) when (ex.StatusCode != 404) catch (NotifoException ex) when (ex.StatusCode != 404)
@ -238,22 +241,22 @@ namespace Squidex.Domain.Apps.Entities.History
} }
} }
private IEnumerable<PublishDto> CreateRequests(Envelope<AppEvent> appEvent, HistoryEvent? historyEvent) private IEnumerable<PublishDto> CreateRequests(Envelope<IEvent> @event, HistoryEvent? historyEvent)
{ {
if (appEvent.Payload is CommentCreated { Mentions.Length: > 0 } comment) if (@event.Payload is CommentCreated { Mentions.Length: > 0 } comment)
{ {
foreach (var userId in comment.Mentions) foreach (var userId in comment.Mentions)
{ {
yield return CreateMentionRequest(comment, userId); yield return CreateMentionRequest(comment, userId);
} }
} }
else if (historyEvent != null) else if (historyEvent != null && @event.Payload is AppEvent appEvent)
{ {
yield return CreateHistoryRequest(historyEvent, appEvent.Payload); yield return CreateHistoryRequest(historyEvent, appEvent);
} }
} }
private PublishDto CreateHistoryRequest(HistoryEvent historyEvent, AppEvent payload) private PublishDto CreateHistoryRequest(HistoryEvent historyEvent, IEvent payload)
{ {
var publishRequest = new PublishDto var publishRequest = new PublishDto
{ {
@ -265,7 +268,25 @@ namespace Squidex.Domain.Apps.Entities.History
publishRequest.Properties.Add(key, value); publishRequest.Properties.Add(key, value);
} }
publishRequest.Properties["SquidexApp"] = payload.AppId.Name; if (payload is AppEvent appEvent)
{
publishRequest.Properties["SquidexApp"] = appEvent.AppId.Name;
}
if (payload is SquidexEvent squidexEvent)
{
SetUser(squidexEvent, publishRequest);
}
if (payload is AppEvent appEvent2)
{
publishRequest.Topic = BuildTopic(GetAppPrefix(appEvent2), historyEvent);
}
if (payload is TeamEvent teamEvent)
{
publishRequest.Topic = BuildTopic(GetTeamPrefix(teamEvent), historyEvent);
}
if (payload is ContentEvent @event and not ContentDeleted) if (payload is ContentEvent @event and not ContentDeleted)
{ {
@ -276,9 +297,6 @@ namespace Squidex.Domain.Apps.Entities.History
publishRequest.TemplateCode = historyEvent.EventType; publishRequest.TemplateCode = historyEvent.EventType;
SetUser(payload, publishRequest);
SetTopic(payload, publishRequest, historyEvent);
return publishRequest; return publishRequest;
} }
@ -309,25 +327,27 @@ namespace Squidex.Domain.Apps.Entities.History
return publishRequest; return publishRequest;
} }
private static void SetUser(AppEvent appEvent, PublishDto publishRequest) private static void SetUser(SquidexEvent @event, PublishDto publishRequest)
{ {
if (appEvent.Actor.IsUser) if (@event.Actor.IsUser)
{ {
publishRequest.CreatorId = appEvent.Actor.Identifier; publishRequest.CreatorId = @event.Actor.Identifier;
} }
} }
private static void SetTopic(AppEvent appEvent, PublishDto publishRequest, HistoryEvent @event) private static string BuildTopic(string prefix, HistoryEvent @event)
{ {
var topicPrefix = GetAppPrefix(appEvent); return $"{prefix}/{@event.Channel.Replace('.', '/').Trim()}";
var topicSuffix = @event.Channel.Replace('.', '/').Trim();
publishRequest.Topic = $"{topicPrefix}/{topicSuffix}";
} }
private static string GetAppPrefix(AppEvent appEvent) private static string GetAppPrefix(AppEvent appEvent)
{ {
return $"apps/{appEvent.AppId.Id}"; return $"apps/{appEvent.AppId.Id}";
} }
private static string GetTeamPrefix(TeamEvent teamEvent)
{
return $"apps/{teamEvent.TeamId}";
}
} }
} }

10
backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs

@ -8,6 +8,7 @@
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
@ -18,6 +19,12 @@ namespace Squidex.Domain.Apps.Entities
Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id, bool canCache = false, Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id, bool canCache = false,
CancellationToken ct = default); CancellationToken ct = default);
Task<ITeamEntity?> GetTeamAsync(DomainId teamId,
CancellationToken ct = default);
Task<List<ITeamEntity>> GetUserTeamsAsync(string userId,
CancellationToken ct = default);
Task<IAppEntity?> GetAppAsync(DomainId appId, bool canCache = false, Task<IAppEntity?> GetAppAsync(DomainId appId, bool canCache = false,
CancellationToken ct = default); CancellationToken ct = default);
@ -27,6 +34,9 @@ namespace Squidex.Domain.Apps.Entities
Task<List<IAppEntity>> GetUserAppsAsync(string userId, PermissionSet permissions, Task<List<IAppEntity>> GetUserAppsAsync(string userId, PermissionSet permissions,
CancellationToken ct = default); CancellationToken ct = default);
Task<List<IAppEntity>> GetTeamAppsAsync(DomainId teamId,
CancellationToken ct = default);
Task<ISchemaEntity?> GetSchemaAsync(DomainId appId, DomainId id, bool canCache = false, Task<ISchemaEntity?> GetSchemaAsync(DomainId appId, DomainId id, bool canCache = false,
CancellationToken ct = default); CancellationToken ct = default);

8
backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs → backend/src/Squidex.Domain.Apps.Entities/ITeamCommand.cs

@ -8,12 +8,10 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Schemas.Commands namespace Squidex.Domain.Apps.Entities
{ {
public abstract class SchemaCommand : SquidexCommand, IAppCommand, IAggregateCommand public interface ITeamCommand : ICommand
{ {
public NamedId<DomainId> AppId { get; set; } DomainId TeamId { get; set; }
public abstract DomainId AggregateId { get; }
} }
} }

131
backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs

@ -0,0 +1,131 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Domain.Apps.Events.Teams;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Invitation
{
public sealed class InvitationEventConsumer : IEventConsumer
{
private static readonly Duration MaxAge = Duration.FromDays(2);
private readonly INotificationSender emailSender;
private readonly IUserResolver userResolver;
private readonly IAppProvider appProvider;
private readonly ILogger<InvitationEventConsumer> log;
public string Name
{
get => "NotificationEmailSender";
}
public string EventsFilter
{
get { return "^app-|^app-"; }
}
public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver, IAppProvider appProvider,
ILogger<InvitationEventConsumer> log)
{
this.emailSender = emailSender;
this.userResolver = userResolver;
this.appProvider = appProvider;
this.log = log;
}
public async Task On(Envelope<IEvent> @event)
{
if (!emailSender.IsActive)
{
return;
}
if (@event.Headers.EventStreamNumber() <= 1)
{
return;
}
var now = SystemClock.Instance.GetCurrentInstant();
var timestamp = @event.Headers.Timestamp();
if (now - timestamp > MaxAge)
{
return;
}
switch (@event.Payload)
{
case AppContributorAssigned assigned when assigned.IsAdded:
{
var (assigner, assignee) = await ResolveUsersAsync(assigned.Actor, assigned.ContributorId, default);
if (assigner == null || assignee == null)
{
return;
}
await emailSender.SendInviteAsync(assigner, assignee, assigned.AppId.Name);
return;
}
case TeamContributorAssigned assigned when assigned.IsAdded:
{
var (assigner, assignee) = await ResolveUsersAsync(assigned.Actor, assigned.ContributorId, default);
if (assigner == null || assignee == null)
{
return;
}
var team = await appProvider.GetTeamAsync(assigned.TeamId);
if (team == null)
{
return;
}
await emailSender.SendTeamInviteAsync(assigner, assignee, team.Name);
break;
}
}
}
private async Task<(IUser? Assignee, IUser? Assigner)> ResolveUsersAsync(RefToken assignerId, string assigneeId,
CancellationToken ct)
{
if (!assignerId.IsUser)
{
return default;
}
var assigner = await userResolver.FindByIdAsync(assignerId.Identifier, ct);
if (assigner == null)
{
log.LogWarning("Failed to invite user: Assigner {assignerId} not found.", assignerId);
return default;
}
var assignee = await userResolver.FindByIdAsync(assigneeId, ct);
if (assignee == null)
{
log.LogWarning("Failed to invite user: Assignee {assigneeId} not found.", assigneeId);
return default;
}
return (assigner, assignee);
}
}
}

90
backend/src/Squidex.Domain.Apps.Entities/Invitation/InviteUserCommandMiddleware.cs

@ -0,0 +1,90 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
using AssignAppContributor = Squidex.Domain.Apps.Entities.Apps.Commands.AssignContributor;
using AssignTeamContributor = Squidex.Domain.Apps.Entities.Teams.Commands.AssignContributor;
namespace Squidex.Domain.Apps.Entities.Invitation
{
public sealed class InviteUserCommandMiddleware : ICommandMiddleware
{
private readonly IUserResolver userResolver;
public InviteUserCommandMiddleware(IUserResolver userResolver)
{
this.userResolver = userResolver;
}
public async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is AssignAppContributor assignAppContributor)
{
var (userId, created) =
await ResolveUserAsync(
assignAppContributor.ContributorId,
assignAppContributor.Invite,
ct);
assignAppContributor.ContributorId = userId;
await next(context, ct);
if (created && context.PlainResult is IAppEntity app)
{
context.Complete(new InvitedResult<IAppEntity> { Entity = app });
}
}
else if (context.Command is AssignTeamContributor assignTeamContributor)
{
var (userId, created) =
await ResolveUserAsync(
assignTeamContributor.ContributorId,
assignTeamContributor.Invite,
ct);
assignTeamContributor.ContributorId = userId;
await next(context, ct);
if (created && context.PlainResult is ITeamEntity team)
{
context.Complete(new InvitedResult<ITeamEntity> { Entity = team });
}
}
else
{
await next(context, ct);
}
}
private async Task<(string Id, bool)> ResolveUserAsync(string id, bool invite,
CancellationToken ct)
{
if (!id.IsEmail())
{
return (id, false);
}
if (invite)
{
var (createdUser, created) = await userResolver.CreateUserIfNotExistsAsync(id, true, ct);
return (createdUser?.Id ?? id, created);
}
var user = await userResolver.FindByIdOrEmailAsync(id, ct);
return (user?.Id ?? id, false);
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save