From dd9a427d06ce596fc371d9cff0f5d15a7af383af Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 26 Sep 2022 19:10:50 +0200 Subject: [PATCH] Teams improvements (#923) * Improvements to teams. * Fixes * Fixes api * Fixes to teams. * Simplify some interfaces. * Teemp * Fix tests and type names. --- .../Apps/AppExtensions.cs | 5 + .../Apps/AppHistoryEventsCreator.cs | 5 - .../Apps/DomainObject/AppDomainObject.cs | 104 ++++++-------- .../Billing/IBillingManager.cs | 20 +-- .../Billing/Messages.cs | 2 - .../Billing/NoopBillingManager.cs | 24 ++-- .../Billing/UsageGate.cs | 5 +- .../Billing/UsageNotifierWorker.cs | 57 +++++--- .../Types/Contents/ComponentUnionGraphType.cs | 2 +- .../Types/Contents/ContentResultGraphType.cs | 2 +- .../Types/Contents/FieldInputVisitor.cs | 4 +- .../GraphQL/Types/Contents/FieldVisitor.cs | 4 +- .../Types/Contents/ReferenceUnionGraphType.cs | 2 +- .../GraphQL/Types/Contents/SchemaInfo.cs | 135 ++++++++---------- .../GraphQL/Types/Primitives/JsonGraphType.cs | 2 +- .../History/HistoryService.cs | 1 - .../Invitation/InvitationEventConsumer.cs | 24 +++- ...ons.cs => EmailUserNotificationOptions.cs} | 10 +- ...ailSender.cs => EmailUserNotifications.cs} | 77 +++++++--- ...icationSender.cs => IUserNotifications.cs} | 13 +- ...tionSender.cs => NoopUserNotifications.cs} | 13 +- .../Teams/DomainObject/TeamDomainObject.cs | 92 +++++------- backend/src/Squidex.Web/Constants.cs | 2 - backend/src/Squidex.Web/Resources.cs | 5 + .../Contents/ContentOpenApiController.cs | 4 +- .../Controllers/Plans/AppPlansController.cs | 32 ++++- .../Api/Controllers/Plans/Models/PlansDto.cs | 34 ++--- .../Plans/Models/PlansLockedReason.cs | 32 +++++ .../Controllers/Plans/TeamPlansController.cs | 30 +++- .../Api/Controllers/Teams/Models/TeamDto.cs | 2 +- ...PortalDashboardAuthenticationMiddleware.cs | 44 ------ .../Middlewares/PortalRedirectMiddleware.cs | 37 ----- .../Config/Domain/NotificationsServices.cs | 10 +- backend/src/Squidex/Startup.cs | 7 - backend/src/Squidex/appsettings.json | 20 ++- .../Apps/DomainObject/AppDomainObjectTests.cs | 28 ++-- .../Billing/NoopBillingManagerTests.cs | 37 ++--- .../Billing/UsageNotifierWorkerTest.cs | 36 +++-- .../Contents/GraphQL/TestContent.cs | 16 +-- .../InvitationEventConsumerTests.cs | 44 +++--- .../InviteUserCommandMiddlewareTests.cs | 1 - ...ests.cs => EmailUserNotificationsTests.cs} | 94 +++++++++--- .../DomainObject/TeamDomainObjectTests.cs | 26 ++-- .../JsonInheritanceConverterBaseTests.cs | 2 +- .../settings/pages/plans/plan.component.html | 4 +- .../pages/plans/plans-page.component.html | 8 +- .../teams/pages/plans/plan.component.html | 4 +- .../pages/plans/plans-page.component.html | 4 +- .../teams/services/team-plans.service.spec.ts | 8 +- .../teams/state/team-contributors.state.ts | 2 +- .../teams/state/team-plans.state.spec.ts | 12 +- .../features/teams/state/team-plans.state.ts | 34 ++--- .../app/shared/services/plans.service.spec.ts | 8 +- frontend/src/app/shared/services/shared.ts | 20 +-- .../src/app/shared/state/plans.state.spec.ts | 12 +- frontend/src/app/shared/state/plans.state.ts | 38 ++--- 56 files changed, 682 insertions(+), 618 deletions(-) rename backend/src/Squidex.Domain.Apps.Entities/Notifications/{NotificationEmailTextOptions.cs => EmailUserNotificationOptions.cs} (72%) rename backend/src/Squidex.Domain.Apps.Entities/Notifications/{NotificationEmailSender.cs => EmailUserNotifications.cs} (66%) rename backend/src/Squidex.Domain.Apps.Entities/Notifications/{INotificationSender.cs => IUserNotifications.cs} (50%) rename backend/src/Squidex.Domain.Apps.Entities/Notifications/{NoopNotificationSender.cs => NoopUserNotifications.cs} (62%) create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansLockedReason.cs delete mode 100644 backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs delete mode 100644 backend/src/Squidex/Areas/Portal/Middlewares/PortalRedirectMiddleware.cs rename backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/{NotificationEmailSenderTests.cs => EmailUserNotificationsTests.cs} (60%) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs index 44902d1b7..0a9842807 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs @@ -18,6 +18,11 @@ namespace Squidex.Domain.Apps.Entities.Apps return new NamedId(app.Id, app.Name); } + public static string DisplayName(this IAppEntity app) + { + return app.Label.Or(app.Name); + } + public static bool TryGetContributorRole(this IAppEntity app, string id, bool isFrontend, [MaybeNullWhen(false)] out Role role) { role = null; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs index 8451b8494..8654976a4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs @@ -133,11 +133,6 @@ namespace Squidex.Domain.Apps.Entities.Apps return ForEvent(e, "general"); } - private HistoryEvent CreateAppSettingsEvent(IEvent e) - { - return ForEvent(e, "settings.appSettings"); - } - private HistoryEvent CreateContributorsEvent(IEvent e, string contributor, string? role = null) { return ForEvent(e, "settings.contributors").Param("Contributor", contributor).Param("Role", role); 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 918335352..e72706fa4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs @@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject case TransferToTeam transfer: return UpdateReturnAsync(transfer, async (c, ct) => { - await GuardApp.CanTransfer(c, Snapshot, AppProvider(), ct); + await GuardApp.CanTransfer(c, Snapshot, AppProvider, ct); Transfer(c); @@ -128,7 +128,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject case AssignContributor assignContributor: return UpdateReturnAsync(assignContributor, async (c, ct) => { - await GuardAppContributors.CanAssign(c, Snapshot, Users(), GetPlan()); + await GuardAppContributors.CanAssign(c, Snapshot, Users, Plan); AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId)); @@ -268,66 +268,52 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject case DeleteApp delete: return UpdateAsync(delete, async (c, ct) => { - await BillingManager().UnsubscribeAsync(c.Actor.Identifier, Snapshot.NamedId(), default); + await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default); DeleteApp(c); }, 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; + return UpdateReturnAsync(changePlan, async (c, ct) => + { + GuardApp.CanChangePlan(c, Snapshot, BillingPlans); - var result = await UpdateReturnAsync(changePlan, async (c, ct) => - { - GuardApp.CanChangePlan(c, Snapshot, BillingPlans()); + if (string.Equals(FreePlan?.Id, c.PlanId, StringComparison.Ordinal)) + { + if (!c.FromCallback) + { + await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default); + } - if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal)) - { - ResetPlan(c); + ResetPlan(c); - return new PlanChangedResult(c.PlanId, true, null); - } + return new PlanChangedResult(c.PlanId, true, null); + } + else + { + if (!c.FromCallback) + { + var redirectUri = await BillingManager.MustRedirectToPortalAsync(c.Actor.Identifier, Snapshot, c.PlanId, ct); - if (!c.FromCallback) - { - var redirectUri = await BillingManager().MustRedirectToPortalAsync(userId, Snapshot.NamedId(), c.PlanId, ct); + if (redirectUri != null) + { + return new PlanChangedResult(c.PlanId, false, redirectUri); + } - if (redirectUri != null) - { - return new PlanChangedResult(c.PlanId, false, redirectUri); - } - } + await BillingManager.SubscribeAsync(c.Actor.Identifier, Snapshot, changePlan.PlanId, default); + } - ChangePlan(c); + ChangePlan(c); - return new PlanChangedResult(c.PlanId); - }, ct); + return new PlanChangedResult(c.PlanId); + } - if (changePlan.FromCallback) - { - return result; - } + }, ct); - if (result.Payload is PlanChangedResult { Unsubscribed: true, RedirectUri: null }) - { - await BillingManager().UnsubscribeAsync(userId, Snapshot.NamedId(), default); - } - else if (result.Payload is PlanChangedResult { RedirectUri: null }) - { - await BillingManager().SubscribeAsync(userId, Snapshot.NamedId(), changePlan.PlanId, default); + default: + ThrowHelper.NotSupportedException(); + return default!; } - - return result; } private void Create(CreateApp command) @@ -480,34 +466,34 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject RaiseEvent(Envelope.Create(@event)); } - private IAppProvider AppProvider() + private IAppProvider AppProvider { - return serviceProvider.GetRequiredService(); + get => serviceProvider.GetRequiredService(); } - private IBillingPlans BillingPlans() + private IBillingPlans BillingPlans { - return serviceProvider.GetRequiredService(); + get => serviceProvider.GetRequiredService(); } - private IBillingManager BillingManager() + private IBillingManager BillingManager { - return serviceProvider.GetRequiredService(); + get => serviceProvider.GetRequiredService(); } - private IUserResolver Users() + private IUserResolver Users { - return serviceProvider.GetRequiredService(); + get => serviceProvider.GetRequiredService(); } - private Plan GetFreePlan() + private Plan FreePlan { - return BillingPlans().GetFreePlan(); + get => BillingPlans.GetFreePlan(); } - private Plan GetPlan() + private Plan Plan { - return BillingPlans().GetActualPlan(Snapshot.Plan?.PlanId).Plan; + get => BillingPlans.GetActualPlan(Snapshot.Plan?.PlanId).Plan; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs index 84cca89ad..3f4a81140 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs @@ -5,33 +5,35 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Teams; namespace Squidex.Domain.Apps.Entities.Billing { public interface IBillingManager { - bool HasPortal { get; } + Task GetPortalLinkAsync(string userId, IAppEntity app, + CancellationToken ct = default); - Task MustRedirectToPortalAsync(string userId, NamedId appId, string? planId, + Task GetPortalLinkAsync(string userId, ITeamEntity team, CancellationToken ct = default); - Task MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId, + Task MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId, CancellationToken ct = default); - Task SubscribeAsync(string userId, NamedId appId, string planId, + Task MustRedirectToPortalAsync(string userId, ITeamEntity team, string? planId, CancellationToken ct = default); - Task SubscribeAsync(string userId, DomainId teamId, string planId, + Task SubscribeAsync(string userId, IAppEntity app, string planId, CancellationToken ct = default); - Task UnsubscribeAsync(string userId, NamedId appId, + Task SubscribeAsync(string userId, ITeamEntity team, string planId, CancellationToken ct = default); - Task UnsubscribeAsync(string userId, DomainId teamId, + Task UnsubscribeAsync(string userId, IAppEntity app, CancellationToken ct = default); - Task GetPortalLinkAsync(string userId, + Task UnsubscribeAsync(string userId, ITeamEntity team, CancellationToken ct = default); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs index 79f3017f9..ca48c3d65 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs @@ -15,8 +15,6 @@ namespace Squidex.Domain.Apps.Entities.Billing { public DomainId AppId { get; init; } - public string AppName { get; init; } - public long Usage { get; init; } public long UsageLimit { get; init; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs index 30a034163..b45e5b307 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs @@ -5,54 +5,56 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Teams; namespace Squidex.Domain.Apps.Entities.Billing { public sealed class NoopBillingManager : IBillingManager { - public bool HasPortal + public Task GetPortalLinkAsync(string userId, IAppEntity app, + CancellationToken ct = default) { - get => false; + return Task.FromResult(null); } - public Task GetPortalLinkAsync(string userId, + public Task GetPortalLinkAsync(string userId, ITeamEntity team, CancellationToken ct = default) { - return Task.FromResult(string.Empty); + return Task.FromResult(null); } - public Task MustRedirectToPortalAsync(string userId, NamedId appId, string? planId, + public Task MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId, CancellationToken ct = default) { return Task.FromResult(null); } - public Task MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId, + public Task MustRedirectToPortalAsync(string userId, ITeamEntity team, string? planId, CancellationToken ct = default) { return Task.FromResult(null); } - public Task SubscribeAsync(string userId, NamedId appId, string planId, + public Task SubscribeAsync(string userId, IAppEntity app, string planId, CancellationToken ct = default) { return Task.CompletedTask; } - public Task SubscribeAsync(string userId, DomainId teamId, string planId, + public Task SubscribeAsync(string userId, ITeamEntity team, string planId, CancellationToken ct = default) { return Task.CompletedTask; } - public Task UnsubscribeAsync(string userId, NamedId appId, + public Task UnsubscribeAsync(string userId, IAppEntity app, CancellationToken ct = default) { return Task.CompletedTask; } - public Task UnsubscribeAsync(string userId, DomainId teamId, + public Task UnsubscribeAsync(string userId, ITeamEntity team, CancellationToken ct = default) { return Task.CompletedTask; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs index cc6ebab97..f1973fe73 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs @@ -22,9 +22,9 @@ namespace Squidex.Domain.Apps.Entities.Billing 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 IAppProvider appProvider; + private readonly IBillingPlans billingPlans; private readonly IMemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); private readonly IMessageBus messaging; private readonly IUsageTracker usageTracker; @@ -150,7 +150,6 @@ namespace Squidex.Domain.Apps.Entities.Billing var notification = new UsageTrackingCheck { AppId = appId, - AppName = app.Name, Usage = usage, UsageLimit = blockLimit, Users = GetUsers(app) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs index 4f95e7033..431a8e0fe 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs @@ -9,7 +9,6 @@ using NodaTime; using Squidex.Domain.Apps.Entities.Notifications; using Squidex.Infrastructure; using Squidex.Infrastructure.States; -using Squidex.Infrastructure.Tasks; using Squidex.Messaging; using Squidex.Shared.Users; @@ -19,7 +18,8 @@ namespace Squidex.Domain.Apps.Entities.Billing { private static readonly TimeSpan TimeBetweenNotifications = TimeSpan.FromDays(3); private readonly SimpleState state; - private readonly INotificationSender notificationSender; + private readonly IAppProvider appProvider; + private readonly IUserNotifications userNotifications; private readonly IUserResolver userResolver; [CollectionName("UsageNotifications")] @@ -31,9 +31,12 @@ namespace Squidex.Domain.Apps.Entities.Billing public IClock Clock { get; set; } = SystemClock.Instance; public UsageNotifierWorker(IPersistenceFactory persistenceFactory, - INotificationSender notificationSender, IUserResolver userResolver) + IAppProvider appProvider, + IUserNotifications userNotifications, + IUserResolver userResolver) { - this.notificationSender = notificationSender; + this.appProvider = appProvider; + this.userNotifications = userNotifications; this.userResolver = userResolver; state = new SimpleState(persistenceFactory, GetType(), DomainId.Create("Default")); @@ -42,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Billing public async Task HandleAsync(UsageTrackingCheck notification, CancellationToken ct) { - if (!notificationSender.IsActive) + if (!userNotifications.IsActive) { return; } @@ -51,26 +54,40 @@ namespace Squidex.Domain.Apps.Entities.Billing if (!HasBeenSentBefore(notification.AppId, now)) { - if (notificationSender.IsActive) - { - foreach (var userId in notification.Users) - { - var user = await userResolver.FindByIdOrEmailAsync(userId, ct); - - if (user != null) - { - notificationSender.SendUsageAsync(user, - notification.AppName, - notification.Usage, - notification.UsageLimit).Forget(); - } - } - } + await SendAsync(notification, ct); await TrackNotifiedAsync(notification.AppId, now); } } + private async Task SendAsync(UsageTrackingCheck notification, + CancellationToken ct) + { + if (!userNotifications.IsActive) + { + return; + } + + var app = await appProvider.GetAppAsync(notification.AppId, true, ct); + + if (app == null) + { + return; + } + + foreach (var userId in notification.Users) + { + var user = await userResolver.FindByIdOrEmailAsync(userId, ct); + + if (user != null) + { + await userNotifications.SendUsageAsync(user, app, + notification.Usage, + notification.UsageLimit, ct); + } + } + } + private bool HasBeenSentBefore(DomainId appId, DateTime now) { if (state.Value.NotificationsSent.TryGetValue(appId, out var lastSent)) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs index f5ddd0c96..d900d58d9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs @@ -22,7 +22,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents public ComponentUnionGraphType(Builder builder, FieldInfo fieldInfo, ReadonlyList? schemaIds) { // The name is used for equal comparison. Therefore it is important to treat it as readonly. - Name = fieldInfo.ReferenceType; + Name = fieldInfo.UnionReferenceType; if (schemaIds?.Any() == true) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResultGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResultGraphType.cs index 139797f69..34843d922 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResultGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResultGraphType.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents public ContentResultGraphType(ContentGraphType contentType, SchemaInfo schemaInfo) { // The name is used for equal comparison. Therefore it is important to treat it as readonly. - Name = schemaInfo.ResultType; + Name = schemaInfo.ContentResultType; AddField(new FieldType { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldInputVisitor.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldInputVisitor.cs index 200c85501..4945bcb8a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldInputVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldInputVisitor.cs @@ -85,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents if (field.Properties?.AllowedValues?.Count > 0 && field.Properties.CreateEnum) { - var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues); + var @enum = builder.GetEnumeration(args.EmbeddedEnumType, field.Properties.AllowedValues); if (@enum != null) { @@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents if (field.Properties?.AllowedValues?.Count > 0 && field.Properties.CreateEnum) { - var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues); + var @enum = builder.GetEnumeration(args.EmbeddedEnumType, field.Properties.AllowedValues); if (@enum != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs index bb3997e98..487c5f27f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs @@ -200,7 +200,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents } else if (field.Properties?.AllowedValues?.Count > 0 && field.Properties.CreateEnum) { - var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues); + var @enum = builder.GetEnumeration(args.EmbeddedEnumType, field.Properties.AllowedValues); if (@enum != null) { @@ -217,7 +217,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents if (field.Properties?.AllowedValues?.Count > 0 && field.Properties.CreateEnum) { - var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues); + var @enum = builder.GetEnumeration(args.EmbeddedEnumType, field.Properties.AllowedValues); if (@enum != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs index ca609f4f5..a9afbba20 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents public ReferenceUnionGraphType(Builder builder, FieldInfo fieldInfo, ReadonlyList? schemaIds) { // The name is used for equal comparison. Therefore it is important to treat it as readonly. - Name = fieldInfo.ReferenceType; + Name = fieldInfo.UnionReferenceType; if (schemaIds?.Any() == true) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs index ecc0b86f3..10a9494e6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs @@ -18,35 +18,34 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents { public ISchemaEntity Schema { get; } - public string TypeName { get; } - - public string DisplayName { get; } + public string DisplayName => Schema.DisplayName(); public string ComponentType { get; } public string ContentType { get; } - public string DataType { get; } + public string DataFlatType { get; } public string DataInputType { get; } - public string DataFlatType { get; } + public string DataType { get; } - public string ResultType { get; } + public string ContentResultType { get; } + + public string TypeName { get; } - public IReadOnlyList Fields { get; } + public IReadOnlyList Fields { get; init; } - private SchemaInfo(ISchemaEntity schema, string typeName, IReadOnlyList fields, Names names) + private SchemaInfo(ISchemaEntity schema, string typeName, Names rootScope) { Schema = schema; - ComponentType = names[$"{typeName}Component"]; - ContentType = names[typeName]; - DataFlatType = names[$"{typeName}FlatDataDto"]; - DataInputType = names[$"{typeName}DataInputDto"]; - ResultType = names[$"{typeName}ResultDto"]; - DataType = names[$"{typeName}DataDto"]; - DisplayName = schema.DisplayName(); - Fields = fields; + + ComponentType = rootScope[$"{typeName}Component"]; + ContentResultType = rootScope[$"{typeName}ResultDto"]; + ContentType = typeName; + DataFlatType = rootScope[$"{typeName}FlatDataDto"]; + DataInputType = rootScope[$"{typeName}DataInputDto"]; + DataType = rootScope[$"{typeName}DataDto"]; TypeName = typeName; } @@ -57,79 +56,65 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents public static IEnumerable Build(IEnumerable schemas) { - var names = new Names(); + var rootScope = new Names(); foreach (var schema in schemas.OrderBy(x => x.Created)) { - var typeName = schema.TypeName(); - - var fieldInfos = new List(schema.SchemaDef.Fields.Count); - var fieldNames = new Names(); + var typeName = rootScope[schema.TypeName()]; - foreach (var field in schema.SchemaDef.Fields.ForApi()) + yield return new SchemaInfo(schema, typeName, rootScope) { - fieldInfos.Add(FieldInfo.Build( - field, - names[$"{typeName}Data{field.TypeName()}"], - names, - fieldNames)); - } - - yield return new SchemaInfo(schema, typeName, fieldInfos, names); + Fields = FieldInfo.Build(schema.SchemaDef.Fields, $"{typeName}Data", rootScope).ToList() + }; } } } internal sealed class FieldInfo { - public static readonly List EmptyFields = new List(); - public IField Field { get; set; } + public string DisplayName => Field.DisplayName(); + + public string EmbeddableStringType { get; } + + public string EmbeddedEnumType { get; } + public string FieldName { get; } public string FieldNameDynamic { get; } - public string DisplayName { get; } - - public string EnumName { get; } + public string LocalizedInputType { get; } public string LocalizedType { get; } public string LocalizedTypeDynamic { get; } - public string LocalizedInputType { get; } - - public string NestedType { get; } - public string NestedInputType { get; } - public string ComponentType { get; } + public string NestedType { get; } - public string ReferenceType { get; } + public string UnionComponentType { get; } - public string EmbeddableStringType { get; } + public string UnionReferenceType { get; } - public IReadOnlyList Fields { get; } + public IReadOnlyList Fields { get; init; } - private FieldInfo(IField field, string typeName, Names names, Names parentNames, IReadOnlyList fields) + private FieldInfo(IField field, string fieldName, string typeName, Names rootScope) { - var fieldName = parentNames[field.Name.ToCamelCase(), false]; - - ComponentType = names[$"{typeName}ComponentUnionDto"]; - DisplayName = field.DisplayName(); - EmbeddableStringType = names[$"{typeName}EmbeddableString"]; - EnumName = names[$"{fieldName}Enum"]; Field = field; + + EmbeddableStringType = rootScope[$"{typeName}EmbeddableString"]; + EmbeddedEnumType = rootScope[$"{typeName}Enum"]; FieldName = fieldName; - FieldNameDynamic = names[$"{fieldName}__Dynamic"]; - Fields = fields; - LocalizedInputType = names[$"{typeName}InputDto"]; - LocalizedType = names[$"{typeName}Dto"]; - LocalizedTypeDynamic = names[$"{typeName}Dto__Dynamic"]; - NestedInputType = names[$"{typeName}ChildInputDto"]; - NestedType = names[$"{typeName}ChildDto"]; - ReferenceType = names[$"{typeName}UnionDto"]; + FieldNameDynamic = $"{fieldName}__Dynamic"; + LocalizedInputType = rootScope[$"{typeName}InputDto"]; + LocalizedType = rootScope[$"{typeName}Dto"]; + LocalizedTypeDynamic = rootScope[$"{typeName}Dto__Dynamic"]; + NestedInputType = rootScope[$"{typeName}ChildInputDto"]; + NestedType = rootScope[$"{typeName}ChildDto"]; + UnionComponentType = rootScope[$"{typeName}ComponentUnionDto"]; + UnionReferenceType = rootScope[$"{typeName}UnionDto"]; } public override string ToString() @@ -137,28 +122,34 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents return FieldName; } - internal static FieldInfo Build(IRootField rootField, string typeName, Names names, Names parentNames) + internal static IEnumerable Build(IEnumerable fields, string typeName, Names rootScope) { - var fieldInfos = EmptyFields; + var typeScope = new Names(); - if (rootField is IArrayField arrayField) + foreach (var field in fields.ForApi()) { - var fieldNames = new Names(); + // Field names must be unique within the scope of the parent type. + var fieldName = typeScope[field.Name.ToCamelCase(), false]; - fieldInfos = new List(arrayField.Fields.Count); + // Type names must be globally unique. + var fieldTypeName = rootScope[$"{typeName}{field.TypeName()}"]; - foreach (var nestedField in arrayField.Fields.ForApi()) + var nested = new List(); + + if (field is IArrayField arrayField) { - fieldInfos.Add(new FieldInfo( - nestedField, - names[$"{typeName}{nestedField.TypeName()}"], - names, - fieldNames, - EmptyFields)); + nested = Build(arrayField.Fields, fieldTypeName, rootScope).ToList(); } - } - return new FieldInfo(rootField, typeName, names, parentNames, fieldInfos); + yield return new FieldInfo( + field, + fieldName, + fieldTypeName, + rootScope) + { + Fields = nested + }; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/JsonGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/JsonGraphType.cs index 389b16e83..66203771f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/JsonGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/JsonGraphType.cs @@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives return booleanValue.BoolValue; case GraphQLFloatValue floatValue: - return double.Parse((string)floatValue.Value, NumberStyles.Integer, CultureInfo.InvariantCulture); + return double.Parse((string)floatValue.Value, NumberStyles.Any, CultureInfo.InvariantCulture); case GraphQLIntValue intValue: return double.Parse((string)intValue.Value, NumberStyles.Integer, CultureInfo.InvariantCulture); diff --git a/backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs b/backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs index b0647cdb2..09fa7fae5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs @@ -49,7 +49,6 @@ namespace Squidex.Domain.Apps.Entities.History texts[key] = value; } } - } public Task ClearAsync() diff --git a/backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs b/backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs index 969b011a2..215c150dd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs @@ -19,7 +19,7 @@ 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 IUserNotifications userNotifications; private readonly IUserResolver userResolver; private readonly IAppProvider appProvider; private readonly ILogger log; @@ -34,18 +34,21 @@ namespace Squidex.Domain.Apps.Entities.Invitation get { return "^app-|^app-"; } } - public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver, IAppProvider appProvider, + public InvitationEventConsumer( + IAppProvider appProvider, + IUserNotifications userNotifications, + IUserResolver userResolver, ILogger log) { - this.emailSender = emailSender; - this.userResolver = userResolver; this.appProvider = appProvider; + this.userNotifications = userNotifications; + this.userResolver = userResolver; this.log = log; } public async Task On(Envelope @event) { - if (!emailSender.IsActive) + if (!userNotifications.IsActive) { return; } @@ -75,7 +78,14 @@ namespace Squidex.Domain.Apps.Entities.Invitation return; } - await emailSender.SendInviteAsync(assigner, assignee, assigned.AppId.Name); + var app = await appProvider.GetAppAsync(assigned.AppId.Id, true); + + if (app == null) + { + return; + } + + await userNotifications.SendInviteAsync(assigner, assignee, app); return; } @@ -95,7 +105,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation return; } - await emailSender.SendTeamInviteAsync(assigner, assignee, team.Name); + await userNotifications.SendInviteAsync(assigner, assignee, team); break; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailTextOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotificationOptions.cs similarity index 72% rename from backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailTextOptions.cs rename to backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotificationOptions.cs index 93f7a2788..810b1b310 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailTextOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotificationOptions.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications { - public sealed class NotificationEmailTextOptions + public sealed class EmailUserNotificationOptions { public string UsageSubject { get; set; } @@ -20,5 +20,13 @@ namespace Squidex.Domain.Apps.Entities.Notifications public string ExistingUserSubject { get; set; } public string ExistingUserBody { get; set; } + + public string NewTeamUserSubject { get; set; } + + public string NewTeamUserBody { get; set; } + + public string ExistingTeamUserSubject { get; set; } + + public string ExistingTeamUserBody { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotifications.cs similarity index 66% rename from backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs rename to backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotifications.cs index da2fc4295..b5a098803 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotifications.cs @@ -8,6 +8,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Teams; using Squidex.Infrastructure; using Squidex.Infrastructure.Email; using Squidex.Shared.Identity; @@ -15,12 +17,12 @@ using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Notifications { - public sealed class NotificationEmailSender : INotificationSender + public sealed class EmailUserNotifications : IUserNotifications { private readonly IEmailSender emailSender; private readonly IUrlGenerator urlGenerator; - private readonly ILogger log; - private readonly NotificationEmailTextOptions texts; + private readonly ILogger log; + private readonly EmailUserNotificationOptions texts; private sealed class TemplatesVars { @@ -28,7 +30,9 @@ namespace Squidex.Domain.Apps.Entities.Notifications public IUser? Assigner { get; init; } - public string TeamName { get; init; } + public string? AppName { get; init; } + + public string? TeamName { get; init; } public long? ApiCalls { get; init; } @@ -42,11 +46,11 @@ namespace Squidex.Domain.Apps.Entities.Notifications get => true; } - public NotificationEmailSender( - IOptions texts, + public EmailUserNotifications( + IOptions texts, IEmailSender emailSender, IUrlGenerator urlGenerator, - ILogger log) + ILogger log) { this.texts = texts.Value; this.emailSender = emailSender; @@ -55,54 +59,77 @@ namespace Squidex.Domain.Apps.Entities.Notifications this.log = log; } - public Task SendUsageAsync(IUser user, string appName, long usage, long usageLimit) + public Task SendUsageAsync(IUser user, IAppEntity app, long usage, long usageLimit, + CancellationToken ct = default) { Guard.NotNull(user); - Guard.NotNull(appName); + Guard.NotNull(app); var vars = new TemplatesVars { ApiCalls = usage, ApiCallsLimit = usageLimit, - TeamName = appName + AppName = app.DisplayName() }; return SendEmailAsync("Usage", texts.UsageSubject, texts.UsageBody, - user, vars); + user, vars, ct); } - public Task SendInviteAsync(IUser assigner, IUser user, string appName) + public Task SendInviteAsync(IUser assigner, IUser user, IAppEntity app, + CancellationToken ct = default) { Guard.NotNull(assigner); Guard.NotNull(user); - Guard.NotNull(appName); + Guard.NotNull(app); - var vars = new TemplatesVars { Assigner = assigner, TeamName = appName }; + var vars = new TemplatesVars { Assigner = assigner, AppName = app.DisplayName() }; if (user.Claims.HasConsent()) { return SendEmailAsync("ExistingUser", texts.ExistingUserSubject, texts.ExistingUserBody, - user, vars); + user, vars, ct); } else { return SendEmailAsync("NewUser", texts.NewUserSubject, texts.NewUserBody, - user, vars); + user, vars, ct); } } - public Task SendTeamInviteAsync(IUser assigner, IUser user, string teamName) + public Task SendInviteAsync(IUser assigner, IUser user, ITeamEntity team, + CancellationToken ct = default) { - return Task.CompletedTask; + Guard.NotNull(assigner); + Guard.NotNull(user); + Guard.NotNull(team); + + var vars = new TemplatesVars { Assigner = assigner, TeamName = team.Name }; + + if (user.Claims.HasConsent()) + { + return SendEmailAsync("ExistingUser", + texts.ExistingTeamUserSubject, + texts.ExistingTeamUserBody, + user, vars, ct); + } + else + { + return SendEmailAsync("NewUser", + texts.NewTeamUserSubject, + texts.NewTeamUserBody, + user, vars, ct); + } } - private async Task SendEmailAsync(string template, string emailSubj, string emailBody, IUser user, TemplatesVars vars) + private async Task SendEmailAsync(string template, string emailSubj, string emailBody, IUser user, TemplatesVars vars, + CancellationToken ct) { if (string.IsNullOrWhiteSpace(emailBody)) { @@ -125,7 +152,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications try { - await emailSender.SendAsync(user.Email, emailSubj, emailBody); + await emailSender.SendAsync(user.Email, emailSubj, emailBody, ct); } catch (Exception ex) { @@ -136,7 +163,15 @@ namespace Squidex.Domain.Apps.Entities.Notifications private static string Format(string text, TemplatesVars vars) { - text = text.Replace("$APP_NAME", vars.TeamName, StringComparison.Ordinal); + if (!string.IsNullOrWhiteSpace(vars.AppName)) + { + text = text.Replace("$APP_NAME", vars.AppName, StringComparison.Ordinal); + } + + if (!string.IsNullOrWhiteSpace(vars.TeamName)) + { + text = text.Replace("$TEAM_NAME", vars.AppName, StringComparison.Ordinal); + } if (vars.Assigner != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/IUserNotifications.cs similarity index 50% rename from backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs rename to backend/src/Squidex.Domain.Apps.Entities/Notifications/IUserNotifications.cs index 539c084ff..306b2bc81 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Notifications/IUserNotifications.cs @@ -5,18 +5,23 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Teams; using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Notifications { - public interface INotificationSender + public interface IUserNotifications { bool IsActive { get; } - Task SendUsageAsync(IUser user, string appName, long usage, long usageLimit); + Task SendUsageAsync(IUser user, IAppEntity app, long usage, long usageLimit, + CancellationToken ct = default); - Task SendInviteAsync(IUser assigner, IUser user, string appName); + Task SendInviteAsync(IUser assigner, IUser user, IAppEntity app, + CancellationToken ct = default); - Task SendTeamInviteAsync(IUser assigner, IUser user, string teamName); + Task SendInviteAsync(IUser assigner, IUser user, ITeamEntity team, + CancellationToken ct = default); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopUserNotifications.cs similarity index 62% rename from backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs rename to backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopUserNotifications.cs index 6f9c3f71f..b1c629d85 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopUserNotifications.cs @@ -5,28 +5,33 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Teams; using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Notifications { - public sealed class NoopNotificationSender : INotificationSender + public sealed class NoopUserNotifications : IUserNotifications { public bool IsActive { get => false; } - public Task SendInviteAsync(IUser assigner, IUser user, string appName) + public Task SendInviteAsync(IUser assigner, IUser user, IAppEntity app, + CancellationToken ct = default) { return Task.CompletedTask; } - public Task SendTeamInviteAsync(IUser assigner, IUser user, string teamName) + public Task SendInviteAsync(IUser assigner, IUser user, ITeamEntity team, + CancellationToken ct = default) { return Task.CompletedTask; } - public Task SendUsageAsync(IUser user, string appName, long usage, long limit) + public Task SendUsageAsync(IUser user, IAppEntity app, long usage, long limit, + CancellationToken ct = default) { return Task.CompletedTask; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs index 1b5d28b1c..b0740ebb6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs @@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject case AssignContributor assignContributor: return UpdateReturnAsync(assignContributor, async (c, ct) => { - await GuardTeamContributors.CanAssign(c, Snapshot, Users()); + await GuardTeamContributors.CanAssign(c, Snapshot, Users); AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId)); @@ -95,60 +95,46 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject }, 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; + return UpdateReturnAsync(changePlan, async (c, ct) => + { + GuardTeam.CanChangePlan(c, BillingPlans); - var result = await UpdateReturnAsync(changePlan, async (c, ct) => - { - GuardTeam.CanChangePlan(c, BillingPlans()); + if (string.Equals(FreePlan?.Id, c.PlanId, StringComparison.Ordinal)) + { + if (!c.FromCallback) + { + await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default); + } - if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal)) - { - ResetPlan(c); + ResetPlan(c); - return new PlanChangedResult(c.PlanId, true, null); - } + return new PlanChangedResult(c.PlanId, true, null); + } + else + { + if (!c.FromCallback) + { + var redirectUri = await BillingManager.MustRedirectToPortalAsync(c.Actor.Identifier, Snapshot, c.PlanId, ct); - if (!c.FromCallback) - { - var redirectUri = await BillingManager().MustRedirectToPortalAsync(userId, UniqueId, c.PlanId, ct); + if (redirectUri != null) + { + return new PlanChangedResult(c.PlanId, false, redirectUri); + } - if (redirectUri != null) - { - return new PlanChangedResult(c.PlanId, false, redirectUri); - } - } + await BillingManager.SubscribeAsync(c.Actor.Identifier, Snapshot, changePlan.PlanId, default); + } - ChangePlan(c); + ChangePlan(c); - return new PlanChangedResult(c.PlanId); - }, ct); + return new PlanChangedResult(c.PlanId); + } - if (changePlan.FromCallback) - { - return result; - } + }, ct); - 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); + default: + ThrowHelper.NotSupportedException(); + return default!; } - - return result; } private void Create(CreateTeam command) @@ -202,24 +188,24 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject RaiseEvent(Envelope.Create(@event)); } - private IBillingPlans BillingPlans() + private IBillingPlans BillingPlans { - return serviceProvider.GetRequiredService(); + get => serviceProvider.GetRequiredService(); } - private IBillingManager BillingManager() + private IBillingManager BillingManager { - return serviceProvider.GetRequiredService(); + get => serviceProvider.GetRequiredService(); } - private IUserResolver Users() + private IUserResolver Users { - return serviceProvider.GetRequiredService(); + get => serviceProvider.GetRequiredService(); } - private Plan GetFreePlan() + private Plan FreePlan { - return BillingPlans().GetFreePlan(); + get => BillingPlans.GetFreePlan(); } } } diff --git a/backend/src/Squidex.Web/Constants.cs b/backend/src/Squidex.Web/Constants.cs index 228576d1f..5c9600740 100644 --- a/backend/src/Squidex.Web/Constants.cs +++ b/backend/src/Squidex.Web/Constants.cs @@ -18,8 +18,6 @@ namespace Squidex.Web public const string PrefixApi = "/api"; - public const string PrefixPortal = "/portal"; - public const string PrefixIdentityServer = "/identity-server"; public const string ScopePermissions = "permissions"; diff --git a/backend/src/Squidex.Web/Resources.cs b/backend/src/Squidex.Web/Resources.cs index 1721b78e7..7c7cd947a 100644 --- a/backend/src/Squidex.Web/Resources.cs +++ b/backend/src/Squidex.Web/Resources.cs @@ -130,6 +130,11 @@ namespace Squidex.Web public bool CanManageEvents => Can(PermissionIds.AdminEventsManage); + // Plans + public bool CanChangePlan => Can(PermissionIds.AppPlansChange); + + public bool CanChangeTeamPlan => Can(PermissionIds.TeamPlansChange); + // Backups public bool CanRestoreBackup => Can(PermissionIds.AdminRestore); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs index 1da7119d0..8993af6f0 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs @@ -19,11 +19,11 @@ namespace Squidex.Areas.Api.Controllers.Contents private readonly IAppProvider appProvider; private readonly SchemasOpenApiGenerator schemasOpenApiGenerator; - public ContentOpenApiController(ICommandBus commandBus, IAppProvider appProvider, SchemasOpenApiGenerator schemasOpenApiGenerator) + public ContentOpenApiController(ICommandBus commandBus, IAppProvider appProvider, + SchemasOpenApiGenerator schemasOpenApiGenerator) : base(commandBus) { this.appProvider = appProvider; - this.schemasOpenApiGenerator = schemasOpenApiGenerator; } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index f284e548a..907bff58a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -53,13 +53,37 @@ namespace Squidex.Areas.Api.Controllers.Plans [ApiCosts(0)] public IActionResult GetPlans(string app) { - var hasPortal = billingManager.HasPortal; - var response = Deferred.AsyncResponse(async () => { - var (_, planId, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted); + var owner = App.Plan?.Owner.Identifier; + + var (_, planId, teamId) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted); + + var lockedReason = PlansLockedReason.None; + + if (teamId != null) + { + lockedReason = PlansLockedReason.ManagedByTeam; + } + else if (!Resources.CanChangePlan) + { + lockedReason = PlansLockedReason.NoPermission; + } + else if (owner != null && !string.Equals(owner, UserId, StringComparison.OrdinalIgnoreCase)) + { + lockedReason = PlansLockedReason.NotOwner; + } + + var linkUrl = (Uri?)null; + + if (lockedReason == PlansLockedReason.None) + { + linkUrl = await billingManager.GetPortalLinkAsync(UserId, App, HttpContext.RequestAborted); + } + + var plans = billingPlans.GetAvailablePlans(); - return PlansDto.FromDomain(App, billingPlans, planId, hasPortal); + return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, lockedReason); }); Response.Headers[HeaderNames.ETag] = App.ToEtag(); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs index 632fde067..5f969f1fc 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs @@ -5,10 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Billing; -using Squidex.Domain.Apps.Entities.Teams; -using Squidex.Infrastructure; using Squidex.Infrastructure.Validation; namespace Squidex.Areas.Api.Controllers.Plans.Models @@ -32,37 +29,24 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models public string? PlanOwner { get; set; } /// - /// The ID of the team. + /// The link to the management portal. /// - public DomainId? TeamId { get; set; } + public string? PortalLink { get; set; } /// - /// Indicates if there is a billing portal. + /// The reason why the plan cannot be changed. /// - public bool HasPortal { get; set; } + public PlansLockedReason Locked { get; set; } - public static PlansDto FromDomain(IAppEntity app, IBillingPlans plans, string planId, bool hasPortal) + public static PlansDto FromDomain(Plan[] plans, string? owner, string planId, Uri? portalLink, PlansLockedReason locked) { var result = new PlansDto { + Locked = locked, CurrentPlanId = planId, - Plans = plans.GetAvailablePlans().Select(PlanDto.FromDomain).ToArray(), - PlanOwner = app.Plan?.Owner.Identifier, - HasPortal = hasPortal, - TeamId = app.TeamId - }; - - return result; - } - - public static PlansDto FromDomain(ITeamEntity team, IBillingPlans plans, string planId, bool hasPortal) - { - var result = new PlansDto - { - CurrentPlanId = planId, - Plans = plans.GetAvailablePlans().Select(PlanDto.FromDomain).ToArray(), - PlanOwner = team.Plan?.Owner.Identifier, - HasPortal = hasPortal + Plans = plans.Select(PlanDto.FromDomain).ToArray(), + PlanOwner = owner, + PortalLink = portalLink?.ToString() }; return result; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansLockedReason.cs b/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansLockedReason.cs new file mode 100644 index 000000000..2322034fe --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansLockedReason.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.Api.Controllers.Plans.Models +{ + public enum PlansLockedReason + { + /// + /// The user can change the plan + /// + None, + + /// + /// The user is not the owner. + /// + NotOwner, + + /// + /// The user does not have permission to change the plan + /// + NoPermission, + + /// + /// The plan is managed by the team. + /// + ManagedByTeam + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs b/backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs index 09eeaf047..09325c48b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs @@ -51,15 +51,35 @@ namespace Squidex.Areas.Api.Controllers.Plans [ProducesResponseType(typeof(PlansDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.TeamPlansRead)] [ApiCosts(0)] - public IActionResult GetPlans(string team) + public IActionResult GetTeamPlans(string team) { - var hasPortal = billingManager.HasPortal; - var response = Deferred.AsyncResponse(async () => { + var owner = Team.Plan?.Owner.Identifier; + var (_, planId) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted); - return PlansDto.FromDomain(Team, billingPlans, planId, hasPortal); + var lockedReason = PlansLockedReason.None; + + if (!Resources.CanChangeTeamPlan) + { + lockedReason = PlansLockedReason.NoPermission; + } + else if (owner != null && !string.Equals(owner, UserId, StringComparison.OrdinalIgnoreCase)) + { + lockedReason = PlansLockedReason.NotOwner; + } + + var linkUrl = (Uri?)null; + + if (lockedReason == PlansLockedReason.None) + { + linkUrl = await billingManager.GetPortalLinkAsync(UserId, Team, HttpContext.RequestAborted); + } + + var plans = billingPlans.GetAvailablePlans(); + + return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, lockedReason); }); Response.Headers[HeaderNames.ETag] = Team.ToEtag(); @@ -81,7 +101,7 @@ namespace Squidex.Areas.Api.Controllers.Plans [ProducesResponseType(typeof(PlanChangedDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.TeamPlansChange)] [ApiCosts(0)] - public async Task PutPlan(string team, [FromBody] ChangePlanDto request) + public async Task PutTeamPlan(string team, [FromBody] ChangePlanDto request) { var command = SimpleMapper.Map(request, new ChangePlan()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Teams/Models/TeamDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Teams/Models/TeamDto.cs index fce9c648e..54e51d9c2 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Teams/Models/TeamDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Teams/Models/TeamDto.cs @@ -90,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Teams.Models if (resources.IsAllowed(PermissionIds.TeamPlansRead, team: values.team, additional: permissions)) { AddGetLink("plans", - resources.Url(x => nameof(x.GetPlans), values)); + resources.Url(x => nameof(x.GetTeamPlans), values)); } return this; diff --git a/backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs b/backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs deleted file mode 100644 index 5f1d74a58..000000000 --- a/backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; - -namespace Squidex.Areas.Portal.Middlewares -{ - public sealed class PortalDashboardAuthenticationMiddleware - { - private readonly RequestDelegate next; - - public PortalDashboardAuthenticationMiddleware(RequestDelegate next) - { - this.next = next; - } - - public async Task InvokeAsync(HttpContext context) - { - var authentication = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); - - if (!authentication.Succeeded) - { - var properties = new AuthenticationProperties - { - RedirectUri = context.Request.PathBase + context.Request.Path - }; - - await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, properties); - } - else - { - context.User = authentication.Principal!; - - await next(context); - } - } - } -} diff --git a/backend/src/Squidex/Areas/Portal/Middlewares/PortalRedirectMiddleware.cs b/backend/src/Squidex/Areas/Portal/Middlewares/PortalRedirectMiddleware.cs deleted file mode 100644 index e81a9bd8c..000000000 --- a/backend/src/Squidex/Areas/Portal/Middlewares/PortalRedirectMiddleware.cs +++ /dev/null @@ -1,37 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Security.Claims; -using Squidex.Domain.Apps.Entities.Billing; - -namespace Squidex.Areas.Portal.Middlewares -{ - public sealed class PortalRedirectMiddleware - { - private readonly IBillingManager billingManager; - - public PortalRedirectMiddleware(RequestDelegate next, IBillingManager billingManager) - { - this.billingManager = billingManager; - } - - public async Task Invoke(HttpContext context) - { - if (context.Request.Path == "/") - { - var userIdClaim = context.User.FindFirst(ClaimTypes.NameIdentifier); - - if (userIdClaim != null) - { - var portalLink = await billingManager.GetPortalLinkAsync(userIdClaim.Value, context.RequestAborted); - - context.Response.Redirect(portalLink); - } - } - } - } -} diff --git a/backend/src/Squidex/Config/Domain/NotificationsServices.cs b/backend/src/Squidex/Config/Domain/NotificationsServices.cs index 0cb9e2279..d6d58cbd3 100644 --- a/backend/src/Squidex/Config/Domain/NotificationsServices.cs +++ b/backend/src/Squidex/Config/Domain/NotificationsServices.cs @@ -23,19 +23,19 @@ namespace Squidex.Config.Domain { services.AddSingleton(Options.Create(emailOptions)); - services.Configure(config, + services.Configure(config, "email:notifications"); services.AddSingletonAs() .As(); - services.AddSingletonAs() - .AsOptional(); + services.AddSingletonAs() + .AsOptional(); } else { - services.AddSingletonAs() - .AsOptional(); + services.AddSingletonAs() + .AsOptional(); } services.AddSingletonAs() diff --git a/backend/src/Squidex/Startup.cs b/backend/src/Squidex/Startup.cs index adc320dc8..cd9b67ffe 100644 --- a/backend/src/Squidex/Startup.cs +++ b/backend/src/Squidex/Startup.cs @@ -8,7 +8,6 @@ using Squidex.Areas.Api.Config.OpenApi; using Squidex.Areas.Frontend; using Squidex.Areas.IdentityServer.Config; -using Squidex.Areas.Portal.Middlewares; using Squidex.Config.Authentication; using Squidex.Config.Domain; using Squidex.Config.Messaging; @@ -113,12 +112,6 @@ namespace Squidex app.UseAuthentication(); app.UseAuthorization(); - app.Map(Constants.PrefixPortal, builder => - { - builder.UseMiddleware(); - builder.UseMiddleware(); - }); - app.UseEndpoints(endpoints => { endpoints.MapControllers(); diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index bf11fba88..f716eca8d 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -176,18 +176,30 @@ "port": 587 }, "notifications": { - // The email subject when a new user is added as contributor. + // The email subject when a new user is added as contributor to an app. "newUserSubject": "You have been invited to join Project $APP_NAME at Squidex CMS", - // The email body when a new user is added as contributor. + // The email body when a new user is added as contributor to an app. "newUserBody": "Welcome to Squidex\r\nDear User,\r\n\r\n$ASSIGNER_NAME ($ASSIGNER_EMAIL) has invited you to join Project (also called an App) $APP_NAME at Squidex Headless CMS. Login with your Github, Google or Microsoft credentials to create a new user account and start editing content now.\r\n\r\nThank you very much,\r\nThe Squidex Team\r\n\r\n<> [$UI_URL]", - // The email subject when an existing user is added as contributor. + // The email subject when an existing user is added as contributor to an app. "existingUserSubject": "[Squidex CMS] You have been invited to join App $APP_NAME", - // The email body when an existing user is added as contributor. + // The email body when an existing user is added as contributor to an app. "existingUserBody": "Dear User,\r\n\r\n$ASSIGNER_NAME ($ASSIGNER_EMAIL) has invited you to join App $APP_NAME at Squidex Headless CMS.\r\n\r\nLogin or reload the Management UI to see the App.\r\n\r\nThank you very much,\r\nThe Squidex Team\r\n\r\n<> [$UI_URL]", + // The email subject when a new user is added as contributor to a team. + "newUserTeamSubject": "You have been invited to join Team $TEAM_NAME at Squidex CMS", + + // The email body when a new user is added as contributor to a team. + "newUserTeamBody": "Welcome to Squidex\r\nDear User,\r\n\r\n$ASSIGNER_NAME ($ASSIGNER_EMAIL) has invited you to join Team $TEAM_NAME at Squidex Headless CMS. Login with your Github, Google or Microsoft credentials to create a new user account and start managing the Team now.\r\n\r\nThank you very much,\r\nThe Squidex Team\r\n\r\n<> [$UI_URL]", + + // The email subject when an existing user is added as contributor to a team. + "existingTeamUserSubject": "[Squidex CMS] You have been invited to join Team $TEAM_NAME", + + // The email body when an existing user is added as contributor to a team. + "existingTeamUserBody": "Dear User,\r\n\r\n$ASSIGNER_NAME ($ASSIGNER_EMAIL) has invited you to join Team $TEAM_NAME at Squidex Headless CMS.\r\n\r\nLogin or reload the Management UI to see the Team.\r\n\r\nThank you very much,\r\nThe Squidex Team\r\n\r\n<> [$UI_URL]", + // The email subject when app usage reached "usageSubject": "[Squidex CMS] You you are about to reach your usage limit for App $APP_NAME", diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs index 8ddadd13e..fdb2e3a3a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs @@ -62,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject A.CallTo(() => billingPlans.GetPlan(planIdPaid)) .Returns(new Plan { Id = planIdPaid, MaxContributors = 30 }); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, A._, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, A._, default)) .Returns(Task.FromResult(null)); A.CallTo(() => appProvider.GetTeamAsync(teamId, default)) @@ -227,7 +227,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject { var command = new ChangePlan { PlanId = planIdPaid }; - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planIdPaid, default)) .Returns(Task.FromResult(null)); await ExecuteCreateAsync(); @@ -243,10 +243,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject CreateEvent(new AppPlanChanged { PlanId = planIdPaid }) ); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planIdPaid, default)) .MustHaveHappened(); - A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) + A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A._, planIdPaid, default)) .MustHaveHappened(); } @@ -268,10 +268,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject CreateEvent(new AppPlanChanged { PlanId = planIdPaid }) ); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(A._, A>._, A._, A._)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(A._, A._, A._, A._)) .MustNotHaveHappened(); - A.CallTo(() => billingManager.SubscribeAsync(A._, A>._, A._, A._)) + A.CallTo(() => billingManager.SubscribeAsync(A._, A._, A._, A._)) .MustNotHaveHappened(); } @@ -294,10 +294,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject CreateEvent(new AppPlanReset()) ); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(A._, A>._, A._, A._)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(A._, A._, A._, A._)) .MustHaveHappenedOnceExactly(); - A.CallTo(() => billingManager.UnsubscribeAsync(A._, A>._, A._)) + A.CallTo(() => billingManager.UnsubscribeAsync(A._, A._, A._)) .MustNotHaveHappened(); } @@ -320,10 +320,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject CreateEvent(new AppPlanReset()) ); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planIdPaid, default)) .MustHaveHappenedOnceExactly(); - A.CallTo(() => billingManager.UnsubscribeAsync(A._, A>._, A._)) + A.CallTo(() => billingManager.UnsubscribeAsync(A._, A._, A._)) .MustHaveHappened(); } @@ -332,7 +332,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject { var command = new ChangePlan { PlanId = planIdPaid }; - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planIdPaid, default)) .Returns(new Uri("http://squidex.io")); await ExecuteCreateAsync(); @@ -357,10 +357,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject Assert.Equal(planIdPaid, sut.Snapshot.Plan?.PlanId); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, A._)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planIdPaid, A._)) .MustNotHaveHappened(); - A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, AppNamedId, planIdPaid, A._)) + A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A._, planIdPaid, A._)) .MustNotHaveHappened(); } @@ -714,7 +714,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject CreateEvent(new AppDeleted()) ); - A.CallTo(() => billingManager.UnsubscribeAsync(command.Actor.Identifier, AppNamedId, default)) + A.CallTo(() => billingManager.UnsubscribeAsync(command.Actor.Identifier, A._, default)) .MustHaveHappened(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs index c76b6d0fc..8a839d650 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs @@ -5,7 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Teams; using Xunit; namespace Squidex.Domain.Apps.Entities.Billing @@ -15,47 +16,49 @@ namespace Squidex.Domain.Apps.Entities.Billing private readonly NoopBillingManager sut = new NoopBillingManager(); [Fact] - public void Should_not_have_portal() + public async Task Should_do_nothing_if_subscribing_to_app() { - Assert.False(sut.HasPortal); + await sut.SubscribeAsync(null!, (IAppEntity)null!, null!); } [Fact] - public async Task Should_do_nothing_if_subscribing() + public async Task Should_do_nothing_if_subscribing_to_team() { - await sut.SubscribeAsync(null!, null!, null!); + await sut.SubscribeAsync(null!, (ITeamEntity)null!, null!); } [Fact] - public async Task Should_do_nothing_if_subscribing_to_team() + public async Task Should_do_nothing_if_unsubscribing_from_app() { - await sut.SubscribeAsync(null!, default(DomainId), null!); + await sut.UnsubscribeAsync(null!, (IAppEntity)null!); } [Fact] - public async Task Should_do_nothing_if_unsubscribing() + public async Task Should_do_nothing_if_unsubscribing_from_team() { - await sut.UnsubscribeAsync(null!, null!); + await sut.UnsubscribeAsync(null!, (ITeamEntity)null!); } [Fact] - public async Task Should_do_nothing_if_unsubscribing_from_team() + public async Task Should_not_return_portal_link_for_app() { - await sut.UnsubscribeAsync(null!, default(DomainId)); + var actual = await sut.GetPortalLinkAsync(null!, (IAppEntity)null!); + + Assert.Null(actual); } [Fact] - public async Task Should_not_return_portal_link() + public async Task Should_not_return_portal_link_for_team() { - var actual = await sut.GetPortalLinkAsync(null!); + var actual = await sut.GetPortalLinkAsync(null!, (ITeamEntity)null!); - Assert.Empty(actual); + Assert.Null(actual); } [Fact] - public async Task Should_do_nothing_if_checking_for_redirect() + public async Task Should_do_nothing_if_checking_for_redirect_for_app() { - var actual = await sut.MustRedirectToPortalAsync(null!, null!, null); + var actual = await sut.MustRedirectToPortalAsync(null!, (IAppEntity)null!, null); Assert.Null(actual); } @@ -63,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.Billing [Fact] public async Task Should_do_nothing_if_checking_for_redirect_for_team() { - var actual = await sut.MustRedirectToPortalAsync(null!, default(DomainId), null); + var actual = await sut.MustRedirectToPortalAsync(null!, (ITeamEntity)null!, null); Assert.Null(actual); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageNotifierWorkerTest.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageNotifierWorkerTest.cs index 9a856fd57..5609ed3e1 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageNotifierWorkerTest.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageNotifierWorkerTest.cs @@ -8,7 +8,10 @@ using FakeItEasy; using NodaTime; using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Notifications; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; using Squidex.Infrastructure.TestHelpers; using Squidex.Shared.Users; using Xunit; @@ -19,20 +22,25 @@ namespace Squidex.Domain.Apps.Entities.Billing { private readonly TestState state = new TestState("Default"); private readonly IClock clock = A.Fake(); - private readonly INotificationSender notificationSender = A.Fake(); + private readonly IAppProvider appProvider = A.Fake(); + private readonly IUserNotifications notificationSender = A.Fake(); private readonly IUserResolver userResolver = A.Fake(); + private readonly IAppEntity app = Mocks.App(NamedId.Of(DomainId.NewGuid(), "my-app")); private readonly UsageNotifierWorker sut; private Instant time = SystemClock.Instance.GetCurrentInstant(); public UsageNotifierWorkerTest() { + A.CallTo(() => appProvider.GetAppAsync(app.Id, true, default)) + .Returns(app); + A.CallTo(() => clock.GetCurrentInstant()) .ReturnsLazily(() => time); A.CallTo(() => notificationSender.IsActive) .Returns(true); - sut = new UsageNotifierWorker(state.PersistenceFactory, notificationSender, userResolver) + sut = new UsageNotifierWorker(state.PersistenceFactory, appProvider, notificationSender, userResolver) { Clock = clock }; @@ -46,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Billing var message = new UsageTrackingCheck { - AppName = "my-app", + AppId = app.Id, Usage = 1000, UsageLimit = 3000, Users = new[] { "1", "2" } @@ -54,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Billing await sut.HandleAsync(message, default); - A.CallTo(() => notificationSender.SendUsageAsync(A._, "my-app", 1000, 3000)) + A.CallTo(() => notificationSender.SendUsageAsync(A._, A._, A._, A._, A._)) .MustNotHaveHappened(); } @@ -67,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.Billing var message = new UsageTrackingCheck { - AppName = "my-app", + AppId = app.Id, Usage = 1000, UsageLimit = 3000, Users = new[] { "1", "2", "3" } @@ -75,13 +83,13 @@ namespace Squidex.Domain.Apps.Entities.Billing await sut.HandleAsync(message, default); - A.CallTo(() => notificationSender.SendUsageAsync(user1!, "my-app", 1000, 3000)) + A.CallTo(() => notificationSender.SendUsageAsync(user1!, app, 1000, 3000, default)) .MustHaveHappened(); - A.CallTo(() => notificationSender.SendUsageAsync(user2!, "my-app", 1000, 3000)) + A.CallTo(() => notificationSender.SendUsageAsync(user2!, app, 1000, 3000, default)) .MustHaveHappened(); - A.CallTo(() => notificationSender.SendUsageAsync(user3!, "my-app", 1000, 3000)) + A.CallTo(() => notificationSender.SendUsageAsync(user3!, app, 1000, 3000, default)) .MustNotHaveHappened(); } @@ -92,7 +100,7 @@ namespace Squidex.Domain.Apps.Entities.Billing var message = new UsageTrackingCheck { - AppName = "my-app", + AppId = app.Id, Usage = 1000, UsageLimit = 3000, Users = new[] { "1" } @@ -101,7 +109,7 @@ namespace Squidex.Domain.Apps.Entities.Billing await sut.HandleAsync(message, default); await sut.HandleAsync(message, default); - A.CallTo(() => notificationSender.SendUsageAsync(user!, "my-app", 1000, 3000)) + A.CallTo(() => notificationSender.SendUsageAsync(user!, app, 1000, 3000, default)) .MustHaveHappenedOnceExactly(); } @@ -112,7 +120,7 @@ namespace Squidex.Domain.Apps.Entities.Billing var message = new UsageTrackingCheck { - AppName = "my-app", + AppId = app.Id, Usage = 1000, UsageLimit = 3000, Users = new[] { "1" } @@ -124,7 +132,7 @@ namespace Squidex.Domain.Apps.Entities.Billing await sut.HandleAsync(message, default); - A.CallTo(() => notificationSender.SendUsageAsync(user!, "my-app", 1000, 3000)) + A.CallTo(() => notificationSender.SendUsageAsync(user!, app, 1000, 3000, default)) .MustHaveHappenedTwiceExactly(); } @@ -140,7 +148,7 @@ namespace Squidex.Domain.Apps.Entities.Billing var message = new UsageTrackingCheck { - AppName = "my-app", + AppId = app.Id, Usage = 1000, UsageLimit = 3000, Users = new[] { "1" } @@ -152,7 +160,7 @@ namespace Squidex.Domain.Apps.Entities.Billing await sut.HandleAsync(message, default); - A.CallTo(() => notificationSender.SendUsageAsync(user!, "my-app", 1000, 3000)) + A.CallTo(() => notificationSender.SendUsageAsync(user!, app, 1000, 3000, default)) .MustHaveHappenedOnceExactly(); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs index 1610b97f1..4081f2408 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs @@ -230,10 +230,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL new ContentFieldData() .AddInvariant(JsonValue.Array( new JsonObject() - .Add("nested-number", 10) + .Add("nested-number", 42) .Add("nested-boolean", true), new JsonObject() - .Add("nested-number", 20) + .Add("nested-number", 3.14) .Add("nested-boolean", false)))); if (assetId != default || refId != default) @@ -448,12 +448,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { new { - nestedNumber = 10.0, + nestedNumber = 42.0, nestedBoolean = true }, new { - nestedNumber = 20.0, + nestedNumber = 3.14, nestedBoolean = false } } @@ -617,12 +617,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { new { - nestedNumber = 10.0, + nestedNumber = 42.0, nestedBoolean = true }, new { - nestedNumber = 20.0, + nestedNumber = 3.14, nestedBoolean = false } } @@ -704,12 +704,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { new { - nestedNumber = 10.0, + nestedNumber = 42.0, nestedBoolean = true }, new { - nestedNumber = 20.0, + nestedNumber = 3.14, nestedBoolean = false } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InvitationEventConsumerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InvitationEventConsumerTests.cs index cd9d7115e..e0f9e4945 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InvitationEventConsumerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InvitationEventConsumerTests.cs @@ -9,6 +9,7 @@ using FakeItEasy; using Microsoft.Extensions.Logging; using NodaTime; using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Notifications; using Squidex.Domain.Apps.Entities.Teams; using Squidex.Domain.Apps.Entities.TestHelpers; @@ -23,21 +24,21 @@ namespace Squidex.Domain.Apps.Entities.Invitation { public class InvitationEventConsumerTests { + private readonly IAppEntity app = Mocks.App(NamedId.Of(DomainId.NewGuid(), "my-app")); private readonly IAppProvider appProvider = A.Fake(); - private readonly INotificationSender notificatíonSender = A.Fake(); - private readonly IUserResolver userResolver = A.Fake(); - private readonly IUser assigner = UserMocks.User("1"); - private readonly IUser assignee = UserMocks.User("2"); private readonly ILogger log = A.Fake>(); + private readonly ITeamEntity team = Mocks.Team(DomainId.NewGuid()); + private readonly IUser assignee = UserMocks.User("2"); + private readonly IUser assigner = UserMocks.User("1"); + private readonly IUserNotifications userNotifications = A.Fake(); + private readonly IUserResolver userResolver = A.Fake(); private readonly string assignerId = DomainId.NewGuid().ToString(); private readonly string assigneeId = DomainId.NewGuid().ToString(); - private readonly string appName = "my-app"; - private readonly string teamName = "my-team"; private readonly InvitationEventConsumer sut; public InvitationEventConsumerTests() { - A.CallTo(() => notificatíonSender.IsActive) + A.CallTo(() => userNotifications.IsActive) .Returns(true); A.CallTo(() => userResolver.FindByIdAsync(assignerId, default)) @@ -46,10 +47,13 @@ namespace Squidex.Domain.Apps.Entities.Invitation A.CallTo(() => userResolver.FindByIdAsync(assigneeId, default)) .Returns(assignee); - A.CallTo(() => appProvider.GetTeamAsync(A._, default)) - .Returns(Mocks.Team(DomainId.NewGuid(), teamName)); + A.CallTo(() => appProvider.GetAppAsync(app.Id, true, default)) + .Returns(app); + + A.CallTo(() => appProvider.GetTeamAsync(team.Id, default)) + .Returns(team); - sut = new InvitationEventConsumer(notificatíonSender, userResolver, appProvider, log); + sut = new InvitationEventConsumer(appProvider, userNotifications, userResolver, log); } [Fact] @@ -136,7 +140,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation { var @event = CreateAppEvent(RefTokenType.Subject, true); - A.CallTo(() => notificatíonSender.IsActive) + A.CallTo(() => userNotifications.IsActive) .Returns(false); await sut.On(@event); @@ -150,7 +154,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation { var @event = CreateTeamEvent(true); - A.CallTo(() => notificatíonSender.IsActive) + A.CallTo(() => userNotifications.IsActive) .Returns(false); await sut.On(@event); @@ -222,7 +226,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation await sut.On(@event); - A.CallTo(() => notificatíonSender.SendInviteAsync(assigner, assignee, appName)) + A.CallTo(() => userNotifications.SendInviteAsync(assigner, assignee, app, default)) .MustHaveHappened(); } @@ -233,7 +237,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation await sut.On(@event); - A.CallTo(() => notificatíonSender.SendTeamInviteAsync(assigner, assignee, teamName)) + A.CallTo(() => userNotifications.SendInviteAsync(assigner, assignee, team, default)) .MustHaveHappened(); } @@ -244,7 +248,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation await sut.On(@event); - A.CallTo(() => notificatíonSender.SendInviteAsync(assigner, assignee, appName)) + A.CallTo(() => userNotifications.SendInviteAsync(assigner, assignee, app, default)) .MustHaveHappened(); } @@ -255,7 +259,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation await sut.On(@event); - A.CallTo(() => notificatíonSender.SendTeamInviteAsync(assigner, assignee, teamName)) + A.CallTo(() => userNotifications.SendInviteAsync(assigner, assignee, team, default)) .MustHaveHappened(); } @@ -286,10 +290,10 @@ namespace Squidex.Domain.Apps.Entities.Invitation private void MustNotSendEmail() { - A.CallTo(() => notificatíonSender.SendInviteAsync(A._, A._, A._)) + A.CallTo(() => userNotifications.SendInviteAsync(A._, A._, A._, default)) .MustNotHaveHappened(); - A.CallTo(() => notificatíonSender.SendTeamInviteAsync(A._, A._, A._)) + A.CallTo(() => userNotifications.SendInviteAsync(A._, A._, A._, default)) .MustNotHaveHappened(); } @@ -298,7 +302,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation var @event = new AppContributorAssigned { Actor = new RefToken(assignerType, assignerId), - AppId = NamedId.Of(DomainId.NewGuid(), appName), + AppId = app.NamedId(), ContributorId = assigneeId, IsCreated = isNewUser, IsAdded = isNewContributor @@ -320,7 +324,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation ContributorId = assigneeId, IsCreated = isNewUser, IsAdded = isNewContributor, - TeamId = DomainId.NewGuid() + TeamId = team.Id }; var envelope = Envelope.Create(@event); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InviteUserCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InviteUserCommandMiddlewareTests.cs index f0596599b..1452fa4f7 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InviteUserCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InviteUserCommandMiddlewareTests.cs @@ -8,7 +8,6 @@ using FakeItEasy; using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Teams; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/NotificationEmailSenderTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/EmailUserNotificationsTests.cs similarity index 60% rename from backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/NotificationEmailSenderTests.cs rename to backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/EmailUserNotificationsTests.cs index e9884fbb7..3b079f66e 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/NotificationEmailSenderTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/EmailUserNotificationsTests.cs @@ -10,30 +10,34 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Teams; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; using Squidex.Infrastructure.Email; using Squidex.Shared.Users; using Xunit; namespace Squidex.Domain.Apps.Entities.Notifications { - public class NotificationEmailSenderTests + public class EmailUserNotificationsTests { private readonly IEmailSender emailSender = A.Fake(); private readonly IUrlGenerator urlGenerator = A.Fake(); private readonly IUser assigner = UserMocks.User("1", "1@email.com", "user1"); private readonly IUser assigned = UserMocks.User("2", "2@email.com", "user2"); - private readonly ILogger log = A.Fake>(); - private readonly string appName = "my-app"; - private readonly string appUI = "my-ui"; - private readonly NotificationEmailTextOptions texts = new NotificationEmailTextOptions(); - private readonly NotificationEmailSender sut; + private readonly ILogger log = A.Fake>(); + private readonly IAppEntity app = Mocks.App(NamedId.Of(DomainId.NewGuid(), "my-app")); + private readonly ITeamEntity team = Mocks.Team(DomainId.NewGuid()); + private readonly EmailUserNotificationOptions texts = new EmailUserNotificationOptions(); + private readonly EmailUserNotifications sut; - public NotificationEmailSenderTests() + public EmailUserNotificationsTests() { A.CallTo(() => urlGenerator.UI()) - .Returns(appUI); + .Returns("my-ui"); - sut = new NotificationEmailSender(Options.Create(texts), emailSender, urlGenerator, log); + sut = new EmailUserNotifications(Options.Create(texts), emailSender, urlGenerator, log); } [Fact] @@ -85,9 +89,9 @@ namespace Squidex.Domain.Apps.Entities.Notifications } [Fact] - public async Task Should_not_send_invitation_email_if_texts_for_new_user_are_empty() + public async Task Should_not_send_app_invitation_email_if_texts_for_new_user_are_empty() { - await sut.SendInviteAsync(assigner, assigned, appName); + await sut.SendInviteAsync(assigner, assigned, app); A.CallTo(() => emailSender.SendAsync(assigned.Email, A._, A._, A._)) .MustNotHaveHappened(); @@ -96,9 +100,31 @@ namespace Squidex.Domain.Apps.Entities.Notifications } [Fact] - public async Task Should_not_send_invitation_email_if_texts_for_existing_user_are_empty() + public async Task Should_not_send_text_invitation_email_if_texts_for_new_user_are_empty() { - await sut.SendInviteAsync(assigner, assigned, appName); + await sut.SendInviteAsync(assigner, assigned, team); + + A.CallTo(() => emailSender.SendAsync(assigned.Email, A._, A._, A._)) + .MustNotHaveHappened(); + + MustLogWarning(); + } + + [Fact] + public async Task Should_not_send_app_invitation_email_if_texts_for_existing_user_are_empty() + { + await sut.SendInviteAsync(assigner, assigned, app); + + A.CallTo(() => emailSender.SendAsync(assigned.Email, A._, A._, A._)) + .MustNotHaveHappened(); + + MustLogWarning(); + } + + [Fact] + public async Task Should_not_send_text_invitation_email_if_texts_for_existing_user_are_empty() + { + await sut.SendInviteAsync(assigner, assigned, team); A.CallTo(() => emailSender.SendAsync(assigned.Email, A._, A._, A._)) .MustNotHaveHappened(); @@ -109,7 +135,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications [Fact] public async Task Should_not_send_usage_email_if_texts_empty() { - await sut.SendUsageAsync(assigned, appName, 100, 120); + await sut.SendUsageAsync(assigned, app, 100, 120); A.CallTo(() => emailSender.SendAsync(assigned.Email, A._, A._, A._)) .MustNotHaveHappened(); @@ -118,28 +144,56 @@ namespace Squidex.Domain.Apps.Entities.Notifications } [Fact] - public async Task Should_not_send_invitation_email_if_no_consent_given() + public async Task Should_not_send_app_invitation_email_if_no_consent_given() { var withoutConsent = UserMocks.User("2", "2@email.com", "user", false); texts.ExistingUserSubject = "email-subject"; texts.ExistingUserBody = "email-body"; - await sut.SendInviteAsync(assigner, withoutConsent, appName); + await sut.SendInviteAsync(assigner, withoutConsent, app); + + A.CallTo(() => emailSender.SendAsync(withoutConsent.Email, "email-subject", "email-body", A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_send_team_invitation_email_if_no_consent_given() + { + var withoutConsent = UserMocks.User("2", "2@email.com", "user", false); + + texts.ExistingTeamUserSubject = "email-subject"; + texts.ExistingTeamUserBody = "email-body"; + + await sut.SendInviteAsync(assigner, withoutConsent, team); A.CallTo(() => emailSender.SendAsync(withoutConsent.Email, "email-subject", "email-body", A._)) .MustNotHaveHappened(); } [Fact] - public async Task Should_send_invitation_email_if_consent_given() + public async Task Should_send_app_invitation_email_if_consent_given() { var withConsent = UserMocks.User("2", "2@email.com", "user", true); texts.ExistingUserSubject = "email-subject"; texts.ExistingUserBody = "email-body"; - await sut.SendInviteAsync(assigner, withConsent, appName); + await sut.SendInviteAsync(assigner, withConsent, app); + + A.CallTo(() => emailSender.SendAsync(withConsent.Email, "email-subject", "email-body", A._)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_send_team_invitation_email_if_consent_given() + { + var withConsent = UserMocks.User("2", "2@email.com", "user", true); + + texts.ExistingTeamUserSubject = "email-subject"; + texts.ExistingTeamUserBody = "email-body"; + + await sut.SendInviteAsync(assigner, withConsent, team); A.CallTo(() => emailSender.SendAsync(withConsent.Email, "email-subject", "email-body", A._)) .MustHaveHappened(); @@ -150,7 +204,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications texts.UsageSubject = pattern; texts.UsageBody = pattern; - await sut.SendUsageAsync(assigned, appName, 100, 120); + await sut.SendUsageAsync(assigned, app, 100, 120); A.CallTo(() => emailSender.SendAsync(assigned.Email, actual, actual, A._)) .MustHaveHappened(); @@ -161,7 +215,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications texts.NewUserSubject = pattern; texts.NewUserBody = pattern; - await sut.SendInviteAsync(assigner, assigned, appName); + await sut.SendInviteAsync(assigner, assigned, app); A.CallTo(() => emailSender.SendAsync(assigned.Email, actual, actual, A._)) .MustHaveHappened(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/TeamDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/TeamDomainObjectTests.cs index b2b1a3246..98baab1a5 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/TeamDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/TeamDomainObjectTests.cs @@ -54,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject A.CallTo(() => billingPlans.GetPlan(planPaid.Id)) .Returns(planPaid); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, A._, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, A._, default)) .Returns(Task.FromResult(null)); var serviceProvider = @@ -130,7 +130,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject { var command = new ChangePlan { PlanId = planPaid.Id }; - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planPaid.Id, default)) .Returns(Task.FromResult(null)); await ExecuteCreateAsync(); @@ -146,10 +146,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject CreateTeamEvent(new TeamPlanChanged { PlanId = planPaid.Id }) ); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planPaid.Id, default)) .MustHaveHappened(); - A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, teamId, planPaid.Id, default)) + A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A._, planPaid.Id, default)) .MustHaveHappened(); } @@ -171,10 +171,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject CreateTeamEvent(new TeamPlanChanged { PlanId = planPaid.Id }) ); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(A._, A._, A._, A._)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(A._, A._, A._, A._)) .MustNotHaveHappened(); - A.CallTo(() => billingManager.SubscribeAsync(A._, A._, A._, A._)) + A.CallTo(() => billingManager.SubscribeAsync(A._, A._, A._, A._)) .MustNotHaveHappened(); } @@ -197,10 +197,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject CreateTeamEvent(new TeamPlanReset()) ); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(A._, teamId, A._, A._)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(A._, A._, A._, A._)) .MustHaveHappenedOnceExactly(); - A.CallTo(() => billingManager.UnsubscribeAsync(A._, A._, A._)) + A.CallTo(() => billingManager.UnsubscribeAsync(A._, A._, A._)) .MustNotHaveHappened(); } @@ -223,10 +223,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject CreateTeamEvent(new TeamPlanReset()) ); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planPaid.Id, default)) .MustHaveHappenedOnceExactly(); - A.CallTo(() => billingManager.UnsubscribeAsync(A._, teamId, A._)) + A.CallTo(() => billingManager.UnsubscribeAsync(A._, A._, A._)) .MustHaveHappened(); } @@ -235,7 +235,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject { var command = new ChangePlan { PlanId = planPaid.Id }; - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planPaid.Id, default)) .Returns(new Uri("http://squidex.io")); await ExecuteCreateAsync(); @@ -260,10 +260,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject Assert.Equal(planPaid.Id, sut.Snapshot.Plan?.PlanId); - A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, A._)) + A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A._, planPaid.Id, A._)) .MustNotHaveHappened(); - A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, teamId, planPaid.Id, A._)) + A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A._, planPaid.Id, A._)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Json/System/JsonInheritanceConverterBaseTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Json/System/JsonInheritanceConverterBaseTests.cs index b2ca32bc8..f2afea900 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Json/System/JsonInheritanceConverterBaseTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Json/System/JsonInheritanceConverterBaseTests.cs @@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Json.System public int PropertyB { get; init; } } - private class Converter : InheritanceConverterBase + private sealed class Converter : InheritanceConverterBase { public Converter() : base("$type") diff --git a/frontend/src/app/features/settings/pages/plans/plan.component.html b/frontend/src/app/features/settings/pages/plans/plan.component.html index be532c74d..eb3ba96fe 100644 --- a/frontend/src/app/features/settings/pages/plans/plan.component.html +++ b/frontend/src/app/features/settings/pages/plans/plan.component.html @@ -25,7 +25,7 @@ ✓ {{ 'plans.selected' | sqxTranslate }} - -