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