diff --git a/Squidex.sln b/Squidex.sln index 21ac0f2e4..67e85c17a 100644 --- a/Squidex.sln +++ b/Squidex.sln @@ -65,6 +65,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Apps.Core.Op EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Benchmarks", "tests\Benchmarks\Benchmarks.csproj", "{9B4A55F4-D9A4-4FC3-8D85-02A9EF93FBAB}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Apps.Entities", "src\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj", "{79FEF326-CA5E-4698-B2BA-C16A4580B4D5}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -315,6 +317,18 @@ Global {9B4A55F4-D9A4-4FC3-8D85-02A9EF93FBAB}.Release|x64.Build.0 = Release|Any CPU {9B4A55F4-D9A4-4FC3-8D85-02A9EF93FBAB}.Release|x86.ActiveCfg = Release|Any CPU {9B4A55F4-D9A4-4FC3-8D85-02A9EF93FBAB}.Release|x86.Build.0 = Release|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Debug|x64.ActiveCfg = Debug|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Debug|x64.Build.0 = Debug|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Debug|x86.ActiveCfg = Debug|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Debug|x86.Build.0 = Debug|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|Any CPU.Build.0 = Release|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|x64.ActiveCfg = Release|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|x64.Build.0 = Release|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|x86.ActiveCfg = Release|Any CPU + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -344,6 +358,7 @@ Global {7931187E-A1E6-4F89-8BC8-20A1E445579F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF} {F0A83301-50A5-40EA-A1A2-07C7858F5A3F} = {C9809D59-6665-471E-AD87-5AC624C65892} {6B3F75B6-5888-468E-BA4F-4FC725DAEF31} = {C9809D59-6665-471E-AD87-5AC624C65892} + {79FEF326-CA5E-4698-B2BA-C16A4580B4D5} = {C9809D59-6665-471E-AD87-5AC624C65892} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {02F2E872-3141-44F5-BD6A-33CD84E9FE08} diff --git a/src/Squidex.Domain.Apps.Entities/AppAggregateCommand.cs b/src/Squidex.Domain.Apps.Entities/AppAggregateCommand.cs new file mode 100644 index 000000000..1bd5d3a75 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/AppAggregateCommand.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// AppAggregateCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities +{ + public class AppAggregateCommand : AppCommand, IAggregateCommand + { + Guid IAggregateCommand.AggregateId + { + get { return AppId.Id; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/AppCommand.cs b/src/Squidex.Domain.Apps.Entities/AppCommand.cs new file mode 100644 index 000000000..9ab07df33 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/AppCommand.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// AppCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class AppCommand : SquidexCommand + { + public NamedId AppId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs new file mode 100644 index 000000000..ae6a2e3ce --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs @@ -0,0 +1,173 @@ +// ========================================================================== +// AppCommandMiddleware.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Guards; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Dispatching; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class AppCommandMiddleware : ICommandMiddleware + { + private readonly IAggregateHandler handler; + private readonly IAppProvider appProvider; + private readonly IAppPlansProvider appPlansProvider; + private readonly IAppPlanBillingManager appPlansBillingManager; + private readonly IUserResolver userResolver; + + public AppCommandMiddleware( + IAggregateHandler handler, + IAppProvider appProvider, + IAppPlansProvider appPlansProvider, + IAppPlanBillingManager appPlansBillingManager, + IUserResolver userResolver) + { + Guard.NotNull(handler, nameof(handler)); + Guard.NotNull(appProvider, nameof(appProvider)); + Guard.NotNull(userResolver, nameof(userResolver)); + Guard.NotNull(appPlansProvider, nameof(appPlansProvider)); + Guard.NotNull(appPlansBillingManager, nameof(appPlansBillingManager)); + + this.handler = handler; + this.userResolver = userResolver; + this.appProvider = appProvider; + this.appPlansProvider = appPlansProvider; + this.appPlansBillingManager = appPlansBillingManager; + } + + protected Task On(CreateApp command, CommandContext context) + { + return handler.CreateAsync(context, async a => + { + await GuardApp.CanCreate(command, appProvider); + + a.Create(command); + + context.Complete(EntityCreatedResult.Create(a.State.Id, a.Version)); + }); + } + + protected Task On(AssignContributor command, CommandContext context) + { + return handler.UpdateAsync(context, async a => + { + await GuardAppContributors.CanAssign(a.State.Contributors, command, userResolver, appPlansProvider.GetPlan(a.State.Plan.PlanId)); + + a.AssignContributor(command); + }); + } + + protected Task On(RemoveContributor command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAppContributors.CanRemove(a.State.Contributors, command); + + a.RemoveContributor(command); + }); + } + + protected Task On(AttachClient command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAppClients.CanAttach(a.State.Clients, command); + + a.AttachClient(command); + }); + } + + protected Task On(UpdateClient command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAppClients.CanUpdate(a.State.Clients, command); + + a.UpdateClient(command); + }); + } + + protected Task On(RevokeClient command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAppClients.CanRevoke(a.State.Clients, command); + + a.RevokeClient(command); + }); + } + + protected Task On(AddLanguage command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAppLanguages.CanAdd(a.State.LanguagesConfig, command); + + a.AddLanguage(command); + }); + } + + protected Task On(RemoveLanguage command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAppLanguages.CanRemove(a.State.LanguagesConfig, command); + + a.RemoveLanguage(command); + }); + } + + protected Task On(UpdateLanguage command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAppLanguages.CanUpdate(a.State.LanguagesConfig, command); + + a.UpdateLanguage(command); + }); + } + + protected Task On(ChangePlan command, CommandContext context) + { + return handler.UpdateAsync(context, async a => + { + GuardApp.CanChangePlan(command, a.State.Plan, appPlansProvider); + + if (command.FromCallback) + { + a.ChangePlan(command); + } + else + { + var result = await appPlansBillingManager.ChangePlanAsync(command.Actor.Identifier, a.State.Id, a.State.Name, command.PlanId); + + if (result is PlanChangedResult) + { + a.ChangePlan(command); + } + + context.Complete(result); + } + }); + } + + public async Task HandleAsync(CommandContext context, Func next) + { + if (!await this.DispatchActionAsync(context.Command, context)) + { + await next(); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs new file mode 100644 index 000000000..e3c1ef9b7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs @@ -0,0 +1,229 @@ +// ========================================================================== +// AppDomainObject.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Linq; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.State; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class AppDomainObject : DomainObjectBase + { + public AppDomainObject Create(CreateApp command) + { + ThrowIfCreated(); + + var appId = new NamedId(command.AppId, command.Name); + + UpdateState(command, s => s.Name = command.Name); + + RaiseEvent(SimpleMapper.Map(command, CreateInitalEvent(appId))); + RaiseEvent(SimpleMapper.Map(command, CreateInitialOwner(appId, command))); + RaiseEvent(SimpleMapper.Map(command, CreateInitialLanguage(appId))); + + return this; + } + + public AppDomainObject UpdateLanguage(UpdateLanguage command) + { + ThrowIfNotCreated(); + + UpdateLanguages(command, l => + { + var fallback = command.Fallback; + + if (fallback != null && fallback.Count > 0) + { + var existingLangauges = l.OfType().Select(x => x.Language); + + fallback = fallback.Intersect(existingLangauges).ToList(); + } + + l = l.Set(new LanguageConfig(command.Language, command.IsOptional, fallback)); + + if (command.IsMaster) + { + l = l.MakeMaster(command.Language); + } + + return l; + }); + + RaiseEvent(SimpleMapper.Map(command, new AppLanguageUpdated())); + + return this; + } + + public AppDomainObject UpdateClient(UpdateClient command) + { + ThrowIfNotCreated(); + + if (!string.IsNullOrWhiteSpace(command.Name)) + { + UpdateClients(command, c => c.Rename(command.Id, command.Name)); + + RaiseEvent(SimpleMapper.Map(command, new AppClientRenamed())); + } + + if (command.Permission.HasValue) + { + UpdateClients(command, c => c.Update(command.Id, command.Permission.Value)); + + RaiseEvent(SimpleMapper.Map(command, new AppClientUpdated { Permission = command.Permission.Value })); + } + + return this; + } + + public AppDomainObject AssignContributor(AssignContributor command) + { + ThrowIfNotCreated(); + + UpdateContributors(command, c => c.Assign(command.ContributorId, command.Permission)); + + RaiseEvent(SimpleMapper.Map(command, new AppContributorAssigned())); + + return this; + } + + public AppDomainObject RemoveContributor(RemoveContributor command) + { + ThrowIfNotCreated(); + + UpdateContributors(command, c => c.Remove(command.ContributorId)); + + RaiseEvent(SimpleMapper.Map(command, new AppContributorRemoved())); + + return this; + } + + public AppDomainObject AttachClient(AttachClient command) + { + ThrowIfNotCreated(); + + UpdateClients(command, c => c.Add(command.Id, command.Secret)); + + RaiseEvent(SimpleMapper.Map(command, new AppClientAttached())); + + return this; + } + + public AppDomainObject RevokeClient(RevokeClient command) + { + ThrowIfNotCreated(); + + UpdateClients(command, c => c.Revoke(command.Id)); + + RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked())); + + return this; + } + + public AppDomainObject AddLanguage(AddLanguage command) + { + ThrowIfNotCreated(); + + UpdateLanguages(command, l => l.Set(new LanguageConfig(command.Language))); + + RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded())); + + return this; + } + + public AppDomainObject RemoveLanguage(RemoveLanguage command) + { + ThrowIfNotCreated(); + + UpdateLanguages(command, l => l.Remove(command.Language)); + + RaiseEvent(SimpleMapper.Map(command, new AppLanguageRemoved())); + + return this; + } + + public AppDomainObject ChangePlan(ChangePlan command) + { + ThrowIfNotCreated(); + + UpdateState(command, s => s.Plan = new AppPlan(command.Actor, command.PlanId)); + + RaiseEvent(SimpleMapper.Map(command, new AppPlanChanged())); + + return this; + } + + private void RaiseEvent(AppEvent @event) + { + if (@event.AppId == null) + { + @event.AppId = new NamedId(State.Id, State.Name); + } + + RaiseEvent(Envelope.Create(@event)); + } + + private static AppCreated CreateInitalEvent(NamedId appId) + { + return new AppCreated { AppId = appId }; + } + + private static AppLanguageAdded CreateInitialLanguage(NamedId id) + { + return new AppLanguageAdded { AppId = id, Language = Language.EN }; + } + + private static AppContributorAssigned CreateInitialOwner(NamedId id, SquidexCommand command) + { + return new AppContributorAssigned { AppId = id, ContributorId = command.Actor.Identifier, Permission = AppContributorPermission.Owner }; + } + + private void ThrowIfNotCreated() + { + if (string.IsNullOrWhiteSpace(State.Name)) + { + throw new DomainException("App has not been created."); + } + } + + private void ThrowIfCreated() + { + if (!string.IsNullOrWhiteSpace(State.Name)) + { + throw new DomainException("App has already been created."); + } + } + + private void UpdateClients(ICommand command, Func updater) + { + UpdateState(command, s => s.Clients = updater(s.Clients)); + } + + private void UpdateContributors(ICommand command, Func updater) + { + UpdateState(command, s => s.Contributors = updater(s.Contributors)); + } + + private void UpdateLanguages(ICommand command, Func updater) + { + UpdateState(command, s => s.LanguagesConfig = updater(s.LanguagesConfig)); + } + + protected override AppState CloneState(ICommand command, Action updater) + { + return State.Clone().Update((SquidexCommand)command, updater); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppEntityExtensions.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppEntityExtensions.cs new file mode 100644 index 000000000..1b9013944 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppEntityExtensions.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// AppEntityExtensions.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public static class AppEntityExtensions + { + public static PartitionResolver PartitionResolver(this IAppEntity entity) + { + return entity.LanguagesConfig.ToResolver(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs new file mode 100644 index 000000000..2d2cdd628 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs @@ -0,0 +1,145 @@ +// ========================================================================== +// AppHistoryEventsCreator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.History; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Dispatching; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public class AppHistoryEventsCreator : HistoryEventsCreatorBase + { + public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry) + : base(typeNameRegistry) + { + AddEventMessage( + "assigned {user:[Contributor]} as [Permission]"); + + AddEventMessage( + "removed {user:[Contributor]} from app"); + + AddEventMessage( + "added client {[Id]} to app"); + + AddEventMessage( + "revoked client {[Id]}"); + + AddEventMessage( + "updated client {[Id]}"); + + AddEventMessage( + "renamed client {[Id]} to {[Name]}"); + + AddEventMessage( + "added language {[Language]}"); + + AddEventMessage( + "removed language {[Language]}"); + + AddEventMessage( + "updated language {[Language]}"); + + AddEventMessage( + "changed master language to {[Language]}"); + } + + protected Task On(AppContributorRemoved @event, EnvelopeHeaders headers) + { + const string channel = "settings.contributors"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Contributor", @event.ContributorId)); + } + + protected Task On(AppContributorAssigned @event, EnvelopeHeaders headers) + { + const string channel = "settings.contributors"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Contributor", @event.ContributorId).AddParameter("Permission", @event.Permission)); + } + + protected Task On(AppClientAttached @event, EnvelopeHeaders headers) + { + const string channel = "settings.clients"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Id", @event.Id)); + } + + protected Task On(AppClientRevoked @event, EnvelopeHeaders headers) + { + const string channel = "settings.clients"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Id", @event.Id)); + } + + protected Task On(AppClientRenamed @event, EnvelopeHeaders headers) + { + const string channel = "settings.clients"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Id", @event.Id).AddParameter("Name", ClientName(@event))); + } + + protected Task On(AppLanguageAdded @event, EnvelopeHeaders headers) + { + const string channel = "settings.languages"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Language", @event.Language)); + } + + protected Task On(AppLanguageRemoved @event, EnvelopeHeaders headers) + { + const string channel = "settings.languages"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Language", @event.Language)); + } + + protected Task On(AppLanguageUpdated @event, EnvelopeHeaders headers) + { + const string channel = "settings.languages"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Language", @event.Language)); + } + + protected Task On(AppMasterLanguageSet @event, EnvelopeHeaders headers) + { + const string channel = "settings.languages"; + + return Task.FromResult( + ForEvent(@event, channel) + .AddParameter("Language", @event.Language)); + } + + protected override Task CreateEventCoreAsync(Envelope @event) + { + return this.DispatchFuncAsync(@event.Payload, @event.Headers, (HistoryEventToStore)null); + } + + private static string ClientName(AppClientRenamed @event) + { + return !string.IsNullOrWhiteSpace(@event.Name) ? @event.Name : @event.Id; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs new file mode 100644 index 000000000..486f8d0aa --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// AddLanguage.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class AddLanguage : AppAggregateCommand + { + public Language Language { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs new file mode 100644 index 000000000..0dad40b86 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// AssignContributor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class AssignContributor : AppAggregateCommand + { + public string ContributorId { get; set; } + + public AppContributorPermission Permission { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs new file mode 100644 index 000000000..2cf0bdf39 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// AttachClient.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class AttachClient : AppAggregateCommand + { + public string Id { get; set; } + + public string Secret { get; } = RandomHash.New(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs new file mode 100644 index 000000000..eab67f42e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// ChangePlan.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class ChangePlan : AppAggregateCommand + { + public bool FromCallback { get; set; } + + public string PlanId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs new file mode 100644 index 000000000..eb173e4f5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// CreateApp.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class CreateApp : SquidexCommand, IAggregateCommand + { + public string Name { get; set; } + + public Guid AppId { get; set; } + + Guid IAggregateCommand.AggregateId + { + get { return AppId; } + } + + public CreateApp() + { + AppId = Guid.NewGuid(); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs new file mode 100644 index 000000000..5bda9002b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// RemoveContributor.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class RemoveContributor : AppAggregateCommand + { + public string ContributorId { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs new file mode 100644 index 000000000..1e7fbf499 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// RemoveLanguage.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class RemoveLanguage : AppAggregateCommand + { + public Language Language { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs new file mode 100644 index 000000000..cf48c425f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// RevokeClient.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class RevokeClient : AppAggregateCommand + { + public string Id { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs new file mode 100644 index 000000000..82059fbcd --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// UpdateClient.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class UpdateClient : AppAggregateCommand + { + public string Id { get; set; } + + public string Name { get; set; } + + public AppClientPermission? Permission { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs new file mode 100644 index 000000000..d8badb558 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// UpdateLanguage.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class UpdateLanguage : AppAggregateCommand + { + public Language Language { get; set; } + + public bool IsOptional { get; set; } + + public bool IsMaster { get; set; } + + public List Fallback { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs new file mode 100644 index 000000000..ced905013 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// GuardApp.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public static class GuardApp + { + public static Task CanCreate(CreateApp command, IAppProvider appProvider) + { + Guard.NotNull(command, nameof(command)); + + return Validate.It(() => "Cannot create app.", async error => + { + if (await appProvider.GetAppAsync(command.Name) != null) + { + error(new ValidationError($"An app with name '{command.Name}' already exists", nameof(command.Name))); + } + + if (!command.Name.IsSlug()) + { + error(new ValidationError("Name must be a valid slug.", nameof(command.Name))); + } + }); + } + + public static void CanChangePlan(ChangePlan command, AppPlan plan, IAppPlansProvider appPlans) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot change plan.", error => + { + if (string.IsNullOrWhiteSpace(command.PlanId)) + { + error(new ValidationError("PlanId is not defined.", nameof(command.PlanId))); + } + else if (appPlans.GetPlan(command.PlanId) == null) + { + error(new ValidationError("Plan id not available.", nameof(command.PlanId))); + } + + if (!string.IsNullOrWhiteSpace(command.PlanId) && plan != null && !plan.Owner.Equals(command.Actor)) + { + error(new ValidationError("Plan can only be changed from current user.")); + } + + if (string.Equals(command.PlanId, plan?.PlanId, StringComparison.OrdinalIgnoreCase)) + { + error(new ValidationError("App has already this plan.")); + } + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs new file mode 100644 index 000000000..1d48aa8d4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs @@ -0,0 +1,102 @@ +// ========================================================================== +// GuardAppClients.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public static class GuardAppClients + { + public static void CanAttach(AppClients clients, AttachClient command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot attach client.", error => + { + if (string.IsNullOrWhiteSpace(command.Id)) + { + error(new ValidationError("Client id must be defined.", nameof(command.Id))); + } + else if (clients.ContainsKey(command.Id)) + { + error(new ValidationError("Client id already added.", nameof(command.Id))); + } + }); + } + + public static void CanRevoke(AppClients clients, RevokeClient command) + { + Guard.NotNull(command, nameof(command)); + + GetClientOrThrow(clients, command.Id); + + Validate.It(() => "Cannot revoke client.", error => + { + if (string.IsNullOrWhiteSpace(command.Id)) + { + error(new ValidationError("Client id must be defined.", nameof(command.Id))); + } + }); + } + + public static void CanUpdate(AppClients clients, UpdateClient command) + { + Guard.NotNull(command, nameof(command)); + + var client = GetClientOrThrow(clients, command.Id); + + Validate.It(() => "Cannot revoke client.", error => + { + if (string.IsNullOrWhiteSpace(command.Id)) + { + error(new ValidationError("Client id must be defined.", nameof(command.Id))); + } + + if (string.IsNullOrWhiteSpace(command.Name) && command.Permission == null) + { + error(new ValidationError("Either name or permission must be defined.", nameof(command.Name), nameof(command.Permission))); + } + + if (command.Permission.HasValue && !command.Permission.Value.IsEnumValue()) + { + error(new ValidationError("Permission is not valid.", nameof(command.Permission))); + } + + if (client != null) + { + if (!string.IsNullOrWhiteSpace(command.Name) && string.Equals(client.Name, command.Name)) + { + error(new ValidationError("Client already has this name.", nameof(command.Permission))); + } + + if (command.Permission == client.Permission) + { + error(new ValidationError("Client already has this permission.", nameof(command.Permission))); + } + } + }); + } + + private static AppClient GetClientOrThrow(AppClients clients, string id) + { + if (id == null) + { + return null; + } + + if (!clients.TryGetValue(id, out var client)) + { + throw new DomainObjectNotFoundException(id, "Clients", typeof(AppDomainObject)); + } + + return client; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs new file mode 100644 index 000000000..bd0304d22 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs @@ -0,0 +1,82 @@ +// ========================================================================== +// GuardAppContributors.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public static class GuardAppContributors + { + public static Task CanAssign(AppContributors contributors, AssignContributor command, IUserResolver users, IAppLimitsPlan plan) + { + Guard.NotNull(command, nameof(command)); + + return Validate.It(() => "Cannot assign contributor.", async error => + { + if (!command.Permission.IsEnumValue()) + { + error(new ValidationError("Permission is not valid.", nameof(command.Permission))); + } + + if (string.IsNullOrWhiteSpace(command.ContributorId)) + { + error(new ValidationError("Contributor id not assigned.", nameof(command.ContributorId))); + } + else + { + if (await users.FindByIdAsync(command.ContributorId) == null) + { + error(new ValidationError("Cannot find contributor id.", nameof(command.ContributorId))); + } + else if (contributors.TryGetValue(command.ContributorId, out var existing)) + { + if (existing == command.Permission) + { + error(new ValidationError("Contributor has already this permission.", nameof(command.Permission))); + } + } + else if (plan.MaxContributors == contributors.Count) + { + error(new ValidationError("You have reached the maximum number of contributors for your plan.")); + } + } + }); + } + + public static void CanRemove(AppContributors contributors, RemoveContributor command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot remove contributor.", error => + { + if (string.IsNullOrWhiteSpace(command.ContributorId)) + { + error(new ValidationError("Contributor id not assigned.", nameof(command.ContributorId))); + } + + var ownerIds = contributors.Where(x => x.Value == AppContributorPermission.Owner).Select(x => x.Key).ToList(); + + if (ownerIds.Count == 1 && ownerIds.Contains(command.ContributorId)) + { + error(new ValidationError("Cannot remove the only owner.", nameof(command.ContributorId))); + } + }); + + if (!contributors.ContainsKey(command.ContributorId)) + { + throw new DomainObjectNotFoundException(command.ContributorId, "Contributors", typeof(AppDomainObject)); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppLanguages.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppLanguages.cs new file mode 100644 index 000000000..a31fa3413 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppLanguages.cs @@ -0,0 +1,100 @@ +// ========================================================================== +// GuardAppLanguages.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public static class GuardAppLanguages + { + public static void CanAdd(LanguagesConfig languages, AddLanguage command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot add language.", error => + { + if (command.Language == null) + { + error(new ValidationError("Language cannot be null.", nameof(command.Language))); + } + else if (languages.Contains(command.Language)) + { + error(new ValidationError("Language already added.", nameof(command.Language))); + } + }); + } + + public static void CanRemove(LanguagesConfig languages, RemoveLanguage command) + { + Guard.NotNull(command, nameof(command)); + + var languageConfig = GetLanguageConfigOrThrow(languages, command.Language); + + Validate.It(() => "Cannot remove language.", error => + { + if (command.Language == null) + { + error(new ValidationError("Language cannot be null.", nameof(command.Language))); + } + + if (languages.Master == languageConfig) + { + error(new ValidationError("Language config is master.", nameof(command.Language))); + } + }); + } + + public static void CanUpdate(LanguagesConfig languages, UpdateLanguage command) + { + Guard.NotNull(command, nameof(command)); + + var languageConfig = GetLanguageConfigOrThrow(languages, command.Language); + + Validate.It(() => "Cannot update language.", error => + { + if (command.Language == null) + { + error(new ValidationError("Language cannot be null.", nameof(command.Language))); + } + + if ((languages.Master == languageConfig || command.IsMaster) && command.IsOptional) + { + error(new ValidationError("Cannot make master language optional.", nameof(command.IsMaster))); + } + + if (command.Fallback != null) + { + foreach (var fallback in command.Fallback) + { + if (!languages.Contains(fallback)) + { + error(new ValidationError($"Config does not contain fallback language {fallback}.", nameof(command.Fallback))); + } + } + } + }); + } + + private static LanguageConfig GetLanguageConfigOrThrow(LanguagesConfig languages, Language language) + { + if (language == null) + { + return null; + } + + if (!languages.TryGetConfig(language, out var languageConfig)) + { + throw new DomainObjectNotFoundException(language, "Languages", typeof(AppDomainObject)); + } + + return languageConfig; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs new file mode 100644 index 000000000..154478f1e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// IAppEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public interface IAppEntity : IEntity, IEntityWithVersion + { + string Name { get; } + + AppPlan Plan { get; } + + AppClients Clients { get; } + + AppContributors Contributors { get; } + + LanguagesConfig LanguagesConfig { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs new file mode 100644 index 000000000..623d71f26 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppLimitsPlan.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// IAppLimitsPlan.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Services +{ + public interface IAppLimitsPlan + { + string Id { get; } + + string Name { get; } + + string Costs { get; } + + long MaxApiCalls { get; } + + long MaxAssetSize { get; } + + int MaxContributors { get; } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs new file mode 100644 index 000000000..d0c601d06 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// IAppPlanBillingManager.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Apps.Services +{ + public interface IAppPlanBillingManager + { + bool HasPortal { get; } + + Task ChangePlanAsync(string userId, Guid appId, string appName, string planId); + + Task GetPortalLinkAsync(string userId); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlansProvider.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlansProvider.cs new file mode 100644 index 000000000..53dea5201 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlansProvider.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// IAppPlansProvider.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities.Apps.Services +{ + public interface IAppPlansProvider + { + IEnumerable GetAvailablePlans(); + + bool IsConfiguredPlan(string planId); + + IAppLimitsPlan GetPlanUpgradeForApp(IAppEntity app); + + IAppLimitsPlan GetPlanUpgrade(string planId); + + IAppLimitsPlan GetPlanForApp(IAppEntity app); + + IAppLimitsPlan GetPlan(string planId); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/IChangePlanResult.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/IChangePlanResult.cs new file mode 100644 index 000000000..c8cde7963 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/IChangePlanResult.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// IChangePlanResult.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Services +{ + public interface IChangePlanResult + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs new file mode 100644 index 000000000..165c11bd2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppLimitsPlan.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// ConfigAppLimitsPlan.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations +{ + public sealed class ConfigAppLimitsPlan : IAppLimitsPlan + { + public string Id { get; set; } + + public string Name { get; set; } + + public string Costs { get; set; } + + public long MaxApiCalls { get; set; } + + public long MaxAssetSize { get; set; } + + public int MaxContributors { get; set; } + + public ConfigAppLimitsPlan Clone() + { + return (ConfigAppLimitsPlan)MemberwiseClone(); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs new file mode 100644 index 000000000..d27f8c189 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/ConfigAppPlansProvider.cs @@ -0,0 +1,86 @@ +// ========================================================================== +// ConfigAppPlansProvider.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations +{ + public sealed class ConfigAppPlansProvider : IAppPlansProvider + { + private static readonly ConfigAppLimitsPlan Infinite = new ConfigAppLimitsPlan + { + Id = "infinite", + Name = "Infinite", + MaxApiCalls = -1, + MaxAssetSize = -1, + MaxContributors = -1 + }; + + private readonly Dictionary plansById; + private readonly List plansList; + + public ConfigAppPlansProvider(IEnumerable config) + { + Guard.NotNull(config, nameof(config)); + + plansList = config.Select(c => c.Clone()).OrderBy(x => x.MaxApiCalls).ToList(); + plansById = plansList.ToDictionary(c => c.Id, StringComparer.OrdinalIgnoreCase); + } + + public IEnumerable GetAvailablePlans() + { + return plansList; + } + + public bool IsConfiguredPlan(string planId) + { + return planId != null && plansById.ContainsKey(planId); + } + + public IAppLimitsPlan GetPlanForApp(IAppEntity app) + { + Guard.NotNull(app, nameof(app)); + + return GetPlan(app.Plan?.PlanId); + } + + public IAppLimitsPlan GetPlan(string planId) + { + return GetPlanCore(planId); + } + + public IAppLimitsPlan GetPlanUpgradeForApp(IAppEntity app) + { + Guard.NotNull(app, nameof(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; + } + + private ConfigAppLimitsPlan GetPlanCore(string planId) + { + return plansById.GetOrDefault(planId ?? string.Empty) ?? plansById.Values.FirstOrDefault() ?? Infinite; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs new file mode 100644 index 000000000..6a053c294 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// NoopAppPlanBillingManager.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations +{ + public sealed class NoopAppPlanBillingManager : IAppPlanBillingManager + { + public bool HasPortal + { + get { return false; } + } + + public Task ChangePlanAsync(string userId, Guid appId, string appName, string planId) + { + return Task.FromResult(PlanChangedResult.Instance); + } + + public Task GetPortalLinkAsync(string userId) + { + return Task.FromResult(string.Empty); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/PlanChangeAsyncResult.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/PlanChangeAsyncResult.cs new file mode 100644 index 000000000..6c0c15865 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/PlanChangeAsyncResult.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// PlanChangeAsyncResult.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Services +{ + public sealed class PlanChangeAsyncResult : IChangePlanResult + { + public static readonly PlanChangeAsyncResult Instance = new PlanChangeAsyncResult(); + + private PlanChangeAsyncResult() + { + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/PlanChangedResult.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/PlanChangedResult.cs new file mode 100644 index 000000000..efcc95b3d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/PlanChangedResult.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// PlanChangedResult.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Services +{ + public sealed class PlanChangedResult : IChangePlanResult + { + public static readonly PlanChangedResult Instance = new PlanChangedResult(); + + private PlanChangedResult() + { + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/RedirectToCheckoutResult.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/RedirectToCheckoutResult.cs new file mode 100644 index 000000000..5d1d2a441 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/RedirectToCheckoutResult.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// RedirectToCheckoutResult.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Services +{ + public sealed class RedirectToCheckoutResult : IChangePlanResult + { + public Uri Url { get; } + + public RedirectToCheckoutResult(Uri url) + { + Guard.NotNull(url, nameof(url)); + + Url = url; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs new file mode 100644 index 000000000..b9c40344e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// AppState.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Newtonsoft.Json; +using Squidex.Domain.Apps.Core.Apps; + +namespace Squidex.Domain.Apps.Entities.Apps.State +{ + public sealed class AppState : DomainObjectState, IAppEntity + { + [JsonProperty] + public string Name { get; set; } + + [JsonProperty] + public AppPlan Plan { get; set; } + + [JsonProperty] + public AppClients Clients { get; set; } = AppClients.Empty; + + [JsonProperty] + public AppContributors Contributors { get; set; } = AppContributors.Empty; + + [JsonProperty] + public LanguagesConfig LanguagesConfig { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs new file mode 100644 index 000000000..5f1fe9459 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs @@ -0,0 +1,117 @@ +// ========================================================================== +// AssetCommandMiddleware.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Assets.Guards; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Dispatching; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class AssetCommandMiddleware : ICommandMiddleware + { + private readonly IAggregateHandler handler; + private readonly IAssetStore assetStore; + private readonly IAssetThumbnailGenerator assetThumbnailGenerator; + + public AssetCommandMiddleware( + IAggregateHandler handler, + IAssetStore assetStore, + IAssetThumbnailGenerator assetThumbnailGenerator) + { + Guard.NotNull(handler, nameof(handler)); + Guard.NotNull(assetStore, nameof(assetStore)); + Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); + + this.handler = handler; + this.assetStore = assetStore; + this.assetThumbnailGenerator = assetThumbnailGenerator; + } + + protected async Task On(CreateAsset command, CommandContext context) + { + command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead()); + try + { + var asset = await handler.CreateAsync(context, async a => + { + GuardAsset.CanCreate(command); + + a.Create(command); + + await assetStore.UploadTemporaryAsync(context.ContextId.ToString(), command.File.OpenRead()); + + context.Complete(EntityCreatedResult.Create(a.State.Id, a.Version)); + }); + + await assetStore.CopyTemporaryAsync(context.ContextId.ToString(), asset.State.Id.ToString(), asset.State.FileVersion, null); + } + finally + { + await assetStore.DeleteTemporaryAsync(context.ContextId.ToString()); + } + } + + protected async Task On(UpdateAsset command, CommandContext context) + { + command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead()); + + try + { + var asset = await handler.UpdateAsync(context, async a => + { + GuardAsset.CanUpdate(command); + + a.Update(command); + + await assetStore.UploadTemporaryAsync(context.ContextId.ToString(), command.File.OpenRead()); + + context.Complete(new AssetSavedResult(a.Version, a.State.FileVersion)); + }); + + await assetStore.CopyTemporaryAsync(context.ContextId.ToString(), asset.State.Id.ToString(), asset.State.FileVersion, null); + } + finally + { + await assetStore.DeleteTemporaryAsync(context.ContextId.ToString()); + } + } + + protected Task On(RenameAsset command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAsset.CanRename(command, a.State.FileName); + + a.Rename(command); + }); + } + + protected Task On(DeleteAsset command, CommandContext context) + { + return handler.UpdateAsync(context, a => + { + GuardAsset.CanDelete(command); + + a.Delete(command); + }); + } + + public async Task HandleAsync(CommandContext context, Func next) + { + if (!await this.DispatchActionAsync(context.Command, context)) + { + await next(); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs new file mode 100644 index 000000000..b38838e99 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs @@ -0,0 +1,107 @@ +// ========================================================================== +// AssetDomainObject.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class AssetDomainObject : DomainObjectBase + { + public AssetDomainObject Create(CreateAsset command) + { + VerifyNotCreated(); + + var @event = SimpleMapper.Map(command, new AssetCreated + { + FileName = command.File.FileName, + FileSize = command.File.FileSize, + FileVersion = State.FileVersion + 1, + MimeType = command.File.MimeType, + PixelWidth = command.ImageInfo?.PixelWidth, + PixelHeight = command.ImageInfo?.PixelHeight, + IsImage = command.ImageInfo != null + }); + + UpdateState(command, s => SimpleMapper.Map(@event, s)); + + RaiseEvent(@event); + + return this; + } + + public AssetDomainObject Update(UpdateAsset command) + { + VerifyCreatedAndNotDeleted(); + + var @event = SimpleMapper.Map(command, new AssetUpdated + { + FileVersion = State.FileVersion + 1, + FileSize = command.File.FileSize, + MimeType = command.File.MimeType, + PixelWidth = command.ImageInfo?.PixelWidth, + PixelHeight = command.ImageInfo?.PixelHeight, + IsImage = command.ImageInfo != null + }); + + UpdateState(command, s => SimpleMapper.Map(@event, s)); + + RaiseEvent(@event); + + return this; + } + + public AssetDomainObject Delete(DeleteAsset command) + { + VerifyCreatedAndNotDeleted(); + + UpdateState(command, s => s.IsDeleted = true); + + RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = State.TotalSize })); + + return this; + } + + public AssetDomainObject Rename(RenameAsset command) + { + VerifyCreatedAndNotDeleted(); + + UpdateState(command, s => s.FileName = command.FileName); + + RaiseEvent(SimpleMapper.Map(command, new AssetRenamed())); + + return this; + } + + private void VerifyNotCreated() + { + if (!string.IsNullOrWhiteSpace(State.FileName)) + { + throw new DomainException("Asset has already been created."); + } + } + + private void VerifyCreatedAndNotDeleted() + { + if (State.IsDeleted || string.IsNullOrWhiteSpace(State.FileName)) + { + throw new DomainException("Asset has already been deleted or not created yet."); + } + } + + protected override AssetState CloneState(ICommand command, Action updater) + { + return State.Clone().Update((SquidexCommand)command, updater); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs new file mode 100644 index 000000000..129bc8379 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// AssetSavedResult.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class AssetSavedResult : EntitySavedResult + { + public long FileVersion { get; } + + public AssetSavedResult(long version, long fileVersion) + : base(version) + { + FileVersion = fileVersion; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetAggregateCommand.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetAggregateCommand.cs new file mode 100644 index 000000000..001cf574b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetAggregateCommand.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// AssetAggregateCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public abstract class AssetAggregateCommand : AppCommand, IAggregateCommand + { + public Guid AssetId { get; set; } + + Guid IAggregateCommand.AggregateId + { + get { return AssetId; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs new file mode 100644 index 000000000..7ac46f525 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// CreateAsset.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure.Assets; + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public sealed class CreateAsset : AssetAggregateCommand + { + public AssetFile File { get; set; } + + public ImageInfo ImageInfo { get; set; } + + public CreateAsset() + { + AssetId = Guid.NewGuid(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs new file mode 100644 index 000000000..1dca53261 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// DeleteAsset.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public sealed class DeleteAsset : AssetAggregateCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAsset.cs new file mode 100644 index 000000000..bc5a278f8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAsset.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// RenameAsset.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public sealed class RenameAsset : AssetAggregateCommand + { + public string FileName { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs new file mode 100644 index 000000000..750e0641c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// UpdateAsset.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure.Assets; + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public sealed class UpdateAsset : AssetAggregateCommand + { + public AssetFile File { get; set; } + + public ImageInfo ImageInfo { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs new file mode 100644 index 000000000..2ac0d086d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// GuardAsset.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Assets.Guards +{ + public static class GuardAsset + { + public static void CanRename(RenameAsset command, string oldName) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot rename asset.", error => + { + if (string.IsNullOrWhiteSpace(command.FileName)) + { + error(new ValidationError("Name must be defined.", nameof(command.FileName))); + } + + if (string.Equals(command.FileName, oldName)) + { + error(new ValidationError("Name is equal to old name.", nameof(command.FileName))); + } + }); + } + + public static void CanCreate(CreateAsset command) + { + Guard.NotNull(command, nameof(command)); + } + + public static void CanUpdate(UpdateAsset command) + { + Guard.NotNull(command, nameof(command)); + } + + public static void CanDelete(DeleteAsset command) + { + Guard.NotNull(command, nameof(command)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs new file mode 100644 index 000000000..8563d3931 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// IAssetEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.ValidateContent; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetEntity : + IEntity, + IEntityWithAppRef, + IEntityWithCreatedBy, + IEntityWithLastModifiedBy, + IEntityWithVersion, + IAssetInfo + { + string MimeType { get; } + + long FileVersion { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEventConsumer.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEventConsumer.cs new file mode 100644 index 000000000..a188fead4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEventConsumer.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// IAssetEventConsumer.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetEventConsumer : IEventConsumer + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetStatsEntity.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetStatsEntity.cs new file mode 100644 index 000000000..7edf5ddb8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetStatsEntity.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// IAssetStatsEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetStatsEntity + { + DateTime Date { get; } + + long TotalSize { get; } + + long TotalCount { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs new file mode 100644 index 000000000..10a16b6e3 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// IAssetRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets.Repositories +{ + public interface IAssetRepository + { + Task> QueryAsync(Guid appId, HashSet mimeTypes = null, HashSet ids = null, string query = null, int take = 10, int skip = 0); + + Task FindAssetAsync(Guid id); + + Task CountAsync(Guid appId, HashSet mimeTypes = null, HashSet ids = null, string query = null); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetStatsRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetStatsRepository.cs new file mode 100644 index 000000000..5980dc6c1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetStatsRepository.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// IAssetStatsRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets.Repositories +{ + public interface IAssetStatsRepository + { + Task> QueryAsync(Guid appId, DateTime fromDate, DateTime toDate); + + Task GetTotalSizeAsync(Guid appId); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs new file mode 100644 index 000000000..77e6ca5c9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// AssetState.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using Squidex.Domain.Apps.Core.ValidateContent; + +namespace Squidex.Domain.Apps.Entities.Assets.State +{ + public sealed class AssetState : DomainObjectState, + IAssetEntity, + IAssetInfo, + IUpdateableEntityWithAppRef + { + [JsonProperty] + public Guid AppId { get; set; } + + [JsonProperty] + public string FileName { get; set; } + + [JsonProperty] + public string MimeType { get; set; } + + [JsonProperty] + public long FileVersion { get; set; } + + [JsonProperty] + public long FileSize { get; set; } + + [JsonProperty] + public long TotalSize { get; set; } + + [JsonProperty] + public bool IsImage { get; set; } + + [JsonProperty] + public int? PixelWidth { get; set; } + + [JsonProperty] + public int? PixelHeight { get; set; } + + [JsonProperty] + public bool IsDeleted { get; set; } + + Guid IAssetInfo.AssetId + { + get { return Id; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs b/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs new file mode 100644 index 000000000..c13e058d7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/DomainObjectState.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// DomainObjectState.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using NodaTime; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class DomainObjectState : Cloneable, + IUpdateableEntityWithCreatedBy, + IUpdateableEntityWithLastModifiedBy, + IUpdateableEntityWithVersion + where T : Cloneable + { + [JsonProperty] + public Guid Id { get; set; } + + [JsonProperty] + public RefToken CreatedBy { get; set; } + + [JsonProperty] + public RefToken LastModifiedBy { get; set; } + + [JsonProperty] + public Instant Created { get; set; } + + [JsonProperty] + public Instant LastModified { get; set; } + + [JsonProperty] + public long Version { get; set; } + + public T Clone() + { + return Clone(x => { }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/EntityMapper.cs b/src/Squidex.Domain.Apps.Entities/EntityMapper.cs new file mode 100644 index 000000000..d100f596a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/EntityMapper.cs @@ -0,0 +1,77 @@ +// ========================================================================== +// EntityMapper.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities +{ + public static class EntityMapper + { + public static T Update(this T entity, SquidexCommand command, Action updater = null) where T : IEntity + { + var timestamp = SystemClock.Instance.GetCurrentInstant(); + + SetAppId(entity, command); + SetVersion(entity); + SetCreated(entity, timestamp); + SetCreatedBy(entity, command); + SetLastModified(entity, timestamp); + SetLastModifiedBy(entity, command); + + updater?.Invoke(entity); + + return entity; + } + + private static void SetLastModified(IEntity entity, Instant timestamp) + { + entity.LastModified = timestamp; + } + + private static void SetCreated(IEntity entity, Instant timestamp) + { + if (entity.Created == default(Instant)) + { + entity.Created = timestamp; + } + } + + private static void SetVersion(IEntity entity) + { + if (entity is IUpdateableEntityWithVersion withVersion) + { + withVersion.Version++; + } + } + + private static void SetCreatedBy(IEntity entity, SquidexCommand command) + { + if (entity is IUpdateableEntityWithCreatedBy withCreatedBy && withCreatedBy.CreatedBy == null) + { + withCreatedBy.CreatedBy = command.Actor; + } + } + + private static void SetLastModifiedBy(IEntity entity, SquidexCommand command) + { + if (entity is IUpdateableEntityWithLastModifiedBy withModifiedBy) + { + withModifiedBy.LastModifiedBy = command.Actor; + } + } + + private static void SetAppId(IEntity entity, SquidexCommand command) + { + if (entity is IUpdateableEntityWithAppRef appEntity && command is AppCommand appCommand) + { + appEntity.AppId = appCommand.AppId.Id; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/History/HistoryEventToStore.cs b/src/Squidex.Domain.Apps.Entities/History/HistoryEventToStore.cs new file mode 100644 index 000000000..aeda6bc40 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/History/HistoryEventToStore.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// HistoryEventToStore.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.History +{ + public sealed class HistoryEventToStore + { + private readonly Dictionary parameters = new Dictionary(); + + public string Channel { get; } + + public string Message { get; } + + public IReadOnlyDictionary Parameters + { + get { return parameters; } + } + + public HistoryEventToStore(string channel, string message) + { + Guard.NotNullOrEmpty(channel, nameof(channel)); + Guard.NotNullOrEmpty(message, nameof(message)); + + Channel = channel; + + Message = message; + } + + public HistoryEventToStore AddParameter(string key, object value) + { + parameters[key] = value.ToString(); + + return this; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/History/HistoryEventsCreatorBase.cs b/src/Squidex.Domain.Apps.Entities/History/HistoryEventsCreatorBase.cs new file mode 100644 index 000000000..e3a59eecd --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/History/HistoryEventsCreatorBase.cs @@ -0,0 +1,66 @@ +// ========================================================================== +// HistoryEventsCreatorBase.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.History +{ + public abstract class HistoryEventsCreatorBase : IHistoryEventsCreator + { + private readonly Dictionary texts = new Dictionary(); + private readonly TypeNameRegistry typeNameRegistry; + + public IReadOnlyDictionary Texts + { + get { return texts; } + } + + protected HistoryEventsCreatorBase(TypeNameRegistry typeNameRegistry) + { + Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry)); + + this.typeNameRegistry = typeNameRegistry; + } + + protected void AddEventMessage(string message) where TEvent : IEvent + { + Guard.NotNullOrEmpty(message, nameof(message)); + + texts[typeNameRegistry.GetName()] = message; + } + + protected bool HasEventText(IEvent @event) + { + var message = typeNameRegistry.GetName(@event.GetType()); + + return texts.ContainsKey(message); + } + + protected HistoryEventToStore ForEvent(IEvent @event, string channel) + { + var message = typeNameRegistry.GetName(@event.GetType()); + + return new HistoryEventToStore(channel, message); + } + + public Task CreateEventAsync(Envelope @event) + { + if (HasEventText(@event.Payload)) + { + return CreateEventCoreAsync(@event); + } + + return Task.FromResult(null); + } + + protected abstract Task CreateEventCoreAsync(Envelope @event); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/History/IHistoryEventEntity.cs b/src/Squidex.Domain.Apps.Entities/History/IHistoryEventEntity.cs new file mode 100644 index 000000000..ae201227e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/History/IHistoryEventEntity.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// IHistoryEventEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.History +{ + public interface IHistoryEventEntity : IEntity + { + Guid EventId { get; } + + RefToken Actor { get; } + + string Message { get; } + + long Version { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/History/IHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/History/IHistoryEventsCreator.cs new file mode 100644 index 000000000..38fadac6b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/History/IHistoryEventsCreator.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// IHistoryEventsCreator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.History +{ + public interface IHistoryEventsCreator + { + IReadOnlyDictionary Texts { get; } + + Task CreateEventAsync(Envelope @event); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs b/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs new file mode 100644 index 000000000..7652fbd28 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/History/Repositories/IHistoryEventRepository.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// IHistoryEventRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.History.Repositories +{ + public interface IHistoryEventRepository + { + Task> QueryByChannelAsync(Guid appId, string channelPrefix, int count); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/src/Squidex.Domain.Apps.Entities/IAppProvider.cs new file mode 100644 index 000000000..404a62e78 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IAppProvider.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// IAppProvider.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IAppProvider + { + Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(string appName, Guid id); + + Task GetAppAsync(string appName); + + Task GetSchemaAsync(string appName, Guid id, bool provideDeleted = false); + + Task GetSchemaAsync(string appName, string name, bool provideDeleted = false); + + Task> GetSchemasAsync(string appName); + + Task> GetRulesAsync(string appName); + + Task> GetUserApps(string userId); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IEntity.cs b/src/Squidex.Domain.Apps.Entities/IEntity.cs new file mode 100644 index 000000000..9aa8ea2b0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IEntity.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// IEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using NodaTime; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IEntity + { + Guid Id { get; set; } + + Instant Created { get; set; } + + Instant LastModified { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs b/src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs new file mode 100644 index 000000000..2b7549767 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// IEntityWithAppRef.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IEntityWithAppRef + { + Guid AppId { get; } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithCreatedBy.cs b/src/Squidex.Domain.Apps.Entities/IEntityWithCreatedBy.cs new file mode 100644 index 000000000..a8ff4bb95 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IEntityWithCreatedBy.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// IEntityWithCreatedBy.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IEntityWithCreatedBy + { + RefToken CreatedBy { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithLastModifiedBy.cs b/src/Squidex.Domain.Apps.Entities/IEntityWithLastModifiedBy.cs new file mode 100644 index 000000000..d5704ac7a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IEntityWithLastModifiedBy.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// IEntityWithLastModifiedBy.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IEntityWithLastModifiedBy + { + RefToken LastModifiedBy { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithVersion.cs b/src/Squidex.Domain.Apps.Entities/IEntityWithVersion.cs new file mode 100644 index 000000000..9ec4e7fa9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IEntityWithVersion.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// IEntityWithVersion.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities +{ + public interface IEntityWithVersion + { + long Version { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithAppRef.cs b/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithAppRef.cs new file mode 100644 index 000000000..aa3b58226 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithAppRef.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// IUpdateableEntityWithAppRef.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IUpdateableEntityWithAppRef + { + Guid AppId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithCreatedBy.cs b/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithCreatedBy.cs new file mode 100644 index 000000000..8f07b421d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithCreatedBy.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// IUpdateableEntityWithCreatedBy.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IUpdateableEntityWithCreatedBy + { + RefToken CreatedBy { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithLastModifiedBy.cs b/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithLastModifiedBy.cs new file mode 100644 index 000000000..c57cf3d75 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithLastModifiedBy.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// IUpdateableEntityWithLastModifiedBy.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public interface IUpdateableEntityWithLastModifiedBy + { + RefToken LastModifiedBy { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithVersion.cs b/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithVersion.cs new file mode 100644 index 000000000..229f7ea2d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithVersion.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// IUpdateableEntityWithVersion.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities +{ + public interface IUpdateableEntityWithVersion + { + long Version { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/CreateRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/CreateRule.cs new file mode 100644 index 000000000..1018ea503 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/CreateRule.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// CreateRule.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Rules.Commands +{ + public sealed class CreateRule : RuleEditCommand + { + public CreateRule() + { + RuleId = Guid.NewGuid(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/DeleteRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/DeleteRule.cs new file mode 100644 index 000000000..96edbb03b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/DeleteRule.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// DeleteRule.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Rules.Commands +{ + public sealed class DeleteRule : RuleAggregateCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/DisableRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/DisableRule.cs new file mode 100644 index 000000000..c4419b680 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/DisableRule.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// DisableRule.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Rules.Commands +{ + public sealed class DisableRule : RuleAggregateCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/EnableRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/EnableRule.cs new file mode 100644 index 000000000..8a4f9a746 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/EnableRule.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// EnableRule.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Rules.Commands +{ + public sealed class EnableRule : RuleAggregateCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleAggregateCommand.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleAggregateCommand.cs new file mode 100644 index 000000000..2b28578ad --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleAggregateCommand.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// RuleAggregateCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Rules.Commands +{ + public abstract class RuleAggregateCommand : AppCommand, IAggregateCommand + { + public Guid RuleId { get; set; } + + Guid IAggregateCommand.AggregateId + { + get { return RuleId; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs new file mode 100644 index 000000000..548e34a9e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// RuleEditCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Domain.Apps.Entities.Rules.Commands +{ + public abstract class RuleEditCommand : RuleAggregateCommand + { + public RuleTrigger Trigger { get; set; } + + public RuleAction Action { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/UpdateRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/UpdateRule.cs new file mode 100644 index 000000000..841248dcc --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/UpdateRule.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// UpdateRule.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Rules.Commands +{ + public sealed class UpdateRule : RuleEditCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs new file mode 100644 index 000000000..ccf3023f1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs @@ -0,0 +1,107 @@ +// ========================================================================== +// GuardRule.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Rules.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Rules.Guards +{ + public static class GuardRule + { + public static Task CanCreate(CreateRule command, IAppProvider appProvider) + { + Guard.NotNull(command, nameof(command)); + + return Validate.It(() => "Cannot create rule.", async error => + { + if (command.Trigger == null) + { + error(new ValidationError("Trigger must be defined.", nameof(command.Trigger))); + } + else + { + var errors = await RuleTriggerValidator.ValidateAsync(command.AppId.Name, command.Trigger, appProvider); + + errors.Foreach(error); + } + + if (command.Action == null) + { + error(new ValidationError("Trigger must be defined.", nameof(command.Action))); + } + else + { + var errors = await RuleActionValidator.ValidateAsync(command.Action); + + errors.Foreach(error); + } + }); + } + + public static Task CanUpdate(UpdateRule command, IAppProvider appProvider) + { + Guard.NotNull(command, nameof(command)); + + return Validate.It(() => "Cannot update rule.", async error => + { + if (command.Trigger == null && command.Action == null) + { + error(new ValidationError("Either trigger or action must be defined.", nameof(command.Trigger), nameof(command.Action))); + } + + if (command.Trigger != null) + { + var errors = await RuleTriggerValidator.ValidateAsync(command.AppId.Name, command.Trigger, appProvider); + + errors.Foreach(error); + } + + if (command.Action != null) + { + var errors = await RuleActionValidator.ValidateAsync(command.Action); + + errors.Foreach(error); + } + }); + } + + public static void CanEnable(EnableRule command, Rule rule) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot enable rule.", error => + { + if (rule.IsEnabled) + { + error(new ValidationError("Rule is already enabled.")); + } + }); + } + + public static void CanDisable(DisableRule command, Rule rule) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot disable rule.", error => + { + if (!rule.IsEnabled) + { + error(new ValidationError("Rule is already disabled.")); + } + }); + } + + public static void CanDelete(DeleteRule command) + { + Guard.NotNull(command, nameof(command)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs new file mode 100644 index 000000000..c1a9578d3 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs @@ -0,0 +1,40 @@ +// ========================================================================== +// RuleActionValidator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Rules.Guards +{ + public sealed class RuleActionValidator : IRuleActionVisitor>> + { + public static Task> ValidateAsync(RuleAction action) + { + Guard.NotNull(action, nameof(action)); + + var visitor = new RuleActionValidator(); + + return action.Accept(visitor); + } + + public Task> Visit(WebhookAction action) + { + var errors = new List(); + + if (action.Url == null || !action.Url.IsAbsoluteUri) + { + errors.Add(new ValidationError("Url must be specified and absolute.", nameof(action.Url))); + } + + return Task.FromResult>(errors); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs new file mode 100644 index 000000000..dbe01af6f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// RuleTriggerValidator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Rules.Guards +{ + public sealed class RuleTriggerValidator : IRuleTriggerVisitor>> + { + public Func> SchemaProvider { get; } + + public RuleTriggerValidator(Func> schemaProvider) + { + SchemaProvider = schemaProvider; + } + + public static Task> ValidateAsync(string appName, RuleTrigger action, IAppProvider appProvider) + { + Guard.NotNull(action, nameof(action)); + Guard.NotNull(appProvider, nameof(appProvider)); + + var visitor = new RuleTriggerValidator(x => appProvider.GetSchemaAsync(appName, x)); + + return action.Accept(visitor); + } + + public async Task> Visit(ContentChangedTrigger trigger) + { + if (trigger.Schemas != null) + { + var schemaErrors = await Task.WhenAll( + trigger.Schemas.Select(async s => + await SchemaProvider(s.SchemaId) == null + ? new ValidationError($"Schema {s.SchemaId} does not exist.", nameof(trigger.Schemas)) + : null)); + + return schemaErrors.Where(x => x != null).ToList(); + } + + return new List(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/IRuleEntity.cs b/src/Squidex.Domain.Apps.Entities/Rules/IRuleEntity.cs new file mode 100644 index 000000000..a358e1c0e --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/IRuleEntity.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// IRuleEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public interface IRuleEntity : + IEntity, + IEntityWithAppRef, + IEntityWithCreatedBy, + IEntityWithLastModifiedBy, + IEntityWithVersion + { + Rule RuleDef { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/IRuleEventEntity.cs b/src/Squidex.Domain.Apps.Entities/Rules/IRuleEventEntity.cs new file mode 100644 index 000000000..7b499253f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/IRuleEventEntity.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// IRuleEventEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using NodaTime; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public interface IRuleEventEntity : IEntity + { + RuleJob Job { get; } + + Instant? NextAttempt { get; } + + RuleJobResult JobResult { get; } + + RuleResult Result { get; } + + int NumCalls { get; } + + string LastDump { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs new file mode 100644 index 000000000..7bf7acb45 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs @@ -0,0 +1,35 @@ +// ========================================================================== +// IRuleEventRepository.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using NodaTime; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Domain.Apps.Entities.Rules.Repositories +{ + public interface IRuleEventRepository + { + Task EnqueueAsync(RuleJob job, Instant nextAttempt); + + Task EnqueueAsync(Guid id, Instant nextAttempt); + + Task MarkSentAsync(Guid jobId, string dump, RuleResult result, RuleJobResult jobResult, TimeSpan elapsed, Instant? nextCall); + + Task QueryPendingAsync(Instant now, Func callback, CancellationToken cancellationToken = default(CancellationToken)); + + Task CountByAppAsync(Guid appId); + + Task> QueryByAppAsync(Guid appId, int skip = 0, int take = 20); + + Task FindAsync(Guid id); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs new file mode 100644 index 000000000..8fe0e34f7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs @@ -0,0 +1,92 @@ +// ========================================================================== +// RuleCommandMiddleware.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Rules.Commands; +using Squidex.Domain.Apps.Entities.Rules.Guards; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Dispatching; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public class RuleCommandMiddleware : ICommandMiddleware + { + private readonly IAggregateHandler handler; + private readonly IAppProvider appProvider; + + public RuleCommandMiddleware(IAggregateHandler handler, IAppProvider appProvider) + { + Guard.NotNull(handler, nameof(handler)); + Guard.NotNull(appProvider, nameof(appProvider)); + + this.handler = handler; + + this.appProvider = appProvider; + } + + protected Task On(CreateRule command, CommandContext context) + { + return handler.CreateAsync(context, async w => + { + await GuardRule.CanCreate(command, appProvider); + + w.Create(command); + }); + } + + protected Task On(UpdateRule command, CommandContext context) + { + return handler.UpdateAsync(context, async c => + { + await GuardRule.CanUpdate(command, appProvider); + + c.Update(command); + }); + } + + protected Task On(EnableRule command, CommandContext context) + { + return handler.UpdateAsync(context, r => + { + GuardRule.CanEnable(command, r.State.RuleDef); + + r.Enable(command); + }); + } + + protected Task On(DisableRule command, CommandContext context) + { + return handler.UpdateAsync(context, r => + { + GuardRule.CanDisable(command, r.State.RuleDef); + + r.Disable(command); + }); + } + + protected Task On(DeleteRule command, CommandContext context) + { + return handler.UpdateAsync(context, c => + { + GuardRule.CanDelete(command); + + c.Delete(command); + }); + } + + public async Task HandleAsync(CommandContext context, Func next) + { + if (!await this.DispatchActionAsync(context.Command, context)) + { + await next(); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs new file mode 100644 index 000000000..a3e07ea47 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs @@ -0,0 +1,157 @@ +// ========================================================================== +// RuleDequeuer.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using NodaTime; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Timers; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class RuleDequeuer : DisposableObjectBase, IExternalSystem + { + private readonly ActionBlock requestBlock; + private readonly IRuleEventRepository ruleEventRepository; + private readonly RuleService ruleService; + private readonly CompletionTimer timer; + private readonly ConcurrentDictionary executing = new ConcurrentDictionary(); + private readonly IClock clock; + private readonly ISemanticLog log; + + public RuleDequeuer(RuleService ruleService, IRuleEventRepository ruleEventRepository, ISemanticLog log, IClock clock) + { + Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); + Guard.NotNull(ruleService, nameof(ruleService)); + Guard.NotNull(clock, nameof(clock)); + Guard.NotNull(log, nameof(log)); + + this.ruleEventRepository = ruleEventRepository; + this.ruleService = ruleService; + + this.clock = clock; + + this.log = log; + + requestBlock = + new ActionBlock(HandleAsync, + new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 }); + + timer = new CompletionTimer(5000, QueryAsync); + } + + protected override void DisposeObject(bool disposing) + { + if (disposing) + { + timer.StopAsync().Wait(); + + requestBlock.Complete(); + requestBlock.Completion.Wait(); + } + } + + public void Connect() + { + } + + public void Next() + { + timer.SkipCurrentDelay(); + } + + private async Task QueryAsync(CancellationToken cancellationToken) + { + try + { + var now = clock.GetCurrentInstant(); + + await ruleEventRepository.QueryPendingAsync(now, requestBlock.SendAsync, cancellationToken); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "QueueWebhookEvents") + .WriteProperty("status", "Failed")); + } + } + + public async Task HandleAsync(IRuleEventEntity @event) + { + if (!executing.TryAdd(@event.Id, false)) + { + return; + } + + try + { + var job = @event.Job; + + var response = await ruleService.InvokeAsync(job.ActionName, job.ActionData); + + var jobInvoke = ComputeJobInvoke(response.Result, @event, job); + var jobResult = ComputeJobResult(response.Result, jobInvoke); + + await ruleEventRepository.MarkSentAsync(@event.Id, response.Dump, response.Result, jobResult, response.Elapsed, jobInvoke); + } + catch (Exception ex) + { + log.LogError(ex, w => w + .WriteProperty("action", "SendWebhookEvent") + .WriteProperty("status", "Failed")); + } + finally + { + executing.TryRemove(@event.Id, out var value); + } + } + + private static RuleJobResult ComputeJobResult(RuleResult result, Instant? nextCall) + { + if (result != RuleResult.Success && !nextCall.HasValue) + { + return RuleJobResult.Failed; + } + else if (result != RuleResult.Success && nextCall.HasValue) + { + return RuleJobResult.Retry; + } + else + { + return RuleJobResult.Success; + } + } + + private static Instant? ComputeJobInvoke(RuleResult result, IRuleEventEntity @event, RuleJob job) + { + if (result != RuleResult.Success) + { + switch (@event.NumCalls) + { + case 0: + return job.Created.Plus(Duration.FromMinutes(5)); + case 1: + return job.Created.Plus(Duration.FromHours(1)); + case 2: + return job.Created.Plus(Duration.FromHours(6)); + case 3: + return job.Created.Plus(Duration.FromHours(12)); + } + } + + return null; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs new file mode 100644 index 000000000..80522b6d5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs @@ -0,0 +1,93 @@ +// ========================================================================== +// RuleDomainObject.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Entities.Rules.Commands; +using Squidex.Domain.Apps.Entities.Rules.State; +using Squidex.Domain.Apps.Events.Rules; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public class RuleDomainObject : DomainObjectBase + { + public void Create(CreateRule command) + { + VerifyNotCreated(); + + UpdateRule(command, r => new Rule(command.Trigger, command.Action)); + + RaiseEvent(SimpleMapper.Map(command, new RuleCreated())); + } + + public void Update(UpdateRule command) + { + VerifyCreatedAndNotDeleted(); + + UpdateRule(command, r => r.Update(command.Trigger).Update(command.Action)); + + RaiseEvent(SimpleMapper.Map(command, new RuleUpdated())); + } + + public void Enable(EnableRule command) + { + VerifyCreatedAndNotDeleted(); + + UpdateRule(command, r => r.Enable()); + + RaiseEvent(SimpleMapper.Map(command, new RuleEnabled())); + } + + public void Disable(DisableRule command) + { + VerifyCreatedAndNotDeleted(); + + UpdateRule(command, r => r.Disable()); + + RaiseEvent(SimpleMapper.Map(command, new RuleDisabled())); + } + + public void Delete(DeleteRule command) + { + VerifyCreatedAndNotDeleted(); + + UpdateState(command, s => s.IsDeleted = true); + + RaiseEvent(SimpleMapper.Map(command, new RuleDeleted())); + } + + private void VerifyNotCreated() + { + if (State.RuleDef != null) + { + throw new DomainException("Webhook has already been created."); + } + } + + private void VerifyCreatedAndNotDeleted() + { + if (State.IsDeleted || State.RuleDef == null) + { + throw new DomainException("Webhook has already been deleted or not created yet."); + } + } + + private void UpdateRule(ICommand command, Func updater) + { + UpdateState(command, s => s.RuleDef = updater(s.RuleDef)); + } + + protected override RuleState CloneState(ICommand command, Action updater) + { + return State.Clone().Update((SquidexCommand)command, updater); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs new file mode 100644 index 000000000..31caed52d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// RuleEnqueuer.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Entities.Rules.Repositories; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class RuleEnqueuer : IEventConsumer + { + private readonly IRuleEventRepository ruleEventRepository; + private readonly IAppProvider appProvider; + private readonly RuleService ruleService; + + public string Name + { + get { return GetType().Name; } + } + + public string EventsFilter + { + get { return ".*"; } + } + + public RuleEnqueuer( + IRuleEventRepository ruleEventRepository, IAppProvider appProvider, + RuleService ruleService) + { + Guard.NotNull(ruleEventRepository, nameof(ruleEventRepository)); + Guard.NotNull(ruleService, nameof(ruleService)); + + Guard.NotNull(appProvider, nameof(appProvider)); + + this.ruleEventRepository = ruleEventRepository; + this.ruleService = ruleService; + + this.appProvider = appProvider; + } + + public Task ClearAsync() + { + return TaskHelper.Done; + } + + public async Task On(Envelope @event) + { + if (@event.Payload is AppEvent appEvent) + { + var rules = await appProvider.GetRulesAsync(appEvent.AppId.Name); + + foreach (var ruleEntity in rules) + { + var job = ruleService.CreateJob(ruleEntity.RuleDef, @event); + + if (job != null) + { + await ruleEventRepository.EnqueueAsync(job, job.Created); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleJobResult.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleJobResult.cs new file mode 100644 index 000000000..79dde166a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleJobResult.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// RuleJobResult.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public enum RuleJobResult + { + Pending, + Success, + Retry, + Failed + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs new file mode 100644 index 000000000..c41d1761d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// RuleState.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using Squidex.Domain.Apps.Core.Rules; + +namespace Squidex.Domain.Apps.Entities.Rules.State +{ + public sealed class RuleState : DomainObjectState, IRuleEntity + { + [JsonProperty] + public Guid AppId { get; set; } + + [JsonProperty] + public Rule RuleDef { get; set; } + + [JsonProperty] + public bool IsDeleted { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/SchemaAggregateCommand.cs b/src/Squidex.Domain.Apps.Entities/SchemaAggregateCommand.cs new file mode 100644 index 000000000..72bc2990a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/SchemaAggregateCommand.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// SchemaAggregateCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class SchemaAggregateCommand : SchemaCommand, IAggregateCommand + { + Guid IAggregateCommand.AggregateId + { + get { return SchemaId.Id; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/SchemaCommand.cs b/src/Squidex.Domain.Apps.Entities/SchemaCommand.cs new file mode 100644 index 000000000..d962f9931 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/SchemaCommand.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// SchemaCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class SchemaCommand : AppCommand + { + public NamedId SchemaId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs new file mode 100644 index 000000000..40fea2376 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// AddField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class AddField : SchemaAggregateCommand + { + public string Name { get; set; } + + public string Partitioning { get; set; } + + public FieldProperties Properties { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs new file mode 100644 index 000000000..685006d55 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// ConfigureScripts.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class ConfigureScripts : SchemaAggregateCommand + { + public string ScriptQuery { get; set; } + + public string ScriptCreate { get; set; } + + public string ScriptUpdate { get; set; } + + public string ScriptDelete { get; set; } + + public string ScriptChange { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs new file mode 100644 index 000000000..ec389bc7b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// CreateSchema.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.Commands; +using SchemaFields = System.Collections.Generic.List; + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class CreateSchema : AppCommand, IAggregateCommand + { + public Guid SchemaId { get; set; } + + public SchemaFields Fields { get; set; } + + public SchemaProperties Properties { get; set; } + + public string Name { get; set; } + + Guid IAggregateCommand.AggregateId + { + get { return SchemaId; } + } + + public CreateSchema() + { + SchemaId = Guid.NewGuid(); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs new file mode 100644 index 000000000..49c84f3ea --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaField.cs @@ -0,0 +1,27 @@ +// ========================================================================== +// CreateSchemaField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class CreateSchemaField + { + public string Partitioning { get; set; } + + public string Name { get; set; } + + public bool IsHidden { get; set; } + + public bool IsLocked { get; set; } + + public bool IsDisabled { get; set; } + + public FieldProperties Properties { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteField.cs new file mode 100644 index 000000000..cc8e5f572 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteField.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// DeleteField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class DeleteField : FieldCommand + { + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs new file mode 100644 index 000000000..c5d61e764 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// DeleteSchema.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class DeleteSchema : SchemaAggregateCommand + { + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DisableField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DisableField.cs new file mode 100644 index 000000000..7ab6a58df --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DisableField.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// DisableField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class DisableField : FieldCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/EnableField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/EnableField.cs new file mode 100644 index 000000000..7905de7d8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/EnableField.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// EnableField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class EnableField : FieldCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs new file mode 100644 index 000000000..54396bec6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// FieldCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public class FieldCommand : SchemaAggregateCommand + { + public long FieldId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/HideField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/HideField.cs new file mode 100644 index 000000000..96a65bc88 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/HideField.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// HideField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class HideField : FieldCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/LockField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/LockField.cs new file mode 100644 index 000000000..705221a9a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/LockField.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// LockField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class LockField : FieldCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs new file mode 100644 index 000000000..4bf22c436 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// PublishSchema.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class PublishSchema : SchemaAggregateCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs new file mode 100644 index 000000000..af23659f6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// ReorderFields.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class ReorderFields : SchemaAggregateCommand + { + public List FieldIds { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ShowField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ShowField.cs new file mode 100644 index 000000000..7e93cb85f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ShowField.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// ShowField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class ShowField : FieldCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs new file mode 100644 index 000000000..ace984eb1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// UnpublishSchema.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class UnpublishSchema : SchemaAggregateCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateField.cs new file mode 100644 index 000000000..6756cd4ba --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateField.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// UpdateField.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class UpdateField : FieldCommand + { + public FieldProperties Properties { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs new file mode 100644 index 000000000..0f0f49252 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// UpdateSchema.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Entities.Schemas.Commands +{ + public sealed class UpdateSchema : SchemaAggregateCommand + { + public SchemaProperties Properties { get; set; } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs new file mode 100644 index 000000000..0102e441f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs @@ -0,0 +1,232 @@ +// ========================================================================== +// FieldpropertiesValidator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Schemas.Guards +{ + public sealed class FieldPropertiesValidator : IFieldPropertiesVisitor> + { + private static readonly FieldPropertiesValidator Instance = new FieldPropertiesValidator(); + + private FieldPropertiesValidator() + { + } + + public static IEnumerable Validate(FieldProperties properties) + { + return properties?.Accept(Instance) ?? Enumerable.Empty(); + } + + public IEnumerable Visit(AssetsFieldProperties properties) + { + if (properties.MaxItems.HasValue && properties.MinItems.HasValue && properties.MinItems.Value >= properties.MaxItems.Value) + { + yield return new ValidationError("Max items must be greater than min items.", + nameof(properties.MinItems), + nameof(properties.MaxItems)); + } + + if (properties.MaxHeight.HasValue && properties.MinHeight.HasValue && properties.MinHeight.Value >= properties.MaxHeight.Value) + { + yield return new ValidationError("Max height must be greater than min height.", + nameof(properties.MaxHeight), + nameof(properties.MinHeight)); + } + + if (properties.MaxWidth.HasValue && properties.MinWidth.HasValue && properties.MinWidth.Value >= properties.MaxWidth.Value) + { + yield return new ValidationError("Max width must be greater than min width.", + nameof(properties.MaxWidth), + nameof(properties.MinWidth)); + } + + if (properties.MaxSize.HasValue && properties.MinSize.HasValue && properties.MinSize.Value >= properties.MaxSize.Value) + { + yield return new ValidationError("Max size must be greater than min size.", + nameof(properties.MaxSize), + nameof(properties.MinSize)); + } + + if (properties.AspectWidth.HasValue != properties.AspectHeight.HasValue) + { + yield return new ValidationError("Aspect width and height must be defined.", + nameof(properties.AspectWidth), + nameof(properties.AspectHeight)); + } + } + + public IEnumerable Visit(BooleanFieldProperties properties) + { + if (!properties.Editor.IsEnumValue()) + { + yield return new ValidationError("Editor is not a valid value.", + nameof(properties.Editor)); + } + } + + public IEnumerable Visit(DateTimeFieldProperties properties) + { + if (!properties.Editor.IsEnumValue()) + { + yield return new ValidationError("Editor is not a valid value.", + nameof(properties.Editor)); + } + + if (properties.DefaultValue.HasValue && properties.MinValue.HasValue && properties.DefaultValue.Value < properties.MinValue.Value) + { + yield return new ValidationError("Default value must be greater than min value.", + nameof(properties.DefaultValue)); + } + + if (properties.DefaultValue.HasValue && properties.MaxValue.HasValue && properties.DefaultValue.Value > properties.MaxValue.Value) + { + yield return new ValidationError("Default value must be less than max value.", + nameof(properties.DefaultValue)); + } + + if (properties.MaxValue.HasValue && properties.MinValue.HasValue && properties.MinValue.Value >= properties.MaxValue.Value) + { + yield return new ValidationError("Max value must be greater than min value.", + nameof(properties.MinValue), + nameof(properties.MaxValue)); + } + + if (properties.CalculatedDefaultValue.HasValue) + { + if (!properties.CalculatedDefaultValue.Value.IsEnumValue()) + { + yield return new ValidationError("Calculated default value is not valid.", + nameof(properties.CalculatedDefaultValue)); + } + + if (properties.DefaultValue.HasValue) + { + yield return new ValidationError("Calculated default value and default value cannot be used together.", + nameof(properties.CalculatedDefaultValue), + nameof(properties.DefaultValue)); + } + } + } + + public IEnumerable Visit(GeolocationFieldProperties properties) + { + if (!properties.Editor.IsEnumValue()) + { + yield return new ValidationError("Editor is not a valid value.", + nameof(properties.Editor)); + } + } + + public IEnumerable Visit(JsonFieldProperties properties) + { + yield break; + } + + public IEnumerable Visit(NumberFieldProperties properties) + { + if (!properties.Editor.IsEnumValue()) + { + yield return new ValidationError("Editor is not a valid value.", + nameof(properties.Editor)); + } + + if ((properties.Editor == NumberFieldEditor.Radio || properties.Editor == NumberFieldEditor.Dropdown) && (properties.AllowedValues == null || properties.AllowedValues.Count == 0)) + { + yield return new ValidationError("Radio buttons or dropdown list need allowed values.", + nameof(properties.AllowedValues)); + } + + if (properties.DefaultValue.HasValue && properties.MinValue.HasValue && properties.DefaultValue.Value < properties.MinValue.Value) + { + yield return new ValidationError("Default value must be greater than min value.", + nameof(properties.DefaultValue)); + } + + if (properties.DefaultValue.HasValue && properties.MaxValue.HasValue && properties.DefaultValue.Value > properties.MaxValue.Value) + { + yield return new ValidationError("Default value must be less than max value.", + nameof(properties.DefaultValue)); + } + + if (properties.MaxValue.HasValue && properties.MinValue.HasValue && properties.MinValue.Value >= properties.MaxValue.Value) + { + yield return new ValidationError("Max value must be greater than min value.", + nameof(properties.MinValue), + nameof(properties.MaxValue)); + } + + if (properties.AllowedValues != null && properties.AllowedValues.Count > 0 && (properties.MinValue.HasValue || properties.MaxValue.HasValue)) + { + yield return new ValidationError("Either allowed values or min and max value can be defined.", + nameof(properties.AllowedValues), + nameof(properties.MinValue), + nameof(properties.MaxValue)); + } + } + + public IEnumerable Visit(ReferencesFieldProperties properties) + { + if (properties.MaxItems.HasValue && properties.MinItems.HasValue && properties.MinItems.Value >= properties.MaxItems.Value) + { + yield return new ValidationError("Max items must be greater than min items.", + nameof(properties.MinItems), + nameof(properties.MaxItems)); + } + } + + public IEnumerable Visit(StringFieldProperties properties) + { + if (!properties.Editor.IsEnumValue()) + { + yield return new ValidationError("Editor is not a valid value.", + nameof(properties.Editor)); + } + + if ((properties.Editor == StringFieldEditor.Radio || properties.Editor == StringFieldEditor.Dropdown) && (properties.AllowedValues == null || properties.AllowedValues.Count == 0)) + { + yield return new ValidationError("Radio buttons or dropdown list need allowed values.", + nameof(properties.AllowedValues)); + } + + if (properties.Pattern != null && !properties.Pattern.IsValidRegex()) + { + yield return new ValidationError("Pattern is not a valid expression.", + nameof(properties.Pattern)); + } + + if (properties.MaxLength.HasValue && properties.MinLength.HasValue && properties.MinLength.Value >= properties.MaxLength.Value) + { + yield return new ValidationError("Max length must be greater than min length.", + nameof(properties.MinLength), + nameof(properties.MaxLength)); + } + + if (properties.AllowedValues != null && properties.AllowedValues.Count > 0 && (properties.MinLength.HasValue || properties.MaxLength.HasValue)) + { + yield return new ValidationError("Either allowed values or min and max length can be defined.", + nameof(properties.AllowedValues), + nameof(properties.MinLength), + nameof(properties.MaxLength)); + } + } + + public IEnumerable Visit(TagsFieldProperties properties) + { + if (properties.MaxItems.HasValue && properties.MinItems.HasValue && properties.MinItems.Value >= properties.MaxItems.Value) + { + yield return new ValidationError("Max items must be greater than min items.", + nameof(properties.MinItems), + nameof(properties.MaxItems)); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs new file mode 100644 index 000000000..70670d74d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs @@ -0,0 +1,128 @@ +// ========================================================================== +// GuardSchema.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Schemas.Guards +{ + public static class GuardSchema + { + public static Task CanCreate(CreateSchema command, IAppProvider appProvider) + { + Guard.NotNull(command, nameof(command)); + + return Validate.It(() => "Cannot create schema.", async error => + { + if (!command.Name.IsSlug()) + { + error(new ValidationError("Name must be a valid slug.", nameof(command.Name))); + } + + if (await appProvider.GetSchemaAsync(command.AppId.Name, command.Name) != null) + { + error(new ValidationError($"A schema with name '{command.Name}' already exists", nameof(command.Name))); + } + + if (command.Fields != null && command.Fields.Any()) + { + var index = 0; + + foreach (var field in command.Fields) + { + var prefix = $"Fields.{index}"; + + if (!field.Partitioning.IsValidPartitioning()) + { + error(new ValidationError("Partitioning is not valid.", $"{prefix}.{nameof(field.Partitioning)}")); + } + + if (!field.Name.IsPropertyName()) + { + error(new ValidationError("Name must be a valid property name.", $"{prefix}.{nameof(field.Name)}")); + } + + if (field.Properties == null) + { + error(new ValidationError("Properties must be defined.", $"{prefix}.{nameof(field.Properties)}")); + } + + var propertyErrors = FieldPropertiesValidator.Validate(field.Properties); + + foreach (var propertyError in propertyErrors) + { + error(propertyError); + } + } + + if (command.Fields.Select(x => x.Name).Distinct().Count() != command.Fields.Count) + { + error(new ValidationError("Fields cannot have duplicate names.", nameof(command.Fields))); + } + } + }); + } + + public static void CanReorder(Schema schema, ReorderFields command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot reorder schema fields.", error => + { + if (command.FieldIds == null) + { + error(new ValidationError("Field ids must be specified.", nameof(command.FieldIds))); + } + + if (command.FieldIds.Count != schema.Fields.Count || command.FieldIds.Any(x => !schema.FieldsById.ContainsKey(x))) + { + error(new ValidationError("Ids must cover all fields.", nameof(command.FieldIds))); + } + }); + } + + public static void CanPublish(Schema schema, PublishSchema command) + { + Guard.NotNull(command, nameof(command)); + + if (schema.IsPublished) + { + throw new DomainException("Schema is already published."); + } + } + + public static void CanUnpublish(Schema schema, UnpublishSchema command) + { + Guard.NotNull(command, nameof(command)); + + if (!schema.IsPublished) + { + throw new DomainException("Schema is not published."); + } + } + + public static void CanUpdate(Schema schema, UpdateSchema command) + { + Guard.NotNull(command, nameof(command)); + } + + public static void CanConfigureScripts(Schema schema, ConfigureScripts command) + { + Guard.NotNull(command, nameof(command)); + } + + public static void CanDelete(Schema schema, DeleteSchema command) + { + Guard.NotNull(command, nameof(command)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs new file mode 100644 index 000000000..dfa761112 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs @@ -0,0 +1,160 @@ +// ========================================================================== +// SchemaFieldGuard.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Schemas.Guards +{ + public static class GuardSchemaField + { + public static void CanAdd(Schema schema, AddField command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot add a new field.", error => + { + if (!command.Partitioning.IsValidPartitioning()) + { + error(new ValidationError("Partitioning is not valid.", nameof(command.Partitioning))); + } + + if (!command.Name.IsPropertyName()) + { + error(new ValidationError("Name must be a valid property name.", nameof(command.Name))); + } + + if (command.Properties == null) + { + error(new ValidationError("Properties must be defined.", nameof(command.Properties))); + } + + var propertyErrors = FieldPropertiesValidator.Validate(command.Properties); + + foreach (var propertyError in propertyErrors) + { + error(propertyError); + } + + if (schema.FieldsByName.ContainsKey(command.Name)) + { + error(new ValidationError($"There is already a field with name '{command.Name}'", nameof(command.Name))); + } + }); + } + + public static void CanUpdate(Schema schema, UpdateField command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot update field.", error => + { + if (command.Properties == null) + { + error(new ValidationError("Properties must be defined.", nameof(command.Properties))); + } + + var propertyErrors = FieldPropertiesValidator.Validate(command.Properties); + + foreach (var propertyError in propertyErrors) + { + error(propertyError); + } + }); + + var field = GetFieldOrThrow(schema, command.FieldId); + + if (field.IsLocked) + { + throw new DomainException("Schema field is already locked."); + } + } + + public static void CanDelete(Schema schema, DeleteField command) + { + Guard.NotNull(command, nameof(command)); + + var field = GetFieldOrThrow(schema, command.FieldId); + + if (field.IsLocked) + { + throw new DomainException("Schema field is locked."); + } + } + + public static void CanHide(Schema schema, HideField command) + { + Guard.NotNull(command, nameof(command)); + + var field = GetFieldOrThrow(schema, command.FieldId); + + if (field.IsHidden) + { + throw new DomainException("Schema field is already hidden."); + } + } + + public static void CanShow(Schema schema, ShowField command) + { + Guard.NotNull(command, nameof(command)); + + var field = GetFieldOrThrow(schema, command.FieldId); + + if (!field.IsHidden) + { + throw new DomainException("Schema field is already visible."); + } + } + + public static void CanDisable(Schema schema, DisableField command) + { + Guard.NotNull(command, nameof(command)); + + var field = GetFieldOrThrow(schema, command.FieldId); + + if (field.IsDisabled) + { + throw new DomainException("Schema field is already disabled."); + } + } + + public static void CanEnable(Schema schema, EnableField command) + { + var field = GetFieldOrThrow(schema, command.FieldId); + + if (!field.IsDisabled) + { + throw new DomainException("Schema field is already enabled."); + } + } + + public static void CanLock(Schema schema, LockField command) + { + Guard.NotNull(command, nameof(command)); + + var field = GetFieldOrThrow(schema, command.FieldId); + + if (field.IsLocked) + { + throw new DomainException("Schema field is already locked."); + } + } + + private static Field GetFieldOrThrow(Schema schema, long fieldId) + { + if (!schema.FieldsById.TryGetValue(fieldId, out var field)) + { + throw new DomainObjectNotFoundException(fieldId.ToString(), "Fields", typeof(Schema)); + } + + return field; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs b/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs new file mode 100644 index 000000000..34bcc8d9a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// ISchemaEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public interface ISchemaEntity : + IEntity, + IEntityWithAppRef, + IEntityWithCreatedBy, + IEntityWithLastModifiedBy, + IEntityWithVersion + { + string Name { get; } + + bool IsPublished { get; } + + bool IsDeleted { get; } + + string ScriptQuery { get; } + + string ScriptCreate { get; } + + string ScriptUpdate { get; } + + string ScriptDelete { get; } + + string ScriptChange { get; } + + Schema SchemaDef { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaCommandMiddleware.cs new file mode 100644 index 000000000..f22b1a599 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaCommandMiddleware.cs @@ -0,0 +1,198 @@ +// ========================================================================== +// SchemaCommandMiddleware.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Domain.Apps.Entities.Schemas.Guards; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Dispatching; + +namespace Squidex.Domain.Apps.Entities.State.SchemaDefs +{ + public class SchemaCommandMiddleware : ICommandMiddleware + { + private readonly IAppProvider appProvider; + private readonly IAggregateHandler handler; + + public SchemaCommandMiddleware(IAggregateHandler handler, IAppProvider appProvider) + { + Guard.NotNull(handler, nameof(handler)); + Guard.NotNull(appProvider, nameof(appProvider)); + + this.handler = handler; + + this.appProvider = appProvider; + } + + protected Task On(CreateSchema command, CommandContext context) + { + return handler.CreateAsync(context, async s => + { + await GuardSchema.CanCreate(command, appProvider); + + s.Create(command); + + context.Complete(EntityCreatedResult.Create(s.State.Id, s.Version)); + }); + } + + protected Task On(AddField command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchemaField.CanAdd(s.State.SchemaDef, command); + + s.Add(command); + + context.Complete(EntityCreatedResult.Create(s.State.SchemaDef.FieldsById.Values.First(x => x.Name == command.Name).Id, s.Version)); + }); + } + + protected Task On(DeleteField command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchemaField.CanDelete(s.State.SchemaDef, command); + + s.DeleteField(command); + }); + } + + protected Task On(LockField command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchemaField.CanLock(s.State.SchemaDef, command); + + s.LockField(command); + }); + } + + protected Task On(HideField command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchemaField.CanHide(s.State.SchemaDef, command); + + s.HideField(command); + }); + } + + protected Task On(ShowField command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchemaField.CanShow(s.State.SchemaDef, command); + + s.ShowField(command); + }); + } + + protected Task On(DisableField command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchemaField.CanDisable(s.State.SchemaDef, command); + + s.DisableField(command); + }); + } + + protected Task On(EnableField command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchemaField.CanEnable(s.State.SchemaDef, command); + + s.EnableField(command); + }); + } + + protected Task On(UpdateField command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchemaField.CanUpdate(s.State.SchemaDef, command); + + s.UpdateField(command); + }); + } + + protected Task On(ReorderFields command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchema.CanReorder(s.State.SchemaDef, command); + + s.Reorder(command); + }); + } + + protected Task On(UpdateSchema command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchema.CanUpdate(s.State.SchemaDef, command); + + s.Update(command); + }); + } + + protected Task On(PublishSchema command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchema.CanPublish(s.State.SchemaDef, command); + + s.Publish(command); + }); + } + + protected Task On(UnpublishSchema command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchema.CanUnpublish(s.State.SchemaDef, command); + + s.Unpublish(command); + }); + } + + protected Task On(ConfigureScripts command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchema.CanConfigureScripts(s.State.SchemaDef, command); + + s.ConfigureScripts(command); + }); + } + + protected Task On(DeleteSchema command, CommandContext context) + { + return handler.UpdateAsync(context, s => + { + GuardSchema.CanDelete(s.State.SchemaDef, command); + + s.Delete(command); + }); + } + + public async Task HandleAsync(CommandContext context, Func next) + { + if (!await this.DispatchActionAsync(context.Command, context)) + { + await next(); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs new file mode 100644 index 000000000..6d3eb5277 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs @@ -0,0 +1,261 @@ +// ========================================================================== +// SchemaDomainObject.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Domain.Apps.Entities.Schemas.State; +using Squidex.Domain.Apps.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public class SchemaDomainObject : DomainObjectBase + { + private readonly FieldRegistry registry; + + public SchemaDomainObject(FieldRegistry registry) + { + Guard.NotNull(registry, nameof(registry)); + + this.registry = registry; + } + + public SchemaDomainObject Create(CreateSchema command) + { + VerifyNotCreated(); + + var @event = SimpleMapper.Map(command, new SchemaCreated { SchemaId = new NamedId(State.Id, command.Name) }); + + if (command.Fields != null) + { + @event.Fields = new List(); + + foreach (var commandField in command.Fields) + { + var eventField = SimpleMapper.Map(commandField, new SchemaCreatedField()); + + @event.Fields.Add(eventField); + } + } + + RaiseEvent(@event); + + return this; + } + + public SchemaDomainObject Add(AddField command) + { + VerifyCreatedAndNotDeleted(); + + var partitioning = + string.Equals(command.Partitioning, Partitioning.Language.Key, StringComparison.OrdinalIgnoreCase) ? + Partitioning.Language : + Partitioning.Invariant; + + var fieldId = State.TotalFields; + + var field = registry.CreateField(fieldId, command.Name, partitioning, command.Properties); + + UpdateState(command, state => + { + state.SchemaDef = state.SchemaDef.AddField(field); + state.TotalFields = fieldId + 1; + }); + + RaiseEvent(SimpleMapper.Map(command, new FieldAdded { FieldId = new NamedId(fieldId + 1, command.Name) })); + + return this; + } + + public SchemaDomainObject UpdateField(UpdateField command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.UpdateField(command.FieldId, command.Properties)); + + RaiseEvent(command, SimpleMapper.Map(command, new FieldUpdated())); + + return this; + } + + public SchemaDomainObject LockField(LockField command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.LockField(command.FieldId)); + + RaiseEvent(command, new FieldLocked()); + + return this; + } + + public SchemaDomainObject HideField(HideField command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.HideField(command.FieldId)); + + RaiseEvent(command, new FieldHidden()); + + return this; + } + + public SchemaDomainObject ShowField(ShowField command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.ShowField(command.FieldId)); + + RaiseEvent(command, new FieldShown()); + + return this; + } + + public SchemaDomainObject DisableField(DisableField command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.DisableField(command.FieldId)); + + RaiseEvent(command, new FieldDisabled()); + + return this; + } + + public SchemaDomainObject EnableField(EnableField command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.EnableField(command.FieldId)); + + RaiseEvent(command, new FieldEnabled()); + + return this; + } + + public SchemaDomainObject DeleteField(DeleteField command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.DeleteField(command.FieldId)); + + RaiseEvent(command, new FieldDeleted()); + + return this; + } + + public SchemaDomainObject Reorder(ReorderFields command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.ReorderFields(command.FieldIds)); + + RaiseEvent(SimpleMapper.Map(command, new SchemaFieldsReordered())); + + return this; + } + + public SchemaDomainObject Publish(PublishSchema command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.Publish()); + + RaiseEvent(SimpleMapper.Map(command, new SchemaPublished())); + + return this; + } + + public SchemaDomainObject Unpublish(UnpublishSchema command) + { + VerifyCreatedAndNotDeleted(); + + UpdateSchema(command, s => s.Unpublish()); + + RaiseEvent(SimpleMapper.Map(command, new SchemaUnpublished())); + + return this; + } + + public SchemaDomainObject ConfigureScripts(ConfigureScripts command) + { + VerifyCreatedAndNotDeleted(); + + UpdateState(command, s => SimpleMapper.Map(command, s)); + + RaiseEvent(SimpleMapper.Map(command, new ScriptsConfigured())); + + return this; + } + + public SchemaDomainObject Delete(DeleteSchema command) + { + VerifyCreatedAndNotDeleted(); + + UpdateState(command, s => s.IsDeleted = true); + + RaiseEvent(SimpleMapper.Map(command, new SchemaDeleted())); + + return this; + } + + public SchemaDomainObject Update(UpdateSchema command) + { + VerifyCreatedAndNotDeleted(); + + UpdateState(command, s => SimpleMapper.Map(command, s)); + + RaiseEvent(SimpleMapper.Map(command, new SchemaUpdated())); + + return this; + } + + protected void RaiseEvent(FieldCommand fieldCommand, FieldEvent @event) + { + SimpleMapper.Map(fieldCommand, @event); + + if (State.SchemaDef.FieldsById.TryGetValue(fieldCommand.FieldId, out var field)) + { + @event.FieldId = new NamedId(field.Id, field.Name); + } + + RaiseEvent(@event); + } + + private void VerifyNotCreated() + { + if (State.SchemaDef != null) + { + throw new DomainException("Schema has already been created."); + } + } + + private void VerifyCreatedAndNotDeleted() + { + if (State.IsDeleted || State.SchemaDef == null) + { + throw new DomainException("Schema has already been deleted or not created yet."); + } + } + + private void UpdateSchema(ICommand command, Func updater) + { + UpdateState(command, s => s.SchemaDef = updater(s.SchemaDef)); + } + + protected override SchemaState CloneState(ICommand command, Action updater) + { + return State.Clone().Update((SquidexCommand)command, updater); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs new file mode 100644 index 000000000..43e1a2328 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs @@ -0,0 +1,88 @@ +// ========================================================================== +// SchemaHistoryEventsCreator.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.History; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities.Schemas +{ + public sealed class SchemaHistoryEventsCreator : HistoryEventsCreatorBase + { + public SchemaHistoryEventsCreator(TypeNameRegistry typeNameRegistry) + : base(typeNameRegistry) + { + AddEventMessage( + "created schema {[Name]}"); + + AddEventMessage( + "updated schema {[Name]}"); + + AddEventMessage( + "deleted schema {[Name]}"); + + AddEventMessage( + "published schema {[Name]}"); + + AddEventMessage( + "unpublished schema {[Name]}"); + + AddEventMessage( + "reordered fields of schema {[Name]}"); + + AddEventMessage( + "added field {[Field]} to schema {[Name]}"); + + AddEventMessage( + "deleted field {[Field]} from schema {[Name]}"); + + AddEventMessage( + "has locked field {[Field]} of schema {[Name]}"); + + AddEventMessage( + "has hidden field {[Field]} of schema {[Name]}"); + + AddEventMessage( + "has shown field {[Field]} of schema {[Name]}"); + + AddEventMessage( + "disabled field {[Field]} of schema {[Name]}"); + + AddEventMessage( + "disabled field {[Field]} of schema {[Name]}"); + + AddEventMessage( + "has updated field {[Field]} of schema {[Name]}"); + + AddEventMessage( + "deleted field {[Field]} of schema {[Name]}"); + } + + protected override Task CreateEventCoreAsync(Envelope @event) + { + if (@event.Payload is SchemaEvent schemaEvent) + { + var channel = $"schemas.{schemaEvent.SchemaId.Name}"; + + var result = ForEvent(@event.Payload, channel).AddParameter("Name", schemaEvent.SchemaId.Name); + + if (schemaEvent is FieldEvent fieldEvent) + { + result.AddParameter("Field", fieldEvent.FieldId.Name); + } + + return Task.FromResult(result); + } + + return Task.FromResult(null); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs new file mode 100644 index 000000000..9fd80f57b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// JsonSchemaEntity.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using Squidex.Domain.Apps.Core.Schemas; + +namespace Squidex.Domain.Apps.Entities.Schemas.State +{ + public sealed class SchemaState : DomainObjectState, + ISchemaEntity, + IUpdateableEntityWithAppRef, + IUpdateableEntityWithCreatedBy, + IUpdateableEntityWithLastModifiedBy + { + [JsonProperty] + public string Name { get; set; } + + [JsonProperty] + public Guid AppId { get; set; } + + [JsonProperty] + public int TotalFields { get; set; } + + [JsonProperty] + public bool IsDeleted { get; set; } + + [JsonProperty] + public string ScriptQuery { get; set; } + + [JsonProperty] + public string ScriptCreate { get; set; } + + [JsonProperty] + public string ScriptUpdate { get; set; } + + [JsonProperty] + public string ScriptDelete { get; set; } + + [JsonProperty] + public string ScriptChange { get; set; } + + [JsonProperty] + public Schema SchemaDef { get; set; } + + [JsonIgnore] + public bool IsPublished + { + get { return SchemaDef.IsPublished; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj new file mode 100644 index 000000000..d273d2afb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -0,0 +1,25 @@ + + + netstandard2.0 + + + full + True + + + + + + + + + + + + + + + + ..\..\Squidex.ruleset + + diff --git a/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs b/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs new file mode 100644 index 000000000..58e1087a0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// SquidexCommand.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Security.Claims; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class SquidexCommand : ICommand + { + public ClaimsPrincipal Principal { get; set; } + + public RefToken Actor { get; set; } + + public long? ExpectedVersion { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/SquidexEvent.cs b/src/Squidex.Domain.Apps.Events/SquidexEvent.cs index 729022e8c..2eb7f3119 100644 --- a/src/Squidex.Domain.Apps.Events/SquidexEvent.cs +++ b/src/Squidex.Domain.Apps.Events/SquidexEvent.cs @@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events { public abstract class SquidexEvent : IEvent { + public string Username { get; set; } + public RefToken Actor { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Read/Apps/Services/PlanChangeAsyncResult.cs b/src/Squidex.Domain.Apps.Read/Apps/Services/PlanChangeAsyncResult.cs index 3a4ec4f9e..4f57733e8 100644 --- a/src/Squidex.Domain.Apps.Read/Apps/Services/PlanChangeAsyncResult.cs +++ b/src/Squidex.Domain.Apps.Read/Apps/Services/PlanChangeAsyncResult.cs @@ -5,6 +5,7 @@ // Copyright (c) Squidex Group // All rights reserved. // ========================================================================== + namespace Squidex.Domain.Apps.Read.Apps.Services { public sealed class PlanChangeAsyncResult : IChangePlanResult diff --git a/src/Squidex.Domain.Apps.Read/EntityMapper.cs b/src/Squidex.Domain.Apps.Read/EntityMapper.cs index 829ea277c..9dfe40a6d 100644 --- a/src/Squidex.Domain.Apps.Read/EntityMapper.cs +++ b/src/Squidex.Domain.Apps.Read/EntityMapper.cs @@ -65,7 +65,7 @@ namespace Squidex.Domain.Apps.Read private static void SetCreatedBy(SquidexEvent @event, IEntity entity) { - if (entity is IUpdateableEntityWithCreatedBy withCreatedBy) + if (entity is IUpdateableEntityWithCreatedBy withCreatedBy && withCreatedBy) { withCreatedBy.CreatedBy = @event.Actor; } diff --git a/src/Squidex.Infrastructure/Commands/AggregateHandler.cs b/src/Squidex.Infrastructure/Commands/AggregateHandler.cs index 6badd2691..10f17b85b 100644 --- a/src/Squidex.Infrastructure/Commands/AggregateHandler.cs +++ b/src/Squidex.Infrastructure/Commands/AggregateHandler.cs @@ -8,6 +8,7 @@ using System; using System.Threading.Tasks; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.States; namespace Squidex.Infrastructure.Commands @@ -15,44 +16,44 @@ namespace Squidex.Infrastructure.Commands public sealed class AggregateHandler : IAggregateHandler { private readonly IStateFactory stateFactory; + private readonly ISemanticLog log; private readonly IServiceProvider serviceProvider; - public AggregateHandler(IStateFactory stateFactory, IServiceProvider serviceProvider) + public AggregateHandler(IStateFactory stateFactory, IServiceProvider serviceProvider, ISemanticLog log) { Guard.NotNull(stateFactory, nameof(stateFactory)); Guard.NotNull(serviceProvider, nameof(serviceProvider)); + Guard.NotNull(log, nameof(log)); this.stateFactory = stateFactory; this.serviceProvider = serviceProvider; + + this.log = log; } - public Task CreateAsync(CommandContext context, Func creator) where T : class, IAggregate + public Task CreateAsync(CommandContext context, Func creator) where T : class, IDomainObject { Guard.NotNull(creator, nameof(creator)); return InvokeAsync(context, creator, false); } - public Task UpdateAsync(CommandContext context, Func updater) where T : class, IAggregate + public Task UpdateAsync(CommandContext context, Func updater) where T : class, IDomainObject { Guard.NotNull(updater, nameof(updater)); return InvokeAsync(context, updater, true); } - private async Task InvokeAsync(CommandContext context, Func handler, bool isUpdate) where T : class, IAggregate + private async Task InvokeAsync(CommandContext context, Func handler, bool isUpdate) where T : class, IDomainObject { Guard.NotNull(context, nameof(context)); - var aggregateCommand = GetCommand(context); - var aggregateFactory = (DomainObjectFactoryFunction)serviceProvider.GetService(typeof(DomainObjectFactoryFunction)); - - var wrapper = await stateFactory.GetDetachedAsync>(aggregateCommand.AggregateId.ToString()); - - var domainObject = aggregateFactory(aggregateCommand.AggregateId); + var domainObjectCommand = GetCommand(context); + var domainObjectId = domainObjectCommand.AggregateId; + var domainObject = await stateFactory.GetDetachedAsync(domainObjectId.ToString()); - await wrapper.LoadAsync(domainObject, isUpdate ? aggregateCommand.ExpectedVersion : -1); - await wrapper.UpdateAsync(handler); + await domainObject.WriteAsync(log); if (!context.IsCompleted) { @@ -62,7 +63,7 @@ namespace Squidex.Infrastructure.Commands } else { - context.Complete(EntityCreatedResult.Create(domainObject.Id, domainObject.Version)); + context.Complete(EntityCreatedResult.Create(domainObjectId, domainObject.Version)); } } diff --git a/src/Squidex.Infrastructure/Commands/CommandExtensions.cs b/src/Squidex.Infrastructure/Commands/CommandExtensions.cs index 86c7640cd..5eb115cd6 100644 --- a/src/Squidex.Infrastructure/Commands/CommandExtensions.cs +++ b/src/Squidex.Infrastructure/Commands/CommandExtensions.cs @@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.Commands { public static class CommandExtensions { - public static Task CreateAsync(this IAggregateHandler handler, CommandContext context, Action creator) where T : class, IAggregate + public static Task CreateAsync(this IAggregateHandler handler, CommandContext context, Action creator) where T : class, IDomainObject { return handler.CreateAsync(context, x => { @@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Commands }); } - public static Task UpdateAsync(this IAggregateHandler handler, CommandContext context, Action updater) where T : class, IAggregate + public static Task UpdateAsync(this IAggregateHandler handler, CommandContext context, Action updater) where T : class, IDomainObject { return handler.UpdateAsync(context, x => { diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs b/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs index bd7595cce..caf135179 100644 --- a/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs +++ b/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs @@ -8,41 +8,35 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.States; namespace Squidex.Infrastructure.Commands { - public abstract class DomainObjectBase : IAggregate, IEquatable + public abstract class DomainObjectBase : IDomainObject { private readonly List> uncomittedEvents = new List>(); - private readonly Guid id; - private int version; + private int version = -1; + private TState state; + private IPersistence persistence; - public int Version + public TState State { - get { return version; } + get { return state; } } - public Guid Id + public int Version { - get { return id; } + get { return version; } } - protected DomainObjectBase(Guid id, int version) + public Task ActivateAsync(string key, IStore store) { - Guard.NotEmpty(id, nameof(id)); - Guard.GreaterEquals(version, -1, nameof(version)); - - this.id = id; + persistence = store.WithSnapshots(key, s => state = s); - this.version = version; - } - - protected abstract void DispatchEvent(Envelope @event); - - private void ApplyEventCore(Envelope @event) - { - DispatchEvent(@event); version++; + return persistence.ReadAsync(); } protected void RaiseEvent(IEvent @event) @@ -55,38 +49,31 @@ namespace Squidex.Infrastructure.Commands Guard.NotNull(@event, nameof(@event)); uncomittedEvents.Add(@event.To()); - - ApplyEventCore(@event.To()); } - void IAggregate.ApplyEvent(Envelope @event) + public void UpdateState(ICommand command, Action updater) { - ApplyEventCore(@event); + state = CloneState(command, updater); } - void IAggregate.ClearUncommittedEvents() - { - uncomittedEvents.Clear(); - } - - public ICollection> GetUncomittedEvents() - { - return uncomittedEvents; - } - - public override int GetHashCode() - { - return id.GetHashCode(); - } - - public override bool Equals(object obj) - { - return Equals(obj as IAggregate); - } + protected abstract TState CloneState(ICommand command, Action updater); - public bool Equals(IAggregate other) + public async Task WriteAsync(ISemanticLog log) { - return other != null && other.Id.Equals(id); + await persistence.WriteSnapshotAsync(state); + + try + { + await persistence.WriteEventsAsync(uncomittedEvents.ToArray()); + } + catch (Exception ex) + { + log.LogFatal(ex, w => w.WriteProperty("action", "writeEvents")); + } + finally + { + uncomittedEvents.Clear(); + } } } } diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectWrapper.cs b/src/Squidex.Infrastructure/Commands/DomainObjectWrapper.cs deleted file mode 100644 index 9eb8a7303..000000000 --- a/src/Squidex.Infrastructure/Commands/DomainObjectWrapper.cs +++ /dev/null @@ -1,55 +0,0 @@ -// ========================================================================== -// DomainObjectWrapper.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using System; -using System.Linq; -using System.Threading.Tasks; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.States; -using Squidex.Infrastructure.Tasks; - -namespace Squidex.Infrastructure.Commands -{ - public delegate T DomainObjectFactoryFunction(Guid id) where T : IAggregate; - - public sealed class DomainObjectWrapper : IStatefulObject where T : IAggregate - { - private IPersistence persistence; - private T domainObject; - - public Task ActivateAsync(string key, IStore store) - { - persistence = store.WithEventSourcing(key, e => domainObject.ApplyEvent(e)); - - return TaskHelper.Done; - } - - public Task LoadAsync(T domainObject, long? expectedVersion) - { - this.domainObject = domainObject; - - return persistence.ReadAsync(expectedVersion); - } - - public async Task UpdateAsync(Func handler) - { - await handler(domainObject); - - var events = domainObject.GetUncomittedEvents(); - - foreach (var @event in events) - { - @event.SetAggregateId(domainObject.Id); - } - - await persistence.WriteEventsAsync(events.ToArray()); - - domainObject.ClearUncommittedEvents(); - } - } -} diff --git a/src/Squidex.Infrastructure/Commands/IAggregateHandler.cs b/src/Squidex.Infrastructure/Commands/IAggregateHandler.cs index 77196fe19..c3a01f38d 100644 --- a/src/Squidex.Infrastructure/Commands/IAggregateHandler.cs +++ b/src/Squidex.Infrastructure/Commands/IAggregateHandler.cs @@ -13,8 +13,8 @@ namespace Squidex.Infrastructure.Commands { public interface IAggregateHandler { - Task CreateAsync(CommandContext context, Func creator) where T : class, IAggregate; + Task CreateAsync(CommandContext context, Func creator) where T : class, IDomainObject; - Task UpdateAsync(CommandContext context, Func updater) where T : class, IAggregate; + Task UpdateAsync(CommandContext context, Func updater) where T : class, IDomainObject; } } diff --git a/src/Squidex.Infrastructure/Commands/IDomainObject.cs b/src/Squidex.Infrastructure/Commands/IDomainObject.cs new file mode 100644 index 000000000..971382c81 --- /dev/null +++ b/src/Squidex.Infrastructure/Commands/IDomainObject.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// IDomainObjectBase.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.States; + +namespace Squidex.Infrastructure.Commands +{ + public interface IDomainObject : IStatefulObject + { + int Version { get; } + + Task WriteAsync(ISemanticLog log); + } +} \ No newline at end of file