diff --git a/.gitignore b/.gitignore
index c0ea04e02..bbfd917df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
*.suo
*.user
*.vs
+*.received.txt
.angular
.awCache
diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json
index 1278def16..75043e30f 100644
--- a/backend/i18n/frontend_en.json
+++ b/backend/i18n/frontend_en.json
@@ -6,12 +6,14 @@
"api.pageTitle": "API",
"api.title": "API",
"apps.allApps": "All Apps",
+ "apps.allTeams": "All Teams",
"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.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.",
"apps.appNameWarning": "The app name cannot be changed later.",
- "apps.appsButtonCreate": "Apps Overview",
- "apps.appsButtonFallbackTitle": "Apps Overview",
+ "apps.appsButtonCreate": "Create App",
+ "apps.appsButtonCreateTeam": "Create Team",
+ "apps.appsButtonFallbackTitle": "Apps and Teams",
"apps.archiveFailed": "Failed to archive app.",
"apps.create": "Create App",
"apps.createBlankApp": "New App",
@@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Remove image",
"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.updateFailed": "Failed to update app. Please reload.",
"apps.updateSettingsFailed": "Failed to update UI settings. Please reload.",
@@ -378,6 +384,7 @@
"common.tagAddSchema": ", to add schema",
"common.tags": "Tags",
"common.tagsAll": "All tags",
+ "common.teams": "Teams",
"common.templates": "Templates",
"common.time": "Time",
"common.to": "To",
@@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "API Documentation",
"dashboard.apiPerformanceCard": "API Performance (ms): {summary}ms avg",
"dashboard.apiPerformanceChart": "API Performance Chart",
+ "dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "Assets Size (MB",
"dashboard.assetSizeLabel": "Total Size",
"dashboard.assetSizeLimitLabel": "Total limit",
@@ -564,7 +572,8 @@
"dashboard.trafficHeader": "Traffic (MB)",
"dashboard.trafficLimitLabel": "Monthly limit",
"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}",
"eventConsumers.count": "Count",
"eventConsumers.loadFailed": "Failed to load event consumers. Please reload.",
@@ -603,6 +612,7 @@
"news.title": "New Features",
"notifications.empty": "No notifications yet",
"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.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.",
"plans.change": "Change",
@@ -613,6 +623,7 @@
"plans.includedStorage": "Storage",
"plans.includedTraffic": "Traffic",
"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.notPlanOwner": "You have not created the subscription, therefore you cannot change the plan.",
"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.madeBy": "Proudly made by",
"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.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates",
diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json
index d392c3fb0..3b2988e9a 100644
--- a/backend/i18n/frontend_it.json
+++ b/backend/i18n/frontend_it.json
@@ -6,11 +6,13 @@
"api.pageTitle": "API",
"api.title": "API",
"apps.allApps": "Tutte le Apps",
+ "apps.allTeams": "All Teams",
"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.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.appsButtonCreate": "Nuova App",
+ "apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Lista App",
"apps.archiveFailed": "Failed to archive app.",
"apps.create": "Crea un'App",
@@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Rimuovi l'immagine",
"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.updateFailed": "Non è stato possibile aggiornare l'app. Per favore ricarica.",
"apps.updateSettingsFailed": "Failed to update UI settings. Please reload.",
@@ -378,6 +384,7 @@
"common.tagAddSchema": ", aggiungi schema",
"common.tags": "Tag",
"common.tagsAll": "Tutti i tag",
+ "common.teams": "Teams",
"common.templates": "Templates",
"common.time": "Ora",
"common.to": "To",
@@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "Documentazione delle API",
"dashboard.apiPerformanceCard": "Performance(ms) delle API: {summary}ms avg",
"dashboard.apiPerformanceChart": "Diagramma delle Performance delle API",
+ "dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "Dimensione delle risorse (MB",
"dashboard.assetSizeLabel": "Dimensione totale",
"dashboard.assetSizeLimitLabel": "Limite totale",
@@ -565,6 +573,7 @@
"dashboard.trafficLimitLabel": "Limite mensile",
"dashboard.trafficSummaryCard": "Riepilogo del traffico delle API",
"dashboard.welcomeText": "Benvenuto sulla dashboard **{app}**.",
+ "dashboard.welcomeTextTeam": "Welcome to Team **{team}**.",
"dashboard.welcomeTitle": "Ciao {user}",
"eventConsumers.count": "Conteggio",
"eventConsumers.loadFailed": "Non è stato possibile caricare event consumers. Per favore ricarica.",
@@ -603,6 +612,7 @@
"news.title": "Nuove funzionalità",
"notifications.empty": "No notifications yet",
"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.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.",
"plans.change": "Cambia",
@@ -613,6 +623,7 @@
"plans.includedStorage": "Spazio disco",
"plans.includedTraffic": "Traffico",
"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.notPlanOwner": "Non hai creato nessun abbonamento, pertanto non è possibile cambiare il piano.",
"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.madeBy": "Realizzato con orgoglio da",
"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.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates",
diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json
index 90b01cdcf..6e69814db 100644
--- a/backend/i18n/frontend_nl.json
+++ b/backend/i18n/frontend_nl.json
@@ -6,11 +6,13 @@
"api.pageTitle": "API",
"api.title": "API",
"apps.allApps": "Alle apps",
+ "apps.allTeams": "All Teams",
"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.appNameValidationMessage": "Naam mag kleine letters (a-z), cijfers en streepjes tussen bevatten.",
"apps.appNameWarning": "De app-naam kan later niet worden gewijzigd.",
"apps.appsButtonCreate": "Apps-overzicht",
+ "apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "Apps-overzicht",
"apps.archiveFailed": "Failed to archive app.",
"apps.create": "App maken",
@@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Afbeelding verwijderen",
"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.updateFailed": "Update app mislukt. Laad opnieuw.",
"apps.updateSettingsFailed": "Failed to update UI settings. Please reload.",
@@ -378,6 +384,7 @@
"common.tagAddSchema": ", om schema toe te voegen",
"common.tags": "Tags",
"common.tagsAll": "Alle tags",
+ "common.teams": "Teams",
"common.templates": "Templates",
"common.time": "Tijd",
"common.to": "Naar",
@@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "API-documentatie",
"dashboard.apiPerformanceCard": "API-prestaties (ms): {summary} ms gem.",
"dashboard.apiPerformanceChart": "API-prestatiegrafiek",
+ "dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "Grootte van bestand (MB",
"dashboard.assetSizeLabel": "Totale grootte",
"dashboard.assetSizeLimitLabel": "Totale limiet",
@@ -565,6 +573,7 @@
"dashboard.trafficLimitLabel": "Maandelijks limiet",
"dashboard.trafficSummaryCard": "API Verkeer Samenvatting",
"dashboard.welcomeText": "Welkom bij **{app}** dashboard.",
+ "dashboard.welcomeTextTeam": "Welcome to Team **{team}**.",
"dashboard.welcomeTitle": "Hallo {user}",
"eventConsumers.count": "Tellen",
"eventConsumers.loadFailed": "Kan gebeurtenisgebruikers niet laden. Laad opnieuw.",
@@ -603,6 +612,7 @@
"news.title": "Nieuwe functies",
"notifications.empty": "Nog geen meldingen",
"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.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.",
"plans.change": "Wijzigen",
@@ -613,6 +623,7 @@
"plans.includedStorage": "Opslag",
"plans.includedTraffic": "Verkeer",
"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.notPlanOwner": "Je hebt geen abonnement aangemaakt. Daarom kun je het plan niet wijzigen.",
"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.madeBy": "Met trots gemaakt door",
"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.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates",
diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json
index 8f5b761ed..16be6a543 100644
--- a/backend/i18n/frontend_zh.json
+++ b/backend/i18n/frontend_zh.json
@@ -6,11 +6,13 @@
"api.pageTitle": "API",
"api.title": "API",
"apps.allApps": "所有应用程序",
+ "apps.allTeams": "All Teams",
"apps.appLoadFailed": "加载应用失败。请重新加载。",
"apps.appNameHint": "您只能使用字母、数字和破折号,并且不能超过 40 个字符。",
"apps.appNameValidationMessage": "名称可以包含小写字母 (a-z)、数字和破折号。",
"apps.appNameWarning": "以后不能更改应用名称。",
"apps.appsButtonCreate": "应用概览",
+ "apps.appsButtonCreateTeam": "Create Team",
"apps.appsButtonFallbackTitle": "应用概览",
"apps.archiveFailed": "Failed to archive app.",
"apps.create": "创建应用程序",
@@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "更新界面设置失败。请重新加载。",
"apps.removeImage": "删除图片",
"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.updateFailed": "更新应用失败。请重新加载。",
"apps.updateSettingsFailed": "更新界面设置失败。请重新加载。",
@@ -378,6 +384,7 @@
"common.tagAddSchema": ", 添加Schemas",
"common.tags": "标签",
"common.tagsAll": "所有标签",
+ "common.teams": "Teams",
"common.templates": "Templates",
"common.time": "时间",
"common.to": "To",
@@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "API 文档",
"dashboard.apiPerformanceCard": "API 性能 (ms): {summary}ms avg",
"dashboard.apiPerformanceChart": "API 性能图表",
+ "dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "资源大小 (MB",
"dashboard.assetSizeLabel": "总大小",
"dashboard.assetSizeLimitLabel": "总限制",
@@ -565,6 +573,7 @@
"dashboard.trafficLimitLabel": "每月限制",
"dashboard.trafficSummaryCard": "API 流量汇总",
"dashboard.welcomeText": "欢迎使用 **{app}** 仪表板。",
+ "dashboard.welcomeTextTeam": "Welcome to Team **{team}**.",
"dashboard.welcomeTitle": "Hi {user}",
"eventConsumers.count": "计数",
"eventConsumers.loadFailed": "加载事件消费者失败。请重新加载。",
@@ -603,6 +612,7 @@
"news.title": "新功能",
"notifications.empty": "No notifications yet",
"notifo.subscripeTooltip": "单击此按钮可订阅所有更改并接收推送通知。",
+ "plans.allApps": "The subscription is shared between all apps of this team. Check the dashboard for the assigned apps.",
"plans.billingPortal": "计费门户",
"plans.billingPortalHint": "前往账单门户查看付款历史和订阅概览。",
"plans.change": "改变",
@@ -613,6 +623,7 @@
"plans.includedStorage": "存储",
"plans.includedTraffic": "交通",
"plans.loadFailed": "加载计划失败。请重新加载。",
+ "plans.managedByTeam": "App has been assigned to a team and therefore the subscription is managed by the team.",
"plans.noPlanConfigured": "未配置计划,此应用无限制使用。",
"plans.notPlanOwner": "您尚未创建订阅。因此您无法更改计划。",
"plans.perMonth": "每月",
@@ -979,6 +990,17 @@
"start.loginHint": "登录按钮将打开一个新的弹出窗口。一旦您登录成功,我们会将您重定向到 Squidex 管理门户。",
"start.madeBy": "自豪地制作",
"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.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates",
diff --git a/backend/i18n/source/backend_en.json b/backend/i18n/source/backend_en.json
index 859950cb3..74d071178 100644
--- a/backend/i18n/source/backend_en.json
+++ b/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.nameAlreadyExists": "An app with the same name already exists.",
"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.notPlanOwner": "Plan can only changed from the user who configured the plan initially.",
"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.usedRoleByClientsNotRemovable": "Cannot remove a role when a client 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.folderRecursion": "Cannot add folder to its own child.",
"assets.maxSizeReached": "You have reached your max asset size.",
@@ -208,11 +211,14 @@
"exceptions.domainObjectDeleted": "Entity ({id}) has been deleted.",
"exceptions.domainObjectNotFound": "Entity ({id}) does not exist.",
"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.clientRevoked": "revoked client {[Id]}",
"history.apps.clientUpdated": "updated client {[Id]}",
"history.apps.contributoreAssigned": "assigned {user:[Contributor]} as {[Role]}",
"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.languagedRemoved": "removed language {[Language]}",
"history.apps.languagedSetToMaster": "changed master language to {[Language]}",
@@ -223,6 +229,8 @@
"history.apps.roleDeleted": "deleted role {[Name]}",
"history.apps.roleUpdated": "updated role {[Name]}",
"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.updated": "updated asset.",
"history.assets.uploaded": "uploaded asset.",
@@ -248,6 +256,11 @@
"history.schemas.unpublished": "unpublished schema {[Name]}.",
"history.schemas.updated": "updated schema {[Name]}.",
"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.",
"rules.ruleAlreadyRunning": "Another rule is already running.",
"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 X-Forwarded-* headers.",
"setup.ruleHttps.success": "Congratulations, you are accessing your Squidex installation over a secure connection (https).",
"setup.rules.headline": "System Checklist",
+ "setup.ruleTeamCreation.warningAdmins": "With your setup, only admins can create new teams. If you want to change this set UI__ONLYADMINSCANCREATETEAMS=false as environment variable.",
+ "setup.ruleTeamCreation.warningAll": "With your setup, every user can create new teams. If you want to change this set UI__ONLYADMINSCANCREATETEAMS=true as environment variable.",
"setup.ruleUrl.failure": "You should access Squidex only over one canonical URL and configure this URL with the URLS__BASEURL environment variable. The current base URL {actual} does not match to the base url {configured}. This variable must point to the public URL under which your Squidex instance is available.",
"setup.ruleUrl.success": "Congratulations, the URLS__BASEURL environment variable is configured properly. This variable must point to the public URL under which your Squidex instance is available.",
"setup.title": "Installation",
diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json
index 1278def16..75043e30f 100644
--- a/backend/i18n/source/frontend_en.json
+++ b/backend/i18n/source/frontend_en.json
@@ -6,12 +6,14 @@
"api.pageTitle": "API",
"api.title": "API",
"apps.allApps": "All Apps",
+ "apps.allTeams": "All Teams",
"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.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.",
"apps.appNameWarning": "The app name cannot be changed later.",
- "apps.appsButtonCreate": "Apps Overview",
- "apps.appsButtonFallbackTitle": "Apps Overview",
+ "apps.appsButtonCreate": "Create App",
+ "apps.appsButtonCreateTeam": "Create Team",
+ "apps.appsButtonFallbackTitle": "Apps and Teams",
"apps.archiveFailed": "Failed to archive app.",
"apps.create": "Create App",
"apps.createBlankApp": "New App",
@@ -37,6 +39,10 @@
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Remove image",
"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.updateFailed": "Failed to update app. Please reload.",
"apps.updateSettingsFailed": "Failed to update UI settings. Please reload.",
@@ -378,6 +384,7 @@
"common.tagAddSchema": ", to add schema",
"common.tags": "Tags",
"common.tagsAll": "All tags",
+ "common.teams": "Teams",
"common.templates": "Templates",
"common.time": "Time",
"common.to": "To",
@@ -531,6 +538,7 @@
"dashboard.apiDocumentationCard": "API Documentation",
"dashboard.apiPerformanceCard": "API Performance (ms): {summary}ms avg",
"dashboard.apiPerformanceChart": "API Performance Chart",
+ "dashboard.appsCard": "Apps",
"dashboard.assetSizeCard": "Assets Size (MB",
"dashboard.assetSizeLabel": "Total Size",
"dashboard.assetSizeLimitLabel": "Total limit",
@@ -564,7 +572,8 @@
"dashboard.trafficHeader": "Traffic (MB)",
"dashboard.trafficLimitLabel": "Monthly limit",
"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}",
"eventConsumers.count": "Count",
"eventConsumers.loadFailed": "Failed to load event consumers. Please reload.",
@@ -603,6 +612,7 @@
"news.title": "New Features",
"notifications.empty": "No notifications yet",
"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.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.",
"plans.change": "Change",
@@ -613,6 +623,7 @@
"plans.includedStorage": "Storage",
"plans.includedTraffic": "Traffic",
"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.notPlanOwner": "You have not created the subscription, therefore you cannot change the plan.",
"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.madeBy": "Proudly made by",
"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.loadFailed": "Failed to load templates. Please reload.",
"templates.refreshTooltip": "Refresh Templates",
diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs b/backend/src/Squidex.Domain.Apps.Core.Model/AssignedPlan.cs
similarity index 86%
rename from backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs
rename to backend/src/Squidex.Domain.Apps.Core.Model/AssignedPlan.cs
index efbddd2f8..36962a8d5 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppPlan.cs
+++ b/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
-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);
diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributors.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contributors.cs
similarity index 66%
rename from backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributors.cs
rename to backend/src/Squidex.Domain.Apps.Core.Model/Contributors.cs
index bdc6bfe5f..d03a3f3fd 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/AppContributors.cs
+++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contributors.cs
@@ -9,23 +9,23 @@ using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
-namespace Squidex.Domain.Apps.Core.Apps
+namespace Squidex.Domain.Apps.Core
{
- public sealed class AppContributors : ReadonlyDictionary
+ public sealed class Contributors : ReadonlyDictionary
{
- public static readonly AppContributors Empty = new AppContributors();
+ public static readonly Contributors Empty = new Contributors();
- private AppContributors()
+ private Contributors()
{
}
- public AppContributors(IDictionary inner)
+ public Contributors(IDictionary inner)
: base(inner)
{
}
[Pure]
- public AppContributors Assign(string contributorId, string role)
+ public Contributors Assign(string contributorId, string role)
{
Guard.NotNullOrEmpty(contributorId);
Guard.NotNullOrEmpty(role);
@@ -35,11 +35,11 @@ namespace Squidex.Domain.Apps.Core.Apps
return this;
}
- return new AppContributors(updated);
+ return new Contributors(updated);
}
[Pure]
- public AppContributors Remove(string contributorId)
+ public Contributors Remove(string contributorId)
{
Guard.NotNullOrEmpty(contributorId);
@@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Core.Apps
return this;
}
- return new AppContributors(updated);
+ return new Contributors(updated);
}
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
index 96461a015..98963a6d9 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
+++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
@@ -214,7 +214,7 @@ namespace Squidex.Domain.Apps.Core {
}
///
- /// 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..
///
public static string AssetParentId {
get {
diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
index bda49992c..d1bd86513 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
+++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
@@ -169,7 +169,7 @@
The mime type.
- The id of the parent folder. Empty for files without parent.
+ The ID of the parent folder. Empty for files without parent.
The full path in the folder hierarchy as array of folder infos.
diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs
index 65264fd14..fe6ceca7c 100644
--- a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs
+++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs
@@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
-using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Messaging.Subscriptions;
diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs
index 2f7b79da1..87f62551f 100644
--- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppEntity.cs
@@ -23,6 +23,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
[BsonElement("_ui")]
public string[] IndexedUserIds { get; set; }
+ [BsonIgnoreIfDefault]
+ [BsonElement("_ti")]
+ public DomainId? IndexedTeamId { get; set; }
+
[BsonRequired]
[BsonElement("_dl")]
public bool IndexedDeleted { get; set; }
@@ -44,6 +48,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
IndexedUserIds = users.ToArray();
IndexedCreated = Document.Created;
IndexedDeleted = Document.IsDeleted;
+ IndexedTeamId = Document.TeamId;
IndexedName = Document.Name;
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs
index 5ed647b2e..07eea20f0 100644
--- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Apps/MongoAppRepository.cs
+++ b/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.Repositories;
using Squidex.Infrastructure;
-using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
@@ -32,7 +31,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
.Ascending(x => x.IndexedName)),
new CreateIndexModel(
Index
- .Ascending(x => x.IndexedUserIds))
+ .Ascending(x => x.IndexedUserIds)),
+ new CreateIndexModel(
+ Index
+ .Ascending(x => x.IndexedTeamId))
}, ct);
}
@@ -42,53 +44,26 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Apps
return Collection.DeleteManyAsync(Filter.Eq(x => x.DocumentId, app.Id), ct);
}
- public async Task> QueryIdsAsync(string contributorId,
- 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> QueryIdsAsync(IEnumerable names,
+ public async Task> QueryAllAsync(string contributorId, IEnumerable names,
CancellationToken ct = default)
{
- using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryAsync"))
- {
- var find = Collection.Find(x => names.Contains(x.IndexedName) && !x.IndexedDeleted);
-
- return await QueryAsync(find, ct);
- }
- }
-
- private static async Task> QueryAsync(IFindFluent find,
- CancellationToken ct)
- {
- var entities = await find.SortBy(x => x.IndexedCreated).Only(x => x.DocumentId, x => x.IndexedName).ToListAsync(ct);
-
- var result = new Dictionary();
-
- foreach (var entity in entities)
+ using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryAllAsync"))
{
- var indexedId = DomainId.Create(entity["_id"].AsString);
- var indexedName = entity["_an"].AsString;
+ var entities =
+ 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> QueryAllAsync(string contributorId, IEnumerable names,
+ public async Task> QueryAllAsync(DomainId teamId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("MongoAppRepository/QueryAllAsync"))
{
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);
return entities.Select(x => (IAppEntity)x.Document).ToList();
diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs
index 1c78b31fe..a1236dc9a 100644
--- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs
@@ -23,6 +23,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
{
cm.AutoMap();
+ cm.MapProperty(x => x.OwnerId)
+ .SetElementName("AppId");
+
cm.MapProperty(x => x.EventType)
.SetElementName("Message");
});
@@ -45,13 +48,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
{
new CreateIndexModel(
Index
- .Ascending(x => x.AppId)
+ .Ascending(x => x.OwnerId)
.Ascending(x => x.Channel)
.Descending(x => x.Created)
.Descending(x => x.Version)),
new CreateIndexModel(
Index
- .Ascending(x => x.AppId)
+ .Ascending(x => x.OwnerId)
.Descending(x => x.Created)
.Descending(x => x.Version))
}, ct);
@@ -60,22 +63,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History
async Task IDeleter.DeleteAppAsync(IAppEntity app,
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> QueryByChannelAsync(DomainId appId, string channelPrefix, int count,
+ public async Task> QueryByChannelAsync(DomainId ownerId, string channelPrefix, int count,
CancellationToken ct = default)
{
- if (!string.IsNullOrWhiteSpace(channelPrefix))
- {
- return await Collection.Find(x => x.AppId == appId && x.Channel == channelPrefix)
- .SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count).ToListAsync(ct);
- }
- else
- {
- return await Collection.Find(x => x.AppId == appId)
- .SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count).ToListAsync(ct);
- }
+ var find =
+ !string.IsNullOrWhiteSpace(channelPrefix) ?
+ Collection.Find(x => x.OwnerId == ownerId && x.Channel == channelPrefix) :
+ Collection.Find(x => x.OwnerId == ownerId);
+
+ return await find.SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count).ToListAsync(ct);
}
public Task InsertManyAsync(IEnumerable historyEvents,
diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamEntity.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamEntity.cs
new file mode 100644
index 000000000..157ad536a
--- /dev/null
+++ b/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
+ {
+ [BsonRequired]
+ [BsonElement("_ui")]
+ public string[] IndexedUserIds { get; set; }
+
+ [BsonIgnoreIfDefault]
+ [BsonElement("_ct")]
+ public Instant IndexedCreated { get; set; }
+
+ public override void Prepare()
+ {
+ var users = new HashSet
+ {
+ Document.CreatedBy.Identifier
+ };
+
+ users.AddRange(Document.Contributors.Keys);
+
+ IndexedUserIds = users.ToArray();
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Teams/MongoTeamRepository.cs
new file mode 100644
index 000000000..33d122942
--- /dev/null
+++ b/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, ITeamRepository
+ {
+ public MongoTeamRepository(IMongoDatabase database)
+ : base(database)
+ {
+ }
+
+ protected override Task SetupCollectionAsync(IMongoCollection collection,
+ CancellationToken ct)
+ {
+ return collection.Indexes.CreateManyAsync(new[]
+ {
+ new CreateIndexModel(
+ Index
+ .Ascending(x => x.IndexedUserIds))
+ }, ct);
+ }
+
+ public async Task> 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 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;
+ }
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs
index c1fd1ebaa..a71ac826f 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs
+++ b/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.Schemas;
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.Security;
@@ -23,14 +25,16 @@ namespace Squidex.Domain.Apps.Entities
private readonly IAppsIndex indexForApps;
private readonly IRulesIndex indexForRules;
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)
{
this.localCache = localCache;
this.indexForApps = indexForApps;
this.indexForRules = indexForRules;
this.indexForSchemas = indexForSchemas;
+ this.indexForTeams = indexForTeams;
}
public async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id, bool canCache = false,
@@ -89,6 +93,19 @@ namespace Squidex.Domain.Apps.Entities
return app;
}
+ public async Task 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 GetSchemaAsync(DomainId appId, string name, bool canCache = false,
CancellationToken ct = default)
{
@@ -136,6 +153,27 @@ namespace Squidex.Domain.Apps.Entities
return apps?.ToList() ?? new List();
}
+ public async Task> GetTeamAppsAsync(DomainId teamId,
+ CancellationToken ct = default)
+ {
+ var apps = await GetOrCreate($"GetTeamApps({teamId})", () =>
+ {
+ return indexForApps.GetAppsForTeamAsync(teamId, ct)!;
+ });
+
+ return apps?.ToList() ?? new List();
+ }
+
+ public async Task> GetUserTeamsAsync(string userId, CancellationToken ct = default)
+ {
+ var teams = await GetOrCreate($"GetUserTeams({userId})", () =>
+ {
+ return indexForTeams.GetTeamsAsync(userId, ct)!;
+ });
+
+ return teams?.ToList() ?? new List();
+ }
+
public async Task> GetSchemasAsync(DomainId appId,
CancellationToken ct = default)
{
@@ -207,6 +245,11 @@ namespace Squidex.Domain.Apps.Entities
return $"APPS_NAME_{appName}";
}
+ private static string TeamCacheKey(DomainId teamId)
+ {
+ return $"TEAMS_ID{teamId}";
+ }
+
private static string SchemaCacheKey(DomainId appId, DomainId id)
{
return $"SCHEMAS_ID_{appId}_{id}";
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
index c024bc7c8..8451b8494 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
@@ -1,4 +1,4 @@
-// ==========================================================================
+// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
@@ -13,7 +13,7 @@ using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Apps
{
- public class AppHistoryEventsCreator : HistoryEventsCreatorBase
+ public sealed class AppHistoryEventsCreator : HistoryEventsCreatorBase
{
public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry)
: base(typeNameRegistry)
@@ -62,6 +62,21 @@ namespace Squidex.Domain.Apps.Entities.Apps
AddEventMessage(
"history.apps.roleUpdated");
+
+ AddEventMessage(
+ "history.apps.assetScriptsConfigured");
+
+ AddEventMessage(
+ "history.apps.updated");
+
+ AddEventMessage(
+ "history.apps.transfered");
+
+ AddEventMessage(
+ "history.apps.imageUploaded");
+
+ AddEventMessage(
+ "history.apps.imageRemoved");
}
private HistoryEvent? CreateEvent(IEvent @event)
@@ -97,13 +112,28 @@ namespace Squidex.Domain.Apps.Entities.Apps
case AppPlanReset e:
return CreatePlansEvent(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;
}
- private HistoryEvent CreateAppSettingsEvent(AppSettingsUpdated e)
+ private HistoryEvent CreateGeneralEvent(IEvent e)
+ {
+ return ForEvent(e, "general");
+ }
+
+ private HistoryEvent CreateAppSettingsEvent(IEvent e)
{
return ForEvent(e, "settings.appSettings");
}
@@ -133,9 +163,14 @@ namespace Squidex.Domain.Apps.Entities.Apps
return ForEvent(e, "settings.plan").Param("Plan", plan);
}
+ private HistoryEvent CreateAssetScriptsEvent(IEvent e)
+ {
+ return ForEvent(e, "settings.assetScripts");
+ }
+
protected override Task CreateEventCoreAsync(Envelope @event)
{
return Task.FromResult(CreateEvent(@event.Payload));
}
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs
index 7af7f6b43..0ce7500b3 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs
@@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class AddLanguage : AppUpdateCommand
+ public sealed class AddLanguage : AppCommand
{
public Language Language { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddRole.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddRole.cs
index a536c7067..c1623759f 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddRole.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddRole.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class AddRole : AppUpdateCommand
+ public sealed class AddRole : AppCommand
{
public string Name { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs
index a96c76829..12c39549e 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs
@@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class AddWorkflow : AppUpdateCommand
+ public sealed class AddWorkflow : AppCommand
{
public DomainId WorkflowId { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs
index aafa9b2f0..7e28f64d1 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs
+++ b/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
{
- public sealed class AssignContributor : AppUpdateCommand
+ public sealed class AssignContributor : AppCommand
{
public string ContributorId { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs
index debde745d..12cca6c65 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs
@@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class AttachClient : AppUpdateCommand
+ public sealed class AttachClient : AppCommand
{
public string Id { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs
index 28351c602..15be5174b 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class ChangePlan : AppUpdateCommand
+ public sealed class ChangePlan : AppCommand
{
public bool FromCallback { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureAssetScripts.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureAssetScripts.cs
index c322b0cca..13f5bf92d 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureAssetScripts.cs
+++ b/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
{
- public sealed class ConfigureAssetScripts : AppUpdateCommand
+ public sealed class ConfigureAssetScripts : AppCommand
{
public AssetScripts? Scripts { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs
index d9fa314cc..72526a486 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs
+++ b/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
{
- public sealed class CreateApp : AppCommand, IAggregateCommand
+ public sealed class CreateApp : AppCommandBase, IAggregateCommand
{
public DomainId AppId { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteApp.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteApp.cs
index 631e5d55e..8ea5a11c4 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteApp.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteApp.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class DeleteApp : AppUpdateCommand
+ public sealed class DeleteApp : AppCommand
{
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteRole.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteRole.cs
index 0ae4cf648..d18e03892 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteRole.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteRole.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class DeleteRole : AppUpdateCommand
+ public sealed class DeleteRole : AppCommand
{
public string Name { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs
index 33a29e8c3..98b29fc7d 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs
@@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class DeleteWorkflow : AppUpdateCommand
+ public sealed class DeleteWorkflow : AppCommand
{
public DomainId WorkflowId { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs
index be603b50f..c88fa5c56 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class RemoveAppImage : AppUpdateCommand
+ public sealed class RemoveAppImage : AppCommand
{
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs
index 6fcf1ee30..d61035173 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class RemoveContributor : AppUpdateCommand
+ public sealed class RemoveContributor : AppCommand
{
public string ContributorId { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs
index dcc33a517..1fe4743f8 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs
@@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class RemoveLanguage : AppUpdateCommand
+ public sealed class RemoveLanguage : AppCommand
{
public Language Language { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs
index 39d464afd..a545686a6 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class RevokeClient : AppUpdateCommand
+ public sealed class RevokeClient : AppCommand
{
public string Id { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/TransferToTeam.cs
similarity index 73%
rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/TransferToTeam.cs
index 09d401854..a5e36102b 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/TransferToTeam.cs
@@ -6,12 +6,11 @@
// ==========================================================================
using Squidex.Infrastructure;
-using Squidex.Infrastructure.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; }
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs
index 605decdd5..f47952812 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class UpdateApp : AppUpdateCommand
+ public sealed class UpdateApp : AppCommand
{
public string? Label { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateAppSettings.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateAppSettings.cs
index 7093a4546..eae5da772 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateAppSettings.cs
+++ b/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
{
- public sealed class UpdateAppSettings : AppUpdateCommand
+ public sealed class UpdateAppSettings : AppCommand
{
public AppSettings Settings { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs
index 3a0909f85..11f15efb0 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class UpdateClient : AppUpdateCommand
+ public sealed class UpdateClient : AppCommand
{
public string Id { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs
index 18cae14c8..b643f678a 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs
@@ -9,7 +9,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class UpdateLanguage : AppUpdateCommand
+ public sealed class UpdateLanguage : AppCommand
{
public Language Language { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateRole.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateRole.cs
index d885d0eca..2465f4b5c 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateRole.cs
+++ b/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
{
- public sealed class UpdateRole : AppUpdateCommand
+ public sealed class UpdateRole : AppCommand
{
public string Name { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs
index 16755127d..5978a00a9 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs
@@ -10,7 +10,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class UpdateWorkflow : AppUpdateCommand
+ public sealed class UpdateWorkflow : AppCommand
{
public DomainId WorkflowId { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs
index 65b1a535d..065fe76d2 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs
@@ -9,7 +9,7 @@ using Squidex.Assets;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
- public sealed class UploadAppImage : AppUpdateCommand
+ public sealed class UploadAppImage : AppCommand
{
public AssetFile File { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/_AppCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/_AppCommand.cs
new file mode 100644
index 000000000..4f676f141
--- /dev/null
+++ b/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 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; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs
index 567a42484..7eda5795d 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs
+++ b/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
{
- public sealed class AppCommandMiddleware : AggregateCommandMiddleware
+ public sealed class AppCommandMiddleware : AggregateCommandMiddleware
{
private readonly IAppImageStore appImageStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs
index d179abd12..cf1b08bb2 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs
@@ -6,6 +6,7 @@
// ==========================================================================
using System.Text.Json.Serialization;
+using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Contents;
@@ -29,17 +30,19 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
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 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();
@@ -64,26 +67,30 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Id = e.AppId.Id;
SimpleMapper.Map(e, this);
-
return true;
}
case AppUpdated e when Is.Change(Label, e.Label) || Is.Change(Description, e.Description):
{
SimpleMapper.Map(e, this);
+ return true;
+ }
+ case AppTransfered e when Is.Change(TeamId, e.TeamId):
+ {
+ SimpleMapper.Map(e, this);
return true;
}
case AppSettingsUpdated e when Is.Change(Settings, 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):
return UpdateAssetScripts(e.Scripts);
+ case AppPlanChanged e when Is.Change(Plan?.PlanId, e.PlanId):
+ return UpdatePlan(e.ToPlan());
+
case AppPlanReset e when Plan != null:
return UpdatePlan(null);
@@ -150,7 +157,6 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Plan = null;
IsDeleted = true;
-
return true;
}
}
@@ -158,7 +164,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return false;
}
- private bool UpdateContributors(T @event, Func update)
+ private bool UpdateContributors(T @event, Func update)
{
var previous = Contributors;
@@ -224,7 +230,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return true;
}
- private bool UpdatePlan(AppPlan? plan)
+ private bool UpdatePlan(AssignedPlan? plan)
{
Plan = plan;
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs
index 611d97f8e..918335352 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs
+++ b/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.Entities.Apps.Commands;
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.Apps;
using Squidex.Infrastructure;
@@ -42,12 +42,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
protected override bool CanAcceptCreation(ICommand command)
{
- return command is CreateApp;
+ return command is AppCommandBase;
}
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 ExecuteAsync(IAggregateCommand command,
@@ -75,6 +75,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return Snapshot;
}, 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:
return UpdateReturn(updateSettings, c =>
{
@@ -258,7 +268,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
case DeleteApp delete:
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);
}, ct);
@@ -279,7 +289,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
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))
{
@@ -290,7 +300,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
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)
{
@@ -310,11 +320,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
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 })
{
- await Billing().SubscribeAsync(userId, Snapshot.NamedId(), changePlan.PlanId, default);
+ await BillingManager().SubscribeAsync(userId, Snapshot.NamedId(), changePlan.PlanId, default);
}
return result;
@@ -324,23 +334,25 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{
var appId = NamedId.Of(command.AppId, command.Name);
- var events = new List
+ void RaiseInitial(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()?.Settings;
- foreach (var @event in events)
+ if (settings != null)
{
- @event.AppId = appId;
-
- Raise(command, @event);
+ RaiseInitial(new AppSettingsUpdated { Settings = settings });
}
}
@@ -359,6 +371,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Raise(command, new AppUpdated());
}
+ private void Transfer(TransferToTeam command)
+ {
+ Raise(command, new AppTransfered());
+ }
+
private void UpdateSettings(UpdateAppSettings command)
{
Raise(command, new AppSettingsUpdated());
@@ -454,38 +471,28 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Raise(command, new AppDeleted());
}
- private void Raise(T command, TEvent @event) where T : class where TEvent : AppEvent
+ private void Raise(T command, TEvent @event, NamedId? id = null) where T : class where TEvent : AppEvent
{
SimpleMapper.Map(command, @event);
- @event.AppId ??= Snapshot.NamedId();
+ @event.AppId ??= id ?? Snapshot.NamedId();
RaiseEvent(Envelope.Create(@event));
}
- private static AppCreated CreateInitalEvent(string name)
- {
- return new AppCreated { Name = name };
- }
-
- private static AppContributorAssigned CreateInitialOwner(RefToken actor)
- {
- return new AppContributorAssigned { ContributorId = actor.Identifier, Role = Role.Owner };
- }
-
- private AppSettingsUpdated CreateInitialSettings()
+ private IAppProvider AppProvider()
{
- return new AppSettingsUpdated { Settings = serviceProvider.GetRequiredService().Settings };
+ return serviceProvider.GetRequiredService();
}
- private IAppPlansProvider Plans()
+ private IBillingPlans BillingPlans()
{
- return serviceProvider.GetRequiredService();
+ return serviceProvider.GetRequiredService();
}
- private IAppPlanBillingManager Billing()
+ private IBillingManager BillingManager()
{
- return serviceProvider.GetRequiredService();
+ return serviceProvider.GetRequiredService();
}
private IUserResolver Users()
@@ -493,14 +500,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return serviceProvider.GetRequiredService();
}
- 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;
}
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardApp.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardApp.cs
index e7ed342e4..f11280caf 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardApp.cs
+++ b/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.Plans;
+using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
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);
@@ -135,11 +160,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
return;
}
- if (appPlans.GetPlan(command.PlanId) == null)
+ if (billingPlans.GetPlan(command.PlanId) == null)
{
e(T.Get("apps.plans.notFound"), nameof(command.PlanId));
}
+ if (app.TeamId != null)
+ {
+ e(T.Get("apps.plans.assignedToTeam"));
+ }
+
var plan = app.Plan;
if (!string.IsNullOrWhiteSpace(command.PlanId) && plan != null && !plan.Owner.Equals(command.Actor))
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs
index 68a76a9e0..a4daee6bc 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/Guards/GuardAppContributors.cs
+++ b/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.Entities.Apps.Commands;
-using Squidex.Domain.Apps.Entities.Apps.Plans;
+using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
@@ -17,7 +17,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject.Guards
{
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);
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
index fd2c599e2..d77f8d715 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
@@ -5,9 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
+using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Contents;
+using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps
{
@@ -23,9 +25,13 @@ namespace Squidex.Domain.Apps.Entities.Apps
string? Description { get; }
+ DomainId? TeamId { get; }
+
Roles Roles { get; }
- AppPlan? Plan { get; }
+ AssignedPlan? Plan { get; }
+
+ Contributors Contributors { get; }
AppImage? Image { get; }
@@ -33,8 +39,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
AppSettings Settings { get; }
- AppContributors Contributors { get; }
-
AssetScripts AssetScripts { get; }
LanguagesConfig Languages { get; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs
index 8e0487e6f..1f7b79b62 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs
@@ -74,6 +74,22 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
}
}
+ public async Task> 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 GetAppAsync(string name, bool canCache = false,
CancellationToken ct = default)
{
@@ -165,7 +181,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
case DeleteApp delete:
await OnDeleteAsync(delete);
break;
- case AppUpdateCommand update:
+ case AppCommand update:
await OnUpdateAsync(update);
break;
}
@@ -195,7 +211,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
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);
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs
index 15cb5163b..0422cb0a4 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs
@@ -15,6 +15,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
Task> GetAppsForUserAsync(string userId, PermissionSet permissions,
CancellationToken ct = default);
+ Task> GetAppsForTeamAsync(DomainId teamId,
+ CancellationToken ct = default);
+
Task GetAppAsync(string name, bool canCache = false,
CancellationToken ct = default);
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs
deleted file mode 100644
index 404133472..000000000
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitationEventConsumer.cs
+++ /dev/null
@@ -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 log;
-
- public string Name
- {
- get => "NotificationEmailSender";
- }
-
- public string EventsFilter
- {
- get { return "^app-"; }
- }
-
- public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver,
- ILogger log)
- {
- this.emailSender = emailSender;
- this.userResolver = userResolver;
-
- this.log = log;
- }
-
- public async Task On(Envelope @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);
- }
- }
- }
-}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
deleted file mode 100644
index b93a27cca..000000000
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
+++ /dev/null
@@ -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();
- }
- }
-}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppLimitsPlan.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppLimitsPlan.cs
deleted file mode 100644
index ea8041c9c..000000000
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppLimitsPlan.cs
+++ /dev/null
@@ -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();
- }
- }
-}
\ No newline at end of file
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs
deleted file mode 100644
index 377b5f10e..000000000
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/ConfigAppPlansProvider.cs
+++ /dev/null
@@ -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 plansById = new Dictionary(StringComparer.OrdinalIgnoreCase);
- private readonly List plansList = new List();
- private readonly ConfigAppLimitsPlan freePlan;
-
- public ConfigAppPlansProvider(IEnumerable 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 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;
- }
- }
-}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppLimitsPlan.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppLimitsPlan.cs
deleted file mode 100644
index 65509948f..000000000
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppLimitsPlan.cs
+++ /dev/null
@@ -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; }
- }
-}
\ No newline at end of file
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlansProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlansProvider.cs
deleted file mode 100644
index 1875d9cd7..000000000
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlansProvider.cs
+++ /dev/null
@@ -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 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);
- }
-}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs
index 05973e9f2..241ea992f 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs
+++ b/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)
{
- var newApps = totalApps + 1;
- var newAppsValue = newApps.ToString(CultureInfo.InvariantCulture);
+ var newAppsCount = totalApps + 1;
+ var newAppsValue = newAppsCount.ToString(CultureInfo.InvariantCulture);
// Always update the user and therefore do nto pass cancellation token.
await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.TotalApps, newAppsValue, true, default);
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs
deleted file mode 100644
index a08eed647..000000000
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageGate.cs
+++ /dev/null
@@ -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 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(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;
- }
- }
-}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs
index 09a7eaad5..4fd6ecb37 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Repositories/IAppRepository.cs
@@ -14,6 +14,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Repositories
Task> QueryAllAsync(string contributorId, IEnumerable names,
CancellationToken ct = default);
+ Task> QueryAllAsync(DomainId teamId,
+ CancellationToken ct = default);
+
Task FindAsync(DomainId id,
CancellationToken ct = default);
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
index f08afe66e..4d63519b7 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker.cs
@@ -7,23 +7,19 @@
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Apps;
-using Squidex.Infrastructure;
+using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure.States;
-using Squidex.Infrastructure.UsageTracking;
#pragma warning disable CS0649
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 ISnapshotStore store;
private readonly ITagService tagService;
- private readonly IUsageTracker usageTracker;
+ private readonly IAppUsageGate appUsageGate;
[CollectionName("Index_TagHistory")]
public sealed class State
@@ -31,13 +27,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
public HashSet? Tags { get; set; }
}
- public AssetUsageTracker(IUsageTracker usageTracker, IAssetLoader assetLoader, ITagService tagService,
+ public AssetUsageTracker(IAppUsageGate appUsageGate, IAssetLoader assetLoader, ITagService tagService,
ISnapshotStore store)
{
+ this.appUsageGate = appUsageGate;
this.assetLoader = assetLoader;
this.tagService = tagService;
this.store = store;
- this.usageTracker = usageTracker;
ClearCache();
}
@@ -45,48 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Task IDeleter.DeleteAppAsync(IAppEntity app,
CancellationToken ct)
{
- var key = GetKey(app.Id);
-
- return usageTracker.DeleteAsync(key, ct);
- }
-
- public async Task GetTotalSizeAsync(DomainId appId)
- {
- var key = GetKey(appId);
-
- var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null);
-
- return counters.GetInt64(CounterTotalSize);
- }
-
- public async Task> QueryAsync(DomainId appId, DateTime fromDate, DateTime toDate)
- {
- var enriched = new List();
-
- 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 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));
- }
+ return appUsageGate.DeleteAssetUsageAsync(app.Id, ct);
}
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
index b61391b5c..10e130449 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetUsageTracker_EventHandling.cs
+++ b/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.EventSourcing;
using Squidex.Infrastructure.States;
-using Squidex.Infrastructure.UsageTracking;
#pragma warning disable MA0048 // File name must match type name
@@ -58,8 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
await store.ClearAsync();
- // Use a well defined prefix query for the deletion to improve performance.
- await usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_Assets");
+ await appUsageGate.DeleteAssetsUsageAsync();
}
public async Task On(IEnumerable> events)
@@ -187,13 +185,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
switch (@event.Payload)
{
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:
- return UpdateSizeAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0);
+ return appUsageGate.TrackAssetAsync(assetUpdated.AppId.Id, GetDate(@event), assetUpdated.FileSize, 0);
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;
@@ -203,25 +201,5 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
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";
- }
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetCommand.cs
similarity index 65%
rename from backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetCommand.cs
index 095855ac5..6f356ec33 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetCommand.cs
@@ -8,19 +8,27 @@
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
+#pragma warning disable MA0048 // File name must match type name
+
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
- public abstract class AssetCommand : SquidexCommand, IAppCommand, IAggregateCommand
+ public abstract class AssetCommand : AssetCommandBase
{
- public NamedId AppId { get; set; }
-
public DomainId AssetId { get; set; }
public bool DoNotScript { get; set; }
- public DomainId AggregateId
+ public override DomainId AggregateId
{
get => DomainId.Combine(AppId, AssetId);
}
}
+
+ // This command is needed as marker for middlewares.
+ public abstract class AssetCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
+ {
+ public NamedId AppId { get; set; }
+
+ public abstract DomainId AggregateId { get; }
+ }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetFolderCommand.cs
similarity index 63%
rename from backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetFolderCommand.cs
index f8a8d1f93..21fb707ee 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/_AssetFolderCommand.cs
@@ -8,17 +8,25 @@
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
+#pragma warning disable MA0048 // File name must match type name
+
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
- public abstract class AssetFolderCommand : SquidexCommand, IAppCommand, IAggregateCommand
+ public abstract class AssetFolderCommand : AssetFolderCommandBase
{
- public NamedId AppId { get; set; }
-
public DomainId AssetFolderId { get; set; }
- public DomainId AggregateId
+ public override DomainId AggregateId
{
get => DomainId.Combine(AppId, AssetFolderId);
}
}
+
+ // This command is needed as marker for middlewares.
+ public abstract class AssetFolderCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
+ {
+ public NamedId AppId { get; set; }
+
+ public abstract DomainId AggregateId { get; }
+ }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.State.cs
index cb60ae55a..8d2ef9758 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.State.cs
+++ b/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;
EnsureProperties();
-
return true;
}
@@ -88,7 +87,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
TotalSize += e.FileSize;
EnsureProperties();
-
return true;
}
@@ -132,7 +130,6 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
}
EnsureProperties();
-
return hasChanged;
}
@@ -141,14 +138,12 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
ParentId = e.ParentId;
EnsureProperties();
-
return true;
}
case AssetDeleted:
{
IsDeleted = true;
-
return true;
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs
index 8b6077663..519f8f158 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetDomainObject.cs
+++ b/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)
{
- return command is AssetCommand;
+ return command is AssetCommandBase;
}
protected override bool CanAccept(ICommand command)
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.State.cs
index 2885e480a..3bf73e437 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.State.cs
+++ b/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;
SimpleMapper.Map(e, this);
-
return true;
}
case AssetFolderRenamed e when Is.OptionalChange(FolderName, e.FolderName):
{
FolderName = e.FolderName;
-
return true;
}
case AssetFolderMoved e when Is.Change(ParentId, e.ParentId):
{
ParentId = e.ParentId;
-
return true;
}
case AssetFolderDeleted:
{
IsDeleted = true;
-
return true;
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs
index e4c09a3d2..e1e480d33 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetFolderDomainObject.cs
+++ b/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)
{
- return command is AssetFolderCommand;
+ return command is AssetFolderCommandBase;
}
protected override bool CanAccept(ICommand command)
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs
index 6d1116128..56bd09cb5 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetUsageTracker.cs
@@ -11,8 +11,16 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public interface IAssetUsageTracker
{
- Task> QueryAsync(DomainId appId, DateTime fromDate, DateTime toDate);
+ Task> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate,
+ CancellationToken ct = default);
- Task GetTotalSizeAsync(DomainId appId);
+ Task> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate,
+ CancellationToken ct = default);
+
+ Task GetTotalSizeByAppAsync(DomainId appId,
+ CancellationToken ct = default);
+
+ Task GetTotalSizeByTeamAsync(DomainId teamId,
+ CancellationToken ct = default);
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/ConfigPlansProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/ConfigPlansProvider.cs
new file mode 100644
index 000000000..759a4f33a
--- /dev/null
+++ b/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 plansById = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ private readonly List plans = new List();
+ private readonly Plan freePlan;
+
+ public ConfigPlansProvider(IEnumerable 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 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);
+ }
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/IAppUsageGate.cs
new file mode 100644
index 000000000..4a481d741
--- /dev/null
+++ b/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 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);
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlanBillingManager.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs
similarity index 67%
rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlanBillingManager.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs
index 963f935a5..84cca89ad 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/IAppPlanBillingManager.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs
@@ -7,21 +7,30 @@
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; }
Task MustRedirectToPortalAsync(string userId, NamedId appId, string? planId,
CancellationToken ct = default);
+ Task MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId,
+ CancellationToken ct = default);
+
Task SubscribeAsync(string userId, NamedId appId, string planId,
CancellationToken ct = default);
+ Task SubscribeAsync(string userId, DomainId teamId, string planId,
+ CancellationToken ct = default);
+
Task UnsubscribeAsync(string userId, NamedId appId,
CancellationToken ct = default);
+ Task UnsubscribeAsync(string userId, DomainId teamId,
+ CancellationToken ct = default);
+
Task GetPortalLinkAsync(string userId,
CancellationToken ct = default);
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingPlans.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingPlans.cs
new file mode 100644
index 000000000..00abfd961
--- /dev/null
+++ b/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 GetAvailablePlans();
+
+ bool IsConfiguredPlan(string? planId);
+
+ Plan? GetPlan(string? planId);
+
+ Plan GetFreePlan();
+
+ (Plan Plan, string PlanId) GetActualPlan(string? planId);
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/Messages.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs
similarity index 93%
rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/Messages.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs
index 8055333b0..79f3017f9 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/Messages.cs
+++ b/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
-namespace Squidex.Domain.Apps.Entities.Apps.Plans
+namespace Squidex.Domain.Apps.Entities.Billing
{
public sealed record UsageTrackingCheck
{
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/NoopAppPlanBillingManager.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs
similarity index 65%
rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/NoopAppPlanBillingManager.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs
index 2a3d22307..30a034163 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/NoopAppPlanBillingManager.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs
@@ -7,9 +7,9 @@
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
{
@@ -28,16 +28,34 @@ namespace Squidex.Domain.Apps.Entities.Apps.Plans
return Task.FromResult(null);
}
+ public Task MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId,
+ CancellationToken ct = default)
+ {
+ return Task.FromResult(null);
+ }
+
public Task SubscribeAsync(string userId, NamedId appId, string planId,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
+ public Task SubscribeAsync(string userId, DomainId teamId, string planId,
+ CancellationToken ct = default)
+ {
+ return Task.CompletedTask;
+ }
+
public Task UnsubscribeAsync(string userId, NamedId appId,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
+
+ public Task UnsubscribeAsync(string userId, DomainId teamId,
+ CancellationToken ct = default)
+ {
+ return Task.CompletedTask;
+ }
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/Plan.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/Plan.cs
new file mode 100644
index 000000000..a12227b11
--- /dev/null
+++ b/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; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/PlanChangedResult.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/PlanChangedResult.cs
similarity index 92%
rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/PlanChangedResult.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Billing/PlanChangedResult.cs
index bbe78e3d3..bd7d4c936 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/PlanChangedResult.cs
+++ b/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
-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)
{
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs
new file mode 100644
index 000000000..cc6ebab97
--- /dev/null
+++ b/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 GetTotalSizeByAppAsync(DomainId appId,
+ CancellationToken ct = default)
+ {
+ return GetTotalSizeAsync(AppAssetsKey(appId), ct);
+ }
+
+ public Task GetTotalSizeByTeamAsync(DomainId teamId,
+ CancellationToken ct = default)
+ {
+ return GetTotalSizeAsync(TeamAssetsKey(teamId), ct);
+ }
+
+ private async Task GetTotalSizeAsync(string key,
+ CancellationToken ct)
+ {
+ var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null, ct);
+
+ return counters.GetInt64(CounterTotalSize);
+ }
+
+ public Task> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate,
+ CancellationToken ct = default)
+ {
+ return QueryAsync(AppAssetsKey(appId), fromDate, toDate, ct);
+ }
+
+ public Task> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate,
+ CancellationToken ct = default)
+ {
+ return QueryAsync(TeamAssetsKey(teamId), fromDate, toDate, ct);
+ }
+
+ private async Task> QueryAsync(string key, DateTime fromDate, DateTime toDate,
+ CancellationToken ct)
+ {
+ var enriched = new List();
+
+ 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 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 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(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
+ {
+ 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";
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierWorker.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs
similarity index 98%
rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierWorker.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs
index 8fb735879..4f95e7033 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/UsageNotifierWorker.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs
@@ -13,7 +13,7 @@ using Squidex.Infrastructure.Tasks;
using Squidex.Messaging;
using Squidex.Shared.Users;
-namespace Squidex.Domain.Apps.Entities.Apps.Plans
+namespace Squidex.Domain.Apps.Entities.Billing
{
public sealed class UsageNotifierWorker : IMessageHandler
{
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs
index de9c66b09..13565080a 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Comments.Commands
{
- public abstract class CommentTextCommand : CommentsCommand
+ public abstract class CommentTextCommand : CommentCommand
{
public string Text { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs
index 756f41c0c..0db887bf9 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Comments.Commands
{
- public sealed class DeleteComment : CommentsCommand
+ public sealed class DeleteComment : CommentCommand
{
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs
similarity index 51%
rename from backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs
index 089ed024a..dabb7d7fe 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs
@@ -8,21 +8,42 @@
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
+#pragma warning disable MA0048 // File name must match type name
+
namespace Squidex.Domain.Apps.Entities.Comments.Commands
{
- public abstract class CommentsCommand : SquidexCommand, IAppCommand, IAggregateCommand
+ public abstract class CommentCommand : CommentsCommand
{
- public static readonly NamedId NoApp = NamedId.Of(DomainId.NewGuid(), "none");
+ public DomainId CommentId { get; set; }
+ }
- public NamedId AppId { get; set; }
+ public abstract class CommentsCommand : CommentsCommandBase
+ {
+ public static readonly NamedId NoApp = NamedId.Of(DomainId.NewGuid(), "none");
public DomainId CommentsId { get; set; }
- public DomainId CommentId { get; set; }
-
- DomainId IAggregateCommand.AggregateId
+ public override DomainId 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 AppId { get; set; }
+
+ public abstract DomainId AggregateId { get; }
+ }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs
index bf3b746ef..6331c4f8d 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs
+++ b/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
{
- public sealed class CommentsCommandMiddleware : AggregateCommandMiddleware
+ public sealed class CommentsCommandMiddleware : AggregateCommandMiddleware
{
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;
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/_ContentCommand.cs
similarity index 69%
rename from backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/_ContentCommand.cs
index 89ba1fb61..33f40a4de 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/_ContentCommand.cs
@@ -8,21 +8,28 @@
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
+#pragma warning disable MA0048 // File name must match type name
+
namespace Squidex.Domain.Apps.Entities.Contents.Commands
{
- public abstract class ContentCommand : SquidexCommand, IAppCommand, ISchemaCommand, IAggregateCommand
+ public abstract class ContentCommand : ContentCommandBase
{
- public NamedId AppId { get; set; }
-
- public NamedId SchemaId { get; set; }
-
public DomainId ContentId { get; set; }
public bool DoNotScript { get; set; }
- public DomainId AggregateId
+ public override DomainId AggregateId
{
get => DomainId.Combine(AppId, ContentId);
}
}
+
+ public abstract class ContentCommandBase : SquidexCommand, IAppCommand, ISchemaCommand, IAggregateCommand
+ {
+ public NamedId AppId { get; set; }
+
+ public NamedId SchemaId { get; set; }
+
+ public abstract DomainId AggregateId { get; }
+ }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
index 63b6302f5..5bf4ee03c 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentDomainObject.cs
+++ b/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)
{
- return command is CreateContent or UpsertContent;
+ return command is ContentCommandBase;
}
protected override bool CanRecreate()
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
index bc4dd77e7..4634fe85f 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
+++ b/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.Entities.Assets;
using Squidex.Infrastructure;
-using Squidex.Messaging.Subscriptions;
using Squidex.Shared;
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)
{
Name = "id",
- Description = "The id of the asset (usually GUID).",
+ Description = "The ID of the asset (usually GUID).",
DefaultValue = null
}
};
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
index 26d95fa30..6b76a5ceb 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
+++ b/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)
{
Name = "id",
- Description = "The id of the content (usually GUID).",
+ Description = "The ID of the content (usually GUID).",
DefaultValue = null
},
new QueryArgument(Scalars.Int)
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
index a1490c282..505472e31 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
+++ b/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))
{
- q = q with { CreatedBy = context.User.Token() };
+ q = q with { CreatedBy = context.UserPrincipal.Token() };
}
q = await queryParser.ParseAsync(context, q, schema);
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
index 737476ecd..f56c38248 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
@@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
}
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;
- 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)
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs
index c12bb3bc1..8cf20b80c 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs
+++ b/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,
SchemaId = schema.Id,
SchemaName = schema.SchemaDef.Name,
- User = context.User
+ User = context.UserPrincipal
};
var preScript = schema.SchemaDef.Scripts.QueryPre;
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
index c73e68414..290c06562 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
@@ -362,7 +362,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
var ids =
events
.Select(x => x.Payload).OfType()
- .Select(x => DomainId.Combine(x.AppId.Id, x.ContentId))
+ .Select(x => DomainId.Combine(x.AppId, x.ContentId))
.ToHashSet();
return textIndexerState.GetAsync(ids);
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Context.cs b/backend/src/Squidex.Domain.Apps.Entities/Context.cs
index 1a1ef2c10..f64b814e3 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Context.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Context.cs
@@ -25,11 +25,11 @@ namespace Squidex.Domain.Apps.Entities
public ClaimsPermissions UserPermissions { get; }
- public ClaimsPrincipal User { get; }
+ public ClaimsPrincipal UserPrincipal { get; }
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)
: this(app, user, user.Claims.Permissions(), EmptyHeaders)
@@ -37,11 +37,15 @@ namespace Squidex.Domain.Apps.Entities
Guard.NotNull(user);
}
- private Context(IAppEntity app, ClaimsPrincipal user, ClaimsPermissions userPermissions, IReadOnlyDictionary headers)
+ private Context(
+ IAppEntity app,
+ ClaimsPrincipal userPrincipal,
+ ClaimsPermissions userPermissions,
+ IReadOnlyDictionary headers)
{
App = app;
- User = user;
+ UserPrincipal = userPrincipal;
UserPermissions = userPermissions;
Headers = headers;
@@ -84,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities
{
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;
diff --git a/backend/src/Squidex.Domain.Apps.Entities/History/HistoryEvent.cs b/backend/src/Squidex.Domain.Apps.Entities/History/HistoryEvent.cs
index d3cfacf7d..f9ae6ef90 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/History/HistoryEvent.cs
+++ b/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 AppId { get; set; }
+ public DomainId OwnerId { get; set; }
public Instant Created { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs b/backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
index e909556ba..b0647cdb2 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
@@ -7,6 +7,7 @@
using Squidex.Domain.Apps.Entities.History.Repositories;
using Squidex.Domain.Apps.Events;
+using Squidex.Domain.Apps.Events.Teams;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
@@ -34,9 +35,12 @@ namespace Squidex.Domain.Apps.Entities.History
get => GetType().Name;
}
- public HistoryService(IHistoryEventRepository repository, IEnumerable creators, NotifoService notifo)
+ public HistoryService(IHistoryEventRepository repository, IEnumerable creators,
+ NotifoService notifo)
{
this.creators = creators.ToList();
+ this.repository = repository;
+ this.notifo = notifo;
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()
@@ -58,32 +59,35 @@ namespace Squidex.Domain.Apps.Entities.History
public async Task On(IEnumerable> events)
{
- var targets = new List<(Envelope Event, HistoryEvent? HistoryEvent)>();
+ var targets = new List<(Envelope Event, HistoryEvent? HistoryEvent)>();
foreach (var @event in events)
{
- if (@event.Payload is AppEvent)
+ switch (@event.Payload)
{
- var appEvent = @event.To();
+ 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)
- {
- historyEvent = await creator.CreateEventAsync(@event);
+ break;
+ }
- if (historyEvent != null)
+ case TeamEvent teamEvent:
{
- historyEvent.Actor = appEvent.Payload.Actor;
- historyEvent.AppId = appEvent.Payload.AppId.Id;
- historyEvent.Created = @event.Headers.Timestamp();
- historyEvent.Version = @event.Headers.EventStreamNumber();
+ var historyEvent = await CreateEvent(teamEvent.TeamId, teamEvent.Actor, @event);
+
+ if (historyEvent != null)
+ {
+ targets.Add((@event, historyEvent));
+ }
break;
}
- }
-
- targets.Add((appEvent, historyEvent));
}
}
@@ -95,10 +99,29 @@ namespace Squidex.Domain.Apps.Entities.History
}
}
- public async Task> QueryByChannelAsync(DomainId appId, string channelPrefix, int count,
+ private async Task CreateEvent(DomainId ownerId, RefToken actor, Envelope @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> QueryByChannelAsync(DomainId ownerId, string channelPrefix, int count,
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();
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/History/IHistoryService.cs b/backend/src/Squidex.Domain.Apps.Entities/History/IHistoryService.cs
index 1070e10b3..48f90ef67 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/History/IHistoryService.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/History/IHistoryService.cs
@@ -11,7 +11,7 @@ namespace Squidex.Domain.Apps.Entities.History
{
public interface IHistoryService
{
- Task> QueryByChannelAsync(DomainId appId, string channelPrefix, int count,
+ Task> QueryByChannelAsync(DomainId ownerId, string channelPrefix, int count,
CancellationToken ct = default);
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs b/backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs
index 4ca364af5..d69bb7c3f 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs
+++ b/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.Comments;
using Squidex.Domain.Apps.Events.Contents;
+using Squidex.Domain.Apps.Events.Teams;
using Squidex.Domain.Users;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
@@ -141,7 +142,7 @@ namespace Squidex.Domain.Apps.Entities.History
}
}
- public async Task HandleEventsAsync(IEnumerable<(Envelope AppEvent, HistoryEvent? HistoryEvent)> events)
+ public async Task HandleEventsAsync(IEnumerable<(Envelope AppEvent, HistoryEvent? HistoryEvent)> events)
{
Guard.NotNull(events);
@@ -176,12 +177,20 @@ namespace Squidex.Domain.Apps.Entities.History
{
switch (@event.AppEvent.Payload)
{
- case AppContributorAssigned contributorAssigned:
- await AssignContributorAsync(client, contributorAssigned);
+ case AppContributorAssigned assigned:
+ await AssignContributorAsync(client, assigned.ContributorId, GetAppPrefix(assigned));
break;
- case AppContributorRemoved contributorRemoved:
- await RemoveContributorAsync(client, contributorRemoved);
+ case AppContributorRemoved removed:
+ 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;
}
}
@@ -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);
if (user != null)
@@ -211,7 +218,7 @@ namespace Squidex.Domain.Apps.Entities.History
{
var request = new AddAllowedTopicDto
{
- Prefix = GetAppPrefix(contributorAssigned)
+ Prefix = prefix
};
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
{
- var prefix = GetAppPrefix(contributorRemoved);
-
await actualClient.Users.DeleteAllowedTopicAsync(options.ApiKey, userId, prefix);
}
catch (NotifoException ex) when (ex.StatusCode != 404)
@@ -238,22 +241,22 @@ namespace Squidex.Domain.Apps.Entities.History
}
}
- private IEnumerable CreateRequests(Envelope appEvent, HistoryEvent? historyEvent)
+ private IEnumerable CreateRequests(Envelope @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)
{
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
{
@@ -265,7 +268,25 @@ namespace Squidex.Domain.Apps.Entities.History
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)
{
@@ -276,9 +297,6 @@ namespace Squidex.Domain.Apps.Entities.History
publishRequest.TemplateCode = historyEvent.EventType;
- SetUser(payload, publishRequest);
- SetTopic(payload, publishRequest, historyEvent);
-
return publishRequest;
}
@@ -309,25 +327,27 @@ namespace Squidex.Domain.Apps.Entities.History
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);
- var topicSuffix = @event.Channel.Replace('.', '/').Trim();
-
- publishRequest.Topic = $"{topicPrefix}/{topicSuffix}";
+ return $"{prefix}/{@event.Channel.Replace('.', '/').Trim()}";
}
private static string GetAppPrefix(AppEvent appEvent)
{
return $"apps/{appEvent.AppId.Id}";
}
+
+ private static string GetTeamPrefix(TeamEvent teamEvent)
+ {
+ return $"apps/{teamEvent.TeamId}";
+ }
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs
index fd1f09be2..db6b2283a 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs
@@ -8,6 +8,7 @@
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Schemas;
+using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
@@ -18,6 +19,12 @@ namespace Squidex.Domain.Apps.Entities
Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id, bool canCache = false,
CancellationToken ct = default);
+ Task GetTeamAsync(DomainId teamId,
+ CancellationToken ct = default);
+
+ Task> GetUserTeamsAsync(string userId,
+ CancellationToken ct = default);
+
Task GetAppAsync(DomainId appId, bool canCache = false,
CancellationToken ct = default);
@@ -27,6 +34,9 @@ namespace Squidex.Domain.Apps.Entities
Task> GetUserAppsAsync(string userId, PermissionSet permissions,
CancellationToken ct = default);
+ Task> GetTeamAppsAsync(DomainId teamId,
+ CancellationToken ct = default);
+
Task GetSchemaAsync(DomainId appId, DomainId id, bool canCache = false,
CancellationToken ct = default);
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/ITeamCommand.cs
similarity index 64%
rename from backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/ITeamCommand.cs
index 2f31e7da0..07cfdf672 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/ITeamCommand.cs
@@ -8,12 +8,10 @@
using Squidex.Infrastructure;
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 AppId { get; set; }
-
- public abstract DomainId AggregateId { get; }
+ DomainId TeamId { get; set; }
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs b/backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs
new file mode 100644
index 000000000..969b011a2
--- /dev/null
+++ b/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 log;
+
+ public string Name
+ {
+ get => "NotificationEmailSender";
+ }
+
+ public string EventsFilter
+ {
+ get { return "^app-|^app-"; }
+ }
+
+ public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver, IAppProvider appProvider,
+ ILogger log)
+ {
+ this.emailSender = emailSender;
+ this.userResolver = userResolver;
+ this.appProvider = appProvider;
+ this.log = log;
+ }
+
+ public async Task On(Envelope @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);
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Invitation/InviteUserCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Invitation/InviteUserCommandMiddleware.cs
new file mode 100644
index 000000000..8d4065a2b
--- /dev/null
+++ b/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 { 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 { 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);
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs b/backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitedResult.cs
similarity index 73%
rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitedResult.cs
index 45c6df7b9..120dc10b7 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitedResult.cs
@@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
-namespace Squidex.Domain.Apps.Entities.Apps.Invitation
+namespace Squidex.Domain.Apps.Entities.Invitation
{
- public sealed class InvitedResult
+ public sealed class InvitedResult
{
- public IAppEntity App { get; set; }
+ public T Entity { get; set; }
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs
index 96de5b228..539c084ff 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs
@@ -16,5 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
Task SendUsageAsync(IUser user, string appName, long usage, long usageLimit);
Task SendInviteAsync(IUser assigner, IUser user, string appName);
+
+ Task SendTeamInviteAsync(IUser assigner, IUser user, string teamName);
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs
index b0da54368..6f9c3f71f 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs
@@ -21,6 +21,11 @@ namespace Squidex.Domain.Apps.Entities.Notifications
return Task.CompletedTask;
}
+ public Task SendTeamInviteAsync(IUser assigner, IUser user, string teamName)
+ {
+ return Task.CompletedTask;
+ }
+
public Task SendUsageAsync(IUser user, string appName, long usage, long limit)
{
return Task.CompletedTask;
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs
index 559ce216b..da2fc4295 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs
@@ -28,7 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
public IUser? Assigner { get; init; }
- public string AppName { get; init; }
+ public string TeamName { get; init; }
public long? ApiCalls { get; init; }
@@ -64,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
{
ApiCalls = usage,
ApiCallsLimit = usageLimit,
- AppName = appName
+ TeamName = appName
};
return SendEmailAsync("Usage",
@@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
Guard.NotNull(user);
Guard.NotNull(appName);
- var vars = new TemplatesVars { Assigner = assigner, AppName = appName };
+ var vars = new TemplatesVars { Assigner = assigner, TeamName = appName };
if (user.Claims.HasConsent())
{
@@ -97,6 +97,11 @@ namespace Squidex.Domain.Apps.Entities.Notifications
}
}
+ public Task SendTeamInviteAsync(IUser assigner, IUser user, string teamName)
+ {
+ return Task.CompletedTask;
+ }
+
private async Task SendEmailAsync(string template, string emailSubj, string emailBody, IUser user, TemplatesVars vars)
{
if (string.IsNullOrWhiteSpace(emailBody))
@@ -131,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
private static string Format(string text, TemplatesVars vars)
{
- text = text.Replace("$APP_NAME", vars.AppName, StringComparison.Ordinal);
+ text = text.Replace("$APP_NAME", vars.TeamName, StringComparison.Ordinal);
if (vars.Assigner != null)
{
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/_RuleCommand.cs
similarity index 64%
rename from backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/_RuleCommand.cs
index f9fc9cec2..a65564715 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/_RuleCommand.cs
@@ -8,17 +8,25 @@
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
+#pragma warning disable MA0048 // File name must match type name
+
namespace Squidex.Domain.Apps.Entities.Rules.Commands
{
- public abstract class RuleCommand : SquidexCommand, IAppCommand, IAggregateCommand
+ public abstract class RuleCommand : RuleCommandBase
{
- public NamedId AppId { get; set; }
-
public DomainId RuleId { get; set; }
- public DomainId AggregateId
+ public override DomainId AggregateId
{
get => DomainId.Combine(AppId, RuleId);
}
}
+
+ // This command is needed as marker for middlewares.
+ public abstract class RuleCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
+ {
+ public NamedId AppId { get; set; }
+
+ public abstract DomainId AggregateId { get; }
+ }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.State.cs
index 20a7db796..e7ed68744 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.State.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.State.cs
@@ -45,7 +45,6 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject
RuleDef = RuleDef.Rename(e.Name);
AppId = e.AppId;
-
return true;
}
@@ -81,21 +80,18 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject
case RuleEnabled:
{
RuleDef = RuleDef.Enable();
-
break;
}
case RuleDisabled:
{
RuleDef = RuleDef.Disable();
-
break;
}
case RuleDeleted:
{
IsDeleted = true;
-
return true;
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
index a3ca8c9ef..87b390b25 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
@@ -39,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject
protected override bool CanAcceptCreation(ICommand command)
{
- return command is RuleCommand;
+ return command is RuleCommandBase;
}
protected override bool CanAccept(ICommand command)
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs
index 692c4ea20..910f99b07 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs
@@ -11,7 +11,7 @@ using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Rules
{
- public sealed class RuleCommandMiddleware : AggregateCommandMiddleware
+ public sealed class RuleCommandMiddleware : AggregateCommandMiddleware
{
private readonly IRuleEnricher ruleEnricher;
private readonly IContextProvider contextProvider;
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ChangeCategory.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ChangeCategory.cs
index b64695777..5f13caed3 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ChangeCategory.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ChangeCategory.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class ChangeCategory : SchemaUpdateCommand
+ public sealed class ChangeCategory : SchemaCommand
{
public string? Name { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureFieldRules.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureFieldRules.cs
index 2ba3bfa2a..b21fe6764 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureFieldRules.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureFieldRules.cs
@@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class ConfigureFieldRules : SchemaUpdateCommand
+ public sealed class ConfigureFieldRules : SchemaCommand
{
public FieldRuleCommand[]? FieldRules { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigurePreviewUrls.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigurePreviewUrls.cs
index 710140670..3316dd897 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigurePreviewUrls.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigurePreviewUrls.cs
@@ -9,7 +9,7 @@ using Squidex.Infrastructure.Collections;
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class ConfigurePreviewUrls : SchemaUpdateCommand
+ public sealed class ConfigurePreviewUrls : SchemaCommand
{
public ReadonlyDictionary? PreviewUrls { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs
index 45182dcc5..0c7c1ad87 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs
@@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class ConfigureScripts : SchemaUpdateCommand
+ public sealed class ConfigureScripts : SchemaCommand
{
public SchemaScripts? Scripts { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs
index 5d9c33211..9655cdab0 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureUIFields.cs
@@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class ConfigureUIFields : SchemaUpdateCommand
+ public sealed class ConfigureUIFields : SchemaCommand
{
public FieldNames? FieldsInLists { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs
index 490f61fba..9856b9f5d 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs
@@ -9,11 +9,10 @@ using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.Commands;
-using SchemaField = Squidex.Domain.Apps.Entities.Schemas.Commands.UpsertSchemaField;
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class CreateSchema : SchemaCommand, IUpsertCommand, IAggregateCommand
+ public sealed class CreateSchema : SchemaCommandBase, IUpsertCommand, IAggregateCommand
{
public DomainId SchemaId { get; set; }
@@ -25,7 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands
public SchemaType Type { get; set; }
- public SchemaField[]? Fields { get; set; }
+ public UpsertSchemaField[]? Fields { get; set; }
public FieldNames? FieldsInReferences { get; set; }
@@ -51,9 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands
public Schema BuildSchema()
{
- IUpsertCommand self = this;
-
- return self.ToSchema(Name, Type);
+ return ((IUpsertCommand)this).ToSchema(Name, Type);
}
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs
index 458779e5f..522dd9515 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class DeleteSchema : SchemaUpdateCommand
+ public sealed class DeleteSchema : SchemaCommand
{
}
}
\ No newline at end of file
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ParentFieldCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ParentFieldCommand.cs
index 69e2425aa..91f067fb1 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ParentFieldCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ParentFieldCommand.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public abstract class ParentFieldCommand : SchemaUpdateCommand
+ public abstract class ParentFieldCommand : SchemaCommand
{
public long? ParentFieldId { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs
index 1395876d3..bde23a6bc 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class PublishSchema : SchemaUpdateCommand
+ public sealed class PublishSchema : SchemaCommand
{
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SynchronizeSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SynchronizeSchema.cs
index 260bb335b..182196876 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SynchronizeSchema.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SynchronizeSchema.cs
@@ -12,7 +12,7 @@ using SchemaField = Squidex.Domain.Apps.Entities.Schemas.Commands.UpsertSchemaFi
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class SynchronizeSchema : SchemaUpdateCommand, IUpsertCommand, IAggregateCommand, ISchemaCommand
+ public sealed class SynchronizeSchema : SchemaCommand, IUpsertCommand, IAggregateCommand, ISchemaCommand
{
public bool NoFieldDeletion { get; set; }
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs
index d84289023..3652b963b 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs
@@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class UnpublishSchema : SchemaUpdateCommand
+ public sealed class UnpublishSchema : SchemaCommand
{
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs
index 82ca36219..37cf41263 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs
@@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public sealed class UpdateSchema : SchemaUpdateCommand
+ public sealed class UpdateSchema : SchemaCommand
{
public SchemaProperties Properties { get; set; }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaUpdateCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/_SchemaCommand.cs
similarity index 59%
rename from backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaUpdateCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/_SchemaCommand.cs
index d0cb2c6ed..247f6d5fb 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaUpdateCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Commands/_SchemaCommand.cs
@@ -6,10 +6,13 @@
// ==========================================================================
using Squidex.Infrastructure;
+using Squidex.Infrastructure.Commands;
+
+#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
- public abstract class SchemaUpdateCommand : SchemaCommand, ISchemaCommand
+ public abstract class SchemaCommand : SchemaCommandBase, ISchemaCommand
{
public NamedId SchemaId { get; set; }
@@ -18,4 +21,12 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands
get => DomainId.Combine(AppId, SchemaId.Id);
}
}
+
+ // This command is needed as marker for middlewares.
+ public abstract class SchemaCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
+ {
+ public NamedId AppId { get; set; }
+
+ public abstract DomainId AggregateId { get; }
+ }
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.State.cs
index f2a7f1e79..f482a6b5a 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.State.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.State.cs
@@ -48,7 +48,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject
SchemaFieldsTotal = e.Schema.MaxId();
AppId = e.AppId;
-
return true;
}
@@ -71,7 +70,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject
}
SchemaFieldsTotal = Math.Max(SchemaFieldsTotal, e.FieldId.Id);
-
break;
}
@@ -93,112 +91,96 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject
case SchemaCategoryChanged e:
{
SchemaDef = SchemaDef.ChangeCategory(e.Name);
-
break;
}
case SchemaPreviewUrlsConfigured e:
{
SchemaDef = SchemaDef.SetPreviewUrls(e.PreviewUrls);
-
break;
}
case SchemaScriptsConfigured e:
{
SchemaDef = SchemaDef.SetScripts(e.Scripts);
-
break;
}
case SchemaFieldRulesConfigured e:
{
SchemaDef = SchemaDef.SetFieldRules(e.FieldRules);
-
break;
}
case SchemaPublished:
{
SchemaDef = SchemaDef.Publish();
-
break;
}
case SchemaUnpublished:
{
SchemaDef = SchemaDef.Unpublish();
-
break;
}
case SchemaUpdated e:
{
SchemaDef = SchemaDef.Update(e.Properties);
-
break;
}
case SchemaFieldsReordered e:
{
SchemaDef = SchemaDef.ReorderFields(e.FieldIds.ToList(), e.ParentFieldId?.Id);
-
break;
}
case FieldUpdated e:
{
SchemaDef = SchemaDef.UpdateField(e.FieldId.Id, e.Properties, e.ParentFieldId?.Id);
-
break;
}
case FieldLocked e:
{
SchemaDef = SchemaDef.LockField(e.FieldId.Id, e.ParentFieldId?.Id);
-
break;
}
case FieldDisabled e:
{
SchemaDef = SchemaDef.DisableField(e.FieldId.Id, e.ParentFieldId?.Id);
-
break;
}
case FieldEnabled e:
{
SchemaDef = SchemaDef.EnableField(e.FieldId.Id, e.ParentFieldId?.Id);
-
break;
}
case FieldHidden e:
{
SchemaDef = SchemaDef.HideField(e.FieldId.Id, e.ParentFieldId?.Id);
-
break;
}
case FieldShown e:
{
SchemaDef = SchemaDef.ShowField(e.FieldId.Id, e.ParentFieldId?.Id);
-
break;
}
case FieldDeleted e:
{
SchemaDef = SchemaDef.DeleteField(e.FieldId.Id, e.ParentFieldId?.Id);
-
break;
}
case SchemaDeleted:
{
IsDeleted = true;
-
return true;
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs
index 06b47d7e1..ff8841464 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/DomainObject/SchemaDomainObject.cs
@@ -36,12 +36,12 @@ namespace Squidex.Domain.Apps.Entities.Schemas.DomainObject
protected override bool CanAcceptCreation(ICommand command)
{
- return command is SchemaCommand;
+ return command is SchemaCommandBase;
}
protected override bool CanAccept(ICommand command)
{
- return command is SchemaUpdateCommand schemaCommand &&
+ return command is SchemaCommand schemaCommand &&
Equals(schemaCommand.AppId, Snapshot.AppId) &&
Equals(schemaCommand.SchemaId?.Id, Snapshot.Id);
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs
index e4777b161..320f8aaea 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs
@@ -144,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
case DeleteSchema delete:
await OnDeleteAsync(delete);
break;
- case SchemaUpdateCommand update:
+ case SchemaCommand update:
await OnUpdateAsync(update);
break;
}
@@ -161,7 +161,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
return InvalidateItAsync(delete.AppId.Id, delete.SchemaId.Id, delete.SchemaId.Name);
}
- private Task OnUpdateAsync(SchemaUpdateCommand update)
+ private Task OnUpdateAsync(SchemaCommand update)
{
return InvalidateItAsync(update.AppId.Id, update.SchemaId.Id, update.SchemaId.Name);
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/AssignContributor.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/AssignContributor.cs
new file mode 100644
index 000000000..bab461dd4
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/AssignContributor.cs
@@ -0,0 +1,24 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Roles = Squidex.Domain.Apps.Core.Apps.Role;
+
+namespace Squidex.Domain.Apps.Entities.Teams.Commands
+{
+ public sealed class AssignContributor : TeamCommand
+ {
+ public string ContributorId { get; set; }
+
+ public string Role { get; set; } = Roles.Owner;
+
+ public bool IgnoreActor { get; set; }
+
+ public bool IgnorePlans { get; set; }
+
+ public bool Invite { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/ChangePlan.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/ChangePlan.cs
new file mode 100644
index 000000000..6a738d416
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/ChangePlan.cs
@@ -0,0 +1,16 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Entities.Teams.Commands
+{
+ public sealed class ChangePlan : TeamCommand
+ {
+ public bool FromCallback { get; set; }
+
+ public string PlanId { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppUpdateCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/CreateTeam.cs
similarity index 63%
rename from backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppUpdateCommand.cs
rename to backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/CreateTeam.cs
index d384884bf..6525ff913 100644
--- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppUpdateCommand.cs
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/CreateTeam.cs
@@ -7,15 +7,15 @@
using Squidex.Infrastructure;
-namespace Squidex.Domain.Apps.Entities.Apps.Commands
+namespace Squidex.Domain.Apps.Entities.Teams.Commands
{
- public abstract class AppUpdateCommand : AppCommand, IAppCommand
+ public sealed class CreateTeam : TeamCommand
{
- public NamedId AppId { get; set; }
+ public string Name { get; set; }
- public override DomainId AggregateId
+ public CreateTeam()
{
- get => AppId.Id;
+ TeamId = DomainId.NewGuid();
}
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/RemoveContributor.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/RemoveContributor.cs
new file mode 100644
index 000000000..ac0b57a17
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/RemoveContributor.cs
@@ -0,0 +1,14 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Entities.Teams.Commands
+{
+ public sealed class RemoveContributor : TeamCommand
+ {
+ public string ContributorId { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/UpdateTeam.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/UpdateTeam.cs
new file mode 100644
index 000000000..4ec1383fe
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/UpdateTeam.cs
@@ -0,0 +1,14 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Entities.Teams.Commands
+{
+ public sealed class UpdateTeam : TeamCommand
+ {
+ public string Name { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/_TeamCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/_TeamCommand.cs
new file mode 100644
index 000000000..8a08e530a
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Commands/_TeamCommand.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.Teams.Commands
+{
+ public abstract class TeamCommand : TeamCommandBase, ITeamCommand
+ {
+ public DomainId TeamId { get; set; }
+
+ public override DomainId AggregateId
+ {
+ get => TeamId;
+ }
+ }
+
+ // This command is needed as marker for middlewares.
+ public abstract class TeamCommandBase : SquidexCommand, IAggregateCommand
+ {
+ public abstract DomainId AggregateId { get; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/Guards/GuardTeam.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/Guards/GuardTeam.cs
new file mode 100644
index 000000000..e61ed1978
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/Guards/GuardTeam.cs
@@ -0,0 +1,63 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Entities.Billing;
+using Squidex.Domain.Apps.Entities.Teams.Commands;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Translations;
+using Squidex.Infrastructure.Validation;
+
+namespace Squidex.Domain.Apps.Entities.Teams.DomainObject.Guards
+{
+ public static class GuardTeam
+ {
+ public static void CanCreate(CreateTeam command)
+ {
+ Guard.NotNull(command);
+
+ Validate.It(e =>
+ {
+ if (string.IsNullOrWhiteSpace(command.Name))
+ {
+ e(Not.Defined(nameof(command.Name)), nameof(command.Name));
+ }
+ });
+ }
+
+ public static void CanUpdate(UpdateTeam command)
+ {
+ Guard.NotNull(command);
+
+ Validate.It(e =>
+ {
+ if (string.IsNullOrWhiteSpace(command.Name))
+ {
+ e(Not.Defined(nameof(command.Name)), nameof(command.Name));
+ }
+ });
+ }
+
+ public static void CanChangePlan(ChangePlan command, IBillingPlans billingPlans)
+ {
+ Guard.NotNull(command);
+
+ Validate.It(e =>
+ {
+ if (string.IsNullOrWhiteSpace(command.PlanId))
+ {
+ e(Not.Defined(nameof(command.PlanId)), nameof(command.PlanId));
+ return;
+ }
+
+ if (billingPlans.GetPlan(command.PlanId) == null)
+ {
+ e(T.Get("apps.plans.notFound"), nameof(command.PlanId));
+ }
+ });
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/Guards/GuardTeamContributors.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/Guards/GuardTeamContributors.cs
new file mode 100644
index 000000000..24b9d45a2
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/Guards/GuardTeamContributors.cs
@@ -0,0 +1,80 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Core.Apps;
+using Squidex.Domain.Apps.Entities.Teams.Commands;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Translations;
+using Squidex.Infrastructure.Validation;
+using Squidex.Shared.Users;
+
+namespace Squidex.Domain.Apps.Entities.Teams.DomainObject.Guards
+{
+ public static class GuardTeamContributors
+ {
+ public static Task CanAssign(AssignContributor command, ITeamEntity team, IUserResolver users)
+ {
+ Guard.NotNull(command);
+
+ var contributors = team.Contributors;
+
+ return Validate.It(async e =>
+ {
+ if (command.Role != Role.Owner)
+ {
+ e(Not.Valid(nameof(command.Role)), nameof(command.Role));
+ }
+
+ if (string.IsNullOrWhiteSpace(command.ContributorId))
+ {
+ e(Not.Defined(nameof(command.ContributorId)), nameof(command.ContributorId));
+ }
+ else
+ {
+ var user = await users.FindByIdAsync(command.ContributorId);
+
+ if (user == null)
+ {
+ throw new DomainObjectNotFoundException(command.ContributorId);
+ }
+
+ if (!command.IgnoreActor && string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new DomainForbiddenException(T.Get("apps.contributors.cannotChangeYourself"));
+ }
+ }
+ });
+ }
+
+ public static void CanRemove(RemoveContributor command, ITeamEntity team)
+ {
+ Guard.NotNull(command);
+
+ var contributors = team.Contributors;
+
+ Validate.It(e =>
+ {
+ if (string.IsNullOrWhiteSpace(command.ContributorId))
+ {
+ e(Not.Defined(nameof(command.ContributorId)), nameof(command.ContributorId));
+ }
+
+ var ownerIds = contributors.Where(x => x.Value == Role.Owner).Select(x => x.Key).ToList();
+
+ if (ownerIds.Count == 1 && ownerIds.Contains(command.ContributorId))
+ {
+ e(T.Get("apps.contributors.onlyOneOwner"));
+ }
+ });
+
+ if (!contributors.ContainsKey(command.ContributorId))
+ {
+ throw new DomainObjectNotFoundException(command.ContributorId);
+ }
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs
new file mode 100644
index 000000000..7d536bbd6
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs
@@ -0,0 +1,85 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using System.Text.Json.Serialization;
+using Squidex.Domain.Apps.Core;
+using Squidex.Domain.Apps.Events.Teams;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Commands;
+using Squidex.Infrastructure.EventSourcing;
+using Squidex.Infrastructure.Reflection;
+
+namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
+{
+ public partial class TeamDomainObject
+ {
+ public sealed class State : DomainObjectState, ITeamEntity
+ {
+ public string Name { get; set; }
+
+ public Contributors Contributors { get; set; } = Contributors.Empty;
+
+ public AssignedPlan? Plan { get; set; }
+
+ [JsonIgnore]
+ public DomainId UniqueId
+ {
+ get => Id;
+ }
+
+ public override bool ApplyEvent(IEvent @event)
+ {
+ switch (@event)
+ {
+ case TeamCreated e:
+ {
+ Id = e.TeamId;
+
+ SimpleMapper.Map(e, this);
+ return true;
+ }
+
+ case TeamUpdated e when Is.Change(Name, e.Name):
+ {
+ SimpleMapper.Map(e, this);
+ return true;
+ }
+
+ case TeamPlanChanged e when Is.Change(Plan?.PlanId, e.PlanId):
+ return UpdatePlan(e.ToPlan());
+
+ case TeamPlanReset e when Plan != null:
+ return UpdatePlan(null);
+
+ case TeamContributorAssigned e:
+ return UpdateContributors(e, (e, c) => c.Assign(e.ContributorId, e.Role));
+
+ case TeamContributorRemoved e:
+ return UpdateContributors(e, (e, c) => c.Remove(e.ContributorId));
+ }
+
+ return false;
+ }
+
+ private bool UpdateContributors(T @event, Func update)
+ {
+ var previous = Contributors;
+
+ Contributors = update(@event, previous);
+
+ return !ReferenceEquals(previous, Contributors);
+ }
+
+ private bool UpdatePlan(AssignedPlan? plan)
+ {
+ Plan = plan;
+
+ return true;
+ }
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs
new file mode 100644
index 000000000..1b5d28b1c
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs
@@ -0,0 +1,225 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Squidex.Domain.Apps.Core.Apps;
+using Squidex.Domain.Apps.Entities.Billing;
+using Squidex.Domain.Apps.Entities.Teams.Commands;
+using Squidex.Domain.Apps.Entities.Teams.DomainObject.Guards;
+using Squidex.Domain.Apps.Events.Teams;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Commands;
+using Squidex.Infrastructure.EventSourcing;
+using Squidex.Infrastructure.Reflection;
+using Squidex.Infrastructure.States;
+using Squidex.Shared.Users;
+
+#pragma warning disable MA0022 // Return Task.FromResult instead of returning null
+
+namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
+{
+ public partial class TeamDomainObject : DomainObject
+ {
+ private readonly IServiceProvider serviceProvider;
+
+ public TeamDomainObject(DomainId id, IPersistenceFactory persistence, ILogger log,
+ IServiceProvider serviceProvider)
+ : base(id, persistence, log)
+ {
+ this.serviceProvider = serviceProvider;
+ }
+
+ protected override bool IsDeleted(State snapshot)
+ {
+ return false;
+ }
+
+ protected override bool CanAcceptCreation(ICommand command)
+ {
+ return command is TeamCommandBase;
+ }
+
+ protected override bool CanAccept(ICommand command)
+ {
+ return command is TeamCommand update && Equals(update?.TeamId, Snapshot.Id);
+ }
+
+ public override Task ExecuteAsync(IAggregateCommand command,
+ CancellationToken ct)
+ {
+ switch (command)
+ {
+ case CreateTeam create:
+ return CreateReturn(create, c =>
+ {
+ GuardTeam.CanCreate(c);
+
+ Create(c);
+
+ return Snapshot;
+ }, ct);
+
+ case UpdateTeam update:
+ return UpdateReturn(update, c =>
+ {
+ GuardTeam.CanUpdate(c);
+
+ Update(c);
+
+ return Snapshot;
+ }, ct);
+
+ case AssignContributor assignContributor:
+ return UpdateReturnAsync(assignContributor, async (c, ct) =>
+ {
+ await GuardTeamContributors.CanAssign(c, Snapshot, Users());
+
+ AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
+
+ return Snapshot;
+ }, ct);
+
+ case RemoveContributor removeContributor:
+ return UpdateReturn(removeContributor, c =>
+ {
+ GuardTeamContributors.CanRemove(c, Snapshot);
+
+ RemoveContributor(c);
+
+ return Snapshot;
+ }, ct);
+
+ case ChangePlan changePlan:
+ return ChangeBillingPlanAsync(changePlan, ct);
+
+ default:
+ ThrowHelper.NotSupportedException();
+ return default!;
+ }
+ }
+
+ private async Task ChangeBillingPlanAsync(ChangePlan changePlan,
+ CancellationToken ct)
+ {
+ var userId = changePlan.Actor.Identifier;
+
+ var result = await UpdateReturnAsync(changePlan, async (c, ct) =>
+ {
+ GuardTeam.CanChangePlan(c, BillingPlans());
+
+ if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal))
+ {
+ ResetPlan(c);
+
+ return new PlanChangedResult(c.PlanId, true, null);
+ }
+
+ if (!c.FromCallback)
+ {
+ var redirectUri = await BillingManager().MustRedirectToPortalAsync(userId, UniqueId, c.PlanId, ct);
+
+ if (redirectUri != null)
+ {
+ return new PlanChangedResult(c.PlanId, false, redirectUri);
+ }
+ }
+
+ ChangePlan(c);
+
+ return new PlanChangedResult(c.PlanId);
+ }, ct);
+
+ if (changePlan.FromCallback)
+ {
+ return result;
+ }
+
+ if (result.Payload is PlanChangedResult { Unsubscribed: true, RedirectUri: null })
+ {
+ await BillingManager().UnsubscribeAsync(userId, UniqueId, default);
+ }
+ else if (result.Payload is PlanChangedResult { RedirectUri: null })
+ {
+ await BillingManager().SubscribeAsync(userId, UniqueId, changePlan.PlanId, default);
+ }
+
+ return result;
+ }
+
+ private void Create(CreateTeam command)
+ {
+ void RaiseInitial(T @event) where T : TeamEvent
+ {
+ Raise(command, @event, command.TeamId);
+ }
+
+ RaiseInitial(new TeamCreated());
+
+ var actor = command.Actor;
+
+ if (actor.IsUser)
+ {
+ RaiseInitial(new TeamContributorAssigned { ContributorId = actor.Identifier, Role = Role.Owner });
+ }
+ }
+
+ private void ChangePlan(ChangePlan command)
+ {
+ Raise(command, new TeamPlanChanged());
+ }
+
+ private void ResetPlan(ChangePlan command)
+ {
+ Raise(command, new TeamPlanReset());
+ }
+
+ private void Update(UpdateTeam command)
+ {
+ Raise(command, new TeamUpdated());
+ }
+
+ private void AssignContributor(AssignContributor command, bool isAdded)
+ {
+ Raise(command, new TeamContributorAssigned { IsAdded = isAdded });
+ }
+
+ private void RemoveContributor(RemoveContributor command)
+ {
+ Raise(command, new TeamContributorRemoved());
+ }
+
+ private void Raise(T command, TEvent @event, DomainId? id = null) where T : class where TEvent : TeamEvent
+ {
+ SimpleMapper.Map(command, @event);
+
+ @event.TeamId = id ?? Snapshot.Id;
+
+ RaiseEvent(Envelope.Create(@event));
+ }
+
+ private IBillingPlans BillingPlans()
+ {
+ return serviceProvider.GetRequiredService();
+ }
+
+ private IBillingManager BillingManager()
+ {
+ return serviceProvider.GetRequiredService();
+ }
+
+ private IUserResolver Users()
+ {
+ return serviceProvider.GetRequiredService();
+ }
+
+ private Plan GetFreePlan()
+ {
+ return BillingPlans().GetFreePlan();
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/ITeamEntity.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/ITeamEntity.cs
new file mode 100644
index 000000000..891391852
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/ITeamEntity.cs
@@ -0,0 +1,24 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Core;
+
+namespace Squidex.Domain.Apps.Entities.Teams
+{
+ public interface ITeamEntity :
+ IEntity,
+ IEntityWithCreatedBy,
+ IEntityWithLastModifiedBy,
+ IEntityWithVersion
+ {
+ string Name { get; }
+
+ Contributors Contributors { get; }
+
+ AssignedPlan? Plan { get; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/ITeamsIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/ITeamsIndex.cs
new file mode 100644
index 000000000..b8ea6f19f
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/ITeamsIndex.cs
@@ -0,0 +1,20 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Entities.Teams.Indexes
+{
+ public interface ITeamsIndex
+ {
+ Task GetTeamAsync(DomainId id,
+ CancellationToken ct = default);
+
+ Task> GetTeamsAsync(string userId,
+ CancellationToken ct = default);
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/TeamsIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/TeamsIndex.cs
new file mode 100644
index 000000000..c64393b8e
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Indexes/TeamsIndex.cs
@@ -0,0 +1,49 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Entities.Teams.Repositories;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Entities.Teams.Indexes
+{
+ public sealed class TeamsIndex : ITeamsIndex
+ {
+ private readonly ITeamRepository teamRepository;
+
+ public TeamsIndex(ITeamRepository teamRepository)
+ {
+ this.teamRepository = teamRepository;
+ }
+
+ public async Task GetTeamAsync(DomainId id,
+ CancellationToken ct = default)
+ {
+ using (Telemetry.Activities.StartActivity("TeamsIndex/GetTeamAsync"))
+ {
+ var team = await teamRepository.FindAsync(id, ct);
+
+ return IsValid(team) ? team : null;
+ }
+ }
+
+ public async Task> GetTeamsAsync(string userId,
+ CancellationToken ct = default)
+ {
+ using (Telemetry.Activities.StartActivity("TeamsIndex/GetTeamsAsync"))
+ {
+ var teams = await teamRepository.QueryAllAsync(userId, ct);
+
+ return teams.Where(IsValid).ToList();
+ }
+ }
+
+ private static bool IsValid(ITeamEntity? rule)
+ {
+ return rule is { Version: > EtagVersion.Empty };
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/Repositories/ITeamRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/Repositories/ITeamRepository.cs
new file mode 100644
index 000000000..afe19510e
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/Repositories/ITeamRepository.cs
@@ -0,0 +1,20 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Entities.Teams.Repositories
+{
+ public interface ITeamRepository
+ {
+ Task> QueryAllAsync(string contributorId,
+ CancellationToken ct = default);
+
+ Task FindAsync(DomainId id,
+ CancellationToken ct = default);
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/TeamExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/TeamExtensions.cs
new file mode 100644
index 000000000..d424621dc
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/TeamExtensions.cs
@@ -0,0 +1,19 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Squidex.Domain.Apps.Entities.Teams
+{
+ public static class TeamExtensions
+ {
+ public static bool TryGetContributorRole(this ITeamEntity app, string id, [MaybeNullWhen(false)] out string role)
+ {
+ return app.Contributors.TryGetValue(id, out role);
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/TeamHistoryEventsCreator.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/TeamHistoryEventsCreator.cs
new file mode 100644
index 000000000..760e759e5
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/TeamHistoryEventsCreator.cs
@@ -0,0 +1,75 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Entities.History;
+using Squidex.Domain.Apps.Events.Teams;
+using Squidex.Infrastructure.EventSourcing;
+using Squidex.Infrastructure.Reflection;
+
+namespace Squidex.Domain.Teams.Entities.Teams
+{
+ public sealed class TeamHistoryEventsCreator : HistoryEventsCreatorBase
+ {
+ public TeamHistoryEventsCreator(TypeNameRegistry typeNameRegistry)
+ : base(typeNameRegistry)
+ {
+ AddEventMessage(
+ "history.teams.contributoreAssigned");
+
+ AddEventMessage(
+ "history.teams.contributoreRemoved");
+
+ AddEventMessage(
+ "history.teams.planChanged");
+
+ AddEventMessage(
+ "history.teams.planReset");
+
+ AddEventMessage(
+ "history.teams.updated");
+ }
+
+ private HistoryEvent? CreateEvent(IEvent @event)
+ {
+ switch (@event)
+ {
+ case TeamContributorAssigned e:
+ return CreateContributorsEvent(e, e.ContributorId, e.Role);
+ case TeamContributorRemoved e:
+ return CreateContributorsEvent(e, e.ContributorId);
+ case TeamPlanChanged e:
+ return CreatePlansEvent(e, e.PlanId);
+ case TeamPlanReset e:
+ return CreatePlansEvent(e);
+ case TeamUpdated e:
+ return CreateGeneralEvent(e);
+ }
+
+ return null;
+ }
+
+ private HistoryEvent CreateGeneralEvent(IEvent e)
+ {
+ return ForEvent(e, "settings.general");
+ }
+
+ private HistoryEvent CreateContributorsEvent(IEvent e, string contributor, string? role = null)
+ {
+ return ForEvent(e, "settings.contributors").Param("Contributor", contributor).Param("Role", role);
+ }
+
+ private HistoryEvent CreatePlansEvent(IEvent e, string? plan = null)
+ {
+ return ForEvent(e, "settings.plan").Param("Plan", plan);
+ }
+
+ protected override Task CreateEventCoreAsync(Envelope @event)
+ {
+ return Task.FromResult(CreateEvent(@event.Payload));
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Apps/AppPlanChanged.cs b/backend/src/Squidex.Domain.Apps.Events/Apps/AppPlanChanged.cs
index 9745430a1..946c9ffcc 100644
--- a/backend/src/Squidex.Domain.Apps.Events/Apps/AppPlanChanged.cs
+++ b/backend/src/Squidex.Domain.Apps.Events/Apps/AppPlanChanged.cs
@@ -5,7 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
-using Squidex.Domain.Apps.Core.Apps;
+using Squidex.Domain.Apps.Core;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps
@@ -15,9 +15,9 @@ namespace Squidex.Domain.Apps.Events.Apps
{
public string PlanId { get; set; }
- public AppPlan ToPlan()
+ public AssignedPlan ToPlan()
{
- return new AppPlan(Actor, PlanId);
+ return new AssignedPlan(Actor, PlanId);
}
}
}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Apps/AppTransfered.cs b/backend/src/Squidex.Domain.Apps.Events/Apps/AppTransfered.cs
new file mode 100644
index 000000000..f00e74a0b
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Events/Apps/AppTransfered.cs
@@ -0,0 +1,18 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.EventSourcing;
+
+namespace Squidex.Domain.Apps.Events.Apps
+{
+ [EventType(nameof(AppTransfered))]
+ public sealed class AppTransfered : AppEvent
+ {
+ public DomainId? TeamId { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Teams/TeamContributorAssigned.cs b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamContributorAssigned.cs
new file mode 100644
index 000000000..a1b9f510a
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamContributorAssigned.cs
@@ -0,0 +1,23 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure.EventSourcing;
+
+namespace Squidex.Domain.Apps.Events.Teams
+{
+ [EventType(nameof(TeamContributorAssigned))]
+ public sealed class TeamContributorAssigned : TeamEvent
+ {
+ public string ContributorId { get; set; }
+
+ public string Role { get; set; }
+
+ public bool IsCreated { get; set; }
+
+ public bool IsAdded { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Teams/TeamContributorRemoved.cs b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamContributorRemoved.cs
new file mode 100644
index 000000000..6ebcf4264
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamContributorRemoved.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure.EventSourcing;
+
+namespace Squidex.Domain.Apps.Events.Teams
+{
+ [EventType(nameof(TeamContributorRemoved))]
+ public sealed class TeamContributorRemoved : TeamEvent
+ {
+ public string ContributorId { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Teams/TeamCreated.cs b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamCreated.cs
new file mode 100644
index 000000000..5c87b4dc0
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamCreated.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure.EventSourcing;
+
+namespace Squidex.Domain.Apps.Events.Teams
+{
+ [EventType(nameof(TeamCreated))]
+ public sealed class TeamCreated : TeamEvent
+ {
+ public string Name { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Teams/TeamEvent.cs b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamEvent.cs
new file mode 100644
index 000000000..271600d81
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamEvent.cs
@@ -0,0 +1,16 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Events.Teams
+{
+ public abstract class TeamEvent : SquidexEvent
+ {
+ public DomainId TeamId { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Teams/TeamPlanChanged.cs b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamPlanChanged.cs
new file mode 100644
index 000000000..7db4d6553
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamPlanChanged.cs
@@ -0,0 +1,23 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Core;
+using Squidex.Infrastructure.EventSourcing;
+
+namespace Squidex.Domain.Apps.Events.Teams
+{
+ [EventType(nameof(TeamPlanChanged))]
+ public sealed class TeamPlanChanged : TeamEvent
+ {
+ public string PlanId { get; set; }
+
+ public AssignedPlan ToPlan()
+ {
+ return new AssignedPlan(Actor, PlanId);
+ }
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Teams/TeamPlanReset.cs b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamPlanReset.cs
new file mode 100644
index 000000000..ba44673c9
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamPlanReset.cs
@@ -0,0 +1,16 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure.EventSourcing;
+
+namespace Squidex.Domain.Apps.Events.Teams
+{
+ [EventType(nameof(TeamPlanReset))]
+ public sealed class TeamPlanReset : TeamEvent
+ {
+ }
+}
diff --git a/backend/src/Squidex.Domain.Apps.Events/Teams/TeamUpdated.cs b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamUpdated.cs
new file mode 100644
index 000000000..ddb8c3c7b
--- /dev/null
+++ b/backend/src/Squidex.Domain.Apps.Events/Teams/TeamUpdated.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Infrastructure.EventSourcing;
+
+namespace Squidex.Domain.Apps.Events.Teams
+{
+ [EventType(nameof(TeamUpdated))]
+ public sealed class TeamUpdated : TeamEvent
+ {
+ public string Name { get; set; }
+ }
+}
diff --git a/backend/src/Squidex.Shared/PermissionExtensions.cs b/backend/src/Squidex.Shared/PermissionExtensions.cs
index b82bf48c3..e7dd49925 100644
--- a/backend/src/Squidex.Shared/PermissionExtensions.cs
+++ b/backend/src/Squidex.Shared/PermissionExtensions.cs
@@ -12,9 +12,9 @@ namespace Squidex.Shared
{
public static class PermissionExtensions
{
- public static bool Allows(this PermissionSet permissions, string id, string app = Permission.Any, string schema = Permission.Any)
+ public static bool Allows(this PermissionSet permissions, string id, string app = Permission.Any, string schema = Permission.Any, string team = Permission.Any)
{
- var permission = PermissionIds.ForApp(id, app, schema);
+ var permission = PermissionIds.ForApp(id, app, schema, team);
return permissions.Allows(permission);
}
diff --git a/backend/src/Squidex.Shared/PermissionIds.cs b/backend/src/Squidex.Shared/PermissionIds.cs
index 35336494d..64d3e4e96 100644
--- a/backend/src/Squidex.Shared/PermissionIds.cs
+++ b/backend/src/Squidex.Shared/PermissionIds.cs
@@ -20,6 +20,9 @@ namespace Squidex.Shared
// Admin App Creation
public const string AdminAppCreate = "squidex.admin.apps.create";
+ // Admin Team Creation
+ public const string AdminTeamCreate = "squidex.admin.teams.create";
+
// Backup Admin
public const string AdminRestore = "squidex.admin.restore";
@@ -36,11 +39,37 @@ namespace Squidex.Shared
public const string AdminUsersUnlock = "squidex.admin.users.unlock";
public const string AdminUsersLock = "squidex.admin.users.lock";
+ // Team
+ public const string Team = "squidex.teams.{team}";
+
+ // Team General
+ public const string TeamAdmin = "squidex.teams.{team}.*";
+ public const string TeamUpdate = "squidex.teams.{team}.update";
+
+ // Team Contributors
+ public const string TeamContributors = "squidex.teams.{team}.contributors";
+ public const string TeamContributorsRead = "squidex.teams.{team}.contributors.read";
+ public const string TeamContributorsAssign = "squidex.teams.{team}.contributors.assign";
+ public const string TeamContributorsRevoke = "squidex.teams.{team}.contributors.revoke";
+
+ // Team Plans
+ public const string TeamPlans = "squidex.teams.{team}.plans";
+ public const string TeamPlansRead = "squidex.teams.{team}.plans.read";
+ public const string TeamPlansChange = "squidex.teams.{team}.plans.change";
+
+ // Team Usage
+ public const string TeamUsage = "squidex.teams.{team}.usage";
+
+ // Team History
+ public const string TeamHistory = "squidex.teams.{team}.history";
+
+ // App
public const string App = "squidex.apps.{app}";
// App General
public const string AppAdmin = "squidex.apps.{app}.*";
public const string AppDelete = "squidex.apps.{app}.delete";
+ public const string AppTransfer = "squidex.apps.{app}.transfer";
public const string AppUpdate = "squidex.apps.{app}.update";
public const string AppUpdateSettings = "squidex.apps.{app}.settings";
@@ -48,75 +77,75 @@ namespace Squidex.Shared
public const string AppImageUpload = "squidex.apps.{app}.image";
public const string AppImageDelete = "squidex.apps.{app}.image";
- // History
+ // App History
public const string AppHistory = "squidex.apps.{app}.history";
- // Ping
+ // App Ping
public const string AppPing = "squidex.apps.{app}.ping";
- // Search
+ // App Search
public const string AppSearch = "squidex.apps.{app}.search";
- // Translate
+ // App Translate
public const string AppTranslate = "squidex.apps.{app}.translate";
- // Usage
+ // App Usage
public const string AppUsage = "squidex.apps.{app}.usage";
- // Comments
+ // App Comments
public const string AppComments = "squidex.apps.{app}.comments";
public const string AppCommentsRead = "squidex.apps.{app}.comments.read";
public const string AppCommentsCreate = "squidex.apps.{app}.comments.create";
public const string AppCommentsUpdate = "squidex.apps.{app}.comments.update";
public const string AppCommentsDelete = "squidex.apps.{app}.comments.delete";
- // Clients
+ // App Clients
public const string AppClients = "squidex.apps.{app}.clients";
public const string AppClientsRead = "squidex.apps.{app}.clients.read";
public const string AppClientsCreate = "squidex.apps.{app}.clients.create";
public const string AppClientsUpdate = "squidex.apps.{app}.clients.update";
public const string AppClientsDelete = "squidex.apps.{app}.clients.delete";
- // Contributors
+ // App Contributors
public const string AppContributors = "squidex.apps.{app}.contributors";
public const string AppContributorsRead = "squidex.apps.{app}.contributors.read";
public const string AppContributorsAssign = "squidex.apps.{app}.contributors.assign";
public const string AppContributorsRevoke = "squidex.apps.{app}.contributors.revoke";
- // Languages
+ // App Languages
public const string AppLanguages = "squidex.apps.{app}.languages";
public const string AppLanguagesRead = "squidex.apps.{app}.languages.read";
public const string AppLanguagesCreate = "squidex.apps.{app}.languages.create";
public const string AppLanguagesUpdate = "squidex.apps.{app}.languages.update";
public const string AppLanguagesDelete = "squidex.apps.{app}.languages.delete";
- // Roles
+ // App Roles
public const string AppRoles = "squidex.apps.{app}.roles";
public const string AppRolesRead = "squidex.apps.{app}.roles.read";
public const string AppRolesCreate = "squidex.apps.{app}.roles.create";
public const string AppRolesUpdate = "squidex.apps.{app}.roles.update";
public const string AppRolesDelete = "squidex.apps.{app}.roles.delete";
- // Workflows
+ // App Workflows
public const string AppWorkflows = "squidex.apps.{app}.workflows";
public const string AppWorkflowsRead = "squidex.apps.{app}.workflows.read";
public const string AppWorkflowsCreate = "squidex.apps.{app}.workflows.create";
public const string AppWorkflowsUpdate = "squidex.apps.{app}.workflows.update";
public const string AppWorkflowsDelete = "squidex.apps.{app}.workflows.delete";
- // Backups
+ // App Backups
public const string AppBackups = "squidex.apps.{app}.backups";
public const string AppBackupsRead = "squidex.apps.{app}.backups.read";
public const string AppBackupsCreate = "squidex.apps.{app}.backups.create";
public const string AppBackupsDelete = "squidex.apps.{app}.backups.delete";
public const string AppBackupsDownload = "squidex.apps.{app}.backups.download";
- // Plans
+ // App Plans
public const string AppPlans = "squidex.apps.{app}.plans";
public const string AppPlansRead = "squidex.apps.{app}.plans.read";
public const string AppPlansChange = "squidex.apps.{app}.plans.change";
- // Assets
+ // App Assets
public const string AppAssets = "squidex.apps.{app}.assets";
public const string AppAssetsRead = "squidex.apps.{app}.assets.read";
public const string AppAssetsCreate = "squidex.apps.{app}.assets.create";
@@ -124,24 +153,27 @@ namespace Squidex.Shared
public const string AppAssetsUpdate = "squidex.apps.{app}.assets.update";
public const string AppAssetsDelete = "squidex.apps.{app}.assets.delete";
+ // App Asset Scripts
public const string AppAssetScripts = "squidex.apps.{app}.asset-scripts";
public const string AppAssetSScriptsRead = "squidex.apps.{app}.asset-scripts.read";
public const string AppAssetsScriptsUpdate = "squidex.apps.{app}.asset-scripts.update";
- // Rules
+ // App Rules
public const string AppRules = "squidex.apps.{app}.rules";
public const string AppRulesRead = "squidex.apps.{app}.rules.read";
+ public const string AppRulesCreate = "squidex.apps.{app}.rules.create";
+ public const string AppRulesUpdate = "squidex.apps.{app}.rules.update";
+ public const string AppRulesDisable = "squidex.apps.{app}.rules.disable";
+ public const string AppRulesDelete = "squidex.apps.{app}.rules.delete";
+
+ // App Rule Events
public const string AppRulesEvents = "squidex.apps.{app}.rules.events";
public const string AppRulesEventsRun = "squidex.apps.{app}.rules.events.run";
public const string AppRulesEventsRead = "squidex.apps.{app}.rules.events.read";
public const string AppRulesEventsUpdate = "squidex.apps.{app}.rules.events.update";
public const string AppRulesEventsDelete = "squidex.apps.{app}.rules.events.delete";
- public const string AppRulesCreate = "squidex.apps.{app}.rules.create";
- public const string AppRulesUpdate = "squidex.apps.{app}.rules.update";
- public const string AppRulesDisable = "squidex.apps.{app}.rules.disable";
- public const string AppRulesDelete = "squidex.apps.{app}.rules.delete";
- // Schemas
+ // App Schemas
public const string AppSchemas = "squidex.apps.{app}.schemas";
public const string AppSchemasRead = "squidex.apps.{app}.schemas.read";
public const string AppSchemasCreate = "squidex.apps.{app}.schemas.create";
@@ -150,7 +182,7 @@ namespace Squidex.Shared
public const string AppSchemasPublish = "squidex.apps.{app}.schemas.{schema}.publish";
public const string AppSchemasDelete = "squidex.apps.{app}.schemas.{schema}.delete";
- // Contents
+ // App Contents
public const string AppContents = "squidex.apps.{app}.contents.{schema}";
public const string AppContentsRead = "squidex.apps.{app}.contents.{schema}.read";
public const string AppContentsReadOwn = "squidex.apps.{app}.contents.{schema}.read.own";
@@ -169,12 +201,13 @@ namespace Squidex.Shared
public const string AppContentsDelete = "squidex.apps.{app}.contents.{schema}.delete";
public const string AppContentsDeleteOwn = "squidex.apps.{app}.contents.{schema}.delete.own";
- public static Permission ForApp(string id, string app = Permission.Any, string schema = Permission.Any)
+ public static Permission ForApp(string id, string app = Permission.Any, string schema = Permission.Any, string team = Permission.Any)
{
Guard.NotNull(id);
id = id.Replace("{app}", app ?? Permission.Any, StringComparison.Ordinal);
id = id.Replace("{schema}", schema ?? Permission.Any, StringComparison.Ordinal);
+ id = id.Replace("{team}", team ?? Permission.Any, StringComparison.Ordinal);
return new Permission(id);
}
diff --git a/backend/src/Squidex.Shared/Texts.it.resx b/backend/src/Squidex.Shared/Texts.it.resx
index 1b093d2c2..483f3acd1 100644
--- a/backend/src/Squidex.Shared/Texts.it.resx
+++ b/backend/src/Squidex.Shared/Texts.it.resx
@@ -142,6 +142,9 @@
Il file non è una immagine
+
+ Plan is managed by the team.
+
Non esiste un piano con questo id.
@@ -163,6 +166,12 @@
Non è possibile rimuovere un ruolo quando questo è assegnato ad un collaboratore.
+
+ Subscription must be cancelled first before the app can be transfered.
+
+
+ The team does not exist.
+
La cartella delle risorse non esiste.
@@ -709,6 +718,9 @@
L'entità ({id}) richiede la versione {expectedVersion}, ma è stata trovata {currentVersion}.
+
+ updated asset scripts
+
aggiunto client {[Id]} all'app
@@ -724,6 +736,12 @@
Rimosso {user:[Contributor]} dall'app
+
+ removed app image
+
+
+ uploaded a new app image
+
aggiunta lingua {[Language]}
@@ -754,6 +772,12 @@
updated UI settings
+
+ updated app to client
+
+
+ updated general settings
+
ha sostituito la risorsa.
@@ -829,6 +853,21 @@
ha cambiato lo stato del contenuto {[Schema]} in {[Status]}.
+
+ assigned {user:[Contributor]} as {[Role]}
+
+
+ removed {user:[Contributor]} from team
+
+
+ changed plan to {[Plan]}
+
+
+ resetted plan
+
+
+ updated general settings
+
Il tuo indirizzo email è impostato su privato in Github. Impostalo come pubblico per poter utilizzare il login Github.
@@ -964,6 +1003,12 @@
Checklist di Sistema
+
+ With your setup, only admins can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=false</code> as environment variable.
+
+
+ With your setup, every user can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=true</code> as environment variable.
+
Dovresti accedere a Squidex solo con un URL canonico e configurare questo URL sulla variabile d'ambiente <code> URLS__BASEURL </code>. Il base URL corrente <code> {actual} </code> non corrisponde al base URL <code>{configured}</code>.
diff --git a/backend/src/Squidex.Shared/Texts.nl.resx b/backend/src/Squidex.Shared/Texts.nl.resx
index d6f0d07a9..9db1e3e7d 100644
--- a/backend/src/Squidex.Shared/Texts.nl.resx
+++ b/backend/src/Squidex.Shared/Texts.nl.resx
@@ -142,6 +142,9 @@
Bestand is geen afbeelding
+
+ Plan is managed by the team.
+
Een plan met deze id bestaat niet.
@@ -163,6 +166,12 @@
Kan een rol niet verwijderen wanneer een bijdrager is toegewezen.
+
+ Subscription must be cancelled first before the app can be transfered.
+
+
+ The team does not exist.
+
Assetmap bestaat niet.
@@ -709,6 +718,9 @@
Entiteit ({id}) heeft versie {verwachteVersion} aangevraagd, maar heeft {currentVersion} gevonden.
+
+ updated asset scripts
+
client {[Id]} toegevoegd aan app
@@ -724,6 +736,12 @@
heeft {user:[Contributor]} verwijderd uit app
+
+ removed app image
+
+
+ uploaded a new app image
+
taal toegevoegd {[Language]}
@@ -754,6 +772,12 @@
Bijgewerkte UI instellingen
+
+ updated app to client
+
+
+ updated general settings
+
item vervangen.
@@ -829,6 +853,21 @@
veranderde status van {[Schema]} inhoud in {[Status]}.
+
+ assigned {user:[Contributor]} as {[Role]}
+
+
+ removed {user:[Contributor]} from team
+
+
+ changed plan to {[Plan]}
+
+
+ resetted plan
+
+
+ updated general settings
+
Jouw e-mailadres is ingesteld op privé in Github. Stel het in op openbaar om Github-login te gebruiken.
@@ -964,6 +1003,12 @@
System Checklist
+
+ With your setup, only admins can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=false</code> as environment variable.
+
+
+ With your setup, every user can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=true</code> as environment variable.
+
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.
diff --git a/backend/src/Squidex.Shared/Texts.resx b/backend/src/Squidex.Shared/Texts.resx
index 5dcfdb078..d171ce3d7 100644
--- a/backend/src/Squidex.Shared/Texts.resx
+++ b/backend/src/Squidex.Shared/Texts.resx
@@ -142,6 +142,9 @@
File is not an image
+
+ Plan is managed by the team.
+
A plan with this id does not exist.
@@ -163,6 +166,12 @@
Cannot remove a role when a contributor is assigned.
+
+ Subscription must be cancelled first before the app can be transfered.
+
+
+ The team does not exist.
+
Asset folder does not exist.
@@ -709,6 +718,9 @@
Entity ({id}) requested version {expectedVersion}, but found {currentVersion}.
+
+ updated asset scripts
+
added client {[Id]} to app
@@ -724,6 +736,12 @@
removed {user:[Contributor]} from app
+
+ removed app image
+
+
+ uploaded a new app image
+
added language {[Language]}
@@ -754,6 +772,12 @@
updated UI settings
+
+ updated app to client
+
+
+ updated general settings
+
replaced asset.
@@ -829,6 +853,21 @@
changed status of {[Schema]} content to {[Status]}.
+
+ assigned {user:[Contributor]} as {[Role]}
+
+
+ removed {user:[Contributor]} from team
+
+
+ changed plan to {[Plan]}
+
+
+ resetted plan
+
+
+ updated general settings
+
Your email address is set to private in Github. Please set it to public to use Github login.
@@ -964,6 +1003,12 @@
System Checklist
+
+ With your setup, only admins can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=false</code> as environment variable.
+
+
+ With your setup, every user can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=true</code> as environment variable.
+
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.
diff --git a/backend/src/Squidex.Shared/Texts.zh.resx b/backend/src/Squidex.Shared/Texts.zh.resx
index 2246193e5..0a17914ef 100644
--- a/backend/src/Squidex.Shared/Texts.zh.resx
+++ b/backend/src/Squidex.Shared/Texts.zh.resx
@@ -142,6 +142,9 @@
文件不是图像
+
+ Plan is managed by the team.
+
不存在具有此 ID 的计划。
@@ -163,6 +166,12 @@
当分配了贡献者时无法删除角色。
+
+ Subscription must be cancelled first before the app can be transfered.
+
+
+ The team does not exist.
+
资源文件夹不存在。
@@ -709,6 +718,9 @@
实体 ({id}) 请求版本 {expectedVersion},但找到 {currentVersion}。
+
+ updated asset scripts
+
将客户端 {[Id]} 添加到应用程序
@@ -724,6 +736,12 @@
从应用中删除了 {user:[Contributor]}
+
+ removed app image
+
+
+ uploaded a new app image
+
添加语言 {[Language]}
@@ -754,6 +772,12 @@
更新的 UI 设置
+
+ updated app to client
+
+
+ updated general settings
+
替换的资源。
@@ -829,6 +853,21 @@
已将 {[Schema]} 内容的状态更改为 {[Status]}。
+
+ assigned {user:[Contributor]} as {[Role]}
+
+
+ removed {user:[Contributor]} from team
+
+
+ changed plan to {[Plan]}
+
+
+ resetted plan
+
+
+ updated general settings
+
您的邮箱在 Github 中设置为私有。请设置为公开以使用 Github 登录。
@@ -964,6 +1003,12 @@
系统清单
+
+ With your setup, only admins can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=false</code> as environment variable.
+
+
+ With your setup, every user can create new teams. If you want to change this set <code>UI__ONLYADMINSCANCREATETEAMS=true</code> as environment variable.
+
您应该仅通过一个规范 URL 访问 Squidex,并通过 <code>URLS__BASEURL</code> 环境变量配置此 URL。当前的基本 URL <code>{actual}</code>与基本 url <code>{configured}</code> 不匹配。
diff --git a/backend/src/Squidex.Web/ApiController.cs b/backend/src/Squidex.Web/ApiController.cs
index edbfd6ec5..4cb86cd7b 100644
--- a/backend/src/Squidex.Web/ApiController.cs
+++ b/backend/src/Squidex.Web/ApiController.cs
@@ -9,8 +9,12 @@ using Microsoft.AspNetCore.Mvc;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
+using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
+using Squidex.Infrastructure.Security;
+using Squidex.Infrastructure.Translations;
+using Squidex.Shared;
namespace Squidex.Web
{
@@ -41,6 +45,22 @@ namespace Squidex.Web
}
}
+ protected ITeamEntity Team
+ {
+ get
+ {
+ var team = HttpContext.Features.Get()?.Team;
+
+ if (team == null)
+ {
+ ThrowHelper.InvalidOperationException("Not in a team context.");
+ return default!;
+ }
+
+ return team;
+ }
+ }
+
protected ISchemaEntity Schema
{
get
@@ -57,6 +77,31 @@ namespace Squidex.Web
}
}
+ protected string UserId
+ {
+ get
+ {
+ var subject = User.OpenIdSubject();
+
+ if (string.IsNullOrWhiteSpace(subject))
+ {
+ throw new DomainForbiddenException(T.Get("common.httpOnlyAsUser"));
+ }
+
+ return subject;
+ }
+ }
+
+ protected bool IsFrontend
+ {
+ get => HttpContext.User.IsInClient(DefaultClients.Frontend);
+ }
+
+ protected string UserOrClientId
+ {
+ get => HttpContext.User.UserOrClientId()!;
+ }
+
protected Resources Resources
{
get => resources.Value;
@@ -72,6 +117,11 @@ namespace Squidex.Web
get => App.Id;
}
+ protected DomainId TeamId
+ {
+ get => Team.Id;
+ }
+
protected ApiController(ICommandBus commandBus)
{
CommandBus = commandBus;
diff --git a/backend/src/Squidex.Web/ApiPermissionAttribute.cs b/backend/src/Squidex.Web/ApiPermissionAttribute.cs
index f7eb6152e..3ba03cdfa 100644
--- a/backend/src/Squidex.Web/ApiPermissionAttribute.cs
+++ b/backend/src/Squidex.Web/ApiPermissionAttribute.cs
@@ -55,7 +55,14 @@ namespace Squidex.Web
schema = Permission.Any;
}
- if (permissions.Allows(id, app, schema))
+ var team = context.HttpContext.Features.Get()?.Team.Id.ToString();
+
+ if (string.IsNullOrWhiteSpace(team))
+ {
+ team = Permission.Any;
+ }
+
+ if (permissions.Allows(id, app, schema, team))
{
hasPermission = true;
break;
diff --git a/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithTeamIdCommandMiddleware.cs b/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithTeamIdCommandMiddleware.cs
new file mode 100644
index 000000000..c74ad539c
--- /dev/null
+++ b/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithTeamIdCommandMiddleware.cs
@@ -0,0 +1,55 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Microsoft.AspNetCore.Http;
+using Squidex.Domain.Apps.Entities;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Commands;
+
+namespace Squidex.Web.CommandMiddlewares
+{
+ public sealed class EnrichWithTeamIdCommandMiddleware : ICommandMiddleware
+ {
+ private readonly IHttpContextAccessor httpContextAccessor;
+
+ public EnrichWithTeamIdCommandMiddleware(IHttpContextAccessor httpContextAccessor)
+ {
+ this.httpContextAccessor = httpContextAccessor;
+ }
+
+ public Task HandleAsync(CommandContext context, NextDelegate next,
+ CancellationToken ct)
+ {
+ if (httpContextAccessor.HttpContext == null)
+ {
+ return next(context, ct);
+ }
+
+ if (context.Command is ITeamCommand teamCommand && teamCommand.TeamId == default)
+ {
+ var teamId = GetTeamId();
+
+ teamCommand.TeamId = teamId;
+ }
+
+ return next(context, ct);
+ }
+
+ private DomainId GetTeamId()
+ {
+ var feature = httpContextAccessor.HttpContext?.Features.Get();
+
+ if (feature == null)
+ {
+ ThrowHelper.InvalidOperationException("Cannot resolve team.");
+ return default!;
+ }
+
+ return feature.Team.Id;
+ }
+ }
+}
diff --git a/backend/src/Squidex.Web/ContextExtensions.cs b/backend/src/Squidex.Web/ContextExtensions.cs
index 7c7f812cd..be499e1be 100644
--- a/backend/src/Squidex.Web/ContextExtensions.cs
+++ b/backend/src/Squidex.Web/ContextExtensions.cs
@@ -18,12 +18,26 @@ namespace Squidex.Web
if (context == null)
{
- context = RequestContext.Anonymous(null!);
+ context = new RequestContext(httpContext.User, null!).WithHeaders(httpContext);
httpContext.Features.Set(context);
}
return context;
}
+
+ public static RequestContext WithHeaders(this RequestContext context, HttpContext httpContext)
+ {
+ return context.Clone(builder =>
+ {
+ foreach (var (key, value) in httpContext.Request.Headers)
+ {
+ if (key.StartsWith("X-", StringComparison.OrdinalIgnoreCase))
+ {
+ builder.SetHeader(key, value.ToString());
+ }
+ }
+ });
+ }
}
}
diff --git a/backend/src/Squidex.Web/ITeamFeature.cs b/backend/src/Squidex.Web/ITeamFeature.cs
new file mode 100644
index 000000000..aff13155b
--- /dev/null
+++ b/backend/src/Squidex.Web/ITeamFeature.cs
@@ -0,0 +1,16 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Entities.Teams;
+
+namespace Squidex.Web
+{
+ public interface ITeamFeature
+ {
+ ITeamEntity Team { get; }
+ }
+}
diff --git a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
index dc48b80d8..fd8f62436 100644
--- a/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
+++ b/backend/src/Squidex.Web/Pipeline/ApiCostsFilter.cs
@@ -8,18 +8,18 @@
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
-using Squidex.Domain.Apps.Entities.Apps.Plans;
+using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure;
namespace Squidex.Web.Pipeline
{
public sealed class ApiCostsFilter : IAsyncActionFilter, IFilterContainer
{
- private readonly UsageGate usageGate;
+ private readonly IAppUsageGate appUsageGate;
- public ApiCostsFilter(UsageGate usageGate)
+ public ApiCostsFilter(IAppUsageGate appUsageGate)
{
- this.usageGate = usageGate;
+ this.appUsageGate = appUsageGate;
}
IFilterMetadata IFilterContainer.FilterDefinition { get; set; }
@@ -50,7 +50,7 @@ namespace Squidex.Web.Pipeline
{
var (_, clientId) = context.HttpContext.User.GetClient();
- var isBlocked = await usageGate.IsBlockedAsync(app, clientId, DateTime.Today, context.HttpContext.RequestAborted);
+ var isBlocked = await appUsageGate.IsBlockedAsync(app, clientId, DateTime.Today, context.HttpContext.RequestAborted);
if (isBlocked)
{
diff --git a/backend/src/Squidex.Web/Pipeline/AppResolver.cs b/backend/src/Squidex.Web/Pipeline/AppResolver.cs
index a6653298d..5468d0a06 100644
--- a/backend/src/Squidex.Web/Pipeline/AppResolver.cs
+++ b/backend/src/Squidex.Web/Pipeline/AppResolver.cs
@@ -7,7 +7,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
@@ -91,7 +90,7 @@ namespace Squidex.Web.Pipeline
}
}
- var requestContext = SetContext(context.HttpContext, app);
+ var requestContext = new Context(context.HttpContext.User, app).WithHeaders(context.HttpContext);
if (!AllowAnonymous(context) && !HasPermission(appName, requestContext))
{
@@ -113,36 +112,14 @@ namespace Squidex.Web.Pipeline
return;
}
+ context.HttpContext.Features.Set(requestContext);
context.HttpContext.Features.Set(new AppFeature(app));
context.HttpContext.Response.Headers.Add("X-AppId", app.Id.ToString());
}
- else
- {
- SetContext(context.HttpContext, null!);
- }
await next();
}
- private static Context SetContext(HttpContext httpContext, IAppEntity app)
- {
- var requestContext =
- new Context(httpContext.User, app).Clone(builder =>
- {
- foreach (var (key, value) in httpContext.Request.Headers)
- {
- if (key.StartsWith("X-", StringComparison.OrdinalIgnoreCase))
- {
- builder.SetHeader(key, value.ToString());
- }
- }
- });
-
- httpContext.Features.Set(requestContext);
-
- return requestContext;
- }
-
private static bool HasPermission(string appName, Context requestContext)
{
return requestContext.UserPermissions.Includes(PermissionIds.ForApp(PermissionIds.App, appName));
diff --git a/backend/src/Squidex.Web/Pipeline/ContextFilter.cs b/backend/src/Squidex.Web/Pipeline/ContextFilter.cs
new file mode 100644
index 000000000..9876d31a6
--- /dev/null
+++ b/backend/src/Squidex.Web/Pipeline/ContextFilter.cs
@@ -0,0 +1,36 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Microsoft.AspNetCore.Mvc.Filters;
+using Squidex.Domain.Apps.Entities;
+
+namespace Squidex.Web.Pipeline
+{
+ public sealed class ContextFilter : IAsyncActionFilter
+ {
+ public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+ {
+ var httpContext = context.HttpContext;
+
+ var requestContext =
+ new Context(httpContext.User, null!).Clone(builder =>
+ {
+ foreach (var (key, value) in httpContext.Request.Headers)
+ {
+ if (key.StartsWith("X-", StringComparison.OrdinalIgnoreCase))
+ {
+ builder.SetHeader(key, value.ToString());
+ }
+ }
+ });
+
+ httpContext.Features.Set(requestContext);
+
+ return next();
+ }
+ }
+}
diff --git a/backend/src/Squidex.Web/Pipeline/TeamFeature.cs b/backend/src/Squidex.Web/Pipeline/TeamFeature.cs
new file mode 100644
index 000000000..9fecc7131
--- /dev/null
+++ b/backend/src/Squidex.Web/Pipeline/TeamFeature.cs
@@ -0,0 +1,15 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Squidex.Domain.Apps.Entities.Teams;
+
+#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
+
+namespace Squidex.Web.Pipeline
+{
+ public sealed record TeamFeature(ITeamEntity Team) : ITeamFeature;
+}
diff --git a/backend/src/Squidex.Web/Pipeline/TeamResolver.cs b/backend/src/Squidex.Web/Pipeline/TeamResolver.cs
new file mode 100644
index 000000000..42c86a908
--- /dev/null
+++ b/backend/src/Squidex.Web/Pipeline/TeamResolver.cs
@@ -0,0 +1,105 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Squidex.Domain.Apps.Entities;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Security;
+using Squidex.Shared;
+using Squidex.Shared.Identity;
+
+namespace Squidex.Web.Pipeline
+{
+ public sealed class TeamResolver : IAsyncActionFilter
+ {
+ private readonly IAppProvider appProvider;
+
+ public TeamResolver(IAppProvider appProvider)
+ {
+ this.appProvider = appProvider;
+ }
+
+ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+ {
+ var user = context.HttpContext.User;
+
+ if (context.RouteData.Values.TryGetValue("team", out var teamValue))
+ {
+ var teamId = teamValue?.ToString();
+
+ if (string.IsNullOrWhiteSpace(teamId))
+ {
+ context.Result = new NotFoundResult();
+ return;
+ }
+
+ var team = await appProvider.GetTeamAsync(DomainId.Create(teamId), default);
+
+ if (team == null)
+ {
+ var log = context.HttpContext.RequestServices?.GetService>();
+
+ log?.LogWarning("Cannot find team with the given id {id}.", teamId);
+
+ context.Result = new NotFoundResult();
+ return;
+ }
+
+ var subjectId = user.OpenIdSubject();
+
+ if (subjectId != null && team.Contributors.TryGetValue(subjectId, out var role))
+ {
+ var identity = user.Identities.First();
+
+ identity.AddClaim(new Claim(ClaimTypes.Role, role));
+ identity.AddClaim(new Claim(SquidexClaimTypes.Permissions, PermissionIds.ForApp(PermissionIds.TeamAdmin, team: team.Id.ToString()).Id));
+ }
+
+ var requestContext = new Context(context.HttpContext.User, null!).WithHeaders(context.HttpContext);
+
+ if (!AllowAnonymous(context) && !HasPermission(team.Id, requestContext))
+ {
+ if (string.IsNullOrWhiteSpace(user.Identity?.AuthenticationType))
+ {
+ context.Result = new UnauthorizedResult();
+ }
+ else
+ {
+ var log = context.HttpContext.RequestServices?.GetService>();
+
+ log?.LogWarning("Authenticated user has no permission to access the team with ID {id}.", team.Id);
+
+ context.Result = new NotFoundResult();
+ }
+
+ return;
+ }
+
+ context.HttpContext.Features.Set(requestContext);
+ context.HttpContext.Features.Set(new TeamFeature(team));
+ context.HttpContext.Response.Headers.Add("X-TeamId", team.Id.ToString());
+ }
+
+ await next();
+ }
+
+ private static bool HasPermission(DomainId teamId, Context requestContext)
+ {
+ return requestContext.UserPermissions.Includes(PermissionIds.ForApp(PermissionIds.Team, team: teamId.ToString()));
+ }
+
+ private static bool AllowAnonymous(ActionExecutingContext context)
+ {
+ return context.ActionDescriptor.EndpointMetadata.Any(x => x is AllowAnonymousAttribute);
+ }
+ }
+}
diff --git a/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs b/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
index 0a2260df2..b2d55c56c 100644
--- a/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
+++ b/backend/src/Squidex.Web/Pipeline/UsageMiddleware.cs
@@ -9,23 +9,23 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using NodaTime;
using Squidex.Domain.Apps.Entities.Apps;
+using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
-using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Web.Pipeline
{
public sealed class UsageMiddleware : IMiddleware
{
- private readonly IAppLogStore usageLog;
- private readonly IApiUsageTracker usageTracker;
+ private readonly IAppLogStore appUsageLog;
+ private readonly IAppUsageGate appUsageGate;
public IClock Clock { get; set; } = SystemClock.Instance;
- public UsageMiddleware(IAppLogStore usageLog, IApiUsageTracker usageTracker )
+ public UsageMiddleware(IAppLogStore appUsageLog, IAppUsageGate appUsageGate)
{
- this.usageLog = usageLog;
- this.usageTracker = usageTracker;
+ this.appUsageLog = appUsageLog;
+ this.appUsageGate = appUsageGate;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
@@ -41,9 +41,9 @@ namespace Squidex.Web.Pipeline
{
if (context.Response.StatusCode != StatusCodes.Status429TooManyRequests)
{
- var appId = context.Features.Get()?.App.Id;
+ var app = context.Features.Get()?.App;
- if (appId != null)
+ if (app != null)
{
var bytes = usageBody.BytesWritten;
@@ -69,14 +69,13 @@ namespace Squidex.Web.Pipeline
request.UserClientId = clientId;
// Do not flow cancellation token because it is too important.
- await usageLog.LogAsync(appId.Value, request, default);
+ await appUsageLog.LogAsync(app.Id, request, default);
if (request.Costs > 0)
{
var date = request.Timestamp.ToDateTimeUtc().Date;
- await usageTracker.TrackAsync(date, appId.Value.ToString(),
- request.UserClientId,
+ await appUsageGate.TrackRequestAsync(app, request.UserClientId, date,
request.Costs,
request.ElapsedMs,
request.Bytes,
diff --git a/backend/src/Squidex.Web/Resources.cs b/backend/src/Squidex.Web/Resources.cs
index a6b814aa5..1721b78e7 100644
--- a/backend/src/Squidex.Web/Resources.cs
+++ b/backend/src/Squidex.Web/Resources.cs
@@ -52,8 +52,12 @@ namespace Squidex.Web
// Contributors
public bool CanAssignContributor => Can(PermissionIds.AppContributorsAssign);
+ public bool CanAssignTeamContributor => Can(PermissionIds.TeamContributorsAssign);
+
public bool CanRevokeContributor => Can(PermissionIds.AppContributorsRevoke);
+ public bool CanRevokeTeamContributor => Can(PermissionIds.TeamContributorsRevoke);
+
// Workflows
public bool CanCreateWorkflow => Can(PermissionIds.AppWorkflowsCreate);
@@ -141,6 +145,8 @@ namespace Squidex.Web
public string? Schema => GetAppName();
+ public string? Team => GetTeamId().ToString();
+
public DomainId AppId => GetAppId();
public ApiController Controller { get; }
@@ -187,7 +193,7 @@ namespace Squidex.Web
return permissions.GetOrAdd((Id: id, Schema: schema), k => IsAllowed(k.Id, Permission.Any, k.Schema));
}
- public bool IsAllowed(string id, string app = Permission.Any, string schema = Permission.Any, PermissionSet? additional = null)
+ public bool IsAllowed(string id, string app = Permission.Any, string schema = Permission.Any, string team = Permission.Any, PermissionSet? additional = null)
{
if (app == Permission.Any)
{
@@ -209,7 +215,17 @@ namespace Squidex.Web
}
}
- var permission = PermissionIds.ForApp(id, app, schema);
+ if (team == Permission.Any)
+ {
+ var fallback = GetTeamId();
+
+ if (fallback != default)
+ {
+ team = fallback.ToString();
+ }
+ }
+
+ var permission = PermissionIds.ForApp(id, app, schema, team);
return Context.UserPermissions.Allows(permission) || additional?.Allows(permission) == true;
}
@@ -228,5 +244,10 @@ namespace Squidex.Web
{
return Controller.HttpContext.Context().App?.Id ?? default;
}
+
+ private DomainId GetTeamId()
+ {
+ return Controller.HttpContext.Features.Get()?.Team?.Id ?? default;
+ }
}
}
diff --git a/backend/src/Squidex.Web/UsageOptions.cs b/backend/src/Squidex.Web/UsageOptions.cs
index 7b4e5dc80..0a001710c 100644
--- a/backend/src/Squidex.Web/UsageOptions.cs
+++ b/backend/src/Squidex.Web/UsageOptions.cs
@@ -5,12 +5,12 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
-using Squidex.Domain.Apps.Entities.Apps.Plans;
+using Squidex.Domain.Apps.Entities.Billing;
namespace Squidex.Web
{
public sealed class UsageOptions
{
- public ConfigAppLimitsPlan[] Plans { get; set; }
+ public Plan[] Plans { get; set; }
}
}
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
index 781ef6349..8191db081 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
@@ -87,7 +87,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// Updates an app client.
///
/// The name of the app.
- /// The id of the client that must be updated.
+ /// The ID of the client that must be updated.
/// Client object that needs to be updated.
///
/// 200 => Client updated.
@@ -115,7 +115,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// Revoke an app client.
///
/// The name of the app.
- /// The id of the client that must be deleted.
+ /// The ID of the client that must be deleted.
///
/// 200 => Client deleted.
/// 404 => Client or app not found.
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
index bc27eaa9f..24483ed83 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
@@ -7,15 +7,12 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
-using Squidex.Areas.Api.Controllers.Apps.Models;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
-using Squidex.Domain.Apps.Entities.Apps.Invitation;
-using Squidex.Domain.Apps.Entities.Apps.Plans;
-using Squidex.Infrastructure;
+using Squidex.Domain.Apps.Entities.Billing;
+using Squidex.Domain.Apps.Entities.Invitation;
using Squidex.Infrastructure.Commands;
-using Squidex.Infrastructure.Security;
-using Squidex.Infrastructure.Translations;
+using Squidex.Infrastructure.Reflection;
using Squidex.Shared;
using Squidex.Shared.Users;
using Squidex.Web;
@@ -28,15 +25,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppContributorsController : ApiController
{
- private readonly IAppPlansProvider appPlansProvider;
- private readonly IUserResolver userResolver;
+ private readonly IAppUsageGate usageTracker;
+ private readonly IUserResolver usageGate;
- public AppContributorsController(ICommandBus commandBus, IAppPlansProvider appPlansProvider, IUserResolver userResolver)
+ public AppContributorsController(ICommandBus commandBus, IAppUsageGate usageGate, IUserResolver userResolver)
: base(commandBus)
{
- this.appPlansProvider = appPlansProvider;
-
- this.userResolver = userResolver;
+ this.usageTracker = usageGate;
+ this.usageGate = userResolver;
}
///
@@ -81,7 +77,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(1)]
public async Task PostContributor(string app, [FromBody] AssignContributorDto request)
{
- var command = request.ToCommand();
+ var command = SimpleMapper.Map(request, new AssignContributor());
var response = await InvokeCommandAsync(command);
@@ -103,7 +99,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(1)]
public async Task DeleteMyself(string app)
{
- var command = new RemoveContributor { ContributorId = UserId() };
+ var command = new RemoveContributor { ContributorId = UserId };
var response = await InvokeCommandAsync(command);
@@ -114,7 +110,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// Remove contributor.
///
/// The name of the app.
- /// The id of the contributor.
+ /// The ID of the contributor.
///
/// 200 => Contributor removed.
/// 404 => Contributor or app not found.
@@ -137,9 +133,9 @@ namespace Squidex.Areas.Api.Controllers.Apps
{
var context = await CommandBus.PublishAsync(command, HttpContext.RequestAborted);
- if (context.PlainResult is InvitedResult invited)
+ if (context.PlainResult is InvitedResult invited)
{
- return await GetResponseAsync(invited.App, true);
+ return await GetResponseAsync(invited.Entity, true);
}
else
{
@@ -147,21 +143,11 @@ namespace Squidex.Areas.Api.Controllers.Apps
}
}
- private string UserId()
+ private async Task GetResponseAsync(IAppEntity app, bool invited)
{
- var subject = User.OpenIdSubject();
-
- if (string.IsNullOrWhiteSpace(subject))
- {
- throw new DomainForbiddenException(T.Get("common.httpOnlyAsUser"));
- }
+ var (plan, _, _) = await usageTracker.GetPlanForAppAsync(app, HttpContext.RequestAborted);
- return subject;
- }
-
- private Task GetResponseAsync(IAppEntity app, bool invited)
- {
- return ContributorsDto.FromAppAsync(app, Resources, userResolver, appPlansProvider, invited);
+ return await ContributorsDto.FromDomainAsync(app, Resources, usageGate, plan, invited);
}
}
}
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
index d51c40c78..c8bc747d2 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
@@ -85,7 +85,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// Update a workflow.
///
/// The name of the app.
- /// The id of the workflow to update.
+ /// The ID of the workflow to update.
/// The new workflow.
///
/// 200 => Workflow updated.
@@ -110,7 +110,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
/// Delete a workflow.
///
/// The name of the app.
- /// The id of the workflow to update.
+ /// The ID of the workflow to update.
///
/// 200 => Workflow deleted.
/// 404 => Workflow or app not found.
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
index 97f666614..577f42f21 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
@@ -51,16 +51,44 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)]
public async Task GetApps()
{
- var userOrClientId = HttpContext.User.UserOrClientId()!;
+ var userOrClientId = UserOrClientId!;
var userPermissions = Resources.Context.UserPermissions;
var apps = await appProvider.GetUserAppsAsync(userOrClientId, userPermissions, HttpContext.RequestAborted);
var response = Deferred.Response(() =>
{
- var isFrontend = HttpContext.User.IsInClient(DefaultClients.Frontend);
+ return apps.OrderBy(x => x.Name).Select(a => AppDto.FromDomain(a, userOrClientId, IsFrontend, Resources)).ToArray();
+ });
+
+ Response.Headers[HeaderNames.ETag] = apps.ToEtag();
+
+ return Ok(response);
+ }
+
+ ///
+ /// Get team apps.
+ ///
+ /// The ID of the team.
+ ///
+ /// 200 => Apps returned.
+ ///
+ ///
+ /// You can only retrieve the list of apps when you are authenticated as a user (OpenID implicit flow).
+ /// You will retrieve all apps, where you are assigned as a contributor.
+ ///
+ [HttpGet]
+ [Route("teams/{team}/apps")]
+ [ProducesResponseType(typeof(AppDto[]), StatusCodes.Status200OK)]
+ [ApiPermission]
+ [ApiCosts(0)]
+ public async Task GetTeamApps(string team)
+ {
+ var apps = await appProvider.GetTeamAppsAsync(Team.Id, HttpContext.RequestAborted);
- return apps.OrderBy(x => x.Name).Select(a => AppDto.FromDomain(a, userOrClientId, isFrontend, Resources)).ToArray();
+ var response = Deferred.Response(() =>
+ {
+ return apps.OrderBy(x => x.Name).Select(a => AppDto.FromDomain(a, UserOrClientId, IsFrontend, Resources)).ToArray();
});
Response.Headers[HeaderNames.ETag] = apps.ToEtag();
@@ -85,11 +113,9 @@ namespace Squidex.Areas.Api.Controllers.Apps
{
var response = Deferred.Response(() =>
{
- var userOrClientId = HttpContext.User.UserOrClientId()!;
-
var isFrontend = HttpContext.User.IsInClient(DefaultClients.Frontend);
- return AppDto.FromDomain(App, userOrClientId, isFrontend, Resources);
+ return AppDto.FromDomain(App, UserOrClientId, IsFrontend, Resources);
});
Response.Headers[HeaderNames.ETag] = App.ToEtag();
@@ -144,6 +170,28 @@ namespace Squidex.Areas.Api.Controllers.Apps
return Ok(response);
}
+ ///
+ /// Transfer the app.
+ ///
+ /// The name of the app to update.
+ /// The team information.
+ ///
+ /// 200 => App transferred.
+ /// 400 => App request not valid.
+ /// 404 => App not found.
+ ///
+ [HttpPut]
+ [Route("apps/{app}/team")]
+ [ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
+ [ApiPermissionOrAnonymous(PermissionIds.AppTransfer)]
+ [ApiCosts(0)]
+ public async Task PutAppTeam(string app, [FromBody] TransferToTeamDto request)
+ {
+ var response = await InvokeCommandAsync(request.ToCommand());
+
+ return Ok(response);
+ }
+
///
/// Upload the app image.
///
@@ -211,11 +259,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
{
return InvokeCommandAsync(command, x =>
{
- var userOrClientId = HttpContext.User.UserOrClientId()!;
-
- var isFrontend = HttpContext.User.IsInClient(DefaultClients.Frontend);
-
- return AppDto.FromDomain(x, userOrClientId, isFrontend, Resources);
+ return AppDto.FromDomain(x, UserOrClientId, IsFrontend, Resources);
});
}
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
index 5610627bb..69846ae84 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
@@ -28,6 +28,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class AppDto : Resource
{
+ ///
+ /// The ID of the app.
+ ///
+ public DomainId Id { get; set; }
+
///
/// The name of the app.
///
@@ -50,11 +55,6 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
///
public long Version { get; set; }
- ///
- /// The id of the app.
- ///
- public DomainId Id { get; set; }
-
///
/// The timestamp when the app has been created.
///
@@ -65,6 +65,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
///
public Instant LastModified { get; set; }
+ ///
+ /// The ID of the team.
+ ///
+ public DomainId? TeamId { get; set; }
+
///
/// The permission level of the user.
///
@@ -123,7 +128,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
result.RoleProperties = new JsonObject();
}
- foreach (var (key, value) in resources.Context.User.Claims.GetUIProperties(app.Name))
+ foreach (var (key, value) in resources.Context.UserPrincipal.Claims.GetUIProperties(app.Name))
{
result.RoleProperties[key] = JsonValue.Create(value);
}
@@ -161,6 +166,12 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
resources.Url(x => nameof(x.DeleteApp), values));
}
+ if (resources.IsAllowed(PermissionIds.AppTransfer, Name, additional: permissions))
+ {
+ AddPutLink("transfer",
+ resources.Url(x => nameof(x.PutAppTeam), values));
+ }
+
if (resources.IsAllowed(PermissionIds.AppUpdate, Name, additional: permissions))
{
AddPutLink("update",
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateClientDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateClientDto.cs
index b1911f6a4..d4fc227da 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateClientDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateClientDto.cs
@@ -14,7 +14,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public sealed class CreateClientDto
{
///
- /// The id of the client.
+ /// The ID of the client.
///
[LocalizedRequired]
[LocalizedRegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/TransferToTeamDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/TransferToTeamDto.cs
new file mode 100644
index 000000000..f69aceb47
--- /dev/null
+++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/TransferToTeamDto.cs
@@ -0,0 +1,26 @@
+// ==========================================================================
+// 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.Reflection;
+
+namespace Squidex.Areas.Api.Controllers.Apps.Models
+{
+ public sealed class TransferToTeamDto
+ {
+ ///
+ /// The ID of the team.
+ ///
+ public DomainId? TeamId { get; set; }
+
+ public TransferToTeam ToCommand()
+ {
+ return SimpleMapper.Map(this, new TransferToTeam());
+ }
+ }
+}
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
index 35cdc13f6..7ec783cd4 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
@@ -79,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
///
/// Get the asset content.
///
- /// The id of the asset.
+ /// The ID of the asset.
/// The request parameters.
///
/// 200 => Asset found and content or (resized) image returned.
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs
index ac9317383..d8353dc3e 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs
@@ -96,7 +96,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// Update an asset folder.
///
/// The name of the app.
- /// The id of the asset folder.
+ /// The ID of the asset folder.
/// The asset folder object that needs to updated.
///
/// 200 => Asset folder updated.
@@ -122,7 +122,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// Move an asset folder.
///
/// The name of the app.
- /// The id of the asset folder.
+ /// The ID of the asset folder.
/// The asset folder object that needs to updated.
///
/// 200 => Asset folder moved.
@@ -148,7 +148,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// Delete an asset folder.
///
/// The name of the app.
- /// The id of the asset folder to delete.
+ /// The ID of the asset folder to delete.
///
/// 204 => Asset folder deleted.
/// 404 => Asset folder or app not found.
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
index 2f0c62617..bfc904bd8 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
@@ -14,9 +14,9 @@ using Squidex.Assets;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities;
-using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
+using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Translations;
@@ -32,24 +32,24 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiExplorerSettings(GroupName = nameof(Assets))]
public sealed class AssetsController : ApiController
{
+ private readonly IAppUsageGate appUsageGate;
private readonly IAssetQueryService assetQuery;
- private readonly IAssetUsageTracker assetStatsRepository;
- private readonly IAppPlansProvider appPlansProvider;
+ private readonly IAssetUsageTracker assetUsageTracker;
private readonly ITagService tagService;
private readonly AssetTusRunner assetTusRunner;
public AssetsController(
ICommandBus commandBus,
+ IAppUsageGate appUsageGate,
IAssetQueryService assetQuery,
- IAssetUsageTracker assetStatsRepository,
- IAppPlansProvider appPlansProvider,
+ IAssetUsageTracker assetUsageTracker,
ITagService tagService,
AssetTusRunner assetTusRunner)
: base(commandBus)
{
- this.appPlansProvider = appPlansProvider;
+ this.appUsageGate = appUsageGate;
this.assetQuery = assetQuery;
- this.assetStatsRepository = assetStatsRepository;
+ this.assetUsageTracker = assetUsageTracker;
this.assetTusRunner = assetTusRunner;
this.tagService = tagService;
}
@@ -165,7 +165,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// Get an asset by id.
///
/// The name of the app.
- /// The id of the asset to retrieve.
+ /// The ID of the asset to retrieve.
///
/// 200 => Asset found.
/// 404 => Asset or app not found.
@@ -319,7 +319,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// Replace asset content.
///
/// The name of the app.
- /// The id of the asset.
+ /// The ID of the asset.
/// The file to upload.
///
/// 200 => Asset updated.
@@ -349,7 +349,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// Update an asset.
///
/// The name of the app.
- /// The id of the asset.
+ /// The ID of the asset.
/// The asset object that needs to updated.
///
/// 200 => Asset updated.
@@ -375,7 +375,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// Moves the asset.
///
/// The name of the app.
- /// The id of the asset.
+ /// The ID of the asset.
/// The asset object that needs to updated.
///
/// 200 => Asset moved.
@@ -401,7 +401,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
/// Delete an asset.
///
/// The name of the app.
- /// The id of the asset to delete.
+ /// The ID of the asset to delete.
/// The request parameters.
///
/// 204 => Asset deleted.
@@ -469,9 +469,9 @@ namespace Squidex.Areas.Api.Controllers.Assets
throw new ValidationException(error);
}
- var (plan, _) = appPlansProvider.GetPlanForApp(App);
+ var (plan, _, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
- var currentSize = await assetStatsRepository.GetTotalSizeAsync(AppId);
+ var currentSize = await assetUsageTracker.GetTotalSizeByAppAsync(AppId, HttpContext.RequestAborted);
if (plan.MaxAssetSize > 0 && plan.MaxAssetSize < currentSize + file.Length)
{
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
index d2873301c..616c742e7 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
@@ -19,12 +19,12 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
public sealed class AssetDto : Resource
{
///
- /// The id of the asset.
+ /// The ID of the asset.
///
public DomainId Id { get; set; }
///
- /// The id of the parent folder. Empty for files without parent.
+ /// The ID of the parent folder. Empty for files without parent.
///
public DomainId ParentId { get; set; }
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFolderDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFolderDto.cs
index 3664a4262..da5b02f95 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFolderDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetFolderDto.cs
@@ -16,12 +16,12 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
public sealed class AssetFolderDto : Resource
{
///
- /// The id of the asset.
+ /// The ID of the asset.
///
public DomainId Id { get; set; }
///
- /// The id of the parent folder. Empty for files without parent.
+ /// The ID of the parent folder. Empty for files without parent.
///
public DomainId ParentId { get; set; }
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/BulkUpdateAssetsJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/BulkUpdateAssetsJobDto.cs
index 3e5bd6d1b..b1ead93c1 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/BulkUpdateAssetsJobDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/BulkUpdateAssetsJobDto.cs
@@ -15,7 +15,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
public class BulkUpdateAssetsJobDto
{
///
- /// An optional id of the asset to update.
+ /// An optional ID of the asset to update.
///
public DomainId Id { get; set; }
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetFolderDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetFolderDto.cs
index 52afd4dab..f1f18987a 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetFolderDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetFolderDto.cs
@@ -21,7 +21,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
public string FolderName { get; set; }
///
- /// The id of the parent folder.
+ /// The ID of the parent folder.
///
public DomainId ParentId { get; set; }
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs b/backend/src/Squidex/Areas/Api/Controllers/AssignContributorDto.cs
similarity index 78%
rename from backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs
rename to backend/src/Squidex/Areas/Api/Controllers/AssignContributorDto.cs
index 64f8be105..88cbaf143 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/AssignContributorDto.cs
@@ -5,12 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
-using Squidex.Domain.Apps.Entities.Apps.Commands;
-using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
using Roles = Squidex.Domain.Apps.Core.Apps.Role;
-namespace Squidex.Areas.Api.Controllers.Apps.Models
+namespace Squidex.Areas.Api.Controllers
{
public sealed class AssignContributorDto
{
@@ -29,10 +27,5 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// Set to true to invite the user if he does not exist.
///
public bool Invite { get; set; }
-
- public AssignContributor ToCommand()
- {
- return SimpleMapper.Map(this, new AssignContributor());
- }
}
-}
\ No newline at end of file
+}
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs
index 9be09dd94..2e66fbf73 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs
@@ -36,7 +36,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
/// Get the backup content.
///
/// The name of the app.
- /// The id of the backup.
+ /// The ID of the backup.
///
/// 200 => Backup found and content returned.
/// 404 => Backup or app not found.
@@ -55,8 +55,8 @@ namespace Squidex.Areas.Api.Controllers.Backups
///
/// Get the backup content.
///
- /// The id of the backup.
- /// The id of the app.
+ /// The ID of the backup.
+ /// The ID of the app.
/// The name of the app.
///
/// 200 => Backup found and content returned.
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
index 27ca1a44c..547cbe112 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
@@ -77,7 +77,7 @@ namespace Squidex.Areas.Api.Controllers.Backups
/// Delete a backup.
///
/// The name of the app.
- /// The id of the backup to delete.
+ /// The ID of the backup to delete.
///
/// 204 => Backup deleted.
/// 404 => Backup or app not found.
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs
index 17aedbf9a..323aecb35 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs
@@ -16,7 +16,7 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models
public sealed class BackupJobDto : Resource
{
///
- /// The id of the backup job.
+ /// The ID of the backup job.
///
public DomainId Id { get; set; }
diff --git a/backend/src/Squidex/Areas/Api/Controllers/BulkResultDto.cs b/backend/src/Squidex/Areas/Api/Controllers/BulkResultDto.cs
index a6bc6df8a..ea7dce662 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/BulkResultDto.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/BulkResultDto.cs
@@ -25,12 +25,12 @@ namespace Squidex.Areas.Api.Controllers
public int JobIndex { get; set; }
///
- /// The id of the entity that has been handled successfully or not.
+ /// The ID of the entity that has been handled successfully or not.
///
public DomainId? Id { get; set; }
///
- /// The id of the entity that has been handled successfully or not.
+ /// The ID of the entity that has been handled successfully or not.
///
[Obsolete("Use 'id' field now.")]
public DomainId? ContentId => Id;
diff --git a/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
index bedc8ece7..0182a6284 100644
--- a/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
+++ b/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
@@ -13,8 +13,6 @@ using Squidex.Domain.Apps.Entities.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
-using Squidex.Infrastructure.Security;
-using Squidex.Infrastructure.Translations;
using Squidex.Shared;
using Squidex.Web;
@@ -54,7 +52,7 @@ namespace Squidex.Areas.Api.Controllers.Comments
[ApiCosts(0)]
public async Task GetWatchingUsers(string app, string? resource = null)
{
- var result = await watchingService.GetWatchingUsersAsync(App.Id, resource, UserId(), HttpContext.RequestAborted);
+ var result = await watchingService.GetWatchingUsersAsync(App.Id, resource, UserId, HttpContext.RequestAborted);
return Ok(result);
}
@@ -63,7 +61,7 @@ namespace Squidex.Areas.Api.Controllers.Comments
/// Get all comments.
///
/// The name of the app.
- /// The id of the comments.
+ /// The ID of the comments.
/// The current version.
///
/// When passing in a version you can retrieve all updates since then.
@@ -95,7 +93,7 @@ namespace Squidex.Areas.Api.Controllers.Comments
/// Create a new comment.
///
/// The name of the app.
- /// The id of the comments.
+ /// The ID of the comments.
/// The comment object that needs to created.
///
/// 201 => Comment created.
@@ -122,8 +120,8 @@ namespace Squidex.Areas.Api.Controllers.Comments
/// Update a comment.
///
/// The name of the app.
- /// The id of the comments.
- /// The id of the comment.
+ /// The ID of the comments.
+ /// The ID of the comment.
/// The comment object that needs to updated.
///
/// 204 => Comment updated.
@@ -147,8 +145,8 @@ namespace Squidex.Areas.Api.Controllers.Comments
/// Delete a comment.
///
///