mirror of https://github.com/Squidex/squidex.git
Browse Source
* Started with teams. * Fixes and tests. * Update tests. * More fixes. * More test fixes. * Consistent command usage. * Started with frontend. * More progress. * More UI * Fix tests. * More tests and texts. * Fix tests.pull/921/head
committed by
GitHub
582 changed files with 13267 additions and 4671 deletions
@ -0,0 +1,38 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using MongoDB.Bson.Serialization.Attributes; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.Teams.DomainObject; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Teams |
|||
{ |
|||
public sealed class MongoTeamEntity : MongoState<TeamDomainObject.State> |
|||
{ |
|||
[BsonRequired] |
|||
[BsonElement("_ui")] |
|||
public string[] IndexedUserIds { get; set; } |
|||
|
|||
[BsonIgnoreIfDefault] |
|||
[BsonElement("_ct")] |
|||
public Instant IndexedCreated { get; set; } |
|||
|
|||
public override void Prepare() |
|||
{ |
|||
var users = new HashSet<string> |
|||
{ |
|||
Document.CreatedBy.Identifier |
|||
}; |
|||
|
|||
users.AddRange(Document.Contributors.Keys); |
|||
|
|||
IndexedUserIds = users.ToArray(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,61 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using MongoDB.Driver; |
|||
using Squidex.Domain.Apps.Entities.Teams; |
|||
using Squidex.Domain.Apps.Entities.Teams.DomainObject; |
|||
using Squidex.Domain.Apps.Entities.Teams.Repositories; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Teams |
|||
{ |
|||
public sealed class MongoTeamRepository : MongoSnapshotStoreBase<TeamDomainObject.State, MongoTeamEntity>, ITeamRepository |
|||
{ |
|||
public MongoTeamRepository(IMongoDatabase database) |
|||
: base(database) |
|||
{ |
|||
} |
|||
|
|||
protected override Task SetupCollectionAsync(IMongoCollection<MongoTeamEntity> collection, |
|||
CancellationToken ct) |
|||
{ |
|||
return collection.Indexes.CreateManyAsync(new[] |
|||
{ |
|||
new CreateIndexModel<MongoTeamEntity>( |
|||
Index |
|||
.Ascending(x => x.IndexedUserIds)) |
|||
}, ct); |
|||
} |
|||
|
|||
public async Task<List<ITeamEntity>> QueryAllAsync(string contributorId, |
|||
CancellationToken ct = default) |
|||
{ |
|||
using (Telemetry.Activities.StartActivity("MongoTeamRepository/QueryAllAsync")) |
|||
{ |
|||
var entities = |
|||
await Collection.Find(x => x.IndexedUserIds.Contains(contributorId)) |
|||
.ToListAsync(ct); |
|||
|
|||
return entities.Select(x => (ITeamEntity)x.Document).ToList(); |
|||
} |
|||
} |
|||
|
|||
public async Task<ITeamEntity?> FindAsync(DomainId id, |
|||
CancellationToken ct = default) |
|||
{ |
|||
using (Telemetry.Activities.StartActivity("MongoTeamRepository/FindAsync")) |
|||
{ |
|||
var entity = |
|||
await Collection.Find(x => x.DocumentId == id) |
|||
.FirstOrDefaultAsync(ct); |
|||
|
|||
return entity?.Document; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
|
|||
#pragma warning disable MA0048 // File name must match type name
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Commands |
|||
{ |
|||
public abstract class AppCommand : AppCommandBase, IAppCommand |
|||
{ |
|||
public NamedId<DomainId> AppId { get; set; } |
|||
|
|||
public override DomainId AggregateId |
|||
{ |
|||
get => AppId.Id; |
|||
} |
|||
} |
|||
|
|||
// This command is needed as marker for middlewares.
|
|||
public abstract class AppCommandBase : SquidexCommand, IAggregateCommand |
|||
{ |
|||
public abstract DomainId AggregateId { get; } |
|||
} |
|||
} |
|||
@ -1,96 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Logging; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.Notifications; |
|||
using Squidex.Domain.Apps.Events.Apps; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Invitation |
|||
{ |
|||
public sealed class InvitationEventConsumer : IEventConsumer |
|||
{ |
|||
private static readonly Duration MaxAge = Duration.FromDays(2); |
|||
private readonly INotificationSender emailSender; |
|||
private readonly IUserResolver userResolver; |
|||
private readonly ILogger<InvitationEventConsumer> log; |
|||
|
|||
public string Name |
|||
{ |
|||
get => "NotificationEmailSender"; |
|||
} |
|||
|
|||
public string EventsFilter |
|||
{ |
|||
get { return "^app-"; } |
|||
} |
|||
|
|||
public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver, |
|||
ILogger<InvitationEventConsumer> log) |
|||
{ |
|||
this.emailSender = emailSender; |
|||
this.userResolver = userResolver; |
|||
|
|||
this.log = log; |
|||
} |
|||
|
|||
public async Task On(Envelope<IEvent> @event) |
|||
{ |
|||
if (!emailSender.IsActive) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (@event.Headers.EventStreamNumber() <= 1) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var now = SystemClock.Instance.GetCurrentInstant(); |
|||
|
|||
var timestamp = @event.Headers.Timestamp(); |
|||
|
|||
if (now - timestamp > MaxAge) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (@event.Payload is AppContributorAssigned appContributorAssigned) |
|||
{ |
|||
if (!appContributorAssigned.Actor.IsUser || !appContributorAssigned.IsAdded) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var assignerId = appContributorAssigned.Actor.Identifier; |
|||
var assigneeId = appContributorAssigned.ContributorId; |
|||
|
|||
var assigner = await userResolver.FindByIdAsync(assignerId); |
|||
|
|||
if (assigner == null) |
|||
{ |
|||
log.LogWarning("Failed to invite user: Assigner {assignerId} not found.", assignerId); |
|||
return; |
|||
} |
|||
|
|||
var assignee = await userResolver.FindByIdAsync(appContributorAssigned.ContributorId); |
|||
|
|||
if (assignee == null) |
|||
{ |
|||
log.LogWarning("Failed to invite user: Assignee {assigneeId} not found.", assigneeId); |
|||
return; |
|||
} |
|||
|
|||
var appName = appContributorAssigned.AppId.Name; |
|||
|
|||
await emailSender.SendInviteAsync(assigner, assignee, appName); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,65 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Apps.Commands; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Invitation |
|||
{ |
|||
public sealed class InviteUserCommandMiddleware : ICommandMiddleware |
|||
{ |
|||
private readonly IUserResolver userResolver; |
|||
|
|||
public InviteUserCommandMiddleware(IUserResolver userResolver) |
|||
{ |
|||
this.userResolver = userResolver; |
|||
} |
|||
|
|||
public async Task HandleAsync(CommandContext context, NextDelegate next, |
|||
CancellationToken ct) |
|||
{ |
|||
if (context.Command is AssignContributor assignContributor && ShouldResolve(assignContributor)) |
|||
{ |
|||
IUser? user; |
|||
|
|||
var created = false; |
|||
|
|||
if (assignContributor.Invite) |
|||
{ |
|||
(user, created) = await userResolver.CreateUserIfNotExistsAsync(assignContributor.ContributorId, true, ct); |
|||
} |
|||
else |
|||
{ |
|||
user = await userResolver.FindByIdOrEmailAsync(assignContributor.ContributorId, ct); |
|||
} |
|||
|
|||
if (user != null) |
|||
{ |
|||
assignContributor.ContributorId = user.Id; |
|||
} |
|||
|
|||
await next(context, ct); |
|||
|
|||
if (created && context.PlainResult is IAppEntity app) |
|||
{ |
|||
context.Complete(new InvitedResult { App = app }); |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
await next(context, ct); |
|||
} |
|||
} |
|||
|
|||
private static bool ShouldResolve(AssignContributor assignContributor) |
|||
{ |
|||
return assignContributor.ContributorId.IsEmail(); |
|||
} |
|||
} |
|||
} |
|||
@ -1,43 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Plans |
|||
{ |
|||
public sealed class ConfigAppLimitsPlan : IAppLimitsPlan |
|||
{ |
|||
public string Id { get; set; } |
|||
|
|||
public string Name { get; set; } |
|||
|
|||
public string Costs { get; set; } |
|||
|
|||
public string? ConfirmText { get; set; } |
|||
|
|||
public string? YearlyCosts { get; set; } |
|||
|
|||
public string? YearlyId { get; set; } |
|||
|
|||
public string? YearlyConfirmText { get; set; } |
|||
|
|||
public long BlockingApiCalls { get; set; } |
|||
|
|||
public long MaxApiCalls { get; set; } |
|||
|
|||
public long MaxApiBytes { get; set; } |
|||
|
|||
public long MaxAssetSize { get; set; } |
|||
|
|||
public int MaxContributors { get; set; } |
|||
|
|||
public bool IsFree { get; set; } |
|||
|
|||
public ConfigAppLimitsPlan Clone() |
|||
{ |
|||
return (ConfigAppLimitsPlan)MemberwiseClone(); |
|||
} |
|||
} |
|||
} |
|||
@ -1,107 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Plans |
|||
{ |
|||
public sealed class ConfigAppPlansProvider : IAppPlansProvider |
|||
{ |
|||
private static readonly ConfigAppLimitsPlan Infinite = new ConfigAppLimitsPlan |
|||
{ |
|||
Id = "infinite", |
|||
Name = "Infinite", |
|||
MaxApiCalls = -1, |
|||
MaxAssetSize = -1, |
|||
MaxContributors = -1, |
|||
BlockingApiCalls = -1 |
|||
}; |
|||
|
|||
private readonly Dictionary<string, ConfigAppLimitsPlan> plansById = new Dictionary<string, ConfigAppLimitsPlan>(StringComparer.OrdinalIgnoreCase); |
|||
private readonly List<ConfigAppLimitsPlan> plansList = new List<ConfigAppLimitsPlan>(); |
|||
private readonly ConfigAppLimitsPlan freePlan; |
|||
|
|||
public ConfigAppPlansProvider(IEnumerable<ConfigAppLimitsPlan> config) |
|||
{ |
|||
foreach (var plan in config.OrderBy(x => x.MaxApiCalls).Select(x => x.Clone())) |
|||
{ |
|||
plansList.Add(plan); |
|||
plansById[plan.Id] = plan; |
|||
|
|||
if (!string.IsNullOrWhiteSpace(plan.YearlyId) && !string.IsNullOrWhiteSpace(plan.YearlyCosts)) |
|||
{ |
|||
plansById[plan.YearlyId] = plan; |
|||
} |
|||
} |
|||
|
|||
freePlan = plansList.Find(x => x.IsFree) ?? Infinite; |
|||
} |
|||
|
|||
public IEnumerable<IAppLimitsPlan> GetAvailablePlans() |
|||
{ |
|||
return plansList; |
|||
} |
|||
|
|||
public bool IsConfiguredPlan(string? planId) |
|||
{ |
|||
return planId != null && plansById.ContainsKey(planId); |
|||
} |
|||
|
|||
public IAppLimitsPlan? GetPlan(string? planId) |
|||
{ |
|||
return plansById.GetValueOrDefault(planId ?? string.Empty); |
|||
} |
|||
|
|||
public IAppLimitsPlan GetFreePlan() |
|||
{ |
|||
return freePlan; |
|||
} |
|||
|
|||
public IAppLimitsPlan? GetPlanUpgradeForApp(IAppEntity app) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
return GetPlanUpgrade(app.Plan?.PlanId); |
|||
} |
|||
|
|||
public IAppLimitsPlan? GetPlanUpgrade(string? planId) |
|||
{ |
|||
var plan = GetPlanCore(planId); |
|||
|
|||
var nextPlanIndex = plansList.IndexOf(plan); |
|||
|
|||
if (nextPlanIndex >= 0 && nextPlanIndex < plansList.Count - 1) |
|||
{ |
|||
return plansList[nextPlanIndex + 1]; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
public (IAppLimitsPlan Plan, string PlanId) GetPlanForApp(IAppEntity app) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
var planId = app.Plan?.PlanId; |
|||
var plan = GetPlanCore(planId); |
|||
|
|||
if (plan.YearlyId != null && plan.YearlyId == planId) |
|||
{ |
|||
return (plan, plan.YearlyId); |
|||
} |
|||
else |
|||
{ |
|||
return (plan, plan.Id); |
|||
} |
|||
} |
|||
|
|||
private ConfigAppLimitsPlan GetPlanCore(string? planId) |
|||
{ |
|||
return plansById.GetValueOrDefault(planId ?? string.Empty) ?? freePlan; |
|||
} |
|||
} |
|||
} |
|||
@ -1,36 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Plans |
|||
{ |
|||
public interface IAppLimitsPlan |
|||
{ |
|||
string Id { get; } |
|||
|
|||
string Name { get; } |
|||
|
|||
string Costs { get; } |
|||
|
|||
string? ConfirmText { get; } |
|||
|
|||
string? YearlyCosts { get; } |
|||
|
|||
string? YearlyId { get; } |
|||
|
|||
string? YearlyConfirmText { get; } |
|||
|
|||
long BlockingApiCalls { get; } |
|||
|
|||
long MaxApiCalls { get; } |
|||
|
|||
long MaxApiBytes { get; } |
|||
|
|||
long MaxAssetSize { get; } |
|||
|
|||
int MaxContributors { get; } |
|||
} |
|||
} |
|||
@ -1,26 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Plans |
|||
{ |
|||
public interface IAppPlansProvider |
|||
{ |
|||
IEnumerable<IAppLimitsPlan> GetAvailablePlans(); |
|||
|
|||
bool IsConfiguredPlan(string? planId); |
|||
|
|||
IAppLimitsPlan? GetPlanUpgradeForApp(IAppEntity app); |
|||
|
|||
IAppLimitsPlan? GetPlanUpgrade(string? planId); |
|||
|
|||
IAppLimitsPlan? GetPlan(string? planId); |
|||
|
|||
IAppLimitsPlan GetFreePlan(); |
|||
|
|||
(IAppLimitsPlan Plan, string PlanId) GetPlanForApp(IAppEntity app); |
|||
} |
|||
} |
|||
@ -1,107 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Caching.Memory; |
|||
using Microsoft.Extensions.Options; |
|||
using Squidex.Domain.Apps.Core.Apps; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.UsageTracking; |
|||
using Squidex.Messaging; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Apps.Plans |
|||
{ |
|||
public class UsageGate |
|||
{ |
|||
private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); |
|||
private readonly IAppPlansProvider appPlansProvider; |
|||
private readonly IApiUsageTracker apiUsageTracker; |
|||
private readonly IMessageBus messaging; |
|||
|
|||
public UsageGate(IAppPlansProvider appPlansProvider, IApiUsageTracker apiUsageTracker, IMessageBus messaging) |
|||
{ |
|||
this.appPlansProvider = appPlansProvider; |
|||
this.apiUsageTracker = apiUsageTracker; |
|||
this.messaging = messaging; |
|||
} |
|||
|
|||
public virtual async Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime today, |
|||
CancellationToken ct = default) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
var (plan, _) = appPlansProvider.GetPlanForApp(app); |
|||
|
|||
var appId = app.Id; |
|||
var blocking = false; |
|||
var blockLimit = plan.MaxApiCalls; |
|||
|
|||
if (blockLimit > 0 || plan.BlockingApiCalls > 0) |
|||
{ |
|||
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, null, ct); |
|||
|
|||
if (IsOver10Percent(blockLimit, usage) && IsAboutToBeLocked(today, blockLimit, usage) && !HasNotifiedBefore(app.Id)) |
|||
{ |
|||
var notification = new UsageTrackingCheck |
|||
{ |
|||
AppId = appId, |
|||
AppName = app.Name, |
|||
Usage = usage, |
|||
UsageLimit = blockLimit, |
|||
Users = GetUsers(app) |
|||
}; |
|||
|
|||
await messaging.PublishAsync(notification, ct: ct); |
|||
|
|||
TrackNotified(appId); |
|||
} |
|||
|
|||
blocking = plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls; |
|||
} |
|||
|
|||
if (!blocking) |
|||
{ |
|||
if (clientId != null && app.Clients.TryGetValue(clientId, out var client) && client.ApiCallsLimit > 0) |
|||
{ |
|||
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), today, clientId, ct); |
|||
|
|||
blocking = usage >= client.ApiCallsLimit; |
|||
} |
|||
} |
|||
|
|||
return blocking; |
|||
} |
|||
|
|||
private bool HasNotifiedBefore(DomainId appId) |
|||
{ |
|||
return memoryCache.Get<bool>(appId); |
|||
} |
|||
|
|||
private bool TrackNotified(DomainId appId) |
|||
{ |
|||
return memoryCache.Set(appId, true, TimeSpan.FromHours(1)); |
|||
} |
|||
|
|||
private static string[] GetUsers(IAppEntity app) |
|||
{ |
|||
return app.Contributors.Where(x => x.Value == Role.Owner).Select(x => x.Key).ToArray(); |
|||
} |
|||
|
|||
private static bool IsOver10Percent(long limit, long usage) |
|||
{ |
|||
return usage > limit * 0.1; |
|||
} |
|||
|
|||
private static bool IsAboutToBeLocked(DateTime today, long limit, long usage) |
|||
{ |
|||
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month); |
|||
|
|||
var forecasted = ((float)usage / today.Day) * daysInMonth; |
|||
|
|||
return forecasted > limit; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Billing |
|||
{ |
|||
public sealed class ConfigPlansProvider : IBillingPlans |
|||
{ |
|||
private static readonly Plan Infinite = new Plan |
|||
{ |
|||
Id = "infinite", |
|||
Name = "Infinite", |
|||
MaxApiCalls = -1, |
|||
MaxAssetSize = -1, |
|||
MaxContributors = -1, |
|||
BlockingApiCalls = -1 |
|||
}; |
|||
|
|||
private readonly Dictionary<string, Plan> plansById = new Dictionary<string, Plan>(StringComparer.OrdinalIgnoreCase); |
|||
private readonly List<Plan> plans = new List<Plan>(); |
|||
private readonly Plan freePlan; |
|||
|
|||
public ConfigPlansProvider(IEnumerable<Plan> config) |
|||
{ |
|||
plans.AddRange(config.OrderBy(x => x.MaxApiCalls)); |
|||
|
|||
foreach (var plan in config.OrderBy(x => x.MaxApiCalls)) |
|||
{ |
|||
plansById[plan.Id] = plan; |
|||
|
|||
if (!string.IsNullOrWhiteSpace(plan.YearlyId) && !string.IsNullOrWhiteSpace(plan.YearlyCosts)) |
|||
{ |
|||
plansById[plan.YearlyId] = plan; |
|||
} |
|||
} |
|||
|
|||
freePlan = config.FirstOrDefault(x => x.IsFree) ?? Infinite; |
|||
} |
|||
|
|||
public IEnumerable<Plan> GetAvailablePlans() |
|||
{ |
|||
return plans; |
|||
} |
|||
|
|||
public bool IsConfiguredPlan(string? planId) |
|||
{ |
|||
return planId != null && plansById.ContainsKey(planId); |
|||
} |
|||
|
|||
public Plan? GetPlan(string? planId) |
|||
{ |
|||
return plansById.GetValueOrDefault(planId ?? string.Empty); |
|||
} |
|||
|
|||
public Plan GetFreePlan() |
|||
{ |
|||
return freePlan; |
|||
} |
|||
|
|||
public (Plan Plan, string PlanId) GetActualPlan(string? planId) |
|||
{ |
|||
if (planId == null || !plansById.TryGetValue(planId, out var plan)) |
|||
{ |
|||
var result = GetFreePlan(); |
|||
|
|||
return (result, result.Id); |
|||
} |
|||
|
|||
if (plan.YearlyId != null && plan.YearlyId == planId) |
|||
{ |
|||
return (plan, plan.YearlyId); |
|||
} |
|||
else |
|||
{ |
|||
return (plan, plan.Id); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Entities.Teams; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Billing |
|||
{ |
|||
public interface IAppUsageGate |
|||
{ |
|||
Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date, |
|||
CancellationToken ct = default); |
|||
|
|||
Task TrackRequestAsync(IAppEntity app, string? clientId, DateTime date, double costs, long elapsedMs, long bytes, |
|||
CancellationToken ct = default); |
|||
|
|||
Task TrackAssetAsync(DomainId appId, DateTime date, long fileSize, long count, |
|||
CancellationToken ct = default); |
|||
|
|||
Task DeleteAssetUsageAsync(DomainId appId, |
|||
CancellationToken ct = default); |
|||
|
|||
Task DeleteAssetsUsageAsync( |
|||
CancellationToken ct = default); |
|||
|
|||
Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(IAppEntity app, |
|||
CancellationToken ct = default); |
|||
|
|||
Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(DomainId appId, |
|||
CancellationToken ct = default); |
|||
|
|||
Task<(Plan Plan, string PlanId)> GetPlanForTeamAsync(ITeamEntity team, |
|||
CancellationToken ct = default); |
|||
} |
|||
} |
|||
@ -0,0 +1,22 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Billing |
|||
{ |
|||
public interface IBillingPlans |
|||
{ |
|||
IEnumerable<Plan> GetAvailablePlans(); |
|||
|
|||
bool IsConfiguredPlan(string? planId); |
|||
|
|||
Plan? GetPlan(string? planId); |
|||
|
|||
Plan GetFreePlan(); |
|||
|
|||
(Plan Plan, string PlanId) GetActualPlan(string? planId); |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Billing |
|||
{ |
|||
public sealed record Plan |
|||
{ |
|||
public string Id { get; init; } |
|||
|
|||
public string Name { get; init; } |
|||
|
|||
public string Costs { get; init; } |
|||
|
|||
public string? ConfirmText { get; init; } |
|||
|
|||
public string? YearlyCosts { get; init; } |
|||
|
|||
public string? YearlyId { get; init; } |
|||
|
|||
public string? YearlyConfirmText { get; init; } |
|||
|
|||
public long BlockingApiCalls { get; init; } |
|||
|
|||
public long MaxApiCalls { get; init; } |
|||
|
|||
public long MaxApiBytes { get; init; } |
|||
|
|||
public long MaxAssetSize { get; init; } |
|||
|
|||
public long MaxContributors { get; init; } |
|||
|
|||
public bool IsFree { get; init; } |
|||
} |
|||
} |
|||
@ -0,0 +1,315 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Caching.Memory; |
|||
using Microsoft.Extensions.Options; |
|||
using Squidex.Domain.Apps.Core.Apps; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Entities.Assets; |
|||
using Squidex.Domain.Apps.Entities.Teams; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.UsageTracking; |
|||
using Squidex.Messaging; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Billing |
|||
{ |
|||
public sealed class UsageGate : IAppUsageGate, IAssetUsageTracker |
|||
{ |
|||
private const string CounterTotalCount = "TotalAssets"; |
|||
private const string CounterTotalSize = "TotalSize"; |
|||
private static readonly DateTime SummaryDate = default; |
|||
private readonly IBillingPlans billingPlans; |
|||
private readonly IAppProvider appProvider; |
|||
private readonly IApiUsageTracker apiUsageTracker; |
|||
private readonly IMemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); |
|||
private readonly IMessageBus messaging; |
|||
private readonly IUsageTracker usageTracker; |
|||
|
|||
public UsageGate( |
|||
IAppProvider appProvider, |
|||
IApiUsageTracker apiUsageTracker, |
|||
IBillingPlans billingPlans, |
|||
IMessageBus messaging, |
|||
IUsageTracker usageTracker) |
|||
{ |
|||
this.appProvider = appProvider; |
|||
this.apiUsageTracker = apiUsageTracker; |
|||
this.billingPlans = billingPlans; |
|||
this.messaging = messaging; |
|||
this.usageTracker = usageTracker; |
|||
} |
|||
|
|||
public Task DeleteAssetUsageAsync(DomainId appId, |
|||
CancellationToken ct = default) |
|||
{ |
|||
// Do not delete the team, as this is only called when an app is deleted.
|
|||
return usageTracker.DeleteAsync(AppAssetsKey(appId), ct); |
|||
} |
|||
|
|||
public Task DeleteAssetsUsageAsync( |
|||
CancellationToken ct = default) |
|||
{ |
|||
// Use a well defined prefix query for the deletion to improve performance.
|
|||
return usageTracker.DeleteByKeyPatternAsync("^([a-zA-Z0-9]+)_Assets", ct); |
|||
} |
|||
|
|||
public Task<long> GetTotalSizeByAppAsync(DomainId appId, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return GetTotalSizeAsync(AppAssetsKey(appId), ct); |
|||
} |
|||
|
|||
public Task<long> GetTotalSizeByTeamAsync(DomainId teamId, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return GetTotalSizeAsync(TeamAssetsKey(teamId), ct); |
|||
} |
|||
|
|||
private async Task<long> GetTotalSizeAsync(string key, |
|||
CancellationToken ct) |
|||
{ |
|||
var counters = await usageTracker.GetAsync(key, SummaryDate, SummaryDate, null, ct); |
|||
|
|||
return counters.GetInt64(CounterTotalSize); |
|||
} |
|||
|
|||
public Task<IReadOnlyList<AssetStats>> QueryByAppAsync(DomainId appId, DateTime fromDate, DateTime toDate, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return QueryAsync(AppAssetsKey(appId), fromDate, toDate, ct); |
|||
} |
|||
|
|||
public Task<IReadOnlyList<AssetStats>> QueryByTeamAsync(DomainId teamId, DateTime fromDate, DateTime toDate, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return QueryAsync(TeamAssetsKey(teamId), fromDate, toDate, ct); |
|||
} |
|||
|
|||
private async Task<IReadOnlyList<AssetStats>> QueryAsync(string key, DateTime fromDate, DateTime toDate, |
|||
CancellationToken ct) |
|||
{ |
|||
var enriched = new List<AssetStats>(); |
|||
|
|||
var usages = await usageTracker.QueryAsync(key, fromDate, toDate, ct); |
|||
|
|||
if (usages.TryGetValue(usageTracker.FallbackCategory, out var byCategory1)) |
|||
{ |
|||
AddCounters(enriched, byCategory1); |
|||
} |
|||
|
|||
return enriched; |
|||
} |
|||
|
|||
private static void AddCounters(List<AssetStats> enriched, List<(DateTime, Counters)> details) |
|||
{ |
|||
foreach (var (date, counters) in details) |
|||
{ |
|||
var totalCount = counters.GetInt64(CounterTotalCount); |
|||
var totalSize = counters.GetInt64(CounterTotalSize); |
|||
|
|||
enriched.Add(new AssetStats(date, totalCount, totalSize)); |
|||
} |
|||
} |
|||
|
|||
public async Task TrackRequestAsync(IAppEntity app, string? clientId, DateTime date, double costs, long elapsedMs, long bytes, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var appId = app.Id.ToString(); |
|||
|
|||
if (app.TeamId != null) |
|||
{ |
|||
await apiUsageTracker.TrackAsync(date, app.TeamId.ToString()!, app.Name, costs, elapsedMs, bytes, ct); |
|||
} |
|||
|
|||
await apiUsageTracker.TrackAsync(date, appId, clientId, costs, elapsedMs, bytes, ct); |
|||
} |
|||
|
|||
public async Task<bool> IsBlockedAsync(IAppEntity app, string? clientId, DateTime date, |
|||
CancellationToken ct = default) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
// Resolve the plan from either the app or the assigned team.
|
|||
var (plan, _, teamId) = await GetPlanForAppAsync(app, ct); |
|||
|
|||
var appId = app.Id; |
|||
var blocking = false; |
|||
var blockLimit = plan.MaxApiCalls; |
|||
var referenceId = teamId ?? app.Id; |
|||
|
|||
if (blockLimit > 0 || plan.BlockingApiCalls > 0) |
|||
{ |
|||
var usage = await apiUsageTracker.GetMonthCallsAsync(referenceId.ToString(), date, null, ct); |
|||
|
|||
if (IsOver10Percent(blockLimit, usage) && IsAboutToBeLocked(date, blockLimit, usage) && !HasNotifiedBefore(appId)) |
|||
{ |
|||
var notification = new UsageTrackingCheck |
|||
{ |
|||
AppId = appId, |
|||
AppName = app.Name, |
|||
Usage = usage, |
|||
UsageLimit = blockLimit, |
|||
Users = GetUsers(app) |
|||
}; |
|||
|
|||
await messaging.PublishAsync(notification, ct: ct); |
|||
|
|||
TrackNotified(appId); |
|||
} |
|||
|
|||
blocking = plan.BlockingApiCalls > 0 && usage > plan.BlockingApiCalls; |
|||
} |
|||
|
|||
if (!blocking) |
|||
{ |
|||
if (clientId != null && app.Clients.TryGetValue(clientId, out var client) && client.ApiCallsLimit > 0) |
|||
{ |
|||
var usage = await apiUsageTracker.GetMonthCallsAsync(appId.ToString(), date, clientId, ct); |
|||
|
|||
blocking = usage >= client.ApiCallsLimit; |
|||
} |
|||
} |
|||
|
|||
return blocking; |
|||
} |
|||
|
|||
private bool HasNotifiedBefore(DomainId appId) |
|||
{ |
|||
return memoryCache.Get<bool>(appId); |
|||
} |
|||
|
|||
private bool TrackNotified(DomainId appId) |
|||
{ |
|||
return memoryCache.Set(appId, true, TimeSpan.FromHours(1)); |
|||
} |
|||
|
|||
private static string[] GetUsers(IAppEntity app) |
|||
{ |
|||
return app.Contributors.Where(x => x.Value == Role.Owner).Select(x => x.Key).ToArray(); |
|||
} |
|||
|
|||
private static bool IsOver10Percent(long limit, long usage) |
|||
{ |
|||
return usage > limit * 0.1; |
|||
} |
|||
|
|||
private static bool IsAboutToBeLocked(DateTime today, long limit, long usage) |
|||
{ |
|||
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month); |
|||
|
|||
var forecasted = ((float)usage / today.Day) * daysInMonth; |
|||
|
|||
return forecasted > limit; |
|||
} |
|||
|
|||
public async Task TrackAssetAsync(DomainId appId, DateTime date, long fileSize, long count, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var counters = new Counters |
|||
{ |
|||
[CounterTotalSize] = fileSize, |
|||
[CounterTotalCount] = count |
|||
}; |
|||
|
|||
var appKey = AppAssetsKey(appId); |
|||
|
|||
var tasks = new List<Task> |
|||
{ |
|||
usageTracker.TrackAsync(date, appKey, null, counters, ct), |
|||
usageTracker.TrackAsync(SummaryDate, appKey, null, counters, ct) |
|||
}; |
|||
|
|||
var (_, _, teamId) = await GetPlanForAppAsync(appId, ct); |
|||
|
|||
if (teamId != null) |
|||
{ |
|||
var teamKey = TeamAssetsKey(teamId.Value); |
|||
|
|||
tasks.Add(usageTracker.TrackAsync(date, teamKey, null, counters, ct)); |
|||
tasks.Add(usageTracker.TrackAsync(SummaryDate, teamKey, null, counters, ct)); |
|||
} |
|||
|
|||
await Task.WhenAll(tasks); |
|||
} |
|||
|
|||
public Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(IAppEntity app, |
|||
CancellationToken ct = default) |
|||
{ |
|||
Guard.NotNull(app); |
|||
|
|||
return memoryCache.GetOrCreateAsync(app, async x => |
|||
{ |
|||
x.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); |
|||
|
|||
return await GetPlanCoreAsync(app, ct); |
|||
}); |
|||
} |
|||
|
|||
public Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanForAppAsync(DomainId appId, |
|||
CancellationToken ct = default) |
|||
{ |
|||
return memoryCache.GetOrCreateAsync(appId, async x => |
|||
{ |
|||
x.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10); |
|||
|
|||
return await GetPlanCoreAsync(appId, ct); |
|||
}); |
|||
} |
|||
|
|||
private async Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanCoreAsync(DomainId appId, |
|||
CancellationToken ct) |
|||
{ |
|||
var app = await appProvider.GetAppAsync(appId, true, ct); |
|||
|
|||
if (app == null) |
|||
{ |
|||
var freePlan = billingPlans.GetFreePlan(); |
|||
|
|||
return (freePlan, freePlan.Id, null); |
|||
} |
|||
|
|||
return await GetPlanCoreAsync(app, ct); |
|||
} |
|||
|
|||
private async Task<(Plan Plan, string PlanId, DomainId? TeamId)> GetPlanCoreAsync(IAppEntity app, |
|||
CancellationToken ct) |
|||
{ |
|||
if (app.TeamId != null) |
|||
{ |
|||
var team = await appProvider.GetTeamAsync(app.TeamId.Value, ct); |
|||
|
|||
var (plan, planId) = billingPlans.GetActualPlan(team?.Plan?.PlanId ?? app.Plan?.PlanId); |
|||
|
|||
return (plan, planId, team?.Id); |
|||
} |
|||
else |
|||
{ |
|||
var (plan, planId) = billingPlans.GetActualPlan(app.Plan?.PlanId); |
|||
|
|||
return (plan, planId, null); |
|||
} |
|||
} |
|||
|
|||
public Task<(Plan Plan, string PlanId)> GetPlanForTeamAsync(ITeamEntity team, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var (plan, planId) = billingPlans.GetActualPlan(team?.Plan?.PlanId); |
|||
|
|||
return Task.FromResult((plan, planId)); |
|||
} |
|||
|
|||
private static string AppAssetsKey(DomainId appId) |
|||
{ |
|||
return $"{appId}_Assets"; |
|||
} |
|||
|
|||
private static string TeamAssetsKey(DomainId appId) |
|||
{ |
|||
return $"{appId}_TeamAssets"; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,131 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Logging; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Entities.Notifications; |
|||
using Squidex.Domain.Apps.Events.Apps; |
|||
using Squidex.Domain.Apps.Events.Teams; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Invitation |
|||
{ |
|||
public sealed class InvitationEventConsumer : IEventConsumer |
|||
{ |
|||
private static readonly Duration MaxAge = Duration.FromDays(2); |
|||
private readonly INotificationSender emailSender; |
|||
private readonly IUserResolver userResolver; |
|||
private readonly IAppProvider appProvider; |
|||
private readonly ILogger<InvitationEventConsumer> log; |
|||
|
|||
public string Name |
|||
{ |
|||
get => "NotificationEmailSender"; |
|||
} |
|||
|
|||
public string EventsFilter |
|||
{ |
|||
get { return "^app-|^app-"; } |
|||
} |
|||
|
|||
public InvitationEventConsumer(INotificationSender emailSender, IUserResolver userResolver, IAppProvider appProvider, |
|||
ILogger<InvitationEventConsumer> log) |
|||
{ |
|||
this.emailSender = emailSender; |
|||
this.userResolver = userResolver; |
|||
this.appProvider = appProvider; |
|||
this.log = log; |
|||
} |
|||
|
|||
public async Task On(Envelope<IEvent> @event) |
|||
{ |
|||
if (!emailSender.IsActive) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
if (@event.Headers.EventStreamNumber() <= 1) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var now = SystemClock.Instance.GetCurrentInstant(); |
|||
|
|||
var timestamp = @event.Headers.Timestamp(); |
|||
|
|||
if (now - timestamp > MaxAge) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
switch (@event.Payload) |
|||
{ |
|||
case AppContributorAssigned assigned when assigned.IsAdded: |
|||
{ |
|||
var (assigner, assignee) = await ResolveUsersAsync(assigned.Actor, assigned.ContributorId, default); |
|||
|
|||
if (assigner == null || assignee == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
await emailSender.SendInviteAsync(assigner, assignee, assigned.AppId.Name); |
|||
return; |
|||
} |
|||
|
|||
case TeamContributorAssigned assigned when assigned.IsAdded: |
|||
{ |
|||
var (assigner, assignee) = await ResolveUsersAsync(assigned.Actor, assigned.ContributorId, default); |
|||
|
|||
if (assigner == null || assignee == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
var team = await appProvider.GetTeamAsync(assigned.TeamId); |
|||
|
|||
if (team == null) |
|||
{ |
|||
return; |
|||
} |
|||
|
|||
await emailSender.SendTeamInviteAsync(assigner, assignee, team.Name); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
private async Task<(IUser? Assignee, IUser? Assigner)> ResolveUsersAsync(RefToken assignerId, string assigneeId, |
|||
CancellationToken ct) |
|||
{ |
|||
if (!assignerId.IsUser) |
|||
{ |
|||
return default; |
|||
} |
|||
|
|||
var assigner = await userResolver.FindByIdAsync(assignerId.Identifier, ct); |
|||
|
|||
if (assigner == null) |
|||
{ |
|||
log.LogWarning("Failed to invite user: Assigner {assignerId} not found.", assignerId); |
|||
return default; |
|||
} |
|||
|
|||
var assignee = await userResolver.FindByIdAsync(assigneeId, ct); |
|||
|
|||
if (assignee == null) |
|||
{ |
|||
log.LogWarning("Failed to invite user: Assignee {assigneeId} not found.", assigneeId); |
|||
return default; |
|||
} |
|||
|
|||
return (assigner, assignee); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Entities.Teams; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Shared.Users; |
|||
using AssignAppContributor = Squidex.Domain.Apps.Entities.Apps.Commands.AssignContributor; |
|||
using AssignTeamContributor = Squidex.Domain.Apps.Entities.Teams.Commands.AssignContributor; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Invitation |
|||
{ |
|||
public sealed class InviteUserCommandMiddleware : ICommandMiddleware |
|||
{ |
|||
private readonly IUserResolver userResolver; |
|||
|
|||
public InviteUserCommandMiddleware(IUserResolver userResolver) |
|||
{ |
|||
this.userResolver = userResolver; |
|||
} |
|||
|
|||
public async Task HandleAsync(CommandContext context, NextDelegate next, |
|||
CancellationToken ct) |
|||
{ |
|||
if (context.Command is AssignAppContributor assignAppContributor) |
|||
{ |
|||
var (userId, created) = |
|||
await ResolveUserAsync( |
|||
assignAppContributor.ContributorId, |
|||
assignAppContributor.Invite, |
|||
ct); |
|||
|
|||
assignAppContributor.ContributorId = userId; |
|||
|
|||
await next(context, ct); |
|||
|
|||
if (created && context.PlainResult is IAppEntity app) |
|||
{ |
|||
context.Complete(new InvitedResult<IAppEntity> { Entity = app }); |
|||
} |
|||
} |
|||
else if (context.Command is AssignTeamContributor assignTeamContributor) |
|||
{ |
|||
var (userId, created) = |
|||
await ResolveUserAsync( |
|||
assignTeamContributor.ContributorId, |
|||
assignTeamContributor.Invite, |
|||
ct); |
|||
|
|||
assignTeamContributor.ContributorId = userId; |
|||
|
|||
await next(context, ct); |
|||
|
|||
if (created && context.PlainResult is ITeamEntity team) |
|||
{ |
|||
context.Complete(new InvitedResult<ITeamEntity> { Entity = team }); |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
await next(context, ct); |
|||
} |
|||
} |
|||
|
|||
private async Task<(string Id, bool)> ResolveUserAsync(string id, bool invite, |
|||
CancellationToken ct) |
|||
{ |
|||
if (!id.IsEmail()) |
|||
{ |
|||
return (id, false); |
|||
} |
|||
|
|||
if (invite) |
|||
{ |
|||
var (createdUser, created) = await userResolver.CreateUserIfNotExistsAsync(id, true, ct); |
|||
|
|||
return (createdUser?.Id ?? id, created); |
|||
} |
|||
|
|||
var user = await userResolver.FindByIdOrEmailAsync(id, ct); |
|||
|
|||
return (user?.Id ?? id, false); |
|||
} |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue