Browse Source

Teams improvements (#923)

* Improvements to teams.

* Fixes

* Fixes api

* Fixes to teams.

* Simplify some interfaces.

* Teemp

* Fix tests and type names.
pull/925/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
dd9a427d06
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs
  2. 5
      backend/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs
  3. 78
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.cs
  4. 20
      backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs
  5. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/Messages.cs
  6. 24
      backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs
  7. 5
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs
  8. 43
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs
  9. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ComponentUnionGraphType.cs
  10. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResultGraphType.cs
  11. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldInputVisitor.cs
  12. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs
  13. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ReferenceUnionGraphType.cs
  14. 135
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/SchemaInfo.cs
  15. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/JsonGraphType.cs
  16. 1
      backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
  17. 24
      backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs
  18. 10
      backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotificationOptions.cs
  19. 77
      backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotifications.cs
  20. 13
      backend/src/Squidex.Domain.Apps.Entities/Notifications/IUserNotifications.cs
  21. 13
      backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopUserNotifications.cs
  22. 66
      backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs
  23. 2
      backend/src/Squidex.Web/Constants.cs
  24. 5
      backend/src/Squidex.Web/Resources.cs
  25. 4
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentOpenApiController.cs
  26. 32
      backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  27. 34
      backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs
  28. 32
      backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansLockedReason.cs
  29. 30
      backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs
  30. 2
      backend/src/Squidex/Areas/Api/Controllers/Teams/Models/TeamDto.cs
  31. 44
      backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs
  32. 37
      backend/src/Squidex/Areas/Portal/Middlewares/PortalRedirectMiddleware.cs
  33. 10
      backend/src/Squidex/Config/Domain/NotificationsServices.cs
  34. 7
      backend/src/Squidex/Startup.cs
  35. 20
      backend/src/Squidex/appsettings.json
  36. 28
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppDomainObjectTests.cs
  37. 37
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs
  38. 36
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageNotifierWorkerTest.cs
  39. 16
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs
  40. 44
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InvitationEventConsumerTests.cs
  41. 1
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InviteUserCommandMiddlewareTests.cs
  42. 94
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/EmailUserNotificationsTests.cs
  43. 26
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Teams/DomainObject/TeamDomainObjectTests.cs
  44. 2
      backend/tests/Squidex.Infrastructure.Tests/Json/System/JsonInheritanceConverterBaseTests.cs
  45. 4
      frontend/src/app/features/settings/pages/plans/plan.component.html
  46. 8
      frontend/src/app/features/settings/pages/plans/plans-page.component.html
  47. 4
      frontend/src/app/features/teams/pages/plans/plan.component.html
  48. 4
      frontend/src/app/features/teams/pages/plans/plans-page.component.html
  49. 8
      frontend/src/app/features/teams/services/team-plans.service.spec.ts
  50. 2
      frontend/src/app/features/teams/state/team-contributors.state.ts
  51. 12
      frontend/src/app/features/teams/state/team-plans.state.spec.ts
  52. 34
      frontend/src/app/features/teams/state/team-plans.state.ts
  53. 8
      frontend/src/app/shared/services/plans.service.spec.ts
  54. 20
      frontend/src/app/shared/services/shared.ts
  55. 12
      frontend/src/app/shared/state/plans.state.spec.ts
  56. 38
      frontend/src/app/shared/state/plans.state.ts

5
backend/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs

@ -18,6 +18,11 @@ namespace Squidex.Domain.Apps.Entities.Apps
return new NamedId<DomainId>(app.Id, app.Name); return new NamedId<DomainId>(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) public static bool TryGetContributorRole(this IAppEntity app, string id, bool isFrontend, [MaybeNullWhen(false)] out Role role)
{ {
role = null; role = null;

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

@ -133,11 +133,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
return ForEvent(e, "general"); return ForEvent(e, "general");
} }
private HistoryEvent CreateAppSettingsEvent(IEvent e)
{
return ForEvent(e, "settings.appSettings");
}
private HistoryEvent CreateContributorsEvent(IEvent e, string contributor, string? role = null) private HistoryEvent CreateContributorsEvent(IEvent e, string contributor, string? role = null)
{ {
return ForEvent(e, "settings.contributors").Param("Contributor", contributor).Param("Role", role); return ForEvent(e, "settings.contributors").Param("Contributor", contributor).Param("Role", role);

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

@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
case TransferToTeam transfer: case TransferToTeam transfer:
return UpdateReturnAsync(transfer, async (c, ct) => return UpdateReturnAsync(transfer, async (c, ct) =>
{ {
await GuardApp.CanTransfer(c, Snapshot, AppProvider(), ct); await GuardApp.CanTransfer(c, Snapshot, AppProvider, ct);
Transfer(c); Transfer(c);
@ -128,7 +128,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
case AssignContributor assignContributor: case AssignContributor assignContributor:
return UpdateReturnAsync(assignContributor, async (c, ct) => 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)); AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
@ -268,66 +268,52 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
case DeleteApp delete: case DeleteApp delete:
return UpdateAsync(delete, async (c, ct) => return UpdateAsync(delete, async (c, ct) =>
{ {
await BillingManager().UnsubscribeAsync(c.Actor.Identifier, Snapshot.NamedId(), default); await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default);
DeleteApp(c); DeleteApp(c);
}, ct); }, ct);
case ChangePlan changePlan: case ChangePlan changePlan:
return ChangeBillingPlanAsync(changePlan, ct); return UpdateReturnAsync(changePlan, async (c, ct) =>
default:
ThrowHelper.NotSupportedException();
return default!;
}
}
private async Task<CommandResult> ChangeBillingPlanAsync(ChangePlan changePlan,
CancellationToken ct)
{ {
var userId = changePlan.Actor.Identifier; GuardApp.CanChangePlan(c, Snapshot, BillingPlans);
var result = await UpdateReturnAsync(changePlan, async (c, ct) => if (string.Equals(FreePlan?.Id, c.PlanId, StringComparison.Ordinal))
{ {
GuardApp.CanChangePlan(c, Snapshot, BillingPlans()); if (!c.FromCallback)
if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal))
{ {
await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default);
}
ResetPlan(c); ResetPlan(c);
return new PlanChangedResult(c.PlanId, true, null); return new PlanChangedResult(c.PlanId, true, null);
} }
else
{
if (!c.FromCallback) if (!c.FromCallback)
{ {
var redirectUri = await BillingManager().MustRedirectToPortalAsync(userId, Snapshot.NamedId(), c.PlanId, ct); var redirectUri = await BillingManager.MustRedirectToPortalAsync(c.Actor.Identifier, Snapshot, c.PlanId, ct);
if (redirectUri != null) if (redirectUri != null)
{ {
return new PlanChangedResult(c.PlanId, false, redirectUri); 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); return new PlanChangedResult(c.PlanId);
}, ct);
if (changePlan.FromCallback)
{
return result;
} }
if (result.Payload is PlanChangedResult { Unsubscribed: true, RedirectUri: null }) }, ct);
{
await BillingManager().UnsubscribeAsync(userId, Snapshot.NamedId(), default);
}
else if (result.Payload is PlanChangedResult { RedirectUri: null })
{
await BillingManager().SubscribeAsync(userId, Snapshot.NamedId(), changePlan.PlanId, default);
}
return result; default:
ThrowHelper.NotSupportedException();
return default!;
}
} }
private void Create(CreateApp command) private void Create(CreateApp command)
@ -480,34 +466,34 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RaiseEvent(Envelope.Create(@event)); RaiseEvent(Envelope.Create(@event));
} }
private IAppProvider AppProvider() private IAppProvider AppProvider
{ {
return serviceProvider.GetRequiredService<IAppProvider>(); get => serviceProvider.GetRequiredService<IAppProvider>();
} }
private IBillingPlans BillingPlans() private IBillingPlans BillingPlans
{ {
return serviceProvider.GetRequiredService<IBillingPlans>(); get => serviceProvider.GetRequiredService<IBillingPlans>();
} }
private IBillingManager BillingManager() private IBillingManager BillingManager
{ {
return serviceProvider.GetRequiredService<IBillingManager>(); get => serviceProvider.GetRequiredService<IBillingManager>();
} }
private IUserResolver Users() private IUserResolver Users
{ {
return serviceProvider.GetRequiredService<IUserResolver>(); get => serviceProvider.GetRequiredService<IUserResolver>();
} }
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;
} }
} }
} }

20
backend/src/Squidex.Domain.Apps.Entities/Billing/IBillingManager.cs

@ -5,33 +5,35 @@
// All rights reserved. Licensed under the MIT license. // 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 namespace Squidex.Domain.Apps.Entities.Billing
{ {
public interface IBillingManager public interface IBillingManager
{ {
bool HasPortal { get; } Task<Uri?> GetPortalLinkAsync(string userId, IAppEntity app,
CancellationToken ct = default);
Task<Uri?> MustRedirectToPortalAsync(string userId, NamedId<DomainId> appId, string? planId, Task<Uri?> GetPortalLinkAsync(string userId, ITeamEntity team,
CancellationToken ct = default); CancellationToken ct = default);
Task<Uri?> MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId, Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId,
CancellationToken ct = default); CancellationToken ct = default);
Task SubscribeAsync(string userId, NamedId<DomainId> appId, string planId, Task<Uri?> MustRedirectToPortalAsync(string userId, ITeamEntity team, string? planId,
CancellationToken ct = default); CancellationToken ct = default);
Task SubscribeAsync(string userId, DomainId teamId, string planId, Task SubscribeAsync(string userId, IAppEntity app, string planId,
CancellationToken ct = default); CancellationToken ct = default);
Task UnsubscribeAsync(string userId, NamedId<DomainId> appId, Task SubscribeAsync(string userId, ITeamEntity team, string planId,
CancellationToken ct = default); CancellationToken ct = default);
Task UnsubscribeAsync(string userId, DomainId teamId, Task UnsubscribeAsync(string userId, IAppEntity app,
CancellationToken ct = default); CancellationToken ct = default);
Task<string> GetPortalLinkAsync(string userId, Task UnsubscribeAsync(string userId, ITeamEntity team,
CancellationToken ct = default); CancellationToken ct = default);
} }
} }

2
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 DomainId AppId { get; init; }
public string AppName { get; init; }
public long Usage { get; init; } public long Usage { get; init; }
public long UsageLimit { get; init; } public long UsageLimit { get; init; }

24
backend/src/Squidex.Domain.Apps.Entities/Billing/NoopBillingManager.cs

@ -5,54 +5,56 @@
// All rights reserved. Licensed under the MIT license. // 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 namespace Squidex.Domain.Apps.Entities.Billing
{ {
public sealed class NoopBillingManager : IBillingManager public sealed class NoopBillingManager : IBillingManager
{ {
public bool HasPortal public Task<Uri?> GetPortalLinkAsync(string userId, IAppEntity app,
CancellationToken ct = default)
{ {
get => false; return Task.FromResult<Uri?>(null);
} }
public Task<string> GetPortalLinkAsync(string userId, public Task<Uri?> GetPortalLinkAsync(string userId, ITeamEntity team,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.FromResult(string.Empty); return Task.FromResult<Uri?>(null);
} }
public Task<Uri?> MustRedirectToPortalAsync(string userId, NamedId<DomainId> appId, string? planId, public Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.FromResult<Uri?>(null); return Task.FromResult<Uri?>(null);
} }
public Task<Uri?> MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId, public Task<Uri?> MustRedirectToPortalAsync(string userId, ITeamEntity team, string? planId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.FromResult<Uri?>(null); return Task.FromResult<Uri?>(null);
} }
public Task SubscribeAsync(string userId, NamedId<DomainId> appId, string planId, public Task SubscribeAsync(string userId, IAppEntity app, string planId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task SubscribeAsync(string userId, DomainId teamId, string planId, public Task SubscribeAsync(string userId, ITeamEntity team, string planId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task UnsubscribeAsync(string userId, NamedId<DomainId> appId, public Task UnsubscribeAsync(string userId, IAppEntity app,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task UnsubscribeAsync(string userId, DomainId teamId, public Task UnsubscribeAsync(string userId, ITeamEntity team,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return Task.CompletedTask; return Task.CompletedTask;

5
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 CounterTotalCount = "TotalAssets";
private const string CounterTotalSize = "TotalSize"; private const string CounterTotalSize = "TotalSize";
private static readonly DateTime SummaryDate = default; private static readonly DateTime SummaryDate = default;
private readonly IBillingPlans billingPlans;
private readonly IAppProvider appProvider;
private readonly IApiUsageTracker apiUsageTracker; private readonly IApiUsageTracker apiUsageTracker;
private readonly IAppProvider appProvider;
private readonly IBillingPlans billingPlans;
private readonly IMemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); private readonly IMemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IMessageBus messaging; private readonly IMessageBus messaging;
private readonly IUsageTracker usageTracker; private readonly IUsageTracker usageTracker;
@ -150,7 +150,6 @@ namespace Squidex.Domain.Apps.Entities.Billing
var notification = new UsageTrackingCheck var notification = new UsageTrackingCheck
{ {
AppId = appId, AppId = appId,
AppName = app.Name,
Usage = usage, Usage = usage,
UsageLimit = blockLimit, UsageLimit = blockLimit,
Users = GetUsers(app) Users = GetUsers(app)

43
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs

@ -9,7 +9,6 @@ using NodaTime;
using Squidex.Domain.Apps.Entities.Notifications; using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
using Squidex.Messaging; using Squidex.Messaging;
using Squidex.Shared.Users; using Squidex.Shared.Users;
@ -19,7 +18,8 @@ namespace Squidex.Domain.Apps.Entities.Billing
{ {
private static readonly TimeSpan TimeBetweenNotifications = TimeSpan.FromDays(3); private static readonly TimeSpan TimeBetweenNotifications = TimeSpan.FromDays(3);
private readonly SimpleState<State> state; private readonly SimpleState<State> state;
private readonly INotificationSender notificationSender; private readonly IAppProvider appProvider;
private readonly IUserNotifications userNotifications;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
[CollectionName("UsageNotifications")] [CollectionName("UsageNotifications")]
@ -31,9 +31,12 @@ namespace Squidex.Domain.Apps.Entities.Billing
public IClock Clock { get; set; } = SystemClock.Instance; public IClock Clock { get; set; } = SystemClock.Instance;
public UsageNotifierWorker(IPersistenceFactory<State> persistenceFactory, public UsageNotifierWorker(IPersistenceFactory<State> persistenceFactory,
INotificationSender notificationSender, IUserResolver userResolver) IAppProvider appProvider,
IUserNotifications userNotifications,
IUserResolver userResolver)
{ {
this.notificationSender = notificationSender; this.appProvider = appProvider;
this.userNotifications = userNotifications;
this.userResolver = userResolver; this.userResolver = userResolver;
state = new SimpleState<State>(persistenceFactory, GetType(), DomainId.Create("Default")); state = new SimpleState<State>(persistenceFactory, GetType(), DomainId.Create("Default"));
@ -42,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
public async Task HandleAsync(UsageTrackingCheck notification, public async Task HandleAsync(UsageTrackingCheck notification,
CancellationToken ct) CancellationToken ct)
{ {
if (!notificationSender.IsActive) if (!userNotifications.IsActive)
{ {
return; return;
} }
@ -51,26 +54,40 @@ namespace Squidex.Domain.Apps.Entities.Billing
if (!HasBeenSentBefore(notification.AppId, now)) if (!HasBeenSentBefore(notification.AppId, now))
{ {
if (notificationSender.IsActive) 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) foreach (var userId in notification.Users)
{ {
var user = await userResolver.FindByIdOrEmailAsync(userId, ct); var user = await userResolver.FindByIdOrEmailAsync(userId, ct);
if (user != null) if (user != null)
{ {
notificationSender.SendUsageAsync(user, await userNotifications.SendUsageAsync(user, app,
notification.AppName,
notification.Usage, notification.Usage,
notification.UsageLimit).Forget(); notification.UsageLimit, ct);
} }
} }
} }
await TrackNotifiedAsync(notification.AppId, now);
}
}
private bool HasBeenSentBefore(DomainId appId, DateTime now) private bool HasBeenSentBefore(DomainId appId, DateTime now)
{ {
if (state.Value.NotificationsSent.TryGetValue(appId, out var lastSent)) if (state.Value.NotificationsSent.TryGetValue(appId, out var lastSent))

2
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<DomainId>? schemaIds) public ComponentUnionGraphType(Builder builder, FieldInfo fieldInfo, ReadonlyList<DomainId>? schemaIds)
{ {
// The name is used for equal comparison. Therefore it is important to treat it as readonly. // 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) if (schemaIds?.Any() == true)
{ {

2
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) public ContentResultGraphType(ContentGraphType contentType, SchemaInfo schemaInfo)
{ {
// The name is used for equal comparison. Therefore it is important to treat it as readonly. // 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 AddField(new FieldType
{ {

4
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) 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) 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) 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) if (@enum != null)
{ {

4
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) 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) 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) 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) if (@enum != null)
{ {

2
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<DomainId>? schemaIds) public ReferenceUnionGraphType(Builder builder, FieldInfo fieldInfo, ReadonlyList<DomainId>? schemaIds)
{ {
// The name is used for equal comparison. Therefore it is important to treat it as readonly. // 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) if (schemaIds?.Any() == true)
{ {

135
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 ISchemaEntity Schema { get; }
public string TypeName { get; } public string DisplayName => Schema.DisplayName();
public string DisplayName { get; }
public string ComponentType { get; } public string ComponentType { get; }
public string ContentType { get; } public string ContentType { get; }
public string DataType { get; } public string DataFlatType { get; }
public string DataInputType { get; } public string DataInputType { get; }
public string DataFlatType { get; } public string DataType { get; }
public string ContentResultType { get; }
public string ResultType { get; } public string TypeName { get; }
public IReadOnlyList<FieldInfo> Fields { get; } public IReadOnlyList<FieldInfo> Fields { get; init; }
private SchemaInfo(ISchemaEntity schema, string typeName, IReadOnlyList<FieldInfo> fields, Names names) private SchemaInfo(ISchemaEntity schema, string typeName, Names rootScope)
{ {
Schema = schema; Schema = schema;
ComponentType = names[$"{typeName}Component"];
ContentType = names[typeName]; ComponentType = rootScope[$"{typeName}Component"];
DataFlatType = names[$"{typeName}FlatDataDto"]; ContentResultType = rootScope[$"{typeName}ResultDto"];
DataInputType = names[$"{typeName}DataInputDto"]; ContentType = typeName;
ResultType = names[$"{typeName}ResultDto"]; DataFlatType = rootScope[$"{typeName}FlatDataDto"];
DataType = names[$"{typeName}DataDto"]; DataInputType = rootScope[$"{typeName}DataInputDto"];
DisplayName = schema.DisplayName(); DataType = rootScope[$"{typeName}DataDto"];
Fields = fields;
TypeName = typeName; TypeName = typeName;
} }
@ -57,79 +56,65 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
public static IEnumerable<SchemaInfo> Build(IEnumerable<ISchemaEntity> schemas) public static IEnumerable<SchemaInfo> Build(IEnumerable<ISchemaEntity> schemas)
{ {
var names = new Names(); var rootScope = new Names();
foreach (var schema in schemas.OrderBy(x => x.Created)) foreach (var schema in schemas.OrderBy(x => x.Created))
{ {
var typeName = schema.TypeName(); var typeName = rootScope[schema.TypeName()];
var fieldInfos = new List<FieldInfo>(schema.SchemaDef.Fields.Count); yield return new SchemaInfo(schema, typeName, rootScope)
var fieldNames = new Names();
foreach (var field in schema.SchemaDef.Fields.ForApi())
{ {
fieldInfos.Add(FieldInfo.Build( Fields = FieldInfo.Build(schema.SchemaDef.Fields, $"{typeName}Data", rootScope).ToList()
field, };
names[$"{typeName}Data{field.TypeName()}"],
names,
fieldNames));
}
yield return new SchemaInfo(schema, typeName, fieldInfos, names);
} }
} }
} }
internal sealed class FieldInfo internal sealed class FieldInfo
{ {
public static readonly List<FieldInfo> EmptyFields = new List<FieldInfo>();
public IField Field { get; set; } public IField Field { get; set; }
public string DisplayName => Field.DisplayName();
public string EmbeddableStringType { get; }
public string EmbeddedEnumType { get; }
public string FieldName { get; } public string FieldName { get; }
public string FieldNameDynamic { get; } public string FieldNameDynamic { get; }
public string DisplayName { get; } public string LocalizedInputType { get; }
public string EnumName { get; }
public string LocalizedType { get; } public string LocalizedType { get; }
public string LocalizedTypeDynamic { get; } public string LocalizedTypeDynamic { get; }
public string LocalizedInputType { get; }
public string NestedType { get; }
public string NestedInputType { 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<FieldInfo> Fields { get; } public IReadOnlyList<FieldInfo> Fields { get; init; }
private FieldInfo(IField field, string typeName, Names names, Names parentNames, IReadOnlyList<FieldInfo> 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; Field = field;
EmbeddableStringType = rootScope[$"{typeName}EmbeddableString"];
EmbeddedEnumType = rootScope[$"{typeName}Enum"];
FieldName = fieldName; FieldName = fieldName;
FieldNameDynamic = names[$"{fieldName}__Dynamic"]; FieldNameDynamic = $"{fieldName}__Dynamic";
Fields = fields; LocalizedInputType = rootScope[$"{typeName}InputDto"];
LocalizedInputType = names[$"{typeName}InputDto"]; LocalizedType = rootScope[$"{typeName}Dto"];
LocalizedType = names[$"{typeName}Dto"]; LocalizedTypeDynamic = rootScope[$"{typeName}Dto__Dynamic"];
LocalizedTypeDynamic = names[$"{typeName}Dto__Dynamic"]; NestedInputType = rootScope[$"{typeName}ChildInputDto"];
NestedInputType = names[$"{typeName}ChildInputDto"]; NestedType = rootScope[$"{typeName}ChildDto"];
NestedType = names[$"{typeName}ChildDto"]; UnionComponentType = rootScope[$"{typeName}ComponentUnionDto"];
ReferenceType = names[$"{typeName}UnionDto"]; UnionReferenceType = rootScope[$"{typeName}UnionDto"];
} }
public override string ToString() public override string ToString()
@ -137,28 +122,34 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
return FieldName; return FieldName;
} }
internal static FieldInfo Build(IRootField rootField, string typeName, Names names, Names parentNames) internal static IEnumerable<FieldInfo> Build(IEnumerable<IField> 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<FieldInfo>(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<FieldInfo>();
if (field is IArrayField arrayField)
{ {
fieldInfos.Add(new FieldInfo( nested = Build(arrayField.Fields, fieldTypeName, rootScope).ToList();
nestedField,
names[$"{typeName}{nestedField.TypeName()}"],
names,
fieldNames,
EmptyFields));
}
} }
return new FieldInfo(rootField, typeName, names, parentNames, fieldInfos); yield return new FieldInfo(
field,
fieldName,
fieldTypeName,
rootScope)
{
Fields = nested
};
}
} }
} }

2
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; return booleanValue.BoolValue;
case GraphQLFloatValue floatValue: 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: case GraphQLIntValue intValue:
return double.Parse((string)intValue.Value, NumberStyles.Integer, CultureInfo.InvariantCulture); return double.Parse((string)intValue.Value, NumberStyles.Integer, CultureInfo.InvariantCulture);

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

@ -49,7 +49,6 @@ namespace Squidex.Domain.Apps.Entities.History
texts[key] = value; texts[key] = value;
} }
} }
} }
public Task ClearAsync() public Task ClearAsync()

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

@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
public sealed class InvitationEventConsumer : IEventConsumer public sealed class InvitationEventConsumer : IEventConsumer
{ {
private static readonly Duration MaxAge = Duration.FromDays(2); private static readonly Duration MaxAge = Duration.FromDays(2);
private readonly INotificationSender emailSender; private readonly IUserNotifications userNotifications;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly ILogger<InvitationEventConsumer> log; private readonly ILogger<InvitationEventConsumer> log;
@ -34,18 +34,21 @@ namespace Squidex.Domain.Apps.Entities.Invitation
get { return "^app-|^app-"; } get { return "^app-|^app-"; }
} }
public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver, IAppProvider appProvider, public InvitationEventConsumer(
IAppProvider appProvider,
IUserNotifications userNotifications,
IUserResolver userResolver,
ILogger<InvitationEventConsumer> log) ILogger<InvitationEventConsumer> log)
{ {
this.emailSender = emailSender;
this.userResolver = userResolver;
this.appProvider = appProvider; this.appProvider = appProvider;
this.userNotifications = userNotifications;
this.userResolver = userResolver;
this.log = log; this.log = log;
} }
public async Task On(Envelope<IEvent> @event) public async Task On(Envelope<IEvent> @event)
{ {
if (!emailSender.IsActive) if (!userNotifications.IsActive)
{ {
return; return;
} }
@ -75,7 +78,14 @@ namespace Squidex.Domain.Apps.Entities.Invitation
return; 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; return;
} }
@ -95,7 +105,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
return; return;
} }
await emailSender.SendTeamInviteAsync(assigner, assignee, team.Name); await userNotifications.SendInviteAsync(assigner, assignee, team);
break; break;
} }
} }

10
backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailTextOptions.cs → backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotificationOptions.cs

@ -7,7 +7,7 @@
namespace Squidex.Domain.Apps.Entities.Notifications namespace Squidex.Domain.Apps.Entities.Notifications
{ {
public sealed class NotificationEmailTextOptions public sealed class EmailUserNotificationOptions
{ {
public string UsageSubject { get; set; } public string UsageSubject { get; set; }
@ -20,5 +20,13 @@ namespace Squidex.Domain.Apps.Entities.Notifications
public string ExistingUserSubject { get; set; } public string ExistingUserSubject { get; set; }
public string ExistingUserBody { 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; }
} }
} }

77
backend/src/Squidex.Domain.Apps.Entities/Notifications/NotificationEmailSender.cs → backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotifications.cs

@ -8,6 +8,8 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Email; using Squidex.Infrastructure.Email;
using Squidex.Shared.Identity; using Squidex.Shared.Identity;
@ -15,12 +17,12 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications namespace Squidex.Domain.Apps.Entities.Notifications
{ {
public sealed class NotificationEmailSender : INotificationSender public sealed class EmailUserNotifications : IUserNotifications
{ {
private readonly IEmailSender emailSender; private readonly IEmailSender emailSender;
private readonly IUrlGenerator urlGenerator; private readonly IUrlGenerator urlGenerator;
private readonly ILogger<NotificationEmailSender> log; private readonly ILogger<EmailUserNotifications> log;
private readonly NotificationEmailTextOptions texts; private readonly EmailUserNotificationOptions texts;
private sealed class TemplatesVars private sealed class TemplatesVars
{ {
@ -28,7 +30,9 @@ namespace Squidex.Domain.Apps.Entities.Notifications
public IUser? Assigner { get; init; } 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; } public long? ApiCalls { get; init; }
@ -42,11 +46,11 @@ namespace Squidex.Domain.Apps.Entities.Notifications
get => true; get => true;
} }
public NotificationEmailSender( public EmailUserNotifications(
IOptions<NotificationEmailTextOptions> texts, IOptions<EmailUserNotificationOptions> texts,
IEmailSender emailSender, IEmailSender emailSender,
IUrlGenerator urlGenerator, IUrlGenerator urlGenerator,
ILogger<NotificationEmailSender> log) ILogger<EmailUserNotifications> log)
{ {
this.texts = texts.Value; this.texts = texts.Value;
this.emailSender = emailSender; this.emailSender = emailSender;
@ -55,54 +59,77 @@ namespace Squidex.Domain.Apps.Entities.Notifications
this.log = log; 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(user);
Guard.NotNull(appName); Guard.NotNull(app);
var vars = new TemplatesVars var vars = new TemplatesVars
{ {
ApiCalls = usage, ApiCalls = usage,
ApiCallsLimit = usageLimit, ApiCallsLimit = usageLimit,
TeamName = appName AppName = app.DisplayName()
}; };
return SendEmailAsync("Usage", return SendEmailAsync("Usage",
texts.UsageSubject, texts.UsageSubject,
texts.UsageBody, 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(assigner);
Guard.NotNull(user); 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()) if (user.Claims.HasConsent())
{ {
return SendEmailAsync("ExistingUser", return SendEmailAsync("ExistingUser",
texts.ExistingUserSubject, texts.ExistingUserSubject,
texts.ExistingUserBody, texts.ExistingUserBody,
user, vars); user, vars, ct);
} }
else else
{ {
return SendEmailAsync("NewUser", return SendEmailAsync("NewUser",
texts.NewUserSubject, texts.NewUserSubject,
texts.NewUserBody, 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)
{
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 Task.CompletedTask; 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)) if (string.IsNullOrWhiteSpace(emailBody))
{ {
@ -125,7 +152,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
try try
{ {
await emailSender.SendAsync(user.Email, emailSubj, emailBody); await emailSender.SendAsync(user.Email, emailSubj, emailBody, ct);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -136,7 +163,15 @@ namespace Squidex.Domain.Apps.Entities.Notifications
private static string Format(string text, TemplatesVars vars) 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) if (vars.Assigner != null)
{ {

13
backend/src/Squidex.Domain.Apps.Entities/Notifications/INotificationSender.cs → backend/src/Squidex.Domain.Apps.Entities/Notifications/IUserNotifications.cs

@ -5,18 +5,23 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Shared.Users; using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications namespace Squidex.Domain.Apps.Entities.Notifications
{ {
public interface INotificationSender public interface IUserNotifications
{ {
bool IsActive { get; } 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);
} }
} }

13
backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopNotificationSender.cs → backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopUserNotifications.cs

@ -5,28 +5,33 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Shared.Users; using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications namespace Squidex.Domain.Apps.Entities.Notifications
{ {
public sealed class NoopNotificationSender : INotificationSender public sealed class NoopUserNotifications : IUserNotifications
{ {
public bool IsActive public bool IsActive
{ {
get => false; 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; 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; 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; return Task.CompletedTask;
} }

66
backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.cs

@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
case AssignContributor assignContributor: case AssignContributor assignContributor:
return UpdateReturnAsync(assignContributor, async (c, ct) => 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)); AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
@ -95,60 +95,46 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
}, ct); }, ct);
case ChangePlan changePlan: case ChangePlan changePlan:
return ChangeBillingPlanAsync(changePlan, ct); return UpdateReturnAsync(changePlan, async (c, ct) =>
default:
ThrowHelper.NotSupportedException();
return default!;
}
}
private async Task<CommandResult> ChangeBillingPlanAsync(ChangePlan changePlan,
CancellationToken ct)
{ {
var userId = changePlan.Actor.Identifier; GuardTeam.CanChangePlan(c, BillingPlans);
var result = await UpdateReturnAsync(changePlan, async (c, ct) => if (string.Equals(FreePlan?.Id, c.PlanId, StringComparison.Ordinal))
{ {
GuardTeam.CanChangePlan(c, BillingPlans()); if (!c.FromCallback)
if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal))
{ {
await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default);
}
ResetPlan(c); ResetPlan(c);
return new PlanChangedResult(c.PlanId, true, null); return new PlanChangedResult(c.PlanId, true, null);
} }
else
{
if (!c.FromCallback) if (!c.FromCallback)
{ {
var redirectUri = await BillingManager().MustRedirectToPortalAsync(userId, UniqueId, c.PlanId, ct); var redirectUri = await BillingManager.MustRedirectToPortalAsync(c.Actor.Identifier, Snapshot, c.PlanId, ct);
if (redirectUri != null) if (redirectUri != null)
{ {
return new PlanChangedResult(c.PlanId, false, redirectUri); 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); return new PlanChangedResult(c.PlanId);
}, ct);
if (changePlan.FromCallback)
{
return result;
} }
if (result.Payload is PlanChangedResult { Unsubscribed: true, RedirectUri: null }) }, ct);
{
await BillingManager().UnsubscribeAsync(userId, UniqueId, default);
}
else if (result.Payload is PlanChangedResult { RedirectUri: null })
{
await BillingManager().SubscribeAsync(userId, UniqueId, changePlan.PlanId, default);
}
return result; default:
ThrowHelper.NotSupportedException();
return default!;
}
} }
private void Create(CreateTeam command) private void Create(CreateTeam command)
@ -202,24 +188,24 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
RaiseEvent(Envelope.Create(@event)); RaiseEvent(Envelope.Create(@event));
} }
private IBillingPlans BillingPlans() private IBillingPlans BillingPlans
{ {
return serviceProvider.GetRequiredService<IBillingPlans>(); get => serviceProvider.GetRequiredService<IBillingPlans>();
} }
private IBillingManager BillingManager() private IBillingManager BillingManager
{ {
return serviceProvider.GetRequiredService<IBillingManager>(); get => serviceProvider.GetRequiredService<IBillingManager>();
} }
private IUserResolver Users() private IUserResolver Users
{ {
return serviceProvider.GetRequiredService<IUserResolver>(); get => serviceProvider.GetRequiredService<IUserResolver>();
} }
private Plan GetFreePlan() private Plan FreePlan
{ {
return BillingPlans().GetFreePlan(); get => BillingPlans.GetFreePlan();
} }
} }
} }

2
backend/src/Squidex.Web/Constants.cs

@ -18,8 +18,6 @@ namespace Squidex.Web
public const string PrefixApi = "/api"; public const string PrefixApi = "/api";
public const string PrefixPortal = "/portal";
public const string PrefixIdentityServer = "/identity-server"; public const string PrefixIdentityServer = "/identity-server";
public const string ScopePermissions = "permissions"; public const string ScopePermissions = "permissions";

5
backend/src/Squidex.Web/Resources.cs

@ -130,6 +130,11 @@ namespace Squidex.Web
public bool CanManageEvents => Can(PermissionIds.AdminEventsManage); public bool CanManageEvents => Can(PermissionIds.AdminEventsManage);
// Plans
public bool CanChangePlan => Can(PermissionIds.AppPlansChange);
public bool CanChangeTeamPlan => Can(PermissionIds.TeamPlansChange);
// Backups // Backups
public bool CanRestoreBackup => Can(PermissionIds.AdminRestore); public bool CanRestoreBackup => Can(PermissionIds.AdminRestore);

4
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 IAppProvider appProvider;
private readonly SchemasOpenApiGenerator schemasOpenApiGenerator; private readonly SchemasOpenApiGenerator schemasOpenApiGenerator;
public ContentOpenApiController(ICommandBus commandBus, IAppProvider appProvider, SchemasOpenApiGenerator schemasOpenApiGenerator) public ContentOpenApiController(ICommandBus commandBus, IAppProvider appProvider,
SchemasOpenApiGenerator schemasOpenApiGenerator)
: base(commandBus) : base(commandBus)
{ {
this.appProvider = appProvider; this.appProvider = appProvider;
this.schemasOpenApiGenerator = schemasOpenApiGenerator; this.schemasOpenApiGenerator = schemasOpenApiGenerator;
} }

32
backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -53,13 +53,37 @@ namespace Squidex.Areas.Api.Controllers.Plans
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetPlans(string app) public IActionResult GetPlans(string app)
{ {
var hasPortal = billingManager.HasPortal;
var response = Deferred.AsyncResponse(async () => 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(); Response.Headers[HeaderNames.ETag] = App.ToEtag();

34
backend/src/Squidex/Areas/Api/Controllers/Plans/Models/PlansDto.cs

@ -5,10 +5,7 @@
// All rights reserved. Licensed under the MIT license. // 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.Billing;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
namespace Squidex.Areas.Api.Controllers.Plans.Models namespace Squidex.Areas.Api.Controllers.Plans.Models
@ -32,37 +29,24 @@ namespace Squidex.Areas.Api.Controllers.Plans.Models
public string? PlanOwner { get; set; } public string? PlanOwner { get; set; }
/// <summary> /// <summary>
/// The ID of the team. /// The link to the management portal.
/// </summary> /// </summary>
public DomainId? TeamId { get; set; } public string? PortalLink { get; set; }
/// <summary> /// <summary>
/// Indicates if there is a billing portal. /// The reason why the plan cannot be changed.
/// </summary> /// </summary>
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 var result = new PlansDto
{ {
Locked = locked,
CurrentPlanId = planId, CurrentPlanId = planId,
Plans = plans.GetAvailablePlans().Select(PlanDto.FromDomain).ToArray(), Plans = plans.Select(PlanDto.FromDomain).ToArray(),
PlanOwner = app.Plan?.Owner.Identifier, PlanOwner = owner,
HasPortal = hasPortal, PortalLink = portalLink?.ToString()
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
}; };
return result; return result;

32
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
{
/// <summary>
/// The user can change the plan
/// </summary>
None,
/// <summary>
/// The user is not the owner.
/// </summary>
NotOwner,
/// <summary>
/// The user does not have permission to change the plan
/// </summary>
NoPermission,
/// <summary>
/// The plan is managed by the team.
/// </summary>
ManagedByTeam
}
}

30
backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs

@ -51,15 +51,35 @@ namespace Squidex.Areas.Api.Controllers.Plans
[ProducesResponseType(typeof(PlansDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PlansDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.TeamPlansRead)] [ApiPermissionOrAnonymous(PermissionIds.TeamPlansRead)]
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetPlans(string team) public IActionResult GetTeamPlans(string team)
{ {
var hasPortal = billingManager.HasPortal;
var response = Deferred.AsyncResponse(async () => var response = Deferred.AsyncResponse(async () =>
{ {
var owner = Team.Plan?.Owner.Identifier;
var (_, planId) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted); 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(); Response.Headers[HeaderNames.ETag] = Team.ToEtag();
@ -81,7 +101,7 @@ namespace Squidex.Areas.Api.Controllers.Plans
[ProducesResponseType(typeof(PlanChangedDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(PlanChangedDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.TeamPlansChange)] [ApiPermissionOrAnonymous(PermissionIds.TeamPlansChange)]
[ApiCosts(0)] [ApiCosts(0)]
public async Task<IActionResult> PutPlan(string team, [FromBody] ChangePlanDto request) public async Task<IActionResult> PutTeamPlan(string team, [FromBody] ChangePlanDto request)
{ {
var command = SimpleMapper.Map(request, new ChangePlan()); var command = SimpleMapper.Map(request, new ChangePlan());

2
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)) if (resources.IsAllowed(PermissionIds.TeamPlansRead, team: values.team, additional: permissions))
{ {
AddGetLink("plans", AddGetLink("plans",
resources.Url<TeamPlansController>(x => nameof(x.GetPlans), values)); resources.Url<TeamPlansController>(x => nameof(x.GetTeamPlans), values));
} }
return this; return this;

44
backend/src/Squidex/Areas/Portal/Middlewares/PortalDashboardAuthenticationMiddleware.cs

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

37
backend/src/Squidex/Areas/Portal/Middlewares/PortalRedirectMiddleware.cs

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

10
backend/src/Squidex/Config/Domain/NotificationsServices.cs

@ -23,19 +23,19 @@ namespace Squidex.Config.Domain
{ {
services.AddSingleton(Options.Create(emailOptions)); services.AddSingleton(Options.Create(emailOptions));
services.Configure<NotificationEmailTextOptions>(config, services.Configure<EmailUserNotificationOptions>(config,
"email:notifications"); "email:notifications");
services.AddSingletonAs<SmtpEmailSender>() services.AddSingletonAs<SmtpEmailSender>()
.As<IEmailSender>(); .As<IEmailSender>();
services.AddSingletonAs<NotificationEmailSender>() services.AddSingletonAs<EmailUserNotifications>()
.AsOptional<INotificationSender>(); .AsOptional<IUserNotifications>();
} }
else else
{ {
services.AddSingletonAs<NoopNotificationSender>() services.AddSingletonAs<NoopUserNotifications>()
.AsOptional<INotificationSender>(); .AsOptional<IUserNotifications>();
} }
services.AddSingletonAs<InvitationEventConsumer>() services.AddSingletonAs<InvitationEventConsumer>()

7
backend/src/Squidex/Startup.cs

@ -8,7 +8,6 @@
using Squidex.Areas.Api.Config.OpenApi; using Squidex.Areas.Api.Config.OpenApi;
using Squidex.Areas.Frontend; using Squidex.Areas.Frontend;
using Squidex.Areas.IdentityServer.Config; using Squidex.Areas.IdentityServer.Config;
using Squidex.Areas.Portal.Middlewares;
using Squidex.Config.Authentication; using Squidex.Config.Authentication;
using Squidex.Config.Domain; using Squidex.Config.Domain;
using Squidex.Config.Messaging; using Squidex.Config.Messaging;
@ -113,12 +112,6 @@ namespace Squidex
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.Map(Constants.PrefixPortal, builder =>
{
builder.UseMiddleware<PortalDashboardAuthenticationMiddleware>();
builder.UseMiddleware<PortalRedirectMiddleware>();
});
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>
{ {
endpoints.MapControllers(); endpoints.MapControllers();

20
backend/src/Squidex/appsettings.json

@ -176,18 +176,30 @@
"port": 587 "port": 587
}, },
"notifications": { "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", "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<<Start now!>> [$UI_URL]", "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<<Start now!>> [$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", "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<<Start now!>> [$UI_URL]", "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<<Start now!>> [$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<<Start now!>> [$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<<Start now!>> [$UI_URL]",
// The email subject when app usage reached // The email subject when app usage reached
"usageSubject": "[Squidex CMS] You you are about to reach your usage limit for App $APP_NAME", "usageSubject": "[Squidex CMS] You you are about to reach your usage limit for App $APP_NAME",

28
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)) A.CallTo(() => billingPlans.GetPlan(planIdPaid))
.Returns(new Plan { Id = planIdPaid, MaxContributors = 30 }); .Returns(new Plan { Id = planIdPaid, MaxContributors = 30 });
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, A<string>._, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, A<string>._, default))
.Returns(Task.FromResult<Uri?>(null)); .Returns(Task.FromResult<Uri?>(null));
A.CallTo(() => appProvider.GetTeamAsync(teamId, default)) A.CallTo(() => appProvider.GetTeamAsync(teamId, default))
@ -227,7 +227,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{ {
var command = new ChangePlan { PlanId = planIdPaid }; var command = new ChangePlan { PlanId = planIdPaid };
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.Returns(Task.FromResult<Uri?>(null)); .Returns(Task.FromResult<Uri?>(null));
await ExecuteCreateAsync(); await ExecuteCreateAsync();
@ -243,10 +243,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
CreateEvent(new AppPlanChanged { PlanId = planIdPaid }) CreateEvent(new AppPlanChanged { PlanId = planIdPaid })
); );
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -268,10 +268,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
CreateEvent(new AppPlanChanged { PlanId = planIdPaid }) CreateEvent(new AppPlanChanged { PlanId = planIdPaid })
); );
A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<NamedId<DomainId>>._, A<string?>._, A<CancellationToken>._)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<IAppEntity>._, A<string?>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(A<string>._, A<NamedId<DomainId>>._, A<string?>._, A<CancellationToken>._)) A.CallTo(() => billingManager.SubscribeAsync(A<string>._, A<IAppEntity>._, A<string?>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -294,10 +294,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
CreateEvent(new AppPlanReset()) CreateEvent(new AppPlanReset())
); );
A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<NamedId<DomainId>>._, A<string?>._, A<CancellationToken>._)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<IAppEntity>._, A<string?>._, A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<NamedId<DomainId>>._, A<CancellationToken>._)) A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<IAppEntity>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -320,10 +320,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
CreateEvent(new AppPlanReset()) CreateEvent(new AppPlanReset())
); );
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<NamedId<DomainId>>._, A<CancellationToken>._)) A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<IAppEntity>._, A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -332,7 +332,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{ {
var command = new ChangePlan { PlanId = planIdPaid }; var command = new ChangePlan { PlanId = planIdPaid };
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.Returns(new Uri("http://squidex.io")); .Returns(new Uri("http://squidex.io"));
await ExecuteCreateAsync(); await ExecuteCreateAsync();
@ -357,10 +357,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Assert.Equal(planIdPaid, sut.Snapshot.Plan?.PlanId); Assert.Equal(planIdPaid, sut.Snapshot.Plan?.PlanId);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, A<CancellationToken>._)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, AppNamedId, planIdPaid, A<CancellationToken>._)) A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -714,7 +714,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
CreateEvent(new AppDeleted()) CreateEvent(new AppDeleted())
); );
A.CallTo(() => billingManager.UnsubscribeAsync(command.Actor.Identifier, AppNamedId, default)) A.CallTo(() => billingManager.UnsubscribeAsync(command.Actor.Identifier, A<IAppEntity>._, default))
.MustHaveHappened(); .MustHaveHappened();
} }

37
backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/NoopBillingManagerTests.cs

@ -5,7 +5,8 @@
// All rights reserved. Licensed under the MIT license. // 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; using Xunit;
namespace Squidex.Domain.Apps.Entities.Billing namespace Squidex.Domain.Apps.Entities.Billing
@ -15,47 +16,49 @@ namespace Squidex.Domain.Apps.Entities.Billing
private readonly NoopBillingManager sut = new NoopBillingManager(); private readonly NoopBillingManager sut = new NoopBillingManager();
[Fact] [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] [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] [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] [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] [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] [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] [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); Assert.Null(actual);
} }
@ -63,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
[Fact] [Fact]
public async Task Should_do_nothing_if_checking_for_redirect_for_team() 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); Assert.Null(actual);
} }

36
backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageNotifierWorkerTest.cs

@ -8,7 +8,10 @@
using FakeItEasy; using FakeItEasy;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Notifications; using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.TestHelpers; using Squidex.Infrastructure.TestHelpers;
using Squidex.Shared.Users; using Squidex.Shared.Users;
using Xunit; using Xunit;
@ -19,20 +22,25 @@ namespace Squidex.Domain.Apps.Entities.Billing
{ {
private readonly TestState<UsageNotifierWorker.State> state = new TestState<UsageNotifierWorker.State>("Default"); private readonly TestState<UsageNotifierWorker.State> state = new TestState<UsageNotifierWorker.State>("Default");
private readonly IClock clock = A.Fake<IClock>(); private readonly IClock clock = A.Fake<IClock>();
private readonly INotificationSender notificationSender = A.Fake<INotificationSender>(); private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IUserNotifications notificationSender = A.Fake<IUserNotifications>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>(); private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly IAppEntity app = Mocks.App(NamedId.Of(DomainId.NewGuid(), "my-app"));
private readonly UsageNotifierWorker sut; private readonly UsageNotifierWorker sut;
private Instant time = SystemClock.Instance.GetCurrentInstant(); private Instant time = SystemClock.Instance.GetCurrentInstant();
public UsageNotifierWorkerTest() public UsageNotifierWorkerTest()
{ {
A.CallTo(() => appProvider.GetAppAsync(app.Id, true, default))
.Returns(app);
A.CallTo(() => clock.GetCurrentInstant()) A.CallTo(() => clock.GetCurrentInstant())
.ReturnsLazily(() => time); .ReturnsLazily(() => time);
A.CallTo(() => notificationSender.IsActive) A.CallTo(() => notificationSender.IsActive)
.Returns(true); .Returns(true);
sut = new UsageNotifierWorker(state.PersistenceFactory, notificationSender, userResolver) sut = new UsageNotifierWorker(state.PersistenceFactory, appProvider, notificationSender, userResolver)
{ {
Clock = clock Clock = clock
}; };
@ -46,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
var message = new UsageTrackingCheck var message = new UsageTrackingCheck
{ {
AppName = "my-app", AppId = app.Id,
Usage = 1000, Usage = 1000,
UsageLimit = 3000, UsageLimit = 3000,
Users = new[] { "1", "2" } Users = new[] { "1", "2" }
@ -54,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
await sut.HandleAsync(message, default); await sut.HandleAsync(message, default);
A.CallTo(() => notificationSender.SendUsageAsync(A<IUser>._, "my-app", 1000, 3000)) A.CallTo(() => notificationSender.SendUsageAsync(A<IUser>._, A<IAppEntity>._, A<long>._, A<long>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -67,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
var message = new UsageTrackingCheck var message = new UsageTrackingCheck
{ {
AppName = "my-app", AppId = app.Id,
Usage = 1000, Usage = 1000,
UsageLimit = 3000, UsageLimit = 3000,
Users = new[] { "1", "2", "3" } Users = new[] { "1", "2", "3" }
@ -75,13 +83,13 @@ namespace Squidex.Domain.Apps.Entities.Billing
await sut.HandleAsync(message, default); await sut.HandleAsync(message, default);
A.CallTo(() => notificationSender.SendUsageAsync(user1!, "my-app", 1000, 3000)) A.CallTo(() => notificationSender.SendUsageAsync(user1!, app, 1000, 3000, default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => notificationSender.SendUsageAsync(user2!, "my-app", 1000, 3000)) A.CallTo(() => notificationSender.SendUsageAsync(user2!, app, 1000, 3000, default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => notificationSender.SendUsageAsync(user3!, "my-app", 1000, 3000)) A.CallTo(() => notificationSender.SendUsageAsync(user3!, app, 1000, 3000, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -92,7 +100,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
var message = new UsageTrackingCheck var message = new UsageTrackingCheck
{ {
AppName = "my-app", AppId = app.Id,
Usage = 1000, Usage = 1000,
UsageLimit = 3000, UsageLimit = 3000,
Users = new[] { "1" } Users = new[] { "1" }
@ -101,7 +109,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
await sut.HandleAsync(message, default); await sut.HandleAsync(message, default);
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(); .MustHaveHappenedOnceExactly();
} }
@ -112,7 +120,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
var message = new UsageTrackingCheck var message = new UsageTrackingCheck
{ {
AppName = "my-app", AppId = app.Id,
Usage = 1000, Usage = 1000,
UsageLimit = 3000, UsageLimit = 3000,
Users = new[] { "1" } Users = new[] { "1" }
@ -124,7 +132,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))
.MustHaveHappenedTwiceExactly(); .MustHaveHappenedTwiceExactly();
} }
@ -140,7 +148,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
var message = new UsageTrackingCheck var message = new UsageTrackingCheck
{ {
AppName = "my-app", AppId = app.Id,
Usage = 1000, Usage = 1000,
UsageLimit = 3000, UsageLimit = 3000,
Users = new[] { "1" } Users = new[] { "1" }
@ -152,7 +160,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(); .MustHaveHappenedOnceExactly();
} }

16
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/TestContent.cs

@ -230,10 +230,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
new ContentFieldData() new ContentFieldData()
.AddInvariant(JsonValue.Array( .AddInvariant(JsonValue.Array(
new JsonObject() new JsonObject()
.Add("nested-number", 10) .Add("nested-number", 42)
.Add("nested-boolean", true), .Add("nested-boolean", true),
new JsonObject() new JsonObject()
.Add("nested-number", 20) .Add("nested-number", 3.14)
.Add("nested-boolean", false)))); .Add("nested-boolean", false))));
if (assetId != default || refId != default) if (assetId != default || refId != default)
@ -448,12 +448,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
new new
{ {
nestedNumber = 10.0, nestedNumber = 42.0,
nestedBoolean = true nestedBoolean = true
}, },
new new
{ {
nestedNumber = 20.0, nestedNumber = 3.14,
nestedBoolean = false nestedBoolean = false
} }
} }
@ -617,12 +617,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
new new
{ {
nestedNumber = 10.0, nestedNumber = 42.0,
nestedBoolean = true nestedBoolean = true
}, },
new new
{ {
nestedNumber = 20.0, nestedNumber = 3.14,
nestedBoolean = false nestedBoolean = false
} }
} }
@ -704,12 +704,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
new new
{ {
nestedNumber = 10.0, nestedNumber = 42.0,
nestedBoolean = true nestedBoolean = true
}, },
new new
{ {
nestedNumber = 20.0, nestedNumber = 3.14,
nestedBoolean = false nestedBoolean = false
} }
} }

44
backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InvitationEventConsumerTests.cs

@ -9,6 +9,7 @@ using FakeItEasy;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Notifications; using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Entities.Teams; using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
@ -23,21 +24,21 @@ namespace Squidex.Domain.Apps.Entities.Invitation
{ {
public class InvitationEventConsumerTests public class InvitationEventConsumerTests
{ {
private readonly IAppEntity app = Mocks.App(NamedId.Of(DomainId.NewGuid(), "my-app"));
private readonly IAppProvider appProvider = A.Fake<IAppProvider>(); private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly INotificationSender notificatíonSender = A.Fake<INotificationSender>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly IUser assigner = UserMocks.User("1");
private readonly IUser assignee = UserMocks.User("2");
private readonly ILogger<InvitationEventConsumer> log = A.Fake<ILogger<InvitationEventConsumer>>(); private readonly ILogger<InvitationEventConsumer> log = A.Fake<ILogger<InvitationEventConsumer>>();
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<IUserNotifications>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly string assignerId = DomainId.NewGuid().ToString(); private readonly string assignerId = DomainId.NewGuid().ToString();
private readonly string assigneeId = 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; private readonly InvitationEventConsumer sut;
public InvitationEventConsumerTests() public InvitationEventConsumerTests()
{ {
A.CallTo(() => notificatíonSender.IsActive) A.CallTo(() => userNotifications.IsActive)
.Returns(true); .Returns(true);
A.CallTo(() => userResolver.FindByIdAsync(assignerId, default)) A.CallTo(() => userResolver.FindByIdAsync(assignerId, default))
@ -46,10 +47,13 @@ namespace Squidex.Domain.Apps.Entities.Invitation
A.CallTo(() => userResolver.FindByIdAsync(assigneeId, default)) A.CallTo(() => userResolver.FindByIdAsync(assigneeId, default))
.Returns(assignee); .Returns(assignee);
A.CallTo(() => appProvider.GetTeamAsync(A<DomainId>._, default)) A.CallTo(() => appProvider.GetAppAsync(app.Id, true, default))
.Returns(Mocks.Team(DomainId.NewGuid(), teamName)); .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] [Fact]
@ -136,7 +140,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
{ {
var @event = CreateAppEvent(RefTokenType.Subject, true); var @event = CreateAppEvent(RefTokenType.Subject, true);
A.CallTo(() => notificatíonSender.IsActive) A.CallTo(() => userNotifications.IsActive)
.Returns(false); .Returns(false);
await sut.On(@event); await sut.On(@event);
@ -150,7 +154,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
{ {
var @event = CreateTeamEvent(true); var @event = CreateTeamEvent(true);
A.CallTo(() => notificatíonSender.IsActive) A.CallTo(() => userNotifications.IsActive)
.Returns(false); .Returns(false);
await sut.On(@event); await sut.On(@event);
@ -222,7 +226,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
await sut.On(@event); await sut.On(@event);
A.CallTo(() => notificatíonSender.SendInviteAsync(assigner, assignee, appName)) A.CallTo(() => userNotifications.SendInviteAsync(assigner, assignee, app, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -233,7 +237,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
await sut.On(@event); await sut.On(@event);
A.CallTo(() => notificatíonSender.SendTeamInviteAsync(assigner, assignee, teamName)) A.CallTo(() => userNotifications.SendInviteAsync(assigner, assignee, team, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -244,7 +248,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
await sut.On(@event); await sut.On(@event);
A.CallTo(() => notificatíonSender.SendInviteAsync(assigner, assignee, appName)) A.CallTo(() => userNotifications.SendInviteAsync(assigner, assignee, app, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -255,7 +259,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
await sut.On(@event); await sut.On(@event);
A.CallTo(() => notificatíonSender.SendTeamInviteAsync(assigner, assignee, teamName)) A.CallTo(() => userNotifications.SendInviteAsync(assigner, assignee, team, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -286,10 +290,10 @@ namespace Squidex.Domain.Apps.Entities.Invitation
private void MustNotSendEmail() private void MustNotSendEmail()
{ {
A.CallTo(() => notificatíonSender.SendInviteAsync(A<IUser>._, A<IUser>._, A<string>._)) A.CallTo(() => userNotifications.SendInviteAsync(A<IUser>._, A<IUser>._, A<IAppEntity>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => notificatíonSender.SendTeamInviteAsync(A<IUser>._, A<IUser>._, A<string>._)) A.CallTo(() => userNotifications.SendInviteAsync(A<IUser>._, A<IUser>._, A<IAppEntity>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -298,7 +302,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
var @event = new AppContributorAssigned var @event = new AppContributorAssigned
{ {
Actor = new RefToken(assignerType, assignerId), Actor = new RefToken(assignerType, assignerId),
AppId = NamedId.Of(DomainId.NewGuid(), appName), AppId = app.NamedId(),
ContributorId = assigneeId, ContributorId = assigneeId,
IsCreated = isNewUser, IsCreated = isNewUser,
IsAdded = isNewContributor IsAdded = isNewContributor
@ -320,7 +324,7 @@ namespace Squidex.Domain.Apps.Entities.Invitation
ContributorId = assigneeId, ContributorId = assigneeId,
IsCreated = isNewUser, IsCreated = isNewUser,
IsAdded = isNewContributor, IsAdded = isNewContributor,
TeamId = DomainId.NewGuid() TeamId = team.Id
}; };
var envelope = Envelope.Create(@event); var envelope = Envelope.Create(@event);

1
backend/tests/Squidex.Domain.Apps.Entities.Tests/Invitation/InviteUserCommandMiddlewareTests.cs

@ -8,7 +8,6 @@
using FakeItEasy; using FakeItEasy;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Teams; using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;

94
backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/NotificationEmailSenderTests.cs → backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/EmailUserNotificationsTests.cs

@ -10,30 +10,34 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.TestHelpers; 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.Infrastructure.Email;
using Squidex.Shared.Users; using Squidex.Shared.Users;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Notifications namespace Squidex.Domain.Apps.Entities.Notifications
{ {
public class NotificationEmailSenderTests public class EmailUserNotificationsTests
{ {
private readonly IEmailSender emailSender = A.Fake<IEmailSender>(); private readonly IEmailSender emailSender = A.Fake<IEmailSender>();
private readonly IUrlGenerator urlGenerator = A.Fake<IUrlGenerator>(); private readonly IUrlGenerator urlGenerator = A.Fake<IUrlGenerator>();
private readonly IUser assigner = UserMocks.User("1", "1@email.com", "user1"); private readonly IUser assigner = UserMocks.User("1", "1@email.com", "user1");
private readonly IUser assigned = UserMocks.User("2", "2@email.com", "user2"); private readonly IUser assigned = UserMocks.User("2", "2@email.com", "user2");
private readonly ILogger<NotificationEmailSender> log = A.Fake<ILogger<NotificationEmailSender>>(); private readonly ILogger<EmailUserNotifications> log = A.Fake<ILogger<EmailUserNotifications>>();
private readonly string appName = "my-app"; private readonly IAppEntity app = Mocks.App(NamedId.Of(DomainId.NewGuid(), "my-app"));
private readonly string appUI = "my-ui"; private readonly ITeamEntity team = Mocks.Team(DomainId.NewGuid());
private readonly NotificationEmailTextOptions texts = new NotificationEmailTextOptions(); private readonly EmailUserNotificationOptions texts = new EmailUserNotificationOptions();
private readonly NotificationEmailSender sut; private readonly EmailUserNotifications sut;
public NotificationEmailSenderTests() public EmailUserNotificationsTests()
{ {
A.CallTo(() => urlGenerator.UI()) 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] [Fact]
@ -85,9 +89,9 @@ namespace Squidex.Domain.Apps.Entities.Notifications
} }
[Fact] [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<string>._, A<string>._, A<CancellationToken>._)) A.CallTo(() => emailSender.SendAsync(assigned.Email, A<string>._, A<string>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -96,9 +100,31 @@ namespace Squidex.Domain.Apps.Entities.Notifications
} }
[Fact] [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<string>._, A<string>._, A<CancellationToken>._))
.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<string>._, A<string>._, A<CancellationToken>._))
.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<string>._, A<string>._, A<CancellationToken>._)) A.CallTo(() => emailSender.SendAsync(assigned.Email, A<string>._, A<string>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -109,7 +135,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
[Fact] [Fact]
public async Task Should_not_send_usage_email_if_texts_empty() 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<string>._, A<string>._, A<CancellationToken>._)) A.CallTo(() => emailSender.SendAsync(assigned.Email, A<string>._, A<string>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
@ -118,28 +144,56 @@ namespace Squidex.Domain.Apps.Entities.Notifications
} }
[Fact] [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); var withoutConsent = UserMocks.User("2", "2@email.com", "user", false);
texts.ExistingUserSubject = "email-subject"; texts.ExistingUserSubject = "email-subject";
texts.ExistingUserBody = "email-body"; 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<CancellationToken>._))
.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<CancellationToken>._)) A.CallTo(() => emailSender.SendAsync(withoutConsent.Email, "email-subject", "email-body", A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [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); var withConsent = UserMocks.User("2", "2@email.com", "user", true);
texts.ExistingUserSubject = "email-subject"; texts.ExistingUserSubject = "email-subject";
texts.ExistingUserBody = "email-body"; 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<CancellationToken>._))
.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<CancellationToken>._)) A.CallTo(() => emailSender.SendAsync(withConsent.Email, "email-subject", "email-body", A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
@ -150,7 +204,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
texts.UsageSubject = pattern; texts.UsageSubject = pattern;
texts.UsageBody = 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<CancellationToken>._)) A.CallTo(() => emailSender.SendAsync(assigned.Email, actual, actual, A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
@ -161,7 +215,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
texts.NewUserSubject = pattern; texts.NewUserSubject = pattern;
texts.NewUserBody = 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<CancellationToken>._)) A.CallTo(() => emailSender.SendAsync(assigned.Email, actual, actual, A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();

26
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)) A.CallTo(() => billingPlans.GetPlan(planPaid.Id))
.Returns(planPaid); .Returns(planPaid);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, A<string>._, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, A<string>._, default))
.Returns(Task.FromResult<Uri?>(null)); .Returns(Task.FromResult<Uri?>(null));
var serviceProvider = var serviceProvider =
@ -130,7 +130,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
{ {
var command = new ChangePlan { PlanId = planPaid.Id }; var command = new ChangePlan { PlanId = planPaid.Id };
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.Returns(Task.FromResult<Uri?>(null)); .Returns(Task.FromResult<Uri?>(null));
await ExecuteCreateAsync(); await ExecuteCreateAsync();
@ -146,10 +146,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
CreateTeamEvent(new TeamPlanChanged { PlanId = planPaid.Id }) CreateTeamEvent(new TeamPlanChanged { PlanId = planPaid.Id })
); );
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, teamId, planPaid.Id, default)) A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -171,10 +171,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
CreateTeamEvent(new TeamPlanChanged { PlanId = planPaid.Id }) CreateTeamEvent(new TeamPlanChanged { PlanId = planPaid.Id })
); );
A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<DomainId>._, A<string?>._, A<CancellationToken>._)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<ITeamEntity>._, A<string?>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(A<string>._, A<DomainId>._, A<string?>._, A<CancellationToken>._)) A.CallTo(() => billingManager.SubscribeAsync(A<string>._, A<ITeamEntity>._, A<string?>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -197,10 +197,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
CreateTeamEvent(new TeamPlanReset()) CreateTeamEvent(new TeamPlanReset())
); );
A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, teamId, A<string?>._, A<CancellationToken>._)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<ITeamEntity>._, A<string?>._, A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<DomainId>._, A<CancellationToken>._)) A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<ITeamEntity>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -223,10 +223,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
CreateTeamEvent(new TeamPlanReset()) CreateTeamEvent(new TeamPlanReset())
); );
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, teamId, A<CancellationToken>._)) A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<ITeamEntity>._, A<CancellationToken>._))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -235,7 +235,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
{ {
var command = new ChangePlan { PlanId = planPaid.Id }; var command = new ChangePlan { PlanId = planPaid.Id };
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.Returns(new Uri("http://squidex.io")); .Returns(new Uri("http://squidex.io"));
await ExecuteCreateAsync(); await ExecuteCreateAsync();
@ -260,10 +260,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
Assert.Equal(planPaid.Id, sut.Snapshot.Plan?.PlanId); Assert.Equal(planPaid.Id, sut.Snapshot.Plan?.PlanId);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, A<CancellationToken>._)) A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, teamId, planPaid.Id, A<CancellationToken>._)) A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }

2
backend/tests/Squidex.Infrastructure.Tests/Json/System/JsonInheritanceConverterBaseTests.cs

@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Json.System
public int PropertyB { get; init; } public int PropertyB { get; init; }
} }
private class Converter : InheritanceConverterBase<Base> private sealed class Converter : InheritanceConverterBase<Base>
{ {
public Converter() public Converter()
: base("$type") : base("$type")

4
frontend/src/app/features/settings/pages/plans/plan.component.html

@ -25,7 +25,7 @@
&#10003; {{ 'plans.selected' | sqxTranslate }} &#10003; {{ 'plans.selected' | sqxTranslate }}
</button> </button>
<button *ngIf="!planInfo.isSelected" class="btn btn-block btn-success" [disabled]="plansState.isDisabled | async" <button *ngIf="!planInfo.isSelected" class="btn btn-block btn-success" [disabled]="(plansState.locked| async) !== 'None'"
(sqxConfirmClick)="changeMonthly()" (sqxConfirmClick)="changeMonthly()"
confirmRememberKey="changePlan" confirmRememberKey="changePlan"
confirmTitle="i18n:plans.changeConfirmTitle" confirmTitle="i18n:plans.changeConfirmTitle"
@ -45,7 +45,7 @@
&#10003; {{ 'plans.selected' | sqxTranslate }} &#10003; {{ 'plans.selected' | sqxTranslate }}
</button> </button>
<button *ngIf="!planInfo.isYearlySelected" class="btn btn-block btn-success" [disabled]="plansState.isDisabled | async" <button *ngIf="!planInfo.isYearlySelected" class="btn btn-block btn-success" [disabled]="(plansState.locked| async) !== 'None'"
(sqxConfirmClick)="changeYearly()" (sqxConfirmClick)="changeYearly()"
confirmTitle="i18n:plans.changeConfirmTitle" confirmTitle="i18n:plans.changeConfirmTitle"
[confirmText]="planInfo.plan.yearlyConfirmText!" [confirmText]="planInfo.plan.yearlyConfirmText!"

8
frontend/src/app/features/settings/pages/plans/plans-page.component.html

@ -12,11 +12,11 @@
<ng-container> <ng-container>
<sqx-list-view innerWidth="60rem" [isLoading]="plansState.isLoading | async"> <sqx-list-view innerWidth="60rem" [isLoading]="plansState.isLoading | async">
<ng-container *ngIf="(plansState.isLoaded | async) && (plansState.plans | async); let plans"> <ng-container *ngIf="(plansState.isLoaded | async) && (plansState.plans | async); let plans">
<div class="alert alert-danger" *ngIf="(plansState.isOwner | async) === false"> <div class="alert alert-danger" *ngIf="(plansState.locked | async) === 'NotOwner'">
{{ 'plans.notPlanOwner' | sqxTranslate }} {{ 'plans.planOwner' | sqxTranslate }}: <strong className="no-wrap">{{plansState.planOwner | async | sqxUserName}}</strong> {{ 'plans.notPlanOwner' | sqxTranslate }} {{ 'plans.planOwner' | sqxTranslate }}: <strong className="no-wrap">{{plansState.planOwner | async | sqxUserName}}</strong>
</div> </div>
<div class="alert alert-danger" *ngIf="(plansState.teamId | async) !== null"> <div class="alert alert-danger" *ngIf="(plansState.locked | async) === 'ManagedByTeam'">
{{ 'plans.managedByTeam' | sqxTranslate }} {{ 'plans.managedByTeam' | sqxTranslate }}
</div> </div>
@ -31,8 +31,8 @@
</div> </div>
</div> </div>
<div *ngIf="plansState.hasPortal | async" class="billing-portal-link"> <div *ngIf="plansState.portalLink| async; let portalLink" class="billing-portal-link">
{{ 'plans.billingPortalHint' | sqxTranslate }} <a [href]="portalUrl" sqxExternalLink>{{ 'plans.billingPortal' | sqxTranslate }}</a> {{ 'plans.billingPortalHint' | sqxTranslate }} <a [href]="portalLink" sqxExternalLink>{{ 'plans.billingPortal' | sqxTranslate }}</a>
</div> </div>
</div> </div>
</ng-container> </ng-container>

4
frontend/src/app/features/teams/pages/plans/plan.component.html

@ -25,7 +25,7 @@
&#10003; {{ 'plans.selected' | sqxTranslate }} &#10003; {{ 'plans.selected' | sqxTranslate }}
</button> </button>
<button *ngIf="!planInfo.isSelected" class="btn btn-block btn-success" <button *ngIf="!planInfo.isSelected" class="btn btn-block btn-success" [disabled]="(plansState.locked| async) !== 'None'"
(sqxConfirmClick)="changeMonthly()" (sqxConfirmClick)="changeMonthly()"
confirmRememberKey="changePlan" confirmRememberKey="changePlan"
confirmTitle="i18n:plans.changeConfirmTitle" confirmTitle="i18n:plans.changeConfirmTitle"
@ -45,7 +45,7 @@
&#10003; {{ 'plans.selected' | sqxTranslate }} &#10003; {{ 'plans.selected' | sqxTranslate }}
</button> </button>
<button *ngIf="!planInfo.isYearlySelected" class="btn btn-block btn-success" <button *ngIf="!planInfo.isYearlySelected" class="btn btn-block btn-success" [disabled]="(plansState.locked| async) !== 'None'"
(sqxConfirmClick)="changeYearly()" (sqxConfirmClick)="changeYearly()"
confirmTitle="i18n:plans.changeConfirmTitle" confirmTitle="i18n:plans.changeConfirmTitle"
[confirmText]="planInfo.plan.yearlyConfirmText!" [confirmText]="planInfo.plan.yearlyConfirmText!"

4
frontend/src/app/features/teams/pages/plans/plans-page.component.html

@ -27,8 +27,8 @@
</div> </div>
</div> </div>
<div *ngIf="plansState.hasPortal | async" class="billing-portal-link"> <div *ngIf="plansState.portalLink| async; let portalLink" class="billing-portal-link">
{{ 'plans.billingPortalHint' | sqxTranslate }} <a [href]="portalUrl" sqxExternalLink>{{ 'plans.billingPortal' | sqxTranslate }}</a> {{ 'plans.billingPortalHint' | sqxTranslate }} <a [href]="portalLink" sqxExternalLink>{{ 'plans.billingPortal' | sqxTranslate }}</a>
</div> </div>
</div> </div>
</ng-container> </ng-container>

8
frontend/src/app/features/teams/services/team-plans.service.spec.ts

@ -43,8 +43,8 @@ describe('TeamPlansService', () => {
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ req.flush({
teamId: 'my-team',
currentPlanId: '123', currentPlanId: '123',
portalLink: 'link/to/portal',
planOwner: '456', planOwner: '456',
plans: [ plans: [
{ {
@ -74,7 +74,7 @@ describe('TeamPlansService', () => {
maxContributors: 6500, maxContributors: 6500,
}, },
], ],
hasPortal: true, locked: 'ManagedByTeam',
}, { }, {
headers: { headers: {
etag: '2', etag: '2',
@ -83,8 +83,8 @@ describe('TeamPlansService', () => {
expect(plans!).toEqual({ expect(plans!).toEqual({
payload: { payload: {
teamId: 'my-team',
currentPlanId: '123', currentPlanId: '123',
portalLink: 'link/to/portal',
planOwner: '456', planOwner: '456',
plans: [ plans: [
new PlanDto( new PlanDto(
@ -100,7 +100,7 @@ describe('TeamPlansService', () => {
'Change for 160 € per year?', 'Change for 160 € per year?',
512, 4000, 5500, 6500), 512, 4000, 5500, 6500),
], ],
hasPortal: true, locked: 'ManagedByTeam',
}, },
version: new Version('2'), version: new Version('2'),
}); });

2
frontend/src/app/features/teams/state/team-contributors.state.ts

@ -70,7 +70,7 @@ export class TeamContributorsState extends State<Snapshot> {
pageSize: 10, pageSize: 10,
total: 0, total: 0,
version: Version.EMPTY, version: Version.EMPTY,
}, 'Contributors'); }, 'Team Contributors');
} }
public loadIfNotLoaded(): Observable<any> { public loadIfNotLoaded(): Observable<any> {

12
frontend/src/app/features/teams/state/team-plans.state.spec.ts

@ -9,12 +9,11 @@ import { of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { TeamPlansService, TeamPlansState } from '@app/features/teams/internal'; import { TeamPlansService, TeamPlansState } from '@app/features/teams/internal';
import { DialogService, PlanDto, versioned } from '@app/shared'; import { DialogService, PlanDto, PlanLockedReason, versioned } from '@app/shared';
import { TestValues } from '@app/shared/state/_test-helpers'; import { TestValues } from '@app/shared/state/_test-helpers';
describe('TeamPlansState', () => { describe('TeamPlansState', () => {
const { const {
authService,
creator, creator,
newVersion, newVersion,
team, team,
@ -29,7 +28,7 @@ describe('TeamPlansState', () => {
new PlanDto('id1', 'name1', '100€', undefined, 'id1_yearly', '200€', undefined, 1, 1, 1, 1), new PlanDto('id1', 'name1', '100€', undefined, 'id1_yearly', '200€', undefined, 1, 1, 1, 1),
new PlanDto('id2', 'name2', '400€', undefined, 'id2_yearly', '800€', undefined, 2, 2, 2, 2), new PlanDto('id2', 'name2', '400€', undefined, 'id2_yearly', '800€', undefined, 2, 2, 2, 2),
], ],
hasPortal: true, locked: 'None' as PlanLockedReason,
}; };
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
@ -40,7 +39,7 @@ describe('TeamPlansState', () => {
dialogs = Mock.ofType<DialogService>(); dialogs = Mock.ofType<DialogService>();
plansService = Mock.ofType<TeamPlansService>(); plansService = Mock.ofType<TeamPlansService>();
plansState = new TeamPlansState(teamsState.object, authService.object, dialogs.object, plansService.object); plansState = new TeamPlansState(teamsState.object, dialogs.object, plansService.object);
}); });
afterEach(() => { afterEach(() => {
@ -58,9 +57,7 @@ describe('TeamPlansState', () => {
{ isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] },
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[1] }, { isSelected: false, isYearlySelected: false, plan: oldPlans.plans[1] },
]); ]);
expect(plansState.snapshot.isOwner).toBeFalsy();
expect(plansState.snapshot.isLoaded).toBeTruthy(); expect(plansState.snapshot.isLoaded).toBeTruthy();
expect(plansState.snapshot.hasPortal).toBeTruthy();
expect(plansState.snapshot.version).toEqual(version); expect(plansState.snapshot.version).toEqual(version);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
@ -76,10 +73,8 @@ describe('TeamPlansState', () => {
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] },
{ isSelected: false, isYearlySelected: true, plan: oldPlans.plans[1] }, { isSelected: false, isYearlySelected: true, plan: oldPlans.plans[1] },
]); ]);
expect(plansState.snapshot.hasPortal).toBeTruthy();
expect(plansState.snapshot.isLoaded).toBeTruthy(); expect(plansState.snapshot.isLoaded).toBeTruthy();
expect(plansState.snapshot.isLoading).toBeFalsy(); expect(plansState.snapshot.isLoading).toBeFalsy();
expect(plansState.snapshot.isOwner).toBeFalsy();
expect(plansState.snapshot.version).toEqual(version); expect(plansState.snapshot.version).toEqual(version);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
@ -144,7 +139,6 @@ describe('TeamPlansState', () => {
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] },
{ isSelected: false, isYearlySelected: true, plan: oldPlans.plans[1] }, { isSelected: false, isYearlySelected: true, plan: oldPlans.plans[1] },
]); ]);
expect(plansState.snapshot.isOwner).toBeTruthy();
expect(plansState.snapshot.version).toEqual(newVersion); expect(plansState.snapshot.version).toEqual(newVersion);
}); });
}); });

34
frontend/src/app/features/teams/state/team-plans.state.ts

@ -9,7 +9,7 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators'; import { finalize, tap } from 'rxjs/operators';
import { TeamPlansService } from '@app/features/teams/internal'; import { TeamPlansService } from '@app/features/teams/internal';
import { AuthService, DialogService, LoadingState, PlanDto, shareSubscribed, State, TeamsState, Version } from '@app/shared'; import { DialogService, LoadingState, PlanDto, PlanLockedReason, shareSubscribed, State, TeamsState, Version } from '@app/shared';
export interface PlanInfo { export interface PlanInfo {
// The plan. // The plan.
@ -26,14 +26,14 @@ interface Snapshot extends LoadingState {
// The current plans. // The current plans.
plans: ReadonlyArray<PlanInfo>; plans: ReadonlyArray<PlanInfo>;
// Indicates if the user is the plan owner.
isOwner?: boolean;
// The user, who owns the plan. // The user, who owns the plan.
planOwner?: string; planOwner?: string;
// Indicates if there is a billing portal for the current Squidex instance. // The portal link if available.
hasPortal?: boolean; portalLink?: string;
// The reason why the plan cannot be changed.
locked?: PlanLockedReason;
// The team version. // The team version.
version: Version; version: Version;
@ -47,20 +47,17 @@ export class TeamPlansState extends State<Snapshot> {
public planOwner = public planOwner =
this.project(x => x.planOwner); this.project(x => x.planOwner);
public isOwner =
this.project(x => x.isOwner === true);
public isLoaded = public isLoaded =
this.project(x => x.isLoaded === true); this.project(x => x.isLoaded === true);
public isLoading = public isLoading =
this.project(x => x.isLoading === true); this.project(x => x.isLoading === true);
public isDisabled = public locked =
this.project(x => !x.isOwner); this.project(x => x.locked);
public hasPortal = public portalLink =
this.project(x => x.hasPortal); this.project(x => x.portalLink);
public get teamId() { public get teamId() {
return this.teamsState.teamId; return this.teamsState.teamId;
@ -70,11 +67,10 @@ export class TeamPlansState extends State<Snapshot> {
constructor( constructor(
private readonly teamsState: TeamsState, private readonly teamsState: TeamsState,
private readonly authState: AuthService,
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
private readonly plansService: TeamPlansService, private readonly plansService: TeamPlansService,
) { ) {
super({ plans: [], version: Version.EMPTY }, 'Plans'); super({ plans: [], version: Version.EMPTY }, 'Teams Plans');
} }
public load(isReload = false, overridePlanId?: string): Observable<any> { public load(isReload = false, overridePlanId?: string): Observable<any> {
@ -98,12 +94,12 @@ export class TeamPlansState extends State<Snapshot> {
const plans = payload.plans.map(x => createPlan(x, planId)); const plans = payload.plans.map(x => createPlan(x, planId));
this.next({ this.next({
hasPortal: payload.hasPortal,
isLoaded: true, isLoaded: true,
isLoading: false, isLoading: false,
isOwner: !payload.planOwner || payload.planOwner === this.userId, locked: payload.locked,
planOwner: payload.planOwner, planOwner: payload.planOwner,
plans, plans,
portalLink: payload.portalLink,
version, version,
}, 'Loading Success'); }, 'Loading Success');
}), }),
@ -129,10 +125,6 @@ export class TeamPlansState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
private get userId() {
return this.authState.user!.id;
}
private get version() { private get version() {
return this.snapshot.version; return this.snapshot.version;
} }

8
frontend/src/app/shared/services/plans.service.spec.ts

@ -42,8 +42,8 @@ describe('PlansService', () => {
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({ req.flush({
teamId: 'my-team',
currentPlanId: '123', currentPlanId: '123',
portalLink: 'link/to/portal',
planOwner: '456', planOwner: '456',
plans: [ plans: [
{ {
@ -73,7 +73,7 @@ describe('PlansService', () => {
maxContributors: 6500, maxContributors: 6500,
}, },
], ],
hasPortal: true, locked: 'ManagedByTeam',
}, { }, {
headers: { headers: {
etag: '2', etag: '2',
@ -82,8 +82,8 @@ describe('PlansService', () => {
expect(plans!).toEqual({ expect(plans!).toEqual({
payload: { payload: {
teamId: 'my-team',
currentPlanId: '123', currentPlanId: '123',
portalLink: 'link/to/portal',
planOwner: '456', planOwner: '456',
plans: [ plans: [
new PlanDto( new PlanDto(
@ -99,7 +99,7 @@ describe('PlansService', () => {
'Change for 160 € per year?', 'Change for 160 € per year?',
512, 4000, 5500, 6500), 512, 4000, 5500, 6500),
], ],
hasPortal: true, locked: 'ManagedByTeam',
}, },
version: new Version('2'), version: new Version('2'),
}); });

20
frontend/src/app/shared/services/shared.ts

@ -74,6 +74,8 @@ export class PlanDto {
} }
} }
export type PlanLockedReason = 'None' | 'NotOwner' | 'NoPermission' | 'ManagedByTeam';
export type PlansDto = Versioned<PlansPayload>; export type PlansDto = Versioned<PlansPayload>;
export type PlansPayload = Readonly<{ export type PlansPayload = Readonly<{
@ -83,14 +85,14 @@ export type PlansPayload = Readonly<{
// The user, who owns the plan. // The user, who owns the plan.
planOwner: string; planOwner: string;
// True, if the installation has a billing portal.
hasPortal: boolean;
// The ID of the team.
teamId?: string | null;
// The actual plans. // The actual plans.
plans: ReadonlyArray<PlanDto>; plans: ReadonlyArray<PlanDto>;
// The portal link if available.
portalLink?: string;
// The reason why the plan cannot be changed.
locked: PlanLockedReason;
}>; }>;
export type PlanChangedDto = Readonly<{ export type PlanChangedDto = Readonly<{
@ -103,11 +105,11 @@ export type ChangePlanDto = Readonly<{
planId: string; planId: string;
}>; }>;
export function parsePlans(response: { plans: any[]; hasPortal: boolean; currentPlanId: string; planOwner: string; teamId: string | null }): PlansPayload { export function parsePlans(response: { plans: any[] } & any): PlansPayload {
const { plans: list, currentPlanId, hasPortal, planOwner, teamId } = response; const { plans: list, ...more } = response;
const plans = list.map(parsePlan); const plans = list.map(parsePlan);
return { plans, planOwner, currentPlanId, hasPortal, teamId }; return { ...more, plans };
} }
export function parsePlan(response: any) { export function parsePlan(response: any) {

12
frontend/src/app/shared/state/plans.state.spec.ts

@ -8,14 +8,13 @@
import { of, throwError } from 'rxjs'; import { of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService, PlanDto, PlansService, PlansState, versioned } from '@app/shared/internal'; import { DialogService, PlanDto, PlanLockedReason, PlansService, PlansState, versioned } from '@app/shared/internal';
import { TestValues } from './_test-helpers'; import { TestValues } from './_test-helpers';
describe('PlansState', () => { describe('PlansState', () => {
const { const {
app, app,
appsState, appsState,
authService,
creator, creator,
newVersion, newVersion,
version, version,
@ -28,7 +27,7 @@ describe('PlansState', () => {
new PlanDto('id1', 'name1', '100€', undefined, 'id1_yearly', '200€', undefined, 1, 1, 1, 1), new PlanDto('id1', 'name1', '100€', undefined, 'id1_yearly', '200€', undefined, 1, 1, 1, 1),
new PlanDto('id2', 'name2', '400€', undefined, 'id2_yearly', '800€', undefined, 2, 2, 2, 2), new PlanDto('id2', 'name2', '400€', undefined, 'id2_yearly', '800€', undefined, 2, 2, 2, 2),
], ],
hasPortal: true, locked: 'None' as PlanLockedReason,
}; };
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
@ -39,7 +38,7 @@ describe('PlansState', () => {
dialogs = Mock.ofType<DialogService>(); dialogs = Mock.ofType<DialogService>();
plansService = Mock.ofType<PlansService>(); plansService = Mock.ofType<PlansService>();
plansState = new PlansState(appsState.object, authService.object, dialogs.object, plansService.object); plansState = new PlansState(appsState.object, dialogs.object, plansService.object);
}); });
afterEach(() => { afterEach(() => {
@ -57,9 +56,7 @@ describe('PlansState', () => {
{ isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] },
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[1] }, { isSelected: false, isYearlySelected: false, plan: oldPlans.plans[1] },
]); ]);
expect(plansState.snapshot.isOwner).toBeFalsy();
expect(plansState.snapshot.isLoaded).toBeTruthy(); expect(plansState.snapshot.isLoaded).toBeTruthy();
expect(plansState.snapshot.hasPortal).toBeTruthy();
expect(plansState.snapshot.version).toEqual(version); expect(plansState.snapshot.version).toEqual(version);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
@ -75,10 +72,8 @@ describe('PlansState', () => {
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] },
{ isSelected: false, isYearlySelected: true, plan: oldPlans.plans[1] }, { isSelected: false, isYearlySelected: true, plan: oldPlans.plans[1] },
]); ]);
expect(plansState.snapshot.hasPortal).toBeTruthy();
expect(plansState.snapshot.isLoaded).toBeTruthy(); expect(plansState.snapshot.isLoaded).toBeTruthy();
expect(plansState.snapshot.isLoading).toBeFalsy(); expect(plansState.snapshot.isLoading).toBeFalsy();
expect(plansState.snapshot.isOwner).toBeFalsy();
expect(plansState.snapshot.version).toEqual(version); expect(plansState.snapshot.version).toEqual(version);
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
@ -143,7 +138,6 @@ describe('PlansState', () => {
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] }, { isSelected: false, isYearlySelected: false, plan: oldPlans.plans[0] },
{ isSelected: false, isYearlySelected: true, plan: oldPlans.plans[1] }, { isSelected: false, isYearlySelected: true, plan: oldPlans.plans[1] },
]); ]);
expect(plansState.snapshot.isOwner).toBeTruthy();
expect(plansState.snapshot.version).toEqual(newVersion); expect(plansState.snapshot.version).toEqual(newVersion);
}); });
}); });

38
frontend/src/app/shared/state/plans.state.ts

@ -9,8 +9,7 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators'; import { finalize, tap } from 'rxjs/operators';
import { DialogService, LoadingState, shareSubscribed, State, Version } from '@app/framework'; import { DialogService, LoadingState, shareSubscribed, State, Version } from '@app/framework';
import { AuthService } from './../services/auth.service'; import { PlanDto, PlanLockedReason, PlansService } from './../services/plans.service';
import { PlanDto, PlansService } from './../services/plans.service';
import { AppsState } from './apps.state'; import { AppsState } from './apps.state';
export interface PlanInfo { export interface PlanInfo {
@ -28,17 +27,14 @@ interface Snapshot extends LoadingState {
// The current plans. // The current plans.
plans: ReadonlyArray<PlanInfo>; plans: ReadonlyArray<PlanInfo>;
// Indicates if the user is the plan owner.
isOwner?: boolean;
// The user, who owns the plan. // The user, who owns the plan.
planOwner?: string; planOwner?: string;
// The ID of the team. // The portal link if available.
teamId?: string | null; portalLink?: string;
// Indicates if there is a billing portal for the current Squidex instance. // The reason why the plan cannot be changed.
hasPortal?: boolean; locked?: PlanLockedReason;
// The app version. // The app version.
version: Version; version: Version;
@ -52,23 +48,17 @@ export class PlansState extends State<Snapshot> {
public planOwner = public planOwner =
this.project(x => x.planOwner); this.project(x => x.planOwner);
public isOwner =
this.project(x => x.isOwner === true);
public teamId =
this.project(x => x.teamId);
public isLoaded = public isLoaded =
this.project(x => x.isLoaded === true); this.project(x => x.isLoaded === true);
public isLoading = public isLoading =
this.project(x => x.isLoading === true); this.project(x => x.isLoading === true);
public isDisabled = public locked =
this.project(x => !x.isOwner || x.teamId); this.project(x => x.locked);
public hasPortal = public portalLink =
this.project(x => x.hasPortal); this.project(x => x.portalLink);
public get appId() { public get appId() {
return this.appsState.appId; return this.appsState.appId;
@ -82,7 +72,6 @@ export class PlansState extends State<Snapshot> {
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly authState: AuthService,
private readonly dialogs: DialogService, private readonly dialogs: DialogService,
private readonly plansService: PlansService, private readonly plansService: PlansService,
) { ) {
@ -110,13 +99,12 @@ export class PlansState extends State<Snapshot> {
const plans = payload.plans.map(x => createPlan(x, planId)); const plans = payload.plans.map(x => createPlan(x, planId));
this.next({ this.next({
hasPortal: payload.hasPortal, locked: payload.locked,
isLoaded: true, isLoaded: true,
isLoading: false, isLoading: false,
isOwner: !payload.planOwner || payload.planOwner === this.userId,
planOwner: payload.planOwner, planOwner: payload.planOwner,
plans, plans,
teamId: payload.teamId, portalLink: payload.portalLink,
version, version,
}, 'Loading Success'); }, 'Loading Success');
}), }),
@ -142,10 +130,6 @@ export class PlansState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
private get userId() {
return this.authState.user!.id;
}
private get version() { private get version() {
return this.snapshot.version; return this.snapshot.version;
} }

Loading…
Cancel
Save