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. 104
      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. 57
      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. 92
      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);
}
public static string DisplayName(this IAppEntity app)
{
return app.Label.Or(app.Name);
}
public static bool TryGetContributorRole(this IAppEntity app, string id, bool isFrontend, [MaybeNullWhen(false)] out Role role)
{
role = null;

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

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

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

@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
case TransferToTeam transfer:
return UpdateReturnAsync(transfer, async (c, ct) =>
{
await GuardApp.CanTransfer(c, Snapshot, AppProvider(), ct);
await GuardApp.CanTransfer(c, Snapshot, AppProvider, ct);
Transfer(c);
@ -128,7 +128,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
case AssignContributor assignContributor:
return UpdateReturnAsync(assignContributor, async (c, ct) =>
{
await GuardAppContributors.CanAssign(c, Snapshot, Users(), GetPlan());
await GuardAppContributors.CanAssign(c, Snapshot, Users, Plan);
AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
@ -268,66 +268,52 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
case DeleteApp delete:
return UpdateAsync(delete, async (c, ct) =>
{
await BillingManager().UnsubscribeAsync(c.Actor.Identifier, Snapshot.NamedId(), default);
await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default);
DeleteApp(c);
}, ct);
case ChangePlan changePlan:
return ChangeBillingPlanAsync(changePlan, ct);
default:
ThrowHelper.NotSupportedException();
return default!;
}
}
private async Task<CommandResult> ChangeBillingPlanAsync(ChangePlan changePlan,
CancellationToken ct)
{
var userId = changePlan.Actor.Identifier;
return UpdateReturnAsync(changePlan, async (c, ct) =>
{
GuardApp.CanChangePlan(c, Snapshot, BillingPlans);
var result = await UpdateReturnAsync(changePlan, async (c, ct) =>
{
GuardApp.CanChangePlan(c, Snapshot, BillingPlans());
if (string.Equals(FreePlan?.Id, c.PlanId, StringComparison.Ordinal))
{
if (!c.FromCallback)
{
await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default);
}
if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal))
{
ResetPlan(c);
ResetPlan(c);
return new PlanChangedResult(c.PlanId, true, null);
}
return new PlanChangedResult(c.PlanId, true, null);
}
else
{
if (!c.FromCallback)
{
var redirectUri = await BillingManager.MustRedirectToPortalAsync(c.Actor.Identifier, Snapshot, c.PlanId, ct);
if (!c.FromCallback)
{
var redirectUri = await BillingManager().MustRedirectToPortalAsync(userId, Snapshot.NamedId(), c.PlanId, ct);
if (redirectUri != null)
{
return new PlanChangedResult(c.PlanId, false, redirectUri);
}
if (redirectUri != null)
{
return new PlanChangedResult(c.PlanId, false, redirectUri);
}
}
await BillingManager.SubscribeAsync(c.Actor.Identifier, Snapshot, changePlan.PlanId, default);
}
ChangePlan(c);
ChangePlan(c);
return new PlanChangedResult(c.PlanId);
}, ct);
return new PlanChangedResult(c.PlanId);
}
if (changePlan.FromCallback)
{
return result;
}
}, ct);
if (result.Payload is PlanChangedResult { Unsubscribed: true, RedirectUri: null })
{
await BillingManager().UnsubscribeAsync(userId, Snapshot.NamedId(), default);
}
else if (result.Payload is PlanChangedResult { RedirectUri: null })
{
await BillingManager().SubscribeAsync(userId, Snapshot.NamedId(), changePlan.PlanId, default);
default:
ThrowHelper.NotSupportedException();
return default!;
}
return result;
}
private void Create(CreateApp command)
@ -480,34 +466,34 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
RaiseEvent(Envelope.Create(@event));
}
private IAppProvider AppProvider()
private IAppProvider AppProvider
{
return serviceProvider.GetRequiredService<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.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
namespace Squidex.Domain.Apps.Entities.Billing
{
public interface IBillingManager
{
bool HasPortal { get; }
Task<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);
Task<Uri?> MustRedirectToPortalAsync(string userId, DomainId teamId, string? planId,
Task<Uri?> MustRedirectToPortalAsync(string userId, IAppEntity app, string? planId,
CancellationToken ct = default);
Task SubscribeAsync(string userId, NamedId<DomainId> appId, string planId,
Task<Uri?> MustRedirectToPortalAsync(string userId, ITeamEntity team, string? planId,
CancellationToken ct = default);
Task SubscribeAsync(string userId, DomainId teamId, string planId,
Task SubscribeAsync(string userId, IAppEntity app, string planId,
CancellationToken ct = default);
Task UnsubscribeAsync(string userId, NamedId<DomainId> appId,
Task SubscribeAsync(string userId, ITeamEntity team, string planId,
CancellationToken ct = default);
Task UnsubscribeAsync(string userId, DomainId teamId,
Task UnsubscribeAsync(string userId, IAppEntity app,
CancellationToken ct = default);
Task<string> GetPortalLinkAsync(string userId,
Task UnsubscribeAsync(string userId, ITeamEntity team,
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 string AppName { get; init; }
public long Usage { 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.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
namespace Squidex.Domain.Apps.Entities.Billing
{
public sealed class NoopBillingManager : IBillingManager
{
public bool HasPortal
public Task<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)
{
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)
{
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)
{
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)
{
return Task.CompletedTask;
}
public Task SubscribeAsync(string userId, DomainId teamId, string planId,
public Task SubscribeAsync(string userId, ITeamEntity team, string planId,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task UnsubscribeAsync(string userId, NamedId<DomainId> appId,
public Task UnsubscribeAsync(string userId, IAppEntity app,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task UnsubscribeAsync(string userId, DomainId teamId,
public Task UnsubscribeAsync(string userId, ITeamEntity team,
CancellationToken ct = default)
{
return Task.CompletedTask;

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

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

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

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

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

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)
{
var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues);
var @enum = builder.GetEnumeration(args.EmbeddedEnumType, field.Properties.AllowedValues);
if (@enum != null)
{
@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
if (field.Properties?.AllowedValues?.Count > 0 && field.Properties.CreateEnum)
{
var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues);
var @enum = builder.GetEnumeration(args.EmbeddedEnumType, field.Properties.AllowedValues);
if (@enum != null)
{

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)
{
var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues);
var @enum = builder.GetEnumeration(args.EmbeddedEnumType, field.Properties.AllowedValues);
if (@enum != null)
{
@ -217,7 +217,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents
if (field.Properties?.AllowedValues?.Count > 0 && field.Properties.CreateEnum)
{
var @enum = builder.GetEnumeration(args.EnumName, field.Properties.AllowedValues);
var @enum = builder.GetEnumeration(args.EmbeddedEnumType, field.Properties.AllowedValues);
if (@enum != null)
{

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

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

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

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

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

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

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

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

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.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications
{
public interface INotificationSender
public interface IUserNotifications
{
bool IsActive { get; }
Task SendUsageAsync(IUser user, string appName, long usage, long usageLimit);
Task SendUsageAsync(IUser user, IAppEntity app, long usage, long usageLimit,
CancellationToken ct = default);
Task SendInviteAsync(IUser assigner, IUser user, string appName);
Task SendInviteAsync(IUser assigner, IUser user, IAppEntity app,
CancellationToken ct = default);
Task SendTeamInviteAsync(IUser assigner, IUser user, string teamName);
Task SendInviteAsync(IUser assigner, IUser user, ITeamEntity team,
CancellationToken ct = default);
}
}

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.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications
{
public sealed class NoopNotificationSender : INotificationSender
public sealed class NoopUserNotifications : IUserNotifications
{
public bool IsActive
{
get => false;
}
public Task SendInviteAsync(IUser assigner, IUser user, string appName)
public Task SendInviteAsync(IUser assigner, IUser user, IAppEntity app,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task SendTeamInviteAsync(IUser assigner, IUser user, string teamName)
public Task SendInviteAsync(IUser assigner, IUser user, ITeamEntity team,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task SendUsageAsync(IUser user, string appName, long usage, long limit)
public Task SendUsageAsync(IUser user, IAppEntity app, long usage, long limit,
CancellationToken ct = default)
{
return Task.CompletedTask;
}

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

@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
case AssignContributor assignContributor:
return UpdateReturnAsync(assignContributor, async (c, ct) =>
{
await GuardTeamContributors.CanAssign(c, Snapshot, Users());
await GuardTeamContributors.CanAssign(c, Snapshot, Users);
AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
@ -95,60 +95,46 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
}, ct);
case ChangePlan changePlan:
return ChangeBillingPlanAsync(changePlan, ct);
default:
ThrowHelper.NotSupportedException();
return default!;
}
}
private async Task<CommandResult> ChangeBillingPlanAsync(ChangePlan changePlan,
CancellationToken ct)
{
var userId = changePlan.Actor.Identifier;
return UpdateReturnAsync(changePlan, async (c, ct) =>
{
GuardTeam.CanChangePlan(c, BillingPlans);
var result = await UpdateReturnAsync(changePlan, async (c, ct) =>
{
GuardTeam.CanChangePlan(c, BillingPlans());
if (string.Equals(FreePlan?.Id, c.PlanId, StringComparison.Ordinal))
{
if (!c.FromCallback)
{
await BillingManager.UnsubscribeAsync(c.Actor.Identifier, Snapshot, default);
}
if (string.Equals(GetFreePlan()?.Id, c.PlanId, StringComparison.Ordinal))
{
ResetPlan(c);
ResetPlan(c);
return new PlanChangedResult(c.PlanId, true, null);
}
return new PlanChangedResult(c.PlanId, true, null);
}
else
{
if (!c.FromCallback)
{
var redirectUri = await BillingManager.MustRedirectToPortalAsync(c.Actor.Identifier, Snapshot, c.PlanId, ct);
if (!c.FromCallback)
{
var redirectUri = await BillingManager().MustRedirectToPortalAsync(userId, UniqueId, c.PlanId, ct);
if (redirectUri != null)
{
return new PlanChangedResult(c.PlanId, false, redirectUri);
}
if (redirectUri != null)
{
return new PlanChangedResult(c.PlanId, false, redirectUri);
}
}
await BillingManager.SubscribeAsync(c.Actor.Identifier, Snapshot, changePlan.PlanId, default);
}
ChangePlan(c);
ChangePlan(c);
return new PlanChangedResult(c.PlanId);
}, ct);
return new PlanChangedResult(c.PlanId);
}
if (changePlan.FromCallback)
{
return result;
}
}, ct);
if (result.Payload is PlanChangedResult { Unsubscribed: true, RedirectUri: null })
{
await BillingManager().UnsubscribeAsync(userId, UniqueId, default);
}
else if (result.Payload is PlanChangedResult { RedirectUri: null })
{
await BillingManager().SubscribeAsync(userId, UniqueId, changePlan.PlanId, default);
default:
ThrowHelper.NotSupportedException();
return default!;
}
return result;
}
private void Create(CreateTeam command)
@ -202,24 +188,24 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
RaiseEvent(Envelope.Create(@event));
}
private IBillingPlans BillingPlans()
private IBillingPlans BillingPlans
{
return serviceProvider.GetRequiredService<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 PrefixPortal = "/portal";
public const string PrefixIdentityServer = "/identity-server";
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);
// Plans
public bool CanChangePlan => Can(PermissionIds.AppPlansChange);
public bool CanChangeTeamPlan => Can(PermissionIds.TeamPlansChange);
// Backups
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 SchemasOpenApiGenerator schemasOpenApiGenerator;
public ContentOpenApiController(ICommandBus commandBus, IAppProvider appProvider, SchemasOpenApiGenerator schemasOpenApiGenerator)
public ContentOpenApiController(ICommandBus commandBus, IAppProvider appProvider,
SchemasOpenApiGenerator schemasOpenApiGenerator)
: base(commandBus)
{
this.appProvider = appProvider;
this.schemasOpenApiGenerator = schemasOpenApiGenerator;
}

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

@ -53,13 +53,37 @@ namespace Squidex.Areas.Api.Controllers.Plans
[ApiCosts(0)]
public IActionResult GetPlans(string app)
{
var hasPortal = billingManager.HasPortal;
var response = Deferred.AsyncResponse(async () =>
{
var (_, planId, _) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var owner = App.Plan?.Owner.Identifier;
var (_, planId, teamId) = await appUsageGate.GetPlanForAppAsync(App, HttpContext.RequestAborted);
var lockedReason = PlansLockedReason.None;
if (teamId != null)
{
lockedReason = PlansLockedReason.ManagedByTeam;
}
else if (!Resources.CanChangePlan)
{
lockedReason = PlansLockedReason.NoPermission;
}
else if (owner != null && !string.Equals(owner, UserId, StringComparison.OrdinalIgnoreCase))
{
lockedReason = PlansLockedReason.NotOwner;
}
var linkUrl = (Uri?)null;
if (lockedReason == PlansLockedReason.None)
{
linkUrl = await billingManager.GetPortalLinkAsync(UserId, App, HttpContext.RequestAborted);
}
var plans = billingPlans.GetAvailablePlans();
return PlansDto.FromDomain(App, billingPlans, planId, hasPortal);
return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, lockedReason);
});
Response.Headers[HeaderNames.ETag] = App.ToEtag();

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

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

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)]
[ApiPermissionOrAnonymous(PermissionIds.TeamPlansRead)]
[ApiCosts(0)]
public IActionResult GetPlans(string team)
public IActionResult GetTeamPlans(string team)
{
var hasPortal = billingManager.HasPortal;
var response = Deferred.AsyncResponse(async () =>
{
var owner = Team.Plan?.Owner.Identifier;
var (_, planId) = await appUsageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
return PlansDto.FromDomain(Team, billingPlans, planId, hasPortal);
var lockedReason = PlansLockedReason.None;
if (!Resources.CanChangeTeamPlan)
{
lockedReason = PlansLockedReason.NoPermission;
}
else if (owner != null && !string.Equals(owner, UserId, StringComparison.OrdinalIgnoreCase))
{
lockedReason = PlansLockedReason.NotOwner;
}
var linkUrl = (Uri?)null;
if (lockedReason == PlansLockedReason.None)
{
linkUrl = await billingManager.GetPortalLinkAsync(UserId, Team, HttpContext.RequestAborted);
}
var plans = billingPlans.GetAvailablePlans();
return PlansDto.FromDomain(plans.ToArray(), owner, planId, linkUrl, lockedReason);
});
Response.Headers[HeaderNames.ETag] = Team.ToEtag();
@ -81,7 +101,7 @@ namespace Squidex.Areas.Api.Controllers.Plans
[ProducesResponseType(typeof(PlanChangedDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.TeamPlansChange)]
[ApiCosts(0)]
public async Task<IActionResult> PutPlan(string team, [FromBody] ChangePlanDto request)
public async Task<IActionResult> PutTeamPlan(string team, [FromBody] ChangePlanDto request)
{
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))
{
AddGetLink("plans",
resources.Url<TeamPlansController>(x => nameof(x.GetPlans), values));
resources.Url<TeamPlansController>(x => nameof(x.GetTeamPlans), values));
}
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.Configure<NotificationEmailTextOptions>(config,
services.Configure<EmailUserNotificationOptions>(config,
"email:notifications");
services.AddSingletonAs<SmtpEmailSender>()
.As<IEmailSender>();
services.AddSingletonAs<NotificationEmailSender>()
.AsOptional<INotificationSender>();
services.AddSingletonAs<EmailUserNotifications>()
.AsOptional<IUserNotifications>();
}
else
{
services.AddSingletonAs<NoopNotificationSender>()
.AsOptional<INotificationSender>();
services.AddSingletonAs<NoopUserNotifications>()
.AsOptional<IUserNotifications>();
}
services.AddSingletonAs<InvitationEventConsumer>()

7
backend/src/Squidex/Startup.cs

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

20
backend/src/Squidex/appsettings.json

@ -176,18 +176,30 @@
"port": 587
},
"notifications": {
// The email subject when a new user is added as contributor.
// The email subject when a new user is added as contributor to an app.
"newUserSubject": "You have been invited to join Project $APP_NAME at Squidex CMS",
// The email body when a new user is added as contributor.
// The email body when a new user is added as contributor to an app.
"newUserBody": "Welcome to Squidex\r\nDear User,\r\n\r\n$ASSIGNER_NAME ($ASSIGNER_EMAIL) has invited you to join Project (also called an App) $APP_NAME at Squidex Headless CMS. Login with your Github, Google or Microsoft credentials to create a new user account and start editing content now.\r\n\r\nThank you very much,\r\nThe Squidex Team\r\n\r\n<<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",
// 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]",
// 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
"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))
.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));
A.CallTo(() => appProvider.GetTeamAsync(teamId, default))
@ -227,7 +227,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{
var command = new ChangePlan { PlanId = planIdPaid };
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.Returns(Task.FromResult<Uri?>(null));
await ExecuteCreateAsync();
@ -243,10 +243,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
CreateEvent(new AppPlanChanged { PlanId = planIdPaid })
);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.MustHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, AppNamedId, planIdPaid, default))
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.MustHaveHappened();
}
@ -268,10 +268,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
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();
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();
}
@ -294,10 +294,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
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();
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<NamedId<DomainId>>._, A<CancellationToken>._))
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<IAppEntity>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -320,10 +320,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
CreateEvent(new AppPlanReset())
);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.MustHaveHappenedOnceExactly();
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<NamedId<DomainId>>._, A<CancellationToken>._))
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<IAppEntity>._, A<CancellationToken>._))
.MustHaveHappened();
}
@ -332,7 +332,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
{
var command = new ChangePlan { PlanId = planIdPaid };
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, default))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, default))
.Returns(new Uri("http://squidex.io"));
await ExecuteCreateAsync();
@ -357,10 +357,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
Assert.Equal(planIdPaid, sut.Snapshot.Plan?.PlanId);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, AppNamedId, planIdPaid, A<CancellationToken>._))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, A<CancellationToken>._))
.MustNotHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, AppNamedId, planIdPaid, A<CancellationToken>._))
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A<IAppEntity>._, planIdPaid, A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -714,7 +714,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
CreateEvent(new AppDeleted())
);
A.CallTo(() => billingManager.UnsubscribeAsync(command.Actor.Identifier, AppNamedId, default))
A.CallTo(() => billingManager.UnsubscribeAsync(command.Actor.Identifier, A<IAppEntity>._, default))
.MustHaveHappened();
}

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

@ -5,7 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Billing
@ -15,47 +16,49 @@ namespace Squidex.Domain.Apps.Entities.Billing
private readonly NoopBillingManager sut = new NoopBillingManager();
[Fact]
public void Should_not_have_portal()
public async Task Should_do_nothing_if_subscribing_to_app()
{
Assert.False(sut.HasPortal);
await sut.SubscribeAsync(null!, (IAppEntity)null!, null!);
}
[Fact]
public async Task Should_do_nothing_if_subscribing()
public async Task Should_do_nothing_if_subscribing_to_team()
{
await sut.SubscribeAsync(null!, null!, null!);
await sut.SubscribeAsync(null!, (ITeamEntity)null!, null!);
}
[Fact]
public async Task Should_do_nothing_if_subscribing_to_team()
public async Task Should_do_nothing_if_unsubscribing_from_app()
{
await sut.SubscribeAsync(null!, default(DomainId), null!);
await sut.UnsubscribeAsync(null!, (IAppEntity)null!);
}
[Fact]
public async Task Should_do_nothing_if_unsubscribing()
public async Task Should_do_nothing_if_unsubscribing_from_team()
{
await sut.UnsubscribeAsync(null!, null!);
await sut.UnsubscribeAsync(null!, (ITeamEntity)null!);
}
[Fact]
public async Task Should_do_nothing_if_unsubscribing_from_team()
public async Task Should_not_return_portal_link_for_app()
{
await sut.UnsubscribeAsync(null!, default(DomainId));
var actual = await sut.GetPortalLinkAsync(null!, (IAppEntity)null!);
Assert.Null(actual);
}
[Fact]
public async Task Should_not_return_portal_link()
public async Task Should_not_return_portal_link_for_team()
{
var actual = await sut.GetPortalLinkAsync(null!);
var actual = await sut.GetPortalLinkAsync(null!, (ITeamEntity)null!);
Assert.Empty(actual);
Assert.Null(actual);
}
[Fact]
public async Task Should_do_nothing_if_checking_for_redirect()
public async Task Should_do_nothing_if_checking_for_redirect_for_app()
{
var actual = await sut.MustRedirectToPortalAsync(null!, null!, null);
var actual = await sut.MustRedirectToPortalAsync(null!, (IAppEntity)null!, null);
Assert.Null(actual);
}
@ -63,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.Billing
[Fact]
public async Task Should_do_nothing_if_checking_for_redirect_for_team()
{
var actual = await sut.MustRedirectToPortalAsync(null!, default(DomainId), null);
var actual = await sut.MustRedirectToPortalAsync(null!, (ITeamEntity)null!, null);
Assert.Null(actual);
}

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

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

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

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

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

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

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

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 Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Email;
using Squidex.Shared.Users;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Notifications
{
public class NotificationEmailSenderTests
public class EmailUserNotificationsTests
{
private readonly IEmailSender emailSender = A.Fake<IEmailSender>();
private readonly IUrlGenerator urlGenerator = A.Fake<IUrlGenerator>();
private readonly IUser assigner = UserMocks.User("1", "1@email.com", "user1");
private readonly IUser assigned = UserMocks.User("2", "2@email.com", "user2");
private readonly ILogger<NotificationEmailSender> log = A.Fake<ILogger<NotificationEmailSender>>();
private readonly string appName = "my-app";
private readonly string appUI = "my-ui";
private readonly NotificationEmailTextOptions texts = new NotificationEmailTextOptions();
private readonly NotificationEmailSender sut;
private readonly ILogger<EmailUserNotifications> log = A.Fake<ILogger<EmailUserNotifications>>();
private readonly IAppEntity app = Mocks.App(NamedId.Of(DomainId.NewGuid(), "my-app"));
private readonly ITeamEntity team = Mocks.Team(DomainId.NewGuid());
private readonly EmailUserNotificationOptions texts = new EmailUserNotificationOptions();
private readonly EmailUserNotifications sut;
public NotificationEmailSenderTests()
public EmailUserNotificationsTests()
{
A.CallTo(() => urlGenerator.UI())
.Returns(appUI);
.Returns("my-ui");
sut = new NotificationEmailSender(Options.Create(texts), emailSender, urlGenerator, log);
sut = new EmailUserNotifications(Options.Create(texts), emailSender, urlGenerator, log);
}
[Fact]
@ -85,9 +89,9 @@ namespace Squidex.Domain.Apps.Entities.Notifications
}
[Fact]
public async Task Should_not_send_invitation_email_if_texts_for_new_user_are_empty()
public async Task Should_not_send_app_invitation_email_if_texts_for_new_user_are_empty()
{
await sut.SendInviteAsync(assigner, assigned, appName);
await sut.SendInviteAsync(assigner, assigned, app);
A.CallTo(() => emailSender.SendAsync(assigned.Email, A<string>._, A<string>._, A<CancellationToken>._))
.MustNotHaveHappened();
@ -96,9 +100,31 @@ namespace Squidex.Domain.Apps.Entities.Notifications
}
[Fact]
public async Task Should_not_send_invitation_email_if_texts_for_existing_user_are_empty()
public async Task Should_not_send_text_invitation_email_if_texts_for_new_user_are_empty()
{
await sut.SendInviteAsync(assigner, assigned, appName);
await sut.SendInviteAsync(assigner, assigned, team);
A.CallTo(() => emailSender.SendAsync(assigned.Email, A<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>._))
.MustNotHaveHappened();
@ -109,7 +135,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
[Fact]
public async Task Should_not_send_usage_email_if_texts_empty()
{
await sut.SendUsageAsync(assigned, appName, 100, 120);
await sut.SendUsageAsync(assigned, app, 100, 120);
A.CallTo(() => emailSender.SendAsync(assigned.Email, A<string>._, A<string>._, A<CancellationToken>._))
.MustNotHaveHappened();
@ -118,28 +144,56 @@ namespace Squidex.Domain.Apps.Entities.Notifications
}
[Fact]
public async Task Should_not_send_invitation_email_if_no_consent_given()
public async Task Should_not_send_app_invitation_email_if_no_consent_given()
{
var withoutConsent = UserMocks.User("2", "2@email.com", "user", false);
texts.ExistingUserSubject = "email-subject";
texts.ExistingUserBody = "email-body";
await sut.SendInviteAsync(assigner, withoutConsent, appName);
await sut.SendInviteAsync(assigner, withoutConsent, app);
A.CallTo(() => emailSender.SendAsync(withoutConsent.Email, "email-subject", "email-body", A<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>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_send_invitation_email_if_consent_given()
public async Task Should_send_app_invitation_email_if_consent_given()
{
var withConsent = UserMocks.User("2", "2@email.com", "user", true);
texts.ExistingUserSubject = "email-subject";
texts.ExistingUserBody = "email-body";
await sut.SendInviteAsync(assigner, withConsent, appName);
await sut.SendInviteAsync(assigner, withConsent, app);
A.CallTo(() => emailSender.SendAsync(withConsent.Email, "email-subject", "email-body", A<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>._))
.MustHaveHappened();
@ -150,7 +204,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
texts.UsageSubject = pattern;
texts.UsageBody = pattern;
await sut.SendUsageAsync(assigned, appName, 100, 120);
await sut.SendUsageAsync(assigned, app, 100, 120);
A.CallTo(() => emailSender.SendAsync(assigned.Email, actual, actual, A<CancellationToken>._))
.MustHaveHappened();
@ -161,7 +215,7 @@ namespace Squidex.Domain.Apps.Entities.Notifications
texts.NewUserSubject = pattern;
texts.NewUserBody = pattern;
await sut.SendInviteAsync(assigner, assigned, appName);
await sut.SendInviteAsync(assigner, assigned, app);
A.CallTo(() => emailSender.SendAsync(assigned.Email, actual, actual, A<CancellationToken>._))
.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))
.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));
var serviceProvider =
@ -130,7 +130,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
{
var command = new ChangePlan { PlanId = planPaid.Id };
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.Returns(Task.FromResult<Uri?>(null));
await ExecuteCreateAsync();
@ -146,10 +146,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
CreateTeamEvent(new TeamPlanChanged { PlanId = planPaid.Id })
);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.MustHaveHappened();
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, teamId, planPaid.Id, default))
A.CallTo(() => billingManager.SubscribeAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.MustHaveHappened();
}
@ -171,10 +171,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
CreateTeamEvent(new TeamPlanChanged { PlanId = planPaid.Id })
);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<DomainId>._, A<string?>._, A<CancellationToken>._))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(A<string>._, A<ITeamEntity>._, A<string?>._, A<CancellationToken>._))
.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();
}
@ -197,10 +197,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
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();
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<DomainId>._, A<CancellationToken>._))
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<ITeamEntity>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
@ -223,10 +223,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
CreateTeamEvent(new TeamPlanReset())
);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.MustHaveHappenedOnceExactly();
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, teamId, A<CancellationToken>._))
A.CallTo(() => billingManager.UnsubscribeAsync(A<string>._, A<ITeamEntity>._, A<CancellationToken>._))
.MustHaveHappened();
}
@ -235,7 +235,7 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
{
var command = new ChangePlan { PlanId = planPaid.Id };
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, default))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, default))
.Returns(new Uri("http://squidex.io"));
await ExecuteCreateAsync();
@ -260,10 +260,10 @@ namespace Squidex.Domain.Apps.Entities.Teams.DomainObject
Assert.Equal(planPaid.Id, sut.Snapshot.Plan?.PlanId);
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, teamId, planPaid.Id, A<CancellationToken>._))
A.CallTo(() => billingManager.MustRedirectToPortalAsync(Actor.Identifier, A<ITeamEntity>._, planPaid.Id, A<CancellationToken>._))
.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();
}

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

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

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

@ -25,7 +25,7 @@
&#10003; {{ 'plans.selected' | sqxTranslate }}
</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()"
confirmRememberKey="changePlan"
confirmTitle="i18n:plans.changeConfirmTitle"
@ -45,7 +45,7 @@
&#10003; {{ 'plans.selected' | sqxTranslate }}
</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()"
confirmTitle="i18n:plans.changeConfirmTitle"
[confirmText]="planInfo.plan.yearlyConfirmText!"

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

@ -12,11 +12,11 @@
<ng-container>
<sqx-list-view innerWidth="60rem" [isLoading]="plansState.isLoading | async">
<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>
</div>
<div class="alert alert-danger" *ngIf="(plansState.teamId | async) !== null">
<div class="alert alert-danger" *ngIf="(plansState.locked | async) === 'ManagedByTeam'">
{{ 'plans.managedByTeam' | sqxTranslate }}
</div>
@ -31,8 +31,8 @@
</div>
</div>
<div *ngIf="plansState.hasPortal | async" class="billing-portal-link">
{{ 'plans.billingPortalHint' | sqxTranslate }} <a [href]="portalUrl" sqxExternalLink>{{ 'plans.billingPortal' | sqxTranslate }}</a>
<div *ngIf="plansState.portalLink| async; let portalLink" class="billing-portal-link">
{{ 'plans.billingPortalHint' | sqxTranslate }} <a [href]="portalLink" sqxExternalLink>{{ 'plans.billingPortal' | sqxTranslate }}</a>
</div>
</div>
</ng-container>

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

@ -25,7 +25,7 @@
&#10003; {{ 'plans.selected' | sqxTranslate }}
</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()"
confirmRememberKey="changePlan"
confirmTitle="i18n:plans.changeConfirmTitle"
@ -45,7 +45,7 @@
&#10003; {{ 'plans.selected' | sqxTranslate }}
</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()"
confirmTitle="i18n:plans.changeConfirmTitle"
[confirmText]="planInfo.plan.yearlyConfirmText!"

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

@ -27,8 +27,8 @@
</div>
</div>
<div *ngIf="plansState.hasPortal | async" class="billing-portal-link">
{{ 'plans.billingPortalHint' | sqxTranslate }} <a [href]="portalUrl" sqxExternalLink>{{ 'plans.billingPortal' | sqxTranslate }}</a>
<div *ngIf="plansState.portalLink| async; let portalLink" class="billing-portal-link">
{{ 'plans.billingPortalHint' | sqxTranslate }} <a [href]="portalLink" sqxExternalLink>{{ 'plans.billingPortal' | sqxTranslate }}</a>
</div>
</div>
</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();
req.flush({
teamId: 'my-team',
currentPlanId: '123',
portalLink: 'link/to/portal',
planOwner: '456',
plans: [
{
@ -74,7 +74,7 @@ describe('TeamPlansService', () => {
maxContributors: 6500,
},
],
hasPortal: true,
locked: 'ManagedByTeam',
}, {
headers: {
etag: '2',
@ -83,8 +83,8 @@ describe('TeamPlansService', () => {
expect(plans!).toEqual({
payload: {
teamId: 'my-team',
currentPlanId: '123',
portalLink: 'link/to/portal',
planOwner: '456',
plans: [
new PlanDto(
@ -100,7 +100,7 @@ describe('TeamPlansService', () => {
'Change for 160 € per year?',
512, 4000, 5500, 6500),
],
hasPortal: true,
locked: 'ManagedByTeam',
},
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,
total: 0,
version: Version.EMPTY,
}, 'Contributors');
}, 'Team Contributors');
}
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 { IMock, It, Mock, Times } from 'typemoq';
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';
describe('TeamPlansState', () => {
const {
authService,
creator,
newVersion,
team,
@ -29,7 +28,7 @@ describe('TeamPlansState', () => {
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),
],
hasPortal: true,
locked: 'None' as PlanLockedReason,
};
let dialogs: IMock<DialogService>;
@ -40,7 +39,7 @@ describe('TeamPlansState', () => {
dialogs = Mock.ofType<DialogService>();
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(() => {
@ -58,9 +57,7 @@ describe('TeamPlansState', () => {
{ isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] },
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[1] },
]);
expect(plansState.snapshot.isOwner).toBeFalsy();
expect(plansState.snapshot.isLoaded).toBeTruthy();
expect(plansState.snapshot.hasPortal).toBeTruthy();
expect(plansState.snapshot.version).toEqual(version);
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: true, plan: oldPlans.plans[1] },
]);
expect(plansState.snapshot.hasPortal).toBeTruthy();
expect(plansState.snapshot.isLoaded).toBeTruthy();
expect(plansState.snapshot.isLoading).toBeFalsy();
expect(plansState.snapshot.isOwner).toBeFalsy();
expect(plansState.snapshot.version).toEqual(version);
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: true, plan: oldPlans.plans[1] },
]);
expect(plansState.snapshot.isOwner).toBeTruthy();
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 { finalize, tap } from 'rxjs/operators';
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 {
// The plan.
@ -26,14 +26,14 @@ interface Snapshot extends LoadingState {
// The current plans.
plans: ReadonlyArray<PlanInfo>;
// Indicates if the user is the plan owner.
isOwner?: boolean;
// The user, who owns the plan.
planOwner?: string;
// Indicates if there is a billing portal for the current Squidex instance.
hasPortal?: boolean;
// The portal link if available.
portalLink?: string;
// The reason why the plan cannot be changed.
locked?: PlanLockedReason;
// The team version.
version: Version;
@ -47,20 +47,17 @@ export class TeamPlansState extends State<Snapshot> {
public planOwner =
this.project(x => x.planOwner);
public isOwner =
this.project(x => x.isOwner === true);
public isLoaded =
this.project(x => x.isLoaded === true);
public isLoading =
this.project(x => x.isLoading === true);
public isDisabled =
this.project(x => !x.isOwner);
public locked =
this.project(x => x.locked);
public hasPortal =
this.project(x => x.hasPortal);
public portalLink =
this.project(x => x.portalLink);
public get teamId() {
return this.teamsState.teamId;
@ -70,11 +67,10 @@ export class TeamPlansState extends State<Snapshot> {
constructor(
private readonly teamsState: TeamsState,
private readonly authState: AuthService,
private readonly dialogs: DialogService,
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> {
@ -98,12 +94,12 @@ export class TeamPlansState extends State<Snapshot> {
const plans = payload.plans.map(x => createPlan(x, planId));
this.next({
hasPortal: payload.hasPortal,
isLoaded: true,
isLoading: false,
isOwner: !payload.planOwner || payload.planOwner === this.userId,
locked: payload.locked,
planOwner: payload.planOwner,
plans,
portalLink: payload.portalLink,
version,
}, 'Loading Success');
}),
@ -129,10 +125,6 @@ export class TeamPlansState extends State<Snapshot> {
shareSubscribed(this.dialogs));
}
private get userId() {
return this.authState.user!.id;
}
private get 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();
req.flush({
teamId: 'my-team',
currentPlanId: '123',
portalLink: 'link/to/portal',
planOwner: '456',
plans: [
{
@ -73,7 +73,7 @@ describe('PlansService', () => {
maxContributors: 6500,
},
],
hasPortal: true,
locked: 'ManagedByTeam',
}, {
headers: {
etag: '2',
@ -82,8 +82,8 @@ describe('PlansService', () => {
expect(plans!).toEqual({
payload: {
teamId: 'my-team',
currentPlanId: '123',
portalLink: 'link/to/portal',
planOwner: '456',
plans: [
new PlanDto(
@ -99,7 +99,7 @@ describe('PlansService', () => {
'Change for 160 € per year?',
512, 4000, 5500, 6500),
],
hasPortal: true,
locked: 'ManagedByTeam',
},
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 PlansPayload = Readonly<{
@ -83,14 +85,14 @@ export type PlansPayload = Readonly<{
// The user, who owns the plan.
planOwner: string;
// True, if the installation has a billing portal.
hasPortal: boolean;
// The ID of the team.
teamId?: string | null;
// The actual plans.
plans: ReadonlyArray<PlanDto>;
// The portal link if available.
portalLink?: string;
// The reason why the plan cannot be changed.
locked: PlanLockedReason;
}>;
export type PlanChangedDto = Readonly<{
@ -103,11 +105,11 @@ export type ChangePlanDto = Readonly<{
planId: string;
}>;
export function parsePlans(response: { plans: any[]; hasPortal: boolean; currentPlanId: string; planOwner: string; teamId: string | null }): PlansPayload {
const { plans: list, currentPlanId, hasPortal, planOwner, teamId } = response;
export function parsePlans(response: { plans: any[] } & any): PlansPayload {
const { plans: list, ...more } = response;
const plans = list.map(parsePlan);
return { plans, planOwner, currentPlanId, hasPortal, teamId };
return { ...more, plans };
}
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 { onErrorResumeNext } from 'rxjs/operators';
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';
describe('PlansState', () => {
const {
app,
appsState,
authService,
creator,
newVersion,
version,
@ -28,7 +27,7 @@ describe('PlansState', () => {
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),
],
hasPortal: true,
locked: 'None' as PlanLockedReason,
};
let dialogs: IMock<DialogService>;
@ -39,7 +38,7 @@ describe('PlansState', () => {
dialogs = Mock.ofType<DialogService>();
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(() => {
@ -57,9 +56,7 @@ describe('PlansState', () => {
{ isSelected: true, isYearlySelected: false, plan: oldPlans.plans[0] },
{ isSelected: false, isYearlySelected: false, plan: oldPlans.plans[1] },
]);
expect(plansState.snapshot.isOwner).toBeFalsy();
expect(plansState.snapshot.isLoaded).toBeTruthy();
expect(plansState.snapshot.hasPortal).toBeTruthy();
expect(plansState.snapshot.version).toEqual(version);
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: true, plan: oldPlans.plans[1] },
]);
expect(plansState.snapshot.hasPortal).toBeTruthy();
expect(plansState.snapshot.isLoaded).toBeTruthy();
expect(plansState.snapshot.isLoading).toBeFalsy();
expect(plansState.snapshot.isOwner).toBeFalsy();
expect(plansState.snapshot.version).toEqual(version);
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: true, plan: oldPlans.plans[1] },
]);
expect(plansState.snapshot.isOwner).toBeTruthy();
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 { finalize, tap } from 'rxjs/operators';
import { DialogService, LoadingState, shareSubscribed, State, Version } from '@app/framework';
import { AuthService } from './../services/auth.service';
import { PlanDto, PlansService } from './../services/plans.service';
import { PlanDto, PlanLockedReason, PlansService } from './../services/plans.service';
import { AppsState } from './apps.state';
export interface PlanInfo {
@ -28,17 +27,14 @@ interface Snapshot extends LoadingState {
// The current plans.
plans: ReadonlyArray<PlanInfo>;
// Indicates if the user is the plan owner.
isOwner?: boolean;
// The user, who owns the plan.
planOwner?: string;
// The ID of the team.
teamId?: string | null;
// The portal link if available.
portalLink?: string;
// Indicates if there is a billing portal for the current Squidex instance.
hasPortal?: boolean;
// The reason why the plan cannot be changed.
locked?: PlanLockedReason;
// The app version.
version: Version;
@ -52,23 +48,17 @@ export class PlansState extends State<Snapshot> {
public planOwner =
this.project(x => x.planOwner);
public isOwner =
this.project(x => x.isOwner === true);
public teamId =
this.project(x => x.teamId);
public isLoaded =
this.project(x => x.isLoaded === true);
public isLoading =
this.project(x => x.isLoading === true);
public isDisabled =
this.project(x => !x.isOwner || x.teamId);
public locked =
this.project(x => x.locked);
public hasPortal =
this.project(x => x.hasPortal);
public portalLink =
this.project(x => x.portalLink);
public get appId() {
return this.appsState.appId;
@ -82,7 +72,6 @@ export class PlansState extends State<Snapshot> {
constructor(
private readonly appsState: AppsState,
private readonly authState: AuthService,
private readonly dialogs: DialogService,
private readonly plansService: PlansService,
) {
@ -110,13 +99,12 @@ export class PlansState extends State<Snapshot> {
const plans = payload.plans.map(x => createPlan(x, planId));
this.next({
hasPortal: payload.hasPortal,
locked: payload.locked,
isLoaded: true,
isLoading: false,
isOwner: !payload.planOwner || payload.planOwner === this.userId,
planOwner: payload.planOwner,
plans,
teamId: payload.teamId,
portalLink: payload.portalLink,
version,
}, 'Loading Success');
}),
@ -142,10 +130,6 @@ export class PlansState extends State<Snapshot> {
shareSubscribed(this.dialogs));
}
private get userId() {
return this.authState.user!.id;
}
private get version() {
return this.snapshot.version;
}

Loading…
Cancel
Save