diff --git a/CHANGELOG.md b/CHANGELOG.md index 5383c8731..2a4c48963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,20 @@ # Changelog +## v1.2.0 - 2018-02-10 + +### Features + +* **EventStore**: Event metadata are stored as json objects in MongoDB now and you cacn query by metadata. +* **Content**: Updated to state can be scheduled, e.g. to publish them. + +> This releases will run a migration, which might take a while and also effects the events. We recommend to make a backup first. + ## v1.1.7 - 2018-02-06 ### Bugfixes * **UI**: Checkbox style fixed. - ## v1.1.6 - 2018-02-06 ### Features diff --git a/src/Squidex.Domain.Apps.Core.Model/SquidexCoreModel.cs b/src/Squidex.Domain.Apps.Core.Model/SquidexCoreModel.cs index 885a8936f..6f4f99495 100644 --- a/src/Squidex.Domain.Apps.Core.Model/SquidexCoreModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/SquidexCoreModel.cs @@ -5,9 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Reflection; + namespace Squidex.Domain.Apps.Core { public static class SquidexCoreModel { + public static readonly Assembly Assembly = typeof(SquidexCoreModel).Assembly; } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs index 934ee5387..79b1e14c4 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataObject.cs @@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper { EnsurePropertiesInitialized(); - return fieldProperties.GetOrDefault(propertyName) ?? new PropertyDescriptor(new ObjectInstance(Engine) { Extensible = true }, true, false, true); + return fieldProperties.GetOrAdd(propertyName, x => new ContentDataProperty(this, new ContentFieldObject(this, new ContentFieldData(), false))); } public override IEnumerable> GetOwnProperties() diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs index 943d41240..4ea31b9ea 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/ContentDataProperty.cs @@ -30,7 +30,7 @@ namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper { if (value == null || !value.IsObject()) { - throw new JavaScriptException("Can only assign object to content data."); + throw new JavaScriptException("You can only assign objects to content data."); } var obj = value.AsObject(); diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index b9abcee99..57e135981 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -115,11 +115,11 @@ namespace Squidex.Domain.Apps.Core.Scripting } catch (ParserException ex) { - throw new ValidationException("Failed to execute script with javascript syntax error.", new ValidationError(ex.Message)); + throw new ValidationException($"Failed to execute script with javascript syntax error: {ex.Message}", new ValidationError(ex.Message)); } catch (JavaScriptException ex) { - throw new ValidationException("Failed to execute script with javascript error.", new ValidationError(ex.Message)); + throw new ValidationException($"Failed to execute script with javascript error: {ex.Message}", new ValidationError(ex.Message)); } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index 468bd2110..2f2729858 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs @@ -20,9 +20,16 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets IAssetEntity, IUpdateableEntityWithVersion, IUpdateableEntityWithCreatedBy, - IUpdateableEntityWithLastModifiedBy, - IUpdateableEntityWithAppRef + IUpdateableEntityWithLastModifiedBy { + [BsonRequired] + [BsonElement] + public Guid AppIdId { get; set; } + + [BsonRequired] + [BsonElement] + public NamedId AppId { get; set; } + [BsonRequired] [BsonElement] public string MimeType { get; set; } @@ -55,10 +62,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets [BsonElement] public int? PixelHeight { get; set; } - [BsonRequired] - [BsonElement] - public Guid AppId { get; set; } - [BsonRequired] [BsonElement] public RefToken CreatedBy { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs index 3e1cf0c22..a4056d0a7 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs @@ -36,6 +36,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets var entity = SimpleMapper.Map(value, new MongoAssetEntity()); entity.Version = newVersion; + entity.AppIdId = value.AppId.Id; await Collection.ReplaceOneAsync(x => x.Id == key && x.Version == oldVersion, entity, Upsert); } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs index b43fc8f48..af3a75764 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs @@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors { var filters = new List> { - Filter.Eq(x => x.AppId, appId), + Filter.Eq(x => x.AppIdId, appId), Filter.Eq(x => x.IsDeleted, false) }; diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs index 68d92be78..f35cb2286 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs @@ -35,12 +35,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents [BsonRequired] [BsonElement("ai")] [BsonRepresentation(BsonType.String)] - public Guid AppId { get; set; } + public Guid AppIdId { get; set; } [BsonRequired] [BsonElement("si")] [BsonRepresentation(BsonType.String)] - public Guid SchemaId { get; set; } + public Guid SchemaIdId { get; set; } [BsonRequired] [BsonElement("rf")] @@ -62,6 +62,26 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents [BsonJson] public IdContentData DataByIds { get; set; } + [BsonRequired] + [BsonElement("ai2")] + public NamedId AppId { get; set; } + + [BsonRequired] + [BsonElement("si2")] + public NamedId SchemaId { get; set; } + + [BsonIgnoreIfNull] + [BsonElement("sdt")] + public Status? ScheduledTo { get; set; } + + [BsonIgnoreIfNull] + [BsonElement("sda")] + public Instant? ScheduledAt { get; set; } + + [BsonIgnoreIfNull] + [BsonElement("sdb")] + public RefToken ScheduledBy { get; set; } + [BsonRequired] [BsonElement("ct")] public Instant Created { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index a8ad1abcc..1d4cc508f 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.OData.UriParser; using MongoDB.Driver; +using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents; @@ -51,6 +52,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { await collection.Indexes.TryDropOneAsync("si_1_st_1_dl_1_dt_text"); + await archiveCollection.Indexes.CreateOneAsync( + Index + .Ascending(x => x.ScheduledTo)); + await archiveCollection.Indexes.CreateOneAsync( Index .Ascending(x => x.Id) @@ -59,13 +64,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await collection.Indexes.CreateOneAsync( Index .Text(x => x.DataText) - .Ascending(x => x.SchemaId) + .Ascending(x => x.SchemaIdId) .Ascending(x => x.Status) .Ascending(x => x.IsDeleted)); await collection.Indexes.CreateOneAsync( Index - .Ascending(x => x.SchemaId) + .Ascending(x => x.SchemaIdId) .Ascending(x => x.Id) .Ascending(x => x.IsDeleted) .Ascending(x => x.Status)); @@ -121,7 +126,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids) { - var find = Collection.Find(x => x.SchemaId == schema.Id && ids.Contains(x.Id) && x.IsDeleted == false && status.Contains(x.Status)); + var find = Collection.Find(x => x.SchemaIdId == schema.Id && ids.Contains(x.Id) && x.IsDeleted == false && status.Contains(x.Status)); var contentItems = find.ToListAsync(); var contentCount = find.CountAsync(); @@ -139,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public async Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids) { var contentEntities = - await Collection.Find(x => x.SchemaId == schemaId && ids.Contains(x.Id) && x.IsDeleted == false).Only(x => x.Id) + await Collection.Find(x => x.SchemaIdId == schemaId && ids.Contains(x.Id) && x.IsDeleted == false).Only(x => x.Id) .ToListAsync(); return ids.Except(contentEntities.Select(x => Guid.Parse(x["id"].AsString))).ToList(); @@ -159,7 +164,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id) { var contentEntity = - await Collection.Find(x => x.SchemaId == schema.Id && x.Id == id && x.IsDeleted == false) + await Collection.Find(x => x.SchemaIdId == schema.Id && x.Id == id && x.IsDeleted == false) .FirstOrDefaultAsync(); contentEntity?.ParseData(schema.SchemaDef); @@ -167,6 +172,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return contentEntity; } + public Task QueryScheduledWithoutDataAsync(Instant now, Func callback) + { + return Collection.Find(x => x.ScheduledAt < now && x.IsDeleted == false) + .ForEachAsync(c => + { + callback(c); + }); + } + public override async Task ClearAsync() { await Database.DropCollectionAsync("States_Contents_Archive"); diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index c220e416e..54e236e4a 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs @@ -28,7 +28,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents if (contentEntity != null) { - var schema = await GetSchemaAsync(contentEntity.AppId, contentEntity.SchemaId); + var schema = await GetSchemaAsync(contentEntity.AppIdId, contentEntity.SchemaIdId); contentEntity?.ParseData(schema.SchemaDef); @@ -40,12 +40,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public async Task WriteAsync(Guid key, ContentState value, long oldVersion, long newVersion) { - if (value.SchemaId == Guid.Empty) + if (value.SchemaId.Id == Guid.Empty) { return; } - var schema = await GetSchemaAsync(value.AppId, value.SchemaId); + var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); var idData = value.Data?.ToIdModel(schema.SchemaDef, true); @@ -53,6 +53,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents var document = SimpleMapper.Map(value, new MongoContentEntity { + AppIdId = value.AppId.Id, + SchemaIdId = value.SchemaId.Id, IsDeleted = value.IsDeleted, DocumentId = key.ToString(), DataText = idData?.ToFullText(), @@ -92,7 +94,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents private async Task GetSchemaAsync(Guid appId, Guid schemaId) { - var schema = await appProvider.GetSchemaAsync(appId, schemaId); + var schema = await appProvider.GetSchemaAsync(appId, schemaId, true); if (schema == null) { diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs index c4ca769b6..cdfaff9b8 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs @@ -80,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors { var filters = new List> { - Filter.Eq(x => x.SchemaId, schemaId), + Filter.Eq(x => x.SchemaIdId, schemaId), Filter.In(x => x.Status, status), Filter.Eq(x => x.IsDeleted, false) }; diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventEntity.cs index 610d38977..5e9d9d50d 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventEntity.cs @@ -16,11 +16,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History { public sealed class MongoHistoryEventEntity : MongoEntity, IEntity, - IEntityWithAppRef, IUpdateableEntity, IUpdateableEntityWithVersion, - IUpdateableEntityWithCreatedBy, - IUpdateableEntityWithAppRef + IUpdateableEntityWithCreatedBy { [BsonElement] [BsonRequired] diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs index d54da9c2d..468d8d831 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleEventRepository.cs @@ -39,9 +39,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Expires), new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }); } - public Task QueryPendingAsync(Instant now, Func callback, CancellationToken cancellationToken = default(CancellationToken)) + public Task QueryPendingAsync(Instant now, Func callback, CancellationToken ct = default(CancellationToken)) { - return Collection.Find(x => x.NextAttempt < now).ForEachAsync(callback, cancellationToken); + return Collection.Find(x => x.NextAttempt < now).ForEachAsync(callback, ct); } public async Task> QueryByAppAsync(Guid appId, int skip = 0, int take = 20) diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository_SnapshotStore.cs index 408a3aaba..cd8a2ee02 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Rules/MongoRuleRepository_SnapshotStore.cs @@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Rules { return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u .Set(x => x.State, value) - .Set(x => x.AppId, value.AppId) + .Set(x => x.AppId, value.AppId.Id) .Set(x => x.IsDeleted, value.IsDeleted)); } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository_SnapshotStore.cs index aadd8cbcb..a23899a7a 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Schemas/MongoSchemaRepository_SnapshotStore.cs @@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Schemas { return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u .Set(x => x.State, value) - .Set(x => x.AppId, value.AppId) + .Set(x => x.AppId, value.AppId.Id) .Set(x => x.Name, value.Name) .Set(x => x.IsDeleted, value.IsDeleted)); } diff --git a/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/src/Squidex.Domain.Apps.Entities/AppProvider.cs index 38f0b2742..b2e028a12 100644 --- a/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -88,11 +88,11 @@ namespace Squidex.Domain.Apps.Entities return (await stateFactory.GetSingleAsync(schemaId)).Snapshot; } - public async Task GetSchemaAsync(Guid appId, Guid id) + public async Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false) { var schema = await stateFactory.GetSingleAsync(id); - if (!IsFound(schema)) + if (!IsFound(schema) || (schema.Snapshot.IsDeleted && !allowDeleted) || schema.Snapshot.AppId.Id != appId) { return null; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs index 9439961c4..829c1388c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs @@ -179,7 +179,7 @@ namespace Squidex.Domain.Apps.Entities.Apps } else { - var result = await appPlansBillingManager.ChangePlanAsync(command.Actor.Identifier, command.AppId.Id, a.Snapshot.Name, command.PlanId); + var result = await appPlansBillingManager.ChangePlanAsync(command.Actor.Identifier, a.Snapshot.Id, a.Snapshot.Name, command.PlanId); if (result is PlanChangedResult) { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs index 411f4a8e8..2dedb3a7a 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppDomainObject.cs @@ -13,13 +13,12 @@ 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 sealed class AppDomainObject : DomainObjectBase + public sealed class AppDomainObject : SquidexDomainObjectBase { private readonly InitialPatterns initialPatterns; diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs index b2619a6f6..3cfec0965 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddLanguage.cs @@ -9,7 +9,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class AddLanguage : AppAggregateCommand + public sealed class AddLanguage : AppCommand { public Language Language { get; set; } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddPattern.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddPattern.cs index 5442536c1..30873adbb 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddPattern.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddPattern.cs @@ -9,7 +9,7 @@ using System; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class AddPattern : AppAggregateCommand + public sealed class AddPattern : AppCommand { public Guid PatternId { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/SchemaAggregateCommand.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppCommand.cs similarity index 71% rename from src/Squidex.Domain.Apps.Entities/SchemaAggregateCommand.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Commands/AppCommand.cs index d3d73dd62..a391da077 100644 --- a/src/Squidex.Domain.Apps.Entities/SchemaAggregateCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AppCommand.cs @@ -8,13 +8,15 @@ using System; using Squidex.Infrastructure.Commands; -namespace Squidex.Domain.Apps.Entities +namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public abstract class SchemaAggregateCommand : SchemaCommand, IAggregateCommand + public abstract class AppCommand : SquidexCommand, IAggregateCommand { + public Guid AppId { get; set; } + Guid IAggregateCommand.AggregateId { - get { return SchemaId.Id; } + get { return AppId; } } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs index 13a60ddda..b54518a66 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs @@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Apps; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class AssignContributor : AppAggregateCommand + public sealed class AssignContributor : AppCommand { public string ContributorId { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs index 4239c1e47..0fd8c6c4d 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs @@ -9,10 +9,15 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class AttachClient : AppAggregateCommand + public sealed class AttachClient : AppCommand { public string Id { get; set; } - public string Secret { get; } = RandomHash.New(); + public string Secret { get; set; } + + public AttachClient() + { + Secret = RandomHash.New(); + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs index b5af896bf..a323b8b10 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ChangePlan.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class ChangePlan : AppAggregateCommand + public sealed class ChangePlan : AppCommand { public bool FromCallback { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs index 5e8050247..d4dc2528b 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs @@ -10,19 +10,12 @@ using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class CreateApp : SquidexCommand, IAggregateCommand + public sealed class CreateApp : AppCommand, IAggregateCommand { - public Guid AppId { get; set; } - public string Name { get; set; } public string Template { get; set; } - Guid IAggregateCommand.AggregateId - { - get { return AppId; } - } - public CreateApp() { AppId = Guid.NewGuid(); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeletePattern.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeletePattern.cs index 5db33b435..199bff83c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeletePattern.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeletePattern.cs @@ -9,7 +9,7 @@ using System; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class DeletePattern : AppAggregateCommand + public sealed class DeletePattern : AppCommand { public Guid PatternId { get; set; } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs index 6f707811f..a4e27d426 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveContributor.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class RemoveContributor : AppAggregateCommand + public sealed class RemoveContributor : AppCommand { public string ContributorId { get; set; } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs index c863e7b85..602c35756 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveLanguage.cs @@ -9,7 +9,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class RemoveLanguage : AppAggregateCommand + public sealed class RemoveLanguage : AppCommand { 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 index 623da3058..9361891ba 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RevokeClient.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class RevokeClient : AppAggregateCommand + public sealed class RevokeClient : AppCommand { 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 index 856de1f4a..6002bba30 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateClient.cs @@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Apps; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class UpdateClient : AppAggregateCommand + public sealed class UpdateClient : AppCommand { public string Id { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs index c0f442a25..52e40e5d0 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateLanguage.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class UpdateLanguage : AppAggregateCommand + public sealed class UpdateLanguage : AppCommand { public Language Language { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdatePattern.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdatePattern.cs index 9ae7b510e..415856189 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdatePattern.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdatePattern.cs @@ -9,7 +9,7 @@ using System; namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class UpdatePattern : AppAggregateCommand + public sealed class UpdatePattern : AppCommand { public Guid PatternId { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs index 604284090..b46cf9240 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppClients.cs @@ -21,7 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { if (string.IsNullOrWhiteSpace(command.Id)) { - error(new ValidationError("Client id must be defined.", nameof(command.Id))); + error(new ValidationError("Client id is required.", nameof(command.Id))); } else if (clients.ContainsKey(command.Id)) { @@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { if (string.IsNullOrWhiteSpace(command.Id)) { - error(new ValidationError("Client id must be defined.", nameof(command.Id))); + error(new ValidationError("Client id is required.", nameof(command.Id))); } }); } @@ -55,12 +55,12 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { if (string.IsNullOrWhiteSpace(command.Id)) { - error(new ValidationError("Client id must be defined.", nameof(command.Id))); + error(new ValidationError("Client id is required.", 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))); + error(new ValidationError("Either name or permission is required.", nameof(command.Name), nameof(command.Permission))); } if (command.Permission.HasValue && !command.Permission.Value.IsEnumValue()) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs index 93e9747b9..adea21e4c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs @@ -36,17 +36,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates { var appId = new NamedId(createApp.AppId, createApp.Name); - Task publishAsync(AppCommand command) - { - command.AppId = appId; - - return context.CommandBus.PublishAsync(command); - } - return Task.WhenAll( - CreatePagesAsync(publishAsync, appId), - CreatePostsAsync(publishAsync, appId), - CreateClientAsync(publishAsync, appId)); + CreatePagesAsync(context.CommandBus, appId), + CreatePostsAsync(context.CommandBus, appId), + CreateClientAsync(context.CommandBus, appId)); } return TaskHelper.Done; @@ -57,16 +50,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates return string.Equals(createApp.Template, TemplateName, StringComparison.OrdinalIgnoreCase); } - private static async Task CreateClientAsync(Func publishAsync, NamedId appId) + private static async Task CreateClientAsync(ICommandBus bus, NamedId appId) { - await publishAsync(new AttachClient { Id = "sample-client" }); + await bus.PublishAsync(new AttachClient { Id = "sample-client" }); } - private async Task CreatePostsAsync(Func publishAsync, NamedId appId) + private async Task CreatePostsAsync(ICommandBus bus, NamedId appId) { - var postsId = await CreatePostsSchema(publishAsync); + var postsId = await CreatePostsSchema(bus, appId); - await publishAsync(new CreateContent + await bus.PublishAsync(new CreateContent { SchemaId = postsId, Data = @@ -81,11 +74,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates }); } - private async Task CreatePagesAsync(Func publishAsync, NamedId appId) + private async Task CreatePagesAsync(ICommandBus bus, NamedId appId) { - var pagesId = await CreatePagesSchema(publishAsync); + var pagesId = await CreatePagesSchema(bus, appId); - await publishAsync(new CreateContent + await bus.PublishAsync(new CreateContent { SchemaId = pagesId, Data = @@ -100,68 +93,70 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates }); } - private async Task> CreatePostsSchema(Func publishAsync) + private async Task> CreatePostsSchema(ICommandBus bus, NamedId appId) { var command = new CreateSchema { Name = "posts", + Publish = true, Properties = new SchemaProperties { Label = "Posts" }, Fields = new List + { + new CreateSchemaField { - new CreateSchemaField + Name = "title", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties { - Name = "title", - Partitioning = Partitioning.Invariant.Key, - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - MaxLength = 100, - MinLength = 0, - Label = "Title" - } - }, - new CreateSchemaField + Editor = StringFieldEditor.Input, + IsRequired = true, + IsListField = true, + MaxLength = 100, + MinLength = 0, + Label = "Title" + } + }, + new CreateSchemaField + { + Name = "slug", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties { - Name = "slug", - Partitioning = Partitioning.Invariant.Key, - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Slug, - IsRequired = false, - IsListField = true, - MaxLength = 100, - MinLength = 0, - Label = "Slug" - } + Editor = StringFieldEditor.Slug, + IsRequired = false, + IsListField = true, + MaxLength = 100, + MinLength = 0, + Label = "Slug (Autogenerated)" }, - new CreateSchemaField + IsDisabled = true + }, + new CreateSchemaField + { + Name = "text", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties { - Name = "text", - Partitioning = Partitioning.Invariant.Key, - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.RichText, - IsRequired = true, - IsListField = false, - Label = "Text" - } + Editor = StringFieldEditor.RichText, + IsRequired = true, + IsListField = false, + Label = "Text" } } + }, + AppId = appId }; - await publishAsync(command); + await bus.PublishAsync(command); var schemaId = new NamedId(command.SchemaId, command.Name); - await publishAsync(new PublishSchema { SchemaId = schemaId }); - await publishAsync(new ConfigureScripts + await bus.PublishAsync(new ConfigureScripts { - SchemaId = schemaId, + SchemaId = schemaId.Id, ScriptCreate = SlugScript, ScriptUpdate = SlugScript }); @@ -169,7 +164,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates return schemaId; } - private async Task> CreatePagesSchema(Func publishAsync) + private async Task> CreatePagesSchema(ICommandBus bus, NamedId appId) { var command = new CreateSchema { @@ -179,58 +174,59 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates Label = "Pages" }, Fields = new List + { + new CreateSchemaField { - new CreateSchemaField + Name = "title", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties { - Name = "title", - Partitioning = Partitioning.Invariant.Key, - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Input, - IsRequired = true, - IsListField = true, - MaxLength = 100, - MinLength = 0, - Label = "Title" - } - }, - new CreateSchemaField + Editor = StringFieldEditor.Input, + IsRequired = true, + IsListField = true, + MaxLength = 100, + MinLength = 0, + Label = "Title" + } + }, + new CreateSchemaField + { + Name = "slug", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties { - Name = "slug", - Partitioning = Partitioning.Invariant.Key, - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.Slug, - IsRequired = false, - IsListField = true, - MaxLength = 100, - MinLength = 0, - Label = "Slug" - } + Editor = StringFieldEditor.Slug, + IsRequired = false, + IsListField = true, + MaxLength = 100, + MinLength = 0, + Label = "Slug (Autogenerated)" }, - new CreateSchemaField + IsDisabled = true + }, + new CreateSchemaField + { + Name = "text", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties { - Name = "text", - Partitioning = Partitioning.Invariant.Key, - Properties = new StringFieldProperties - { - Editor = StringFieldEditor.RichText, - IsRequired = true, - IsListField = false, - Label = "Text" - } + Editor = StringFieldEditor.RichText, + IsRequired = true, + IsListField = false, + Label = "Text" } } + }, + AppId = appId }; - await publishAsync(command); + await bus.PublishAsync(command); var schemaId = new NamedId(command.SchemaId, command.Name); - await publishAsync(new PublishSchema { SchemaId = schemaId }); - await publishAsync(new ConfigureScripts + await bus.PublishAsync(new ConfigureScripts { - SchemaId = schemaId, + SchemaId = schemaId.Id, ScriptCreate = SlugScript, ScriptUpdate = SlugScript }); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs index 103cd9981..7cc351c83 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs @@ -7,15 +7,15 @@ using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Assets; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class AssetDomainObject : DomainObjectBase + public sealed class AssetDomainObject : SquidexDomainObjectBase { public AssetDomainObject Create(CreateAsset command) { @@ -74,6 +74,16 @@ namespace Squidex.Domain.Apps.Entities.Assets return this; } + private void RaiseEvent(AppEvent @event) + { + if (@event.AppId == null) + { + @event.AppId = Snapshot.AppId; + } + + RaiseEvent(Envelope.Create(@event)); + } + private void VerifyNotCreated() { if (!string.IsNullOrWhiteSpace(Snapshot.FileName)) diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetAggregateCommand.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs similarity index 88% rename from src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetAggregateCommand.cs rename to src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs index efdb41e00..4898243dd 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetAggregateCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Assets.Commands { - public abstract class AssetAggregateCommand : AppCommand, IAggregateCommand + public abstract class AssetCommand : SquidexCommand, IAggregateCommand { public Guid AssetId { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs index f7696e14b..f421d9ed8 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs @@ -6,12 +6,15 @@ // ========================================================================== using System; +using Squidex.Infrastructure; using Squidex.Infrastructure.Assets; namespace Squidex.Domain.Apps.Entities.Assets.Commands { - public sealed class CreateAsset : AssetAggregateCommand + public sealed class CreateAsset : AssetCommand, IAppCommand { + public NamedId AppId { get; set; } + public AssetFile File { get; set; } public ImageInfo ImageInfo { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs index 351333962..4848be209 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/DeleteAsset.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands { - public sealed class DeleteAsset : AssetAggregateCommand + public sealed class DeleteAsset : AssetCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAsset.cs index 3dc784b88..65cba7f35 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/RenameAsset.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands { - public sealed class RenameAsset : AssetAggregateCommand + public sealed class RenameAsset : AssetCommand { 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 index 8c419ca71..1bb193419 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs @@ -9,7 +9,7 @@ using Squidex.Infrastructure.Assets; namespace Squidex.Domain.Apps.Entities.Assets.Commands { - public sealed class UpdateAsset : AssetAggregateCommand + public sealed class UpdateAsset : AssetCommand { public AssetFile File { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs index 756441da2..757959a6a 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Guards/GuardAsset.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Guards { if (string.IsNullOrWhiteSpace(command.FileName)) { - error(new ValidationError("Name must be defined.", nameof(command.FileName))); + error(new ValidationError("Name is required.", nameof(command.FileName))); } if (string.Equals(command.FileName, oldName)) diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs index c95179f66..c61c52cc0 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEntity.cs @@ -5,18 +5,21 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Squidex.Domain.Apps.Core.ValidateContent; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Assets { public interface IAssetEntity : IEntity, - IEntityWithAppRef, IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion, IAssetInfo { + NamedId AppId { get; } + string MimeType { get; } long FileVersion { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs index 8a2f65796..3ed7714a7 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs @@ -10,6 +10,7 @@ using Newtonsoft.Json; using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; @@ -18,11 +19,10 @@ namespace Squidex.Domain.Apps.Entities.Assets.State { public class AssetState : DomainObjectState, IAssetEntity, - IAssetInfo, - IUpdateableEntityWithAppRef + IAssetInfo { [JsonProperty] - public Guid AppId { get; set; } + public NamedId AppId { get; set; } [JsonProperty] public string FileName { get; set; } @@ -61,6 +61,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.State SimpleMapper.Map(@event, this); TotalSize += @event.FileSize; + + AppId = @event.AppId; } protected void On(AssetUpdated @event) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs index 9e8de0bd2..e855a9aff 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ChangeContentStatus.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================= +using NodaTime; using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Domain.Apps.Entities.Contents.Commands @@ -12,5 +13,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public sealed class ChangeContentStatus : ContentCommand { public Status Status { get; set; } + + public Instant? DueTime { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs index 3629bdfdc..8e15d2a7b 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public abstract class ContentCommand : SchemaCommand, IAggregateCommand + public abstract class ContentCommand : SquidexCommand, IAggregateCommand { public Guid ContentId { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs index 71e0a11e7..2eec65f73 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs @@ -6,11 +6,16 @@ // ========================================================================== using System; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class CreateContent : ContentDataCommand + public sealed class CreateContent : ContentDataCommand, ISchemaCommand, IAppCommand { + public NamedId AppId { get; set; } + + public NamedId SchemaId { get; set; } + public bool Publish { get; set; } public CreateContent() diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs index 6b10ba6df..7674f1512 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs @@ -109,9 +109,12 @@ namespace Squidex.Domain.Apps.Entities.Contents { GuardContent.CanChangeContentStatus(content.Snapshot.Status, command); - var operationContext = await CreateContext(command, content, () => "Failed to patch content."); + if (!command.DueTime.HasValue) + { + var operationContext = await CreateContext(command, content, () => "Failed to patch content."); - await operationContext.ExecuteScriptAsync(x => x.ScriptChange, command.Status); + await operationContext.ExecuteScriptAsync(x => x.ScriptChange, command.Status); + } content.ChangeStatus(command); }); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs index 0b46b12d7..47db34ab1 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs @@ -8,15 +8,15 @@ using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.State; +using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Contents { - public sealed class ContentDomainObject : DomainObjectBase + public sealed class ContentDomainObject : SquidexDomainObjectBase { public ContentDomainObject Create(CreateContent command) { @@ -45,7 +45,14 @@ namespace Squidex.Domain.Apps.Entities.Contents { VerifyCreatedAndNotDeleted(); - RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged())); + if (command.DueTime.HasValue) + { + RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value })); + } + else + { + RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged())); + } return this; } @@ -80,6 +87,21 @@ namespace Squidex.Domain.Apps.Entities.Contents return this; } + private void RaiseEvent(SchemaEvent @event) + { + if (@event.AppId == null) + { + @event.AppId = Snapshot.AppId; + } + + if (@event.SchemaId == null) + { + @event.SchemaId = Snapshot.SchemaId; + } + + RaiseEvent(Envelope.Create(@event)); + } + private void VerifyNotCreated() { if (Snapshot.Data != null) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 9c0bc7af7..27e1c1895 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -18,7 +18,9 @@ namespace Squidex.Domain.Apps.Entities.Contents { public Guid Id { get; set; } - public Guid AppId { get; set; } + public NamedId AppId { get; set; } + + public NamedId SchemaId { get; set; } public long Version { get; set; } @@ -26,14 +28,20 @@ namespace Squidex.Domain.Apps.Entities.Contents public Instant LastModified { get; set; } + public Status Status { get; set; } + + public Status? ScheduledTo { get; set; } + + public Instant? ScheduledAt { get; set; } + + public RefToken ScheduledBy { get; set; } + public RefToken CreatedBy { get; set; } public RefToken LastModifiedBy { get; set; } public NamedContentData Data { get; set; } - public Status Status { get; set; } - public static ContentEntity Create(CreateContent command, EntityCreatedResult result) { var now = SystemClock.Instance.GetCurrentInstant(); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index c6a8c72b3..8660af246 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs @@ -42,7 +42,16 @@ namespace Squidex.Domain.Apps.Entities.Contents IScriptEngine scriptEngine, Func message) { - var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(command.AppId.Id, command.SchemaId.Id); + var a = content.Snapshot.AppId; + var s = content.Snapshot.SchemaId; + + if (command is CreateContent createContent) + { + a = a ?? createContent.AppId; + s = s ?? createContent.SchemaId; + } + + var (appEntity, schemaEntity) = await appProvider.GetAppWithSchemaAsync(a.Id, s.Id); var context = new ContentOperationContext { @@ -75,17 +84,15 @@ namespace Squidex.Domain.Apps.Entities.Contents { var errors = new List(); - var appId = command.AppId.Id; - var ctx = new ValidationContext( (contentIds, schemaId) => { - return QueryContentsAsync(appId, schemaId, contentIds); + return QueryContentsAsync(content.Snapshot.AppId.Id, schemaId, contentIds); }, assetIds => { - return QueryAssetsAsync(appId, assetIds); + return QueryAssetsAsync(content.Snapshot.AppId.Id, assetIds); }); if (partial) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentScheduler.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentScheduler.cs new file mode 100644 index 000000000..23d3c05a9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentScheduler.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using NodaTime; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Timers; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentScheduler : IRunnable + { + private readonly CompletionTimer timer; + private readonly IContentRepository contentRepository; + private readonly ICommandBus commandBus; + private readonly IClock clock; + + public ContentScheduler( + IContentRepository contentRepository, + ICommandBus commandBus, + IClock clock) + { + Guard.NotNull(contentRepository, nameof(contentRepository)); + Guard.NotNull(commandBus, nameof(commandBus)); + Guard.NotNull(clock, nameof(clock)); + + this.contentRepository = contentRepository; + this.commandBus = commandBus; + this.clock = clock; + + timer = new CompletionTimer(5000, x => PublishAsync()); + } + + public void Run() + { + } + + private Task PublishAsync() + { + var now = clock.GetCurrentInstant(); + + return contentRepository.QueryScheduledWithoutDataAsync(now, content => + { + var command = new ChangeContentStatus { ContentId = content.Id, Status = content.ScheduledTo.Value, Actor = content.ScheduledBy }; + + return commandBus.PublishAsync(command); + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs index 22c30512c..58ec5d018 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs @@ -38,13 +38,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types var inputType = new ContentDataGraphInputType(model, schema); AddContentCreate(schemaId, schemaType, schemaName, inputType, contentDataType, contentType); - AddContentUpdate(schemaId, schemaType, schemaName, inputType, resultType); - AddContentPatch(schemaId, schemaType, schemaName, inputType, resultType); - AddContentPublish(schemaId, schemaType, schemaName); - AddContentUnpublish(schemaId, schemaType, schemaName); - AddContentArchive(schemaId, schemaType, schemaName); - AddContentRestore(schemaId, schemaType, schemaName); - AddContentDelete(schemaId, schemaType, schemaName); + AddContentUpdate(schemaType, schemaName, inputType, resultType); + AddContentPatch(schemaType, schemaName, inputType, resultType); + AddContentPublish(schemaType, schemaName); + AddContentUnpublish(schemaType, schemaName); + AddContentArchive(schemaType, schemaName); + AddContentRestore(schemaType, schemaName); + AddContentDelete(schemaType, schemaName); } Description = "The app mutations."; @@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types var contentData = GetContentData(c); - var command = new CreateContent { SchemaId = schemaId, ContentId = Guid.NewGuid(), Data = contentData, Publish = argPublish }; + var command = new CreateContent { SchemaId = schemaId, Data = contentData, Publish = argPublish }; var commandContext = await publish(command); var result = commandContext.Result>(); @@ -98,7 +98,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddContentUpdate(NamedId schemaId, string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType resultType) + private void AddContentUpdate(string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType resultType) { AddField(new FieldType { @@ -133,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types var contentId = c.GetArgument("id"); var contentData = GetContentData(c); - var command = new UpdateContent { SchemaId = schemaId, ContentId = contentId, Data = contentData }; + var command = new UpdateContent { ContentId = contentId, Data = contentData }; var commandContext = await publish(command); var result = commandContext.Result(); @@ -144,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddContentPatch(NamedId schemaId, string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType resultType) + private void AddContentPatch(string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType resultType) { AddField(new FieldType { @@ -179,7 +179,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types var contentId = c.GetArgument("id"); var contentData = GetContentData(c); - var command = new PatchContent { SchemaId = schemaId, ContentId = contentId, Data = contentData }; + var command = new PatchContent { ContentId = contentId, Data = contentData }; var commandContext = await publish(command); var result = commandContext.Result(); @@ -190,7 +190,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddContentPublish(NamedId schemaId, string schemaType, string schemaName) + private void AddContentPublish(string schemaType, string schemaName) { AddField(new FieldType { @@ -201,7 +201,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { var contentId = c.GetArgument("id"); - var command = new ChangeContentStatus { SchemaId = schemaId, ContentId = contentId, Status = Status.Published }; + var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Published }; return publish(command); }), @@ -209,7 +209,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddContentUnpublish(NamedId schemaId, string schemaType, string schemaName) + private void AddContentUnpublish(string schemaType, string schemaName) { AddField(new FieldType { @@ -220,7 +220,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { var contentId = c.GetArgument("id"); - var command = new ChangeContentStatus { SchemaId = schemaId, ContentId = contentId, Status = Status.Draft }; + var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Draft }; return publish(command); }), @@ -228,7 +228,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddContentArchive(NamedId schemaId, string schemaType, string schemaName) + private void AddContentArchive(string schemaType, string schemaName) { AddField(new FieldType { @@ -239,7 +239,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { var contentId = c.GetArgument("id"); - var command = new ChangeContentStatus { SchemaId = schemaId, ContentId = contentId, Status = Status.Archived }; + var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Archived }; return publish(command); }), @@ -247,7 +247,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddContentRestore(NamedId schemaId, string schemaType, string schemaName) + private void AddContentRestore(string schemaType, string schemaName) { AddField(new FieldType { @@ -258,7 +258,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { var contentId = c.GetArgument("id"); - var command = new ChangeContentStatus { SchemaId = schemaId, ContentId = contentId, Status = Status.Draft }; + var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Draft }; return publish(command); }), @@ -266,7 +266,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types }); } - private void AddContentDelete(NamedId schemaId, string schemaType, string schemaName) + private void AddContentDelete(string schemaType, string schemaName) { AddField(new FieldType { @@ -277,7 +277,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { var contentId = c.GetArgument("id"); - var command = new DeleteContent { SchemaId = schemaId, ContentId = contentId }; + var command = new DeleteContent { ContentId = contentId }; return publish(command); }), diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index 1ae0abb79..dfd5d8b68 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Infrastructure; @@ -62,6 +64,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { error(new ValidationError($"Content cannot be changed from status {status} to {command.Status}.", nameof(command.Status))); } + + if (command.DueTime.HasValue && command.DueTime.Value < SystemClock.Instance.GetCurrentInstant()) + { + error(new ValidationError("DueTime must be in the future.", nameof(command.DueTime))); + } }); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs index 4e9573116..11a33154c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentEntity.cs @@ -6,19 +6,31 @@ // ========================================================================== // ========================================================================== +using System; +using NodaTime; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents { public interface IContentEntity : IEntity, - IEntityWithAppRef, IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion { + NamedId AppId { get; } + + NamedId SchemaId { get; } + Status Status { get; } + Status? ScheduledTo { get; } + + Instant? ScheduledAt { get; } + + RefToken ScheduledBy { get; } + NamedContentData Data { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index 509fc42ca..b9aba61be 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.OData.UriParser; +using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Schemas; @@ -27,5 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id); Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, long version); + + Task QueryScheduledWithoutDataAsync(Instant now, Func callback); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index cdc9884f1..489a03832 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs @@ -7,38 +7,50 @@ using System; using Newtonsoft.Json; +using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Entities.Contents.State { public class ContentState : DomainObjectState, - IContentEntity, - IUpdateableEntityWithAppRef + IContentEntity { [JsonProperty] - public NamedContentData Data { get; set; } + public NamedId AppId { get; set; } [JsonProperty] - public Guid AppId { get; set; } + public NamedId SchemaId { get; set; } [JsonProperty] - public Guid SchemaId { get; set; } + public NamedContentData Data { get; set; } [JsonProperty] public Status Status { get; set; } + [JsonProperty] + public Status? ScheduledTo { get; set; } + + [JsonProperty] + public Instant? ScheduledAt { get; set; } + + [JsonProperty] + public RefToken ScheduledBy { get; set; } + [JsonProperty] public bool IsDeleted { get; set; } protected void On(ContentCreated @event) { - SchemaId = @event.SchemaId.Id; + SchemaId = @event.SchemaId; Data = @event.Data; + + AppId = @event.AppId; } protected void On(ContentUpdated @event) @@ -46,9 +58,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.State Data = @event.Data; } + protected void On(ContentStatusScheduled @event) + { + ScheduledAt = @event.DueTime; + ScheduledBy = @event.Actor; + ScheduledTo = @event.Status; + } + protected void On(ContentStatusChanged @event) { Status = @event.Status; + + ScheduledAt = null; + ScheduledBy = null; + ScheduledTo = null; } protected void On(ContentDeleted @event) diff --git a/src/Squidex.Domain.Apps.Entities/EntityMapper.cs b/src/Squidex.Domain.Apps.Entities/EntityMapper.cs index 1dd17d847..f990ae781 100644 --- a/src/Squidex.Domain.Apps.Entities/EntityMapper.cs +++ b/src/Squidex.Domain.Apps.Entities/EntityMapper.cs @@ -17,7 +17,6 @@ namespace Squidex.Domain.Apps.Entities public static T Update(this T entity, SquidexEvent @event, EnvelopeHeaders headers, Action updater = null) where T : IEntity { SetId(entity, headers); - SetAppId(entity, @event); SetCreated(entity, headers); SetCreatedBy(entity, @event); SetLastModified(entity, headers); @@ -76,13 +75,5 @@ namespace Squidex.Domain.Apps.Entities withModifiedBy.LastModifiedBy = @event.Actor; } } - - private static void SetAppId(IEntity entity, SquidexEvent @event) - { - if (entity is IUpdateableEntityWithAppRef appEntity && @event is AppEvent appEvent) - { - appEntity.AppId = appEvent.AppId.Id; - } - } } } diff --git a/src/Squidex.Domain.Apps.Entities/AppCommand.cs b/src/Squidex.Domain.Apps.Entities/IAppCommand.cs similarity index 70% rename from src/Squidex.Domain.Apps.Entities/AppCommand.cs rename to src/Squidex.Domain.Apps.Entities/IAppCommand.cs index ba5b6a4e4..6a7bcf31b 100644 --- a/src/Squidex.Domain.Apps.Entities/AppCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/IAppCommand.cs @@ -1,17 +1,18 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities { - public abstract class AppCommand : SquidexCommand + public interface IAppCommand : ICommand { - public NamedId AppId { get; set; } + NamedId AppId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/src/Squidex.Domain.Apps.Entities/IAppProvider.cs index c41da76c9..e246b3cb7 100644 --- a/src/Squidex.Domain.Apps.Entities/IAppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/IAppProvider.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities Task GetAppAsync(string appName); - Task GetSchemaAsync(Guid appId, Guid id); + Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); Task GetSchemaAsync(Guid appId, string name); diff --git a/src/Squidex.Domain.Apps.Entities/SchemaCommand.cs b/src/Squidex.Domain.Apps.Entities/ISchemaCommand.cs similarity index 69% rename from src/Squidex.Domain.Apps.Entities/SchemaCommand.cs rename to src/Squidex.Domain.Apps.Entities/ISchemaCommand.cs index 62db6405d..bd75842d8 100644 --- a/src/Squidex.Domain.Apps.Entities/SchemaCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/ISchemaCommand.cs @@ -1,17 +1,18 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities { - public abstract class SchemaCommand : AppCommand + public interface ISchemaCommand : ICommand { - public NamedId SchemaId { get; set; } + NamedId SchemaId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/CreateRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/CreateRule.cs index 667f95dd7..07b49c12b 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Commands/CreateRule.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/CreateRule.cs @@ -6,11 +6,14 @@ // ========================================================================== using System; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Rules.Commands { - public sealed class CreateRule : RuleEditCommand + public sealed class CreateRule : RuleEditCommand, IAppCommand { + public NamedId AppId { get; set; } + 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 index 055730bd9..d895ed5b4 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Commands/DeleteRule.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/DeleteRule.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Commands { - public sealed class DeleteRule : RuleAggregateCommand + public sealed class DeleteRule : RuleCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/DisableRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/DisableRule.cs index 0718a7b12..de40cf95a 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Commands/DisableRule.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/DisableRule.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Commands { - public sealed class DisableRule : RuleAggregateCommand + public sealed class DisableRule : RuleCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/EnableRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/EnableRule.cs index b35f97d86..62cd528f0 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Commands/EnableRule.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/EnableRule.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Commands { - public sealed class EnableRule : RuleAggregateCommand + public sealed class EnableRule : RuleCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleAggregateCommand.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs similarity index 88% rename from src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleAggregateCommand.cs rename to src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs index 41a81ea3e..7d8690c46 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleAggregateCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs @@ -10,7 +10,7 @@ using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Rules.Commands { - public abstract class RuleAggregateCommand : AppCommand, IAggregateCommand + public abstract class RuleCommand : SquidexCommand, IAggregateCommand { public Guid RuleId { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs index ba8d77981..e461ff8ac 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleEditCommand.cs @@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Rules; namespace Squidex.Domain.Apps.Entities.Rules.Commands { - public abstract class RuleEditCommand : RuleAggregateCommand + public abstract class RuleEditCommand : RuleCommand { public RuleTrigger Trigger { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs index 86707be2f..77f1298df 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/GuardRule.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Rules.Commands; @@ -22,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards { if (command.Trigger == null) { - error(new ValidationError("Trigger must be defined.", nameof(command.Trigger))); + error(new ValidationError("Trigger is required.", nameof(command.Trigger))); } else { @@ -33,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards if (command.Action == null) { - error(new ValidationError("Trigger must be defined.", nameof(command.Action))); + error(new ValidationError("Trigger is required.", nameof(command.Action))); } else { @@ -44,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards }); } - public static Task CanUpdate(UpdateRule command, IAppProvider appProvider) + public static Task CanUpdate(UpdateRule command, Guid appId, IAppProvider appProvider) { Guard.NotNull(command, nameof(command)); @@ -52,12 +53,12 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards { if (command.Trigger == null && command.Action == null) { - error(new ValidationError("Either trigger or action must be defined.", nameof(command.Trigger), nameof(command.Action))); + error(new ValidationError("Either trigger or action is required.", nameof(command.Trigger), nameof(command.Action))); } if (command.Trigger != null) { - var errors = await RuleTriggerValidator.ValidateAsync(command.AppId.Id, command.Trigger, appProvider); + var errors = await RuleTriggerValidator.ValidateAsync(appId, command.Trigger, appProvider); errors.Foreach(error); } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs index 2d36a13c5..3b794922d 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs @@ -31,17 +31,17 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards if (string.IsNullOrWhiteSpace(action.ApiKey)) { - errors.Add(new ValidationError("Api key must be defined.", nameof(action.ApiKey))); + errors.Add(new ValidationError("Api key is required.", nameof(action.ApiKey))); } if (string.IsNullOrWhiteSpace(action.AppId)) { - errors.Add(new ValidationError("Application ID key must be defined.", nameof(action.AppId))); + errors.Add(new ValidationError("Application ID key is required.", nameof(action.AppId))); } if (string.IsNullOrWhiteSpace(action.IndexName)) { - errors.Add(new ValidationError("Index name must be defined.", nameof(action.ApiKey))); + errors.Add(new ValidationError("Index name is required.", nameof(action.IndexName))); } return Task.FromResult>(errors); @@ -53,12 +53,12 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards if (string.IsNullOrWhiteSpace(action.ConnectionString)) { - errors.Add(new ValidationError("Connection string must be defined.", nameof(action.ConnectionString))); + errors.Add(new ValidationError("Connection string is required.", nameof(action.ConnectionString))); } if (string.IsNullOrWhiteSpace(action.Queue)) { - errors.Add(new ValidationError("Queue must be defined.", nameof(action.Queue))); + errors.Add(new ValidationError("Queue is required.", nameof(action.Queue))); } else if (!Regex.IsMatch(action.Queue, "^[a-z][a-z0-9]{2,}(\\-[a-z0-9]+)*$")) { @@ -74,12 +74,12 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards if (string.IsNullOrWhiteSpace(action.ApiKey)) { - errors.Add(new ValidationError("Api key must be defined.", nameof(action.ApiKey))); + errors.Add(new ValidationError("Api key is required.", nameof(action.ApiKey))); } if (string.IsNullOrWhiteSpace(action.ServiceId)) { - errors.Add(new ValidationError("Service name must be defined.", nameof(action.ServiceId))); + errors.Add(new ValidationError("Service ID is required.", nameof(action.ServiceId))); } return Task.FromResult>(errors); @@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards if (action.WebhookUrl == null || !action.WebhookUrl.IsAbsoluteUri) { - errors.Add(new ValidationError("Webhook Url must be specified and absolute.", nameof(action.WebhookUrl))); + errors.Add(new ValidationError("Webhook Url is required and must be an absolute URL.", nameof(action.WebhookUrl))); } return Task.FromResult>(errors); @@ -103,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards if (action.Url == null || !action.Url.IsAbsoluteUri) { - errors.Add(new ValidationError("Url must be specified and absolute.", nameof(action.Url))); + errors.Add(new ValidationError("Url is required and must be an absolute URL.", nameof(action.Url))); } return Task.FromResult>(errors); diff --git a/src/Squidex.Domain.Apps.Entities/Rules/IRuleEntity.cs b/src/Squidex.Domain.Apps.Entities/Rules/IRuleEntity.cs index 2caaa814c..ef69c574f 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/IRuleEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/IRuleEntity.cs @@ -5,17 +5,20 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Squidex.Domain.Apps.Core.Rules; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Rules { public interface IRuleEntity : IEntity, - IEntityWithAppRef, IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion { + NamedId AppId { get; set; } + Rule RuleDef { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs index 8edc62f37..31c09131b 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Repositories/IRuleEventRepository.cs @@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Repositories 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 QueryPendingAsync(Instant now, Func callback, CancellationToken ct = default(CancellationToken)); Task CountByAppAsync(Guid appId); diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs index c0ee1cf5b..8a6e43441 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs @@ -44,7 +44,7 @@ namespace Squidex.Domain.Apps.Entities.Rules { return handler.UpdateSyncedAsync(context, async r => { - await GuardRule.CanUpdate(command, appProvider); + await GuardRule.CanUpdate(command, r.Snapshot.AppId.Id, appProvider); r.Update(command); }); diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs index 32d0096bb..693d661bb 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs @@ -72,13 +72,13 @@ namespace Squidex.Domain.Apps.Entities.Rules timer.SkipCurrentDelay(); } - private async Task QueryAsync(CancellationToken cancellationToken) + private async Task QueryAsync(CancellationToken ct) { try { var now = clock.GetCurrentInstant(); - await ruleEventRepository.QueryPendingAsync(now, requestBlock.SendAsync, cancellationToken); + await ruleEventRepository.QueryPendingAsync(now, requestBlock.SendAsync, ct); } catch (Exception ex) { diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs index bc05ac8bd..54c9afc2d 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs @@ -7,15 +7,15 @@ using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.State; +using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Rules; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Rules { - public sealed class RuleDomainObject : DomainObjectBase + public sealed class RuleDomainObject : SquidexDomainObjectBase { public void Create(CreateRule command) { @@ -52,6 +52,16 @@ namespace Squidex.Domain.Apps.Entities.Rules RaiseEvent(SimpleMapper.Map(command, new RuleDeleted())); } + private void RaiseEvent(AppEvent @event) + { + if (@event.AppId == null) + { + @event.AppId = Snapshot.AppId; + } + + RaiseEvent(Envelope.Create(@event)); + } + private void VerifyNotCreated() { if (Snapshot.RuleDef != null) diff --git a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs index 7b6dd9602..fa87078da 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs @@ -10,18 +10,17 @@ using Newtonsoft.Json; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Rules; +using Squidex.Infrastructure; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Entities.Rules.State { public class RuleState : DomainObjectState, - IRuleEntity, - IEntityWithAppRef, - IUpdateableEntityWithAppRef + IRuleEntity { [JsonProperty] - public Guid AppId { get; set; } + public NamedId AppId { get; set; } [JsonProperty] public Rule RuleDef { get; set; } @@ -32,6 +31,8 @@ namespace Squidex.Domain.Apps.Entities.Rules.State protected void On(RuleCreated @event) { RuleDef = new Rule(@event.Trigger, @event.Action); + + AppId = @event.AppId; } protected void On(RuleUpdated @event) diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs index e14d082e8..8856821d6 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/AddField.cs @@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Schemas; namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class AddField : SchemaAggregateCommand + public sealed class AddField : SchemaCommand { public string Name { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs index f4fea680a..d850076a6 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ConfigureScripts.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class ConfigureScripts : SchemaAggregateCommand + public sealed class ConfigureScripts : SchemaCommand { public string ScriptQuery { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs index f303e5c95..923499e79 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchema.cs @@ -7,25 +7,22 @@ using System; using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure; using SchemaFields = System.Collections.Generic.List; namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class CreateSchema : AppCommand, IAggregateCommand + public sealed class CreateSchema : SchemaCommand, IAppCommand { - public Guid SchemaId { get; set; } + public NamedId AppId { get; set; } + + public string Name { get; set; } public SchemaFields Fields { get; set; } public SchemaProperties Properties { get; set; } - public string Name { get; set; } - - Guid IAggregateCommand.AggregateId - { - get { return SchemaId; } - } + public bool Publish { get; set; } public CreateSchema() { diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs index d4c3d9bfb..d3b79c454 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/DeleteSchema.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class DeleteSchema : SchemaAggregateCommand + public sealed class DeleteSchema : SchemaCommand { } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs index 9ddbd0301..5ad93ddf1 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/FieldCommand.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public class FieldCommand : SchemaAggregateCommand + public class FieldCommand : SchemaCommand { public long FieldId { get; set; } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs index 8bb789b72..c8d68314d 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/PublishSchema.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class PublishSchema : SchemaAggregateCommand + public sealed class PublishSchema : SchemaCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs index 068b41162..9afe0346c 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/ReorderFields.cs @@ -9,7 +9,7 @@ using System.Collections.Generic; namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class ReorderFields : SchemaAggregateCommand + public sealed class ReorderFields : SchemaCommand { public List FieldIds { get; set; } } diff --git a/src/Squidex.Domain.Apps.Entities/AppAggregateCommand.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs similarity index 77% rename from src/Squidex.Domain.Apps.Entities/AppAggregateCommand.cs rename to src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs index a4143215c..49bba3620 100644 --- a/src/Squidex.Domain.Apps.Entities/AppAggregateCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/SchemaCommand.cs @@ -10,11 +10,13 @@ using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities { - public class AppAggregateCommand : AppCommand, IAggregateCommand + public abstract class SchemaCommand : SquidexCommand, IAggregateCommand { + public Guid SchemaId { get; set; } + Guid IAggregateCommand.AggregateId { - get { return AppId.Id; } + get { return SchemaId; } } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs index c8c2b722d..31d5c284a 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UnpublishSchema.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class UnpublishSchema : SchemaAggregateCommand + public sealed class UnpublishSchema : SchemaCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs index 329cbc400..579f55bb7 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/UpdateSchema.cs @@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Core.Schemas; namespace Squidex.Domain.Apps.Entities.Schemas.Commands { - public sealed class UpdateSchema : SchemaAggregateCommand + public sealed class UpdateSchema : SchemaCommand { public SchemaProperties Properties { get; set; } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs index 730a8083a..44becd941 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/FieldPropertiesValidator.cs @@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards if (properties.AspectWidth.HasValue != properties.AspectHeight.HasValue) { - yield return new ValidationError("Aspect width and height must be defined.", + yield return new ValidationError("Aspect width and height is required.", nameof(properties.AspectWidth), nameof(properties.AspectHeight)); } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs index a7cd406c5..c95bac2f6 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs @@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards if (field.Properties == null) { - error(new ValidationError("Properties must be defined.", $"{prefix}.{nameof(field.Properties)}")); + error(new ValidationError("Properties is required.", $"{prefix}.{nameof(field.Properties)}")); } var propertyErrors = FieldPropertiesValidator.Validate(field.Properties); @@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { if (command.FieldIds == null) { - error(new ValidationError("Field ids must be specified.", nameof(command.FieldIds))); + error(new ValidationError("Field ids is required.", nameof(command.FieldIds))); } if (command.FieldIds.Count != schema.Fields.Count || command.FieldIds.Any(x => !schema.FieldsById.ContainsKey(x))) diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs index 58ec7dc5b..73463a710 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs @@ -32,7 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards if (command.Properties == null) { - error(new ValidationError("Properties must be defined.", nameof(command.Properties))); + error(new ValidationError("Properties is required.", nameof(command.Properties))); } var propertyErrors = FieldPropertiesValidator.Validate(command.Properties); @@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { if (command.Properties == null) { - error(new ValidationError("Properties must be defined.", nameof(command.Properties))); + error(new ValidationError("Properties is required.", nameof(command.Properties))); } var propertyErrors = FieldPropertiesValidator.Validate(command.Properties); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs b/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs index da1fd2045..8c341e76e 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/ISchemaEntity.cs @@ -5,17 +5,20 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Schemas { public interface ISchemaEntity : IEntity, - IEntityWithAppRef, IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion { + NamedId AppId { get; } + string Name { get; } bool IsPublished { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs index cc081ee28..b57b31333 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs @@ -10,15 +10,15 @@ using System.Collections.Generic; 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; using Squidex.Domain.Apps.Events.Schemas; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Schemas { - public sealed class SchemaDomainObject : DomainObjectBase + public sealed class SchemaDomainObject : SquidexDomainObjectBase { private readonly FieldRegistry registry; @@ -190,6 +190,21 @@ namespace Squidex.Domain.Apps.Entities.Schemas RaiseEvent(@event); } + private void RaiseEvent(SchemaEvent @event) + { + if (@event.SchemaId == null) + { + @event.SchemaId = new NamedId(Snapshot.Id, Snapshot.Name); + } + + if (@event.AppId == null) + { + @event.AppId = Snapshot.AppId; + } + + RaiseEvent(Envelope.Create(@event)); + } + private void VerifyNotCreated() { if (Snapshot.SchemaDef != null) diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs index 5a8755b82..bc148430b 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs @@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Schemas; +using Squidex.Infrastructure; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; @@ -18,16 +19,13 @@ using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Schemas.State { public class SchemaState : DomainObjectState, - ISchemaEntity, - IUpdateableEntityWithAppRef, - IUpdateableEntityWithCreatedBy, - IUpdateableEntityWithLastModifiedBy + ISchemaEntity { [JsonProperty] - public string Name { get; set; } + public NamedId AppId { get; set; } [JsonProperty] - public Guid AppId { get; set; } + public string Name { get; set; } [JsonProperty] public int TotalFields { get; set; } = 0; @@ -70,6 +68,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State schema = schema.Update(@event.Properties); } + if (@event.Publish) + { + schema = schema.Publish(); + } + if (@event.Fields != null) { foreach (var eventField in @event.Fields) @@ -103,6 +106,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas.State } SchemaDef = schema; + + AppId = @event.AppId; } protected void On(FieldAdded @event, FieldRegistry registry) diff --git a/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectBase.cs b/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectBase.cs new file mode 100644 index 000000000..63f0f94ed --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectBase.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class SquidexDomainObjectBase : DomainObjectBase where T : IDomainState, new() + { + public override void RaiseEvent(Envelope @event) + { + if (@event.Payload is AppEvent appEvent) + { + @event.SetAppId(appEvent.AppId.Id); + } + + base.RaiseEvent(@event); + } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusScheduled.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusScheduled.cs new file mode 100644 index 000000000..e0d0a5bac --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusScheduled.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NodaTime; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Contents +{ + [EventType(nameof(ContentStatusScheduled))] + public sealed class ContentStatusScheduled : ContentEvent + { + public Status Status { get; set; } + + public Instant DueTime { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreated.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreated.cs index c44595bbc..2e7c6ec2b 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreated.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreated.cs @@ -19,5 +19,7 @@ namespace Squidex.Domain.Apps.Events.Schemas public SchemaFields Fields { get; set; } public SchemaProperties Properties { get; set; } + + public bool Publish { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/SquidexEvents.cs b/src/Squidex.Domain.Apps.Events/SquidexEvents.cs index 906a579bf..d49cb8921 100644 --- a/src/Squidex.Domain.Apps.Events/SquidexEvents.cs +++ b/src/Squidex.Domain.Apps.Events/SquidexEvents.cs @@ -5,9 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Reflection; + namespace Squidex.Domain.Apps.Events { public static class SquidexEvents { + public static readonly Assembly Assembly = typeof(SquidexEvents).Assembly; } } diff --git a/src/Squidex.Domain.Apps.Events/SquidexHeaderExtensions.cs b/src/Squidex.Domain.Apps.Events/SquidexHeaderExtensions.cs new file mode 100644 index 000000000..0673d3b5b --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/SquidexHeaderExtensions.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Globalization; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events +{ + public static class SquidexHeaderExtensions + { + public static Guid AppId(this EnvelopeHeaders headers) + { + return headers[SquidexHeaders.AppId].ToGuid(CultureInfo.InvariantCulture); + } + + public static Envelope SetAppId(this Envelope envelope, Guid value) where T : class + { + envelope.Headers.Set(SquidexHeaders.AppId, value); + + return envelope; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs b/src/Squidex.Domain.Apps.Events/SquidexHeaders.cs similarity index 64% rename from src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs rename to src/Squidex.Domain.Apps.Events/SquidexHeaders.cs index bf899acef..e2f610a10 100644 --- a/src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs +++ b/src/Squidex.Domain.Apps.Events/SquidexHeaders.cs @@ -1,16 +1,14 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; - -namespace Squidex.Domain.Apps.Entities +namespace Squidex.Domain.Apps.Events { - public interface IEntityWithAppRef + public static class SquidexHeaders { - Guid AppId { get; } + public static readonly string AppId = "AppId"; } -} \ No newline at end of file +} diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs index f0721a586..aefd883b2 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Formatter.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Text; using EventStore.ClientAPI; using EventStoreData = EventStore.ClientAPI.EventData; @@ -20,7 +21,7 @@ namespace Squidex.Infrastructure.EventSourcing var body = Encoding.UTF8.GetString(@event.Data); var meta = Encoding.UTF8.GetString(@event.Metadata); - var eventData = new EventData { Type = @event.EventType, EventId = @event.EventId, Payload = body, Metadata = meta }; + var eventData = new EventData { Type = @event.EventType, Payload = body, Metadata = meta }; return new StoredEvent( resolvedEvent.OriginalEventNumber.ToString(), @@ -30,13 +31,10 @@ namespace Squidex.Infrastructure.EventSourcing public static EventStoreData Write(EventData eventData) { - var body = Encoding.UTF8.GetBytes(eventData.Payload); - var meta = Encoding.UTF8.GetBytes(eventData.Metadata); + var body = Encoding.UTF8.GetBytes(eventData.Payload.ToString()); + var meta = Encoding.UTF8.GetBytes(eventData.Metadata.ToString()); - return new EventStoreData( - eventData.EventId, - eventData.Type, - true, body, meta); + return new EventStoreData(Guid.NewGuid(), eventData.Type, true, body, meta); } } } diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs index 2023d0f73..9e4d1a4d8 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs @@ -11,7 +11,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using EventStore.ClientAPI; -using EventStore.ClientAPI.Projections; namespace Squidex.Infrastructure.EventSourcing { @@ -20,18 +19,18 @@ namespace Squidex.Infrastructure.EventSourcing private const int WritePageSize = 500; private const int ReadPageSize = 500; private readonly IEventStoreConnection connection; - private readonly string projectionHost; private readonly string prefix; - private ProjectionsManager projectionsManager; + private ProjectionClient projectionClient; public GetEventStore(IEventStoreConnection connection, string prefix, string projectionHost) { Guard.NotNull(connection, nameof(connection)); this.connection = connection; - this.projectionHost = projectionHost; this.prefix = prefix?.Trim(' ', '-').WithFallback("squidex"); + + projectionClient = new ProjectionClient(connection, prefix, projectionHost); } public void Initialize() @@ -45,50 +44,43 @@ namespace Squidex.Infrastructure.EventSourcing throw new ConfigurationException("Cannot connect to event store.", ex); } - try - { - projectionsManager = connection.GetProjectionsManagerAsync(projectionHost).Result; - - projectionsManager.ListAllAsync(connection.Settings.DefaultUserCredentials).Wait(); - } - catch (Exception ex) - { - throw new ConfigurationException($"Cannot connect to event store projections: {projectionHost}.", ex); - } + projectionClient.ConnectAsync().Wait(); } public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter, string position = null) { - return new GetEventStoreSubscription(connection, subscriber, projectionsManager, prefix, position, streamFilter); + return new GetEventStoreSubscription(connection, subscriber, projectionClient, prefix, position, streamFilter); } - public async Task GetEventsAsync(Func callback, string streamFilter = null, string position = null, CancellationToken cancellationToken = default(CancellationToken)) + public Task CreateIndexAsync(string property) { - var streamName = await connection.CreateProjectionAsync(projectionsManager, prefix, streamFilter); + return projectionClient.CreateProjectionAsync(property, string.Empty); + } - var sliceStart = ProjectionHelper.ParsePosition(position); + public async Task QueryAsync(Func callback, string property, object value, string position = null, CancellationToken ct = default(CancellationToken)) + { + var streamName = await projectionClient.CreateProjectionAsync(property, value); - StreamEventsSlice currentSlice; - do - { - currentSlice = await connection.ReadStreamEventsForwardAsync(streamName, sliceStart, ReadPageSize, true); + var sliceStart = projectionClient.ParsePosition(position); - if (currentSlice.Status == SliceReadStatus.Success) - { - sliceStart = currentSlice.NextEventNumber; + await QueryAsync(callback, streamName, sliceStart, ct); + } - foreach (var resolved in currentSlice.Events) - { - var storedEvent = Formatter.Read(resolved); + public async Task QueryAsync(Func callback, string streamFilter = null, string position = null, CancellationToken ct = default(CancellationToken)) + { + var streamName = await projectionClient.CreateProjectionAsync(streamFilter); - await callback(storedEvent); - } - } - } - while (!currentSlice.IsEndOfStream && !cancellationToken.IsCancellationRequested); + var sliceStart = projectionClient.ParsePosition(position); + + await QueryAsync(callback, streamName, sliceStart, ct); + } + + private Task QueryAsync(Func callback, string streamName, long sliceStart, CancellationToken ct) + { + return QueryAsync(callback, GetStreamName(streamName), sliceStart, ct); } - public async Task> GetEventsAsync(string streamName, long streamPosition = 0) + public async Task> QueryAsync(string streamName, long streamPosition = 0) { var result = new List(); @@ -97,7 +89,7 @@ namespace Squidex.Infrastructure.EventSourcing StreamEventsSlice currentSlice; do { - currentSlice = await connection.ReadStreamEventsForwardAsync(GetStreamName(streamName), sliceStart, ReadPageSize, false); + currentSlice = await connection.ReadStreamEventsForwardAsync(streamName, sliceStart, ReadPageSize, false); if (currentSlice.Status == SliceReadStatus.Success) { @@ -116,12 +108,12 @@ namespace Squidex.Infrastructure.EventSourcing return result; } - public Task AppendEventsAsync(Guid commitId, string streamName, ICollection events) + public Task AppendAsync(Guid commitId, string streamName, ICollection events) { return AppendEventsInternalAsync(streamName, EtagVersion.Any, events); } - public Task AppendEventsAsync(Guid commitId, string streamName, long expectedVersion, ICollection events) + public Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events) { Guard.GreaterEquals(expectedVersion, -1, nameof(expectedVersion)); diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs index f5102f54a..b3cfed490 100644 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStoreSubscription.cs @@ -8,33 +8,32 @@ using System.Threading.Tasks; using EventStore.ClientAPI; using EventStore.ClientAPI.Exceptions; -using EventStore.ClientAPI.Projections; using Squidex.Infrastructure.Tasks; namespace Squidex.Infrastructure.EventSourcing { internal sealed class GetEventStoreSubscription : IEventSubscription { - private readonly IEventStoreConnection eventStoreConnection; - private readonly IEventSubscriber eventSubscriber; + private readonly IEventStoreConnection connection; + private readonly IEventSubscriber subscriber; private readonly EventStoreCatchUpSubscription subscription; private readonly long? position; public GetEventStoreSubscription( - IEventStoreConnection eventStoreConnection, - IEventSubscriber eventSubscriber, - ProjectionsManager projectionsManager, + IEventStoreConnection connection, + IEventSubscriber subscriber, + ProjectionClient projectionClient, string prefix, string position, string streamFilter) { - Guard.NotNull(eventSubscriber, nameof(eventSubscriber)); + Guard.NotNull(subscriber, nameof(subscriber)); - this.eventStoreConnection = eventStoreConnection; - this.eventSubscriber = eventSubscriber; - this.position = ProjectionHelper.ParsePositionOrNull(position); + this.connection = connection; + this.position = projectionClient.ParsePositionOrNull(position); + this.subscriber = subscriber; - var streamName = eventStoreConnection.CreateProjectionAsync(projectionsManager, prefix, streamFilter).Result; + var streamName = projectionClient.CreateProjectionAsync(streamFilter).Result; subscription = SubscribeToStream(streamName); } @@ -54,12 +53,12 @@ namespace Squidex.Infrastructure.EventSourcing { var settings = CatchUpSubscriptionSettings.Default; - return eventStoreConnection.SubscribeToStreamFrom(streamName, position, settings, + return connection.SubscribeToStreamFrom(streamName, position, settings, (s, e) => { var storedEvent = Formatter.Read(e); - eventSubscriber.OnEventAsync(this, storedEvent).Wait(); + subscriber.OnEventAsync(this, storedEvent).Wait(); }, null, (s, reason, ex) => { @@ -68,7 +67,7 @@ namespace Squidex.Infrastructure.EventSourcing { ex = ex ?? new ConnectionClosedException($"Subscription closed with reason {reason}."); - eventSubscriber.OnErrorAsync(this, ex); + subscriber.OnErrorAsync(this, ex); } }); } diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs new file mode 100644 index 000000000..6c4ef3f75 --- /dev/null +++ b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs @@ -0,0 +1,142 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using EventStore.ClientAPI; +using EventStore.ClientAPI.Exceptions; +using EventStore.ClientAPI.Projections; + +namespace Squidex.Infrastructure.EventSourcing +{ + public sealed class ProjectionClient + { + private readonly ConcurrentDictionary projections = new ConcurrentDictionary(); + private readonly IEventStoreConnection connection; + private readonly string prefix; + private readonly string projectionHost; + private ProjectionsManager projectionsManager; + + public ProjectionClient(IEventStoreConnection connection, string prefix, string projectionHost) + { + this.connection = connection; + + this.prefix = prefix; + this.projectionHost = projectionHost; + } + + private string CreateFilterProjectionName(string filter) + { + return $"by-{prefix.Slugify()}-{filter.Slugify()}"; + } + + private string CreatePropertyProjectionName(string property) + { + return $"by-{prefix.Slugify()}-{property.Slugify()}-property"; + } + + public async Task CreateProjectionAsync(string property, object value) + { + var name = CreatePropertyProjectionName(property); + + var query = + $@"fromAll() + .when({{ + $any: function (s, e) {{ + if (e.streamId.indexOf('{prefix}') === 0 && e.metadata.{property}) {{ + linkTo('{name}-' + e.metadata.{property}, e); + }} + }} + }});"; + + await CreateProjectionAsync(name, query); + + return $"{name}-{value}"; + } + + public async Task CreateProjectionAsync(string streamFilter = null) + { + streamFilter = streamFilter ?? ".*"; + + var name = CreateFilterProjectionName(streamFilter); + + var query = + $@"fromAll() + .when({{ + $any: function (s, e) {{ + if (e.streamId.indexOf('{prefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({prefix.Length + 1}))) {{ + linkTo('{name}', e); + }} + }} + }});"; + + await CreateProjectionAsync(name, query); + + return name; + } + + private async Task CreateProjectionAsync(string name, string query) + { + if (projections.TryAdd(name, true)) + { + try + { + var credentials = connection.Settings.DefaultUserCredentials; + + await projectionsManager.CreateContinuousAsync(name, query, credentials); + } + catch (Exception ex) + { + if (!ex.Is()) + { + throw; + } + } + } + } + + public async Task ConnectAsync() + { + var addressParts = projectionHost.Split(':'); + + if (addressParts.Length < 2 || !int.TryParse(addressParts[1], out var port)) + { + port = 2113; + } + + var endpoints = await Dns.GetHostAddressesAsync(addressParts[0]); + var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), port); + + projectionsManager = + new ProjectionsManager( + connection.Settings.Log, endpoint, + connection.Settings.OperationTimeout); + try + { + await projectionsManager.ListAllAsync(connection.Settings.DefaultUserCredentials); + } + catch (Exception ex) + { + throw new ConfigurationException($"Cannot connect to event store projections: {projectionHost}.", ex); + } + } + + public long? ParsePositionOrNull(string position) + { + return long.TryParse(position, out var parsedPosition) ? (long?)parsedPosition : null; + } + + public long ParsePosition(string position) + { + return long.TryParse(position, out var parsedPosition) ? parsedPosition : 0; + } + } +} diff --git a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionHelper.cs b/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionHelper.cs deleted file mode 100644 index 4219dcdb0..000000000 --- a/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionHelper.cs +++ /dev/null @@ -1,97 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Concurrent; -using System.Globalization; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Threading.Tasks; -using EventStore.ClientAPI; -using EventStore.ClientAPI.Exceptions; -using EventStore.ClientAPI.Projections; - -namespace Squidex.Infrastructure.EventSourcing -{ - public static class ProjectionHelper - { - private const string ProjectionName = "by-{0}-{1}"; - private static readonly ConcurrentDictionary SubscriptionsCreated = new ConcurrentDictionary(); - - private static string ParseFilter(string prefix, string filter) - { - return string.Format(CultureInfo.InvariantCulture, ProjectionName, prefix.Slugify(), filter.Slugify()); - } - - public static async Task CreateProjectionAsync(this IEventStoreConnection connection, ProjectionsManager projectionsManager, string prefix, string streamFilter = null) - { - streamFilter = streamFilter ?? ".*"; - - var streamName = ParseFilter(prefix, streamFilter); - - if (SubscriptionsCreated.TryAdd(streamName, true)) - { - var projectionConfig = - $@"fromAll() - .when({{ - $any: function (s, e) {{ - if (e.streamId.indexOf('{prefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({prefix.Length + 1}))) {{ - linkTo('{streamName}', e); - }} - }} - }});"; - - try - { - var credentials = connection.Settings.DefaultUserCredentials; - - await projectionsManager.CreateContinuousAsync($"${streamName}", projectionConfig, credentials); - } - catch (Exception ex) - { - if (!ex.Is()) - { - throw; - } - } - } - - return streamName; - } - - public static async Task GetProjectionsManagerAsync(this IEventStoreConnection connection, string projectionHost) - { - var addressParts = projectionHost.Split(':'); - - if (addressParts.Length < 2 || !int.TryParse(addressParts[1], out var port)) - { - port = 2113; - } - - var endpoints = await Dns.GetHostAddressesAsync(addressParts[0]); - var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), port); - - var projectionsManager = - new ProjectionsManager( - connection.Settings.Log, endpoint, - connection.Settings.OperationTimeout); - - return projectionsManager; - } - - public static long? ParsePositionOrNull(string position) - { - return long.TryParse(position, out var parsedPosition) ? (long?)parsedPosition : null; - } - - public static long ParsePosition(string position) - { - return long.TryParse(position, out var parsedPosition) ? parsedPosition : 0; - } - } -} diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEvent.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEvent.cs index 703fb5223..62d15ca20 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEvent.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEvent.cs @@ -5,42 +5,33 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using MongoDB.Bson.Serialization.Attributes; -using Squidex.Infrastructure.Reflection; +using Newtonsoft.Json.Linq; namespace Squidex.Infrastructure.EventSourcing { public class MongoEvent { - [BsonElement] - [BsonRequired] - public Guid EventId { get; set; } - [BsonElement] [BsonRequired] public string Payload { get; set; } [BsonElement] [BsonRequired] - public string Metadata { get; set; } + public JToken Metadata { get; set; } [BsonElement] [BsonRequired] public string Type { get; set; } - public MongoEvent() - { - } - - public MongoEvent(EventData data) + public static MongoEvent FromEventData(EventData data) { - SimpleMapper.Map(data, this); + return new MongoEvent { Type = data.Type, Metadata = data.Metadata, Payload = data.ToString() }; } public EventData ToEventData() { - return SimpleMapper.Map(this, new EventData()); + return new EventData { Type = Type, Metadata = Metadata, Payload = JObject.Parse(Payload) }; } } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs index 6bca83110..e5779e42e 100644 --- a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs @@ -5,10 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading; using System.Threading.Tasks; using MongoDB.Bson; using MongoDB.Driver; @@ -16,16 +12,19 @@ using Squidex.Infrastructure.MongoDb; namespace Squidex.Infrastructure.EventSourcing { - public class MongoEventStore : MongoRepositoryBase, IEventStore + public partial class MongoEventStore : MongoRepositoryBase, IEventStore { - private const int MaxAttempts = 20; - private static readonly BsonTimestamp EmptyTimestamp = new BsonTimestamp(0); private static readonly FieldDefinition TimestampField = Fields.Build(x => x.Timestamp); private static readonly FieldDefinition EventsCountField = Fields.Build(x => x.EventsCount); private static readonly FieldDefinition EventStreamOffsetField = Fields.Build(x => x.EventStreamOffset); private static readonly FieldDefinition EventStreamField = Fields.Build(x => x.EventStream); private readonly IEventNotifier notifier; + public IMongoCollection RawCollection + { + get { return Database.GetCollection(CollectionName()); } + } + public MongoEventStore(IMongoDatabase database, IEventNotifier notifier) : base(database) { @@ -50,220 +49,5 @@ namespace Squidex.Infrastructure.EventSourcing collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Timestamp).Ascending(x => x.EventStream)), collection.Indexes.CreateOneAsync(Index.Ascending(x => x.EventStream).Descending(x => x.EventStreamOffset), new CreateIndexOptions { Unique = true })); } - - public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter, string position = null) - { - Guard.NotNull(subscriber, nameof(subscriber)); - Guard.NotNullOrEmpty(streamFilter, nameof(streamFilter)); - - return new PollingSubscription(this, subscriber, streamFilter, position); - } - - public async Task> GetEventsAsync(string streamName, long streamPosition = 0) - { - var commits = - await Collection.Find( - Filter.And( - Filter.Eq(EventStreamField, streamName), - Filter.Gte(EventStreamOffsetField, streamPosition - 1))) - .Sort(Sort.Ascending(TimestampField)).ToListAsync(); - - var result = new List(); - - foreach (var commit in commits) - { - var eventStreamOffset = (int)commit.EventStreamOffset; - - var commitTimestamp = commit.Timestamp; - var commitOffset = 0; - - foreach (var e in commit.Events) - { - eventStreamOffset++; - - if (eventStreamOffset >= streamPosition) - { - var eventData = e.ToEventData(); - var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); - - result.Add(new StoredEvent(eventToken, eventStreamOffset, eventData)); - } - } - } - - return result; - } - - public async Task GetEventsAsync(Func callback, string streamFilter = null, string position = null, CancellationToken cancellationToken = default(CancellationToken)) - { - Guard.NotNull(callback, nameof(callback)); - - StreamPosition lastPosition = position; - - var filter = CreateFilter(streamFilter, lastPosition); - - await Collection.Find(filter).Sort(Sort.Ascending(TimestampField)).ForEachAsync(async commit => - { - var eventStreamOffset = (int)commit.EventStreamOffset; - - var commitTimestamp = commit.Timestamp; - var commitOffset = 0; - - foreach (var e in commit.Events) - { - eventStreamOffset++; - - if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) - { - var eventData = e.ToEventData(); - var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); - - await callback(new StoredEvent(eventToken, eventStreamOffset, eventData)); - - commitOffset++; - } - } - }, cancellationToken); - } - - public Task AppendEventsAsync(Guid commitId, string streamName, ICollection events) - { - return AppendEventsInternalAsync(commitId, streamName, EtagVersion.Any, events); - } - - public Task AppendEventsAsync(Guid commitId, string streamName, long expectedVersion, ICollection events) - { - Guard.GreaterEquals(expectedVersion, EtagVersion.Any, nameof(expectedVersion)); - - return AppendEventsInternalAsync(commitId, streamName, expectedVersion, events); - } - - private async Task AppendEventsInternalAsync(Guid commitId, string streamName, long expectedVersion, ICollection events) - { - Guard.NotNullOrEmpty(streamName, nameof(streamName)); - Guard.NotNull(events, nameof(events)); - - if (events.Count == 0) - { - return; - } - - var currentVersion = await GetEventStreamOffset(streamName); - - if (expectedVersion != EtagVersion.Any && expectedVersion != currentVersion) - { - throw new WrongEventVersionException(currentVersion, expectedVersion); - } - - var commit = BuildCommit(commitId, streamName, expectedVersion >= -1 ? expectedVersion : currentVersion, events); - - for (var attempt = 0; attempt < MaxAttempts; attempt++) - { - try - { - await Collection.InsertOneAsync(commit); - - notifier.NotifyEventsStored(streamName); - - return; - } - catch (MongoWriteException ex) - { - if (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) - { - currentVersion = await GetEventStreamOffset(streamName); - - if (expectedVersion != EtagVersion.Any) - { - throw new WrongEventVersionException(currentVersion, expectedVersion); - } - else if (attempt < MaxAttempts) - { - expectedVersion = currentVersion; - } - else - { - throw new TimeoutException("Could not acquire a free slot for the commit within the provided time."); - } - } - else - { - throw; - } - } - } - } - - private async Task GetEventStreamOffset(string streamName) - { - var document = - await Collection.Find(Filter.Eq(EventStreamField, streamName)) - .Project(Projection - .Include(EventStreamOffsetField) - .Include(EventsCountField)) - .Sort(Sort.Descending(EventStreamOffsetField)).Limit(1) - .FirstOrDefaultAsync(); - - if (document != null) - { - return document[nameof(MongoEventCommit.EventStreamOffset)].ToInt64() + document[nameof(MongoEventCommit.EventsCount)].ToInt64(); - } - - return EtagVersion.Empty; - } - - private static FilterDefinition CreateFilter(string streamFilter, StreamPosition streamPosition) - { - var filters = new List>(); - - if (streamPosition.IsEndOfCommit) - { - filters.Add(Filter.Gt(TimestampField, streamPosition.Timestamp)); - } - else - { - filters.Add(Filter.Gte(TimestampField, streamPosition.Timestamp)); - } - - if (!string.IsNullOrWhiteSpace(streamFilter) && !string.Equals(streamFilter, ".*", StringComparison.OrdinalIgnoreCase)) - { - if (streamFilter.Contains("^")) - { - filters.Add(Filter.Regex(EventStreamField, streamFilter)); - } - else - { - filters.Add(Filter.Eq(EventStreamField, streamFilter)); - } - } - - return Filter.And(filters); - } - - private static MongoEventCommit BuildCommit(Guid commitId, string streamName, long expectedVersion, ICollection events) - { - var commitEvents = new MongoEvent[events.Count]; - - var i = 0; - - foreach (var e in events) - { - var mongoEvent = new MongoEvent(e); - - commitEvents[i++] = mongoEvent; - } - - var mongoCommit = new MongoEventCommit - { - Id = commitId, - Events = commitEvents, - EventsCount = events.Count, - EventStream = streamName, - EventStreamOffset = expectedVersion, - Timestamp = EmptyTimestamp - }; - - return mongoCommit; - } } } \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs new file mode 100644 index 000000000..eed2d0bce --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs @@ -0,0 +1,173 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using MongoDB.Driver; +using Squidex.Infrastructure.MongoDb; + +namespace Squidex.Infrastructure.EventSourcing +{ + public partial class MongoEventStore : MongoRepositoryBase, IEventStore + { + public Task CreateIndexAsync(string property) + { + return Collection.Indexes.CreateOneAsync(Index.Ascending(CreateIndexPath(property))); + } + + public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter, string position = null) + { + Guard.NotNull(subscriber, nameof(subscriber)); + Guard.NotNullOrEmpty(streamFilter, nameof(streamFilter)); + + return new PollingSubscription(this, notifier, subscriber, streamFilter, position); + } + + public async Task> QueryAsync(string streamName, long streamPosition = 0) + { + var commits = + await Collection.Find( + Filter.And( + Filter.Eq(EventStreamField, streamName), + Filter.Gte(EventStreamOffsetField, streamPosition - 1))) + .Sort(Sort.Ascending(TimestampField)).ToListAsync(); + + var result = new List(); + + foreach (var commit in commits) + { + var eventStreamOffset = (int)commit.EventStreamOffset; + + var commitTimestamp = commit.Timestamp; + var commitOffset = 0; + + foreach (var e in commit.Events) + { + eventStreamOffset++; + + if (eventStreamOffset >= streamPosition) + { + var eventData = e.ToEventData(); + var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + + result.Add(new StoredEvent(eventToken, eventStreamOffset, eventData)); + } + } + } + + return result; + } + + public Task QueryAsync(Func callback, string property, object value, string position = null, CancellationToken ct = default(CancellationToken)) + { + Guard.NotNull(callback, nameof(callback)); + + StreamPosition lastPosition = position; + + var filter = CreateFilter(property, value, lastPosition); + + return QueryAsync(callback, lastPosition, filter, ct); + } + + public Task QueryAsync(Func callback, string streamFilter = null, string position = null, CancellationToken ct = default(CancellationToken)) + { + Guard.NotNull(callback, nameof(callback)); + + StreamPosition lastPosition = position; + + var filter = CreateFilter(streamFilter, lastPosition); + + return QueryAsync(callback, lastPosition, filter, ct); + } + + private async Task QueryAsync(Func callback, StreamPosition lastPosition, FilterDefinition filter, CancellationToken ct) + { + await Collection.Find(filter).Sort(Sort.Ascending(TimestampField)).ForEachAsync(async commit => + { + var eventStreamOffset = (int)commit.EventStreamOffset; + + var commitTimestamp = commit.Timestamp; + var commitOffset = 0; + + foreach (var e in commit.Events) + { + eventStreamOffset++; + + if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) + { + var eventData = e.ToEventData(); + var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + + await callback(new StoredEvent(eventToken, eventStreamOffset, eventData)); + + commitOffset++; + } + } + }, ct); + } + + private static FilterDefinition CreateFilter(string property, object value, StreamPosition streamPosition) + { + var filters = new List>(); + + AddPositionFilter(streamPosition, filters); + AddPropertyFitler(property, value, filters); + + return Filter.And(filters); + } + + private static FilterDefinition CreateFilter(string streamFilter, StreamPosition streamPosition) + { + var filters = new List>(); + + AddPositionFilter(streamPosition, filters); + AddStreamFilter(streamFilter, filters); + + return Filter.And(filters); + } + + private static void AddPropertyFitler(string property, object value, List> filters) + { + filters.Add(Filter.Eq(CreateIndexPath(property), value)); + } + + private static void AddStreamFilter(string streamFilter, List> filters) + { + if (!string.IsNullOrWhiteSpace(streamFilter) && !string.Equals(streamFilter, ".*", StringComparison.OrdinalIgnoreCase)) + { + if (streamFilter.Contains("^")) + { + filters.Add(Filter.Regex(EventStreamField, streamFilter)); + } + else + { + filters.Add(Filter.Eq(EventStreamField, streamFilter)); + } + } + } + + private static void AddPositionFilter(StreamPosition streamPosition, List> filters) + { + if (streamPosition.IsEndOfCommit) + { + filters.Add(Filter.Gt(TimestampField, streamPosition.Timestamp)); + } + else + { + filters.Add(Filter.Gte(TimestampField, streamPosition.Timestamp)); + } + } + + private static string CreateIndexPath(string property) + { + return $"Events.Metadata.{property}"; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs new file mode 100644 index 000000000..937b66050 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Writer.cs @@ -0,0 +1,129 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading.Tasks; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Squidex.Infrastructure.EventSourcing +{ + public partial class MongoEventStore + { + private const int MaxWriteAttempts = 20; + private static readonly BsonTimestamp EmptyTimestamp = new BsonTimestamp(0); + + public Task AppendAsync(Guid commitId, string streamName, ICollection events) + { + return AppendAsync(commitId, streamName, EtagVersion.Any, events); + } + + public async Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events) + { + Guard.GreaterEquals(expectedVersion, EtagVersion.Any, nameof(expectedVersion)); + Guard.NotNullOrEmpty(streamName, nameof(streamName)); + Guard.NotNull(events, nameof(events)); + + if (events.Count == 0) + { + return; + } + + var currentVersion = await GetEventStreamOffset(streamName); + + if (expectedVersion != EtagVersion.Any && expectedVersion != currentVersion) + { + throw new WrongEventVersionException(currentVersion, expectedVersion); + } + + var commit = BuildCommit(commitId, streamName, expectedVersion >= -1 ? expectedVersion : currentVersion, events); + + for (var attempt = 0; attempt < MaxWriteAttempts; attempt++) + { + try + { + await Collection.InsertOneAsync(commit); + + notifier.NotifyEventsStored(streamName); + + return; + } + catch (MongoWriteException ex) + { + if (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + currentVersion = await GetEventStreamOffset(streamName); + + if (expectedVersion != EtagVersion.Any) + { + throw new WrongEventVersionException(currentVersion, expectedVersion); + } + + if (attempt < MaxWriteAttempts) + { + expectedVersion = currentVersion; + } + else + { + throw new TimeoutException("Could not acquire a free slot for the commit within the provided time."); + } + } + else + { + throw; + } + } + } + } + + private async Task GetEventStreamOffset(string streamName) + { + var document = + await Collection.Find(Filter.Eq(EventStreamField, streamName)) + .Project(Projection + .Include(EventStreamOffsetField) + .Include(EventsCountField)) + .Sort(Sort.Descending(EventStreamOffsetField)).Limit(1) + .FirstOrDefaultAsync(); + + if (document != null) + { + return document[nameof(MongoEventCommit.EventStreamOffset)].ToInt64() + document[nameof(MongoEventCommit.EventsCount)].ToInt64(); + } + + return EtagVersion.Empty; + } + + private static MongoEventCommit BuildCommit(Guid commitId, string streamName, long expectedVersion, ICollection events) + { + var commitEvents = new MongoEvent[events.Count]; + + var i = 0; + + foreach (var e in events) + { + var mongoEvent = MongoEvent.FromEventData(e); + + commitEvents[i++] = mongoEvent; + } + + var mongoCommit = new MongoEventCommit + { + Id = commitId, + Events = commitEvents, + EventsCount = events.Count, + EventStream = streamName, + EventStreamOffset = expectedVersion, + Timestamp = EmptyTimestamp + }; + + return mongoCommit; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConvention.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConvention.cs index ab9f13d11..2a8d6e572 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConvention.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/BsonJsonConvention.cs @@ -11,6 +11,7 @@ using System.Reflection; using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization.Conventions; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace Squidex.Infrastructure.MongoDb { @@ -31,6 +32,18 @@ namespace Squidex.Infrastructure.MongoDb memberMap.SetSerializer((IBsonSerializer)bsonSerializer); } + else if (memberMap.MemberType == typeof(JToken)) + { + memberMap.SetSerializer(JTokenSerializer.Instance); + } + else if (memberMap.MemberType == typeof(JObject)) + { + memberMap.SetSerializer(JTokenSerializer.Instance); + } + else if (memberMap.MemberType == typeof(JValue)) + { + memberMap.SetSerializer(JTokenSerializer.Instance); + } }); ConventionRegistry.Register("json", pack, t => true); diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/JTokenSerializer.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/JTokenSerializer.cs new file mode 100644 index 000000000..fbb1039e0 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/JTokenSerializer.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using Newtonsoft.Json.Linq; + +namespace Squidex.Infrastructure.MongoDb +{ + public sealed class JTokenSerializer : ClassSerializerBase where T : JToken + { + public static readonly JTokenSerializer Instance = new JTokenSerializer(); + + protected override T DeserializeValue(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var jsonReader = new BsonJsonReader(context.Reader); + + return (T)JToken.ReadFrom(jsonReader); + } + + protected override void SerializeValue(BsonSerializationContext context, BsonSerializationArgs args, T value) + { + var jsonWriter = new BsonJsonWriter(context.Writer); + + value.WriteTo(jsonWriter); + } + } +} diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs b/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs index fd7e62c36..c4d0e0ef4 100644 --- a/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs +++ b/src/Squidex.Infrastructure/Commands/DomainObjectBase.cs @@ -44,17 +44,15 @@ namespace Squidex.Infrastructure.Commands RaiseEvent(Envelope.Create(@event)); } - public void RaiseEvent(Envelope @event) where TEvent : class, IEvent + public virtual void RaiseEvent(Envelope @event) { Guard.NotNull(@event, nameof(@event)); @event.SetAggregateId(id); - ApplyEvent(@event.To()); + ApplyEvent(@event); - snapshot.Version++; - - uncomittedEvents.Add(@event.To()); + uncomittedEvents.Add(@event); } public IReadOnlyList> GetUncomittedEvents() diff --git a/src/Squidex.Infrastructure/EventSourcing/JsonEventDataFormatter.cs b/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs similarity index 51% rename from src/Squidex.Infrastructure/EventSourcing/JsonEventDataFormatter.cs rename to src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs index 9af9e3a68..72f01b63a 100644 --- a/src/Squidex.Infrastructure/EventSourcing/JsonEventDataFormatter.cs +++ b/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs @@ -7,38 +7,37 @@ using System; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace Squidex.Infrastructure.EventSourcing { - public class JsonEventDataFormatter : IEventDataFormatter + public class DefaultEventDataFormatter : IEventDataFormatter { - private readonly JsonSerializerSettings serializerSettings; + private readonly JsonSerializer serializer; private readonly TypeNameRegistry typeNameRegistry; - public JsonEventDataFormatter(TypeNameRegistry typeNameRegistry, JsonSerializerSettings serializerSettings = null) + public DefaultEventDataFormatter(TypeNameRegistry typeNameRegistry, JsonSerializer serializer = null) { Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry)); this.typeNameRegistry = typeNameRegistry; - this.serializerSettings = serializerSettings ?? new JsonSerializerSettings(); + this.serializer = serializer ?? JsonSerializer.CreateDefault(); } public Envelope Parse(EventData eventData, bool migrate = true) { - var headers = ReadJson(eventData.Metadata); - var eventType = typeNameRegistry.GetType(eventData.Type); - var eventPayload = ReadJson(eventData.Payload, eventType); - if (migrate && eventPayload is IMigratedEvent migratedEvent) + var headers = eventData.Metadata.ToObject(); + var content = eventData.Payload.ToObject(eventType, serializer) as IEvent; + + if (migrate && content is IMigratedEvent migratedEvent) { - eventPayload = migratedEvent.Migrate(); + content = migratedEvent.Migrate(); } - var envelope = new Envelope(eventPayload, headers); - - envelope.SetEventId(eventData.EventId); + var envelope = new Envelope(content, headers); return envelope; } @@ -56,20 +55,10 @@ namespace Squidex.Infrastructure.EventSourcing envelope.SetCommitId(commitId); - var headers = WriteJson(envelope.Headers); - var content = WriteJson(envelope.Payload); + var headers = JToken.FromObject(envelope.Headers, serializer); + var content = JToken.FromObject(envelope.Payload, serializer); - return new EventData { EventId = envelope.Headers.EventId(), Type = eventType, Payload = content, Metadata = headers }; - } - - private T ReadJson(string data, Type type = null) - { - return (T)JsonConvert.DeserializeObject(data, type ?? typeof(T), serializerSettings); - } - - private string WriteJson(object value) - { - return JsonConvert.SerializeObject(value, serializerSettings); + return new EventData { Type = eventType, Payload = content, Metadata = headers }; } } } diff --git a/src/Squidex.Infrastructure/EventSourcing/EventData.cs b/src/Squidex.Infrastructure/EventSourcing/EventData.cs index 9ca13635d..739ea8068 100644 --- a/src/Squidex.Infrastructure/EventSourcing/EventData.cs +++ b/src/Squidex.Infrastructure/EventSourcing/EventData.cs @@ -5,17 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using Newtonsoft.Json.Linq; namespace Squidex.Infrastructure.EventSourcing { public class EventData { - public Guid EventId { get; set; } + public JToken Payload { get; set; } - public string Payload { get; set; } - - public string Metadata { get; set; } + public JToken Metadata { get; set; } public string Type { get; set; } } diff --git a/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs b/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs index 2993ef86b..c33d86e5a 100644 --- a/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs +++ b/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs @@ -14,13 +14,17 @@ namespace Squidex.Infrastructure.EventSourcing { public interface IEventStore { - Task> GetEventsAsync(string streamName, long streamPosition = 0); + Task CreateIndexAsync(string property); - Task GetEventsAsync(Func callback, string streamFilter = null, string position = null, CancellationToken cancellationToken = default(CancellationToken)); + Task> QueryAsync(string streamName, long streamPosition = 0); - Task AppendEventsAsync(Guid commitId, string streamName, ICollection events); + Task QueryAsync(Func callback, string streamFilter = null, string position = null, CancellationToken ct = default(CancellationToken)); - Task AppendEventsAsync(Guid commitId, string streamName, long expectedVersion, ICollection events); + Task QueryAsync(Func callback, string property, object value, string position = null, CancellationToken ct = default(CancellationToken)); + + Task AppendAsync(Guid commitId, string streamName, ICollection events); + + Task AppendAsync(Guid commitId, string streamName, long expectedVersion, ICollection events); IEventSubscription CreateSubscription(IEventSubscriber subscriber, string streamFilter, string position = null); } diff --git a/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs b/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs index cfdb4486d..10c908ad5 100644 --- a/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs +++ b/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs @@ -41,7 +41,7 @@ namespace Squidex.Infrastructure.EventSourcing { try { - await eventStore.GetEventsAsync(async storedEvent => + await eventStore.QueryAsync(async storedEvent => { await eventSubscriber.OnEventAsync(this, storedEvent); diff --git a/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs b/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs index 747dce5bc..3c93e21a4 100644 --- a/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs +++ b/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs @@ -9,33 +9,21 @@ namespace Squidex.Infrastructure.EventSourcing { public sealed class StoredEvent { - private readonly string eventPosition; - private readonly long eventStreamNumber; - private readonly EventData data; + public string EventPosition { get; } - public string EventPosition - { - get { return eventPosition; } - } - - public long EventStreamNumber - { - get { return eventStreamNumber; } - } + public long EventStreamNumber { get; } - public EventData Data - { - get { return data; } - } + public EventData Data { get; } public StoredEvent(string eventPosition, long eventStreamNumber, EventData data) { Guard.NotNullOrEmpty(eventPosition, nameof(eventPosition)); Guard.NotNull(data, nameof(data)); - this.data = data; - this.eventPosition = eventPosition; - this.eventStreamNumber = eventStreamNumber; + Data = data; + + EventPosition = eventPosition; + EventStreamNumber = eventStreamNumber; } } } diff --git a/src/Squidex.Infrastructure/Json/NamedGuidIdConverter.cs b/src/Squidex.Infrastructure/Json/NamedGuidIdConverter.cs index 4499419d3..1695a96f9 100644 --- a/src/Squidex.Infrastructure/Json/NamedGuidIdConverter.cs +++ b/src/Squidex.Infrastructure/Json/NamedGuidIdConverter.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Linq; using Newtonsoft.Json; namespace Squidex.Infrastructure.Json @@ -25,19 +24,14 @@ namespace Squidex.Infrastructure.Json throw new JsonException($"Expected String, but got {reader.TokenType}."); } - var parts = reader.Value.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (parts.Length < 2) + try { - throw new JsonException("Named id must have more than 2 parts divided by commata."); + return NamedId.Parse(reader.Value.ToString(), Guid.TryParse); } - - if (!Guid.TryParse(parts[0], out var id)) + catch (ArgumentException ex) { - throw new JsonException("Named id must be a valid guid."); + throw new JsonException(ex.Message); } - - return new NamedId(id, string.Join(",", parts.Skip(1))); } } } diff --git a/src/Squidex.Infrastructure/Json/NamedLongIdConverter.cs b/src/Squidex.Infrastructure/Json/NamedLongIdConverter.cs index 4efb97dc0..cc85acd9b 100644 --- a/src/Squidex.Infrastructure/Json/NamedLongIdConverter.cs +++ b/src/Squidex.Infrastructure/Json/NamedLongIdConverter.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Linq; using Newtonsoft.Json; namespace Squidex.Infrastructure.Json @@ -25,19 +24,14 @@ namespace Squidex.Infrastructure.Json throw new JsonException($"Expected String, but got {reader.TokenType}."); } - var parts = reader.Value.ToString().Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); - - if (parts.Length < 2) + try { - throw new JsonException("Named id must have more than 2 parts divided by commata."); + return NamedId.Parse(reader.Value.ToString(), long.TryParse); } - - if (!long.TryParse(parts[0], out var id)) + catch (ArgumentException ex) { - throw new JsonException("Named id must be a valid long."); + throw new JsonException(ex.Message); } - - return new NamedId(id, string.Join(",", parts.Skip(1))); } } } diff --git a/src/Squidex.Infrastructure/Migrations/IMigration.cs b/src/Squidex.Infrastructure/Migrations/IMigration.cs index 89d698688..3a837e088 100644 --- a/src/Squidex.Infrastructure/Migrations/IMigration.cs +++ b/src/Squidex.Infrastructure/Migrations/IMigration.cs @@ -5,17 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Generic; using System.Threading.Tasks; namespace Squidex.Infrastructure.Migrations { public interface IMigration { - int FromVersion { get; } - - int ToVersion { get; } - - Task UpdateAsync(IEnumerable previousMigrations); + Task UpdateAsync(); } } diff --git a/src/Squidex.Infrastructure/Migrations/IMigrationPath.cs b/src/Squidex.Infrastructure/Migrations/IMigrationPath.cs new file mode 100644 index 000000000..3992f953e --- /dev/null +++ b/src/Squidex.Infrastructure/Migrations/IMigrationPath.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Infrastructure.Migrations +{ + public interface IMigrationPath + { + (int Version, IEnumerable Migrations) GetNext(int version); + } +} diff --git a/src/Squidex.Infrastructure/Migrations/Migrator.cs b/src/Squidex.Infrastructure/Migrations/Migrator.cs index 4ad2f4065..0ba832d9e 100644 --- a/src/Squidex.Infrastructure/Migrations/Migrator.cs +++ b/src/Squidex.Infrastructure/Migrations/Migrator.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Squidex.Infrastructure.Log; @@ -15,20 +13,20 @@ namespace Squidex.Infrastructure.Migrations { public sealed class Migrator { - private readonly IMigrationStatus migrationStatus; - private readonly IEnumerable migrations; private readonly ISemanticLog log; + private readonly IMigrationStatus migrationStatus; + private readonly IMigrationPath migrationPath; - public int LockWaitMs { get; set; } = 5000; + public int LockWaitMs { get; set; } = 500; - public Migrator(IMigrationStatus migrationStatus, IEnumerable migrations, ISemanticLog log) + public Migrator(IMigrationStatus migrationStatus, IMigrationPath migrationPath, ISemanticLog log) { Guard.NotNull(migrationStatus, nameof(migrationStatus)); - Guard.NotNull(migrations, nameof(migrations)); + Guard.NotNull(migrationPath, nameof(migrationPath)); Guard.NotNull(log, nameof(log)); this.migrationStatus = migrationStatus; - this.migrations = migrations.OrderByDescending(x => x.ToVersion).ToList(); + this.migrationPath = migrationPath; this.log = log; } @@ -39,8 +37,6 @@ namespace Squidex.Infrastructure.Migrations try { - var lastMigrator = migrations.FirstOrDefault(); - while (!await migrationStatus.TryLockAsync()) { log.LogInformation(w => w @@ -52,13 +48,16 @@ namespace Squidex.Infrastructure.Migrations version = await migrationStatus.GetVersionAsync(); - if (lastMigrator != null && lastMigrator.ToVersion != version) + while (true) { - var migrationPath = FindMigratorPath(version, lastMigrator.ToVersion).ToList(); + var migrationStep = migrationPath.GetNext(version); - var previousMigrations = new List(); + if (migrationStep.Migrations == null || !migrationStep.Migrations.Any()) + { + break; + } - foreach (var migration in migrationPath) + foreach (var migration in migrationStep.Migrations) { var name = migration.GetType().ToString(); @@ -72,13 +71,11 @@ namespace Squidex.Infrastructure.Migrations .WriteProperty("status", "Completed") .WriteProperty("migrator", name))) { - await migration.UpdateAsync(previousMigrations.ToList()); - - version = migration.ToVersion; + await migration.UpdateAsync(); } - - previousMigrations.Add(migration); } + + version = migrationStep.Version; } } finally @@ -86,30 +83,5 @@ namespace Squidex.Infrastructure.Migrations await migrationStatus.UnlockAsync(version); } } - - private IEnumerable FindMigratorPath(int fromVersion, int toVersion) - { - var addedMigrators = new HashSet(); - - while (true) - { - var bestMigrator = migrations.Where(x => x.FromVersion < x.ToVersion).FirstOrDefault(x => x.FromVersion == fromVersion); - - if (bestMigrator != null && addedMigrators.Add(bestMigrator)) - { - fromVersion = bestMigrator.ToVersion; - - yield return bestMigrator; - } - else if (fromVersion != toVersion) - { - throw new InvalidOperationException($"There is no migration path from {fromVersion} to {toVersion}."); - } - else - { - break; - } - } - } } } diff --git a/src/Squidex.Infrastructure/NamedId.cs b/src/Squidex.Infrastructure/NamedId.cs index 0b16475fe..e8f99f6d4 100644 --- a/src/Squidex.Infrastructure/NamedId.cs +++ b/src/Squidex.Infrastructure/NamedId.cs @@ -6,9 +6,12 @@ // ========================================================================== using System; +using System.Linq; namespace Squidex.Infrastructure { + public delegate bool Parser(string input, out T result); + public sealed class NamedId : IEquatable> { public T Id { get; } @@ -44,5 +47,24 @@ namespace Squidex.Infrastructure { return (Id.GetHashCode() * 397) ^ Name.GetHashCode(); } + + public static NamedId Parse(string value, Parser parser) + { + Guard.NotNull(value, nameof(value)); + + var parts = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length < 2) + { + throw new ArgumentException("Named id must have more than 2 parts divided by commata."); + } + + if (!parser(parts[0], out var id)) + { + throw new ArgumentException("Named id must be a valid guid."); + } + + return new NamedId(id, string.Join(",", parts.Skip(1))); + } } } diff --git a/src/Squidex.Infrastructure/SquidexInfrastructure.cs b/src/Squidex.Infrastructure/SquidexInfrastructure.cs index 11cfb6764..8dac58f91 100644 --- a/src/Squidex.Infrastructure/SquidexInfrastructure.cs +++ b/src/Squidex.Infrastructure/SquidexInfrastructure.cs @@ -5,9 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Reflection; + namespace Squidex.Infrastructure { public static class SquidexInfrastructure { + public static readonly Assembly Assembly = typeof(SquidexInfrastructure).Assembly; } } diff --git a/src/Squidex.Infrastructure/States/Persistence{TOwner,TSnapshot,TKey}.cs b/src/Squidex.Infrastructure/States/Persistence{TOwner,TSnapshot,TKey}.cs index 024a05119..cb0900aed 100644 --- a/src/Squidex.Infrastructure/States/Persistence{TOwner,TSnapshot,TKey}.cs +++ b/src/Squidex.Infrastructure/States/Persistence{TOwner,TSnapshot,TKey}.cs @@ -101,7 +101,7 @@ namespace Squidex.Infrastructure.States { if (UseEventSourcing()) { - var events = await eventStore.GetEventsAsync(GetStreamName(), versionEvents + 1); + var events = await eventStore.QueryAsync(GetStreamName(), versionEvents + 1); foreach (var @event in events) { @@ -160,7 +160,7 @@ namespace Squidex.Infrastructure.States try { - await eventStore.AppendEventsAsync(commitId, GetStreamName(), expectedVersion, eventData); + await eventStore.AppendAsync(commitId, GetStreamName(), expectedVersion, eventData); } catch (WrongEventVersionException ex) { diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 386e84edc..327a28e8f 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -146,7 +146,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// 400 => Asset exceeds the maximum size. /// /// - /// You can only upload one file at a time. The mime type of the file is not calculated by Squidex and must be defined correctly. + /// You can only upload one file at a time. The mime type of the file is not calculated by Squidex and is required correctly. /// [MustBeAppEditor] [HttpPost] @@ -248,7 +248,7 @@ namespace Squidex.Areas.Api.Controllers.Assets { if (file.Count != 1) { - var error = new ValidationError($"Can only upload one file, found {file.Count}."); + var error = new ValidationError($"Can only upload one file, found {file.Count} files."); throw new ValidationException("Cannot create asset.", error); } diff --git a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs index 9fb56d378..f5a497941 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs @@ -10,6 +10,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; +using NodaTime; +using NodaTime.Text; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Contents.Models; using Squidex.Domain.Apps.Core.Contents; @@ -42,12 +44,24 @@ namespace Squidex.Areas.Api.Controllers.Contents this.graphQl = graphQl; } + /// + /// GraphQL endpoint. + /// + /// The name of the app. + /// The graphql endpoint. + /// + /// 200 => Contents retrieved or mutated. + /// 404 => Schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppReader] [HttpGet] [HttpPost] [Route("content/{app}/graphql/")] [ApiCosts(2)] - public async Task PostGraphQL([FromBody] GraphQLQuery query) + public async Task PostGraphQL(string app, [FromBody] GraphQLQuery query) { var result = await graphQl.QueryAsync(App, User, query); @@ -61,11 +75,25 @@ namespace Squidex.Areas.Api.Controllers.Contents } } + /// + /// Queries contents. + /// + /// The name of the app. + /// The name of the schema. + /// The optional ids of the content to fetch. + /// Indicates whether to query content items from the archive. + /// + /// 200 => Contents retrieved. + /// 404 => Schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppReader] [HttpGet] [Route("content/{app}/{name}/")] [ApiCosts(2)] - public async Task GetContents(string name, [FromQuery] bool archived = false, [FromQuery] string ids = null) + public async Task GetContents(string app, string name, [FromQuery] bool archived = false, [FromQuery] string ids = null) { HashSet idsList = null; @@ -110,11 +138,24 @@ namespace Squidex.Areas.Api.Controllers.Contents return Ok(response); } + /// + /// Get a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content to fetch. + /// + /// 200 => Content found. + /// 404 => Content, schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppReader] [HttpGet] [Route("content/{app}/{name}/{id}/")] [ApiCosts(1)] - public async Task GetContent(string name, Guid id) + public async Task GetContent(string app, string name, Guid id) { var (schema, entity) = await contentQuery.FindContentAsync(App, name, User, id); @@ -133,11 +174,26 @@ namespace Squidex.Areas.Api.Controllers.Contents return Ok(response); } + /// + /// Get a content item with a specific version. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content to fetch. + /// The version fo the content to fetch. + /// + /// 200 => Content found. + /// 404 => Content, schema or app not found. + /// 400 => Content data is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppReader] [HttpGet] [Route("content/{app}/{name}/{id}/{version}/")] [ApiCosts(1)] - public async Task GetContentVersion(string name, Guid id, int version) + public async Task GetContentVersion(string app, string name, Guid id, int version) { var content = await contentQuery.FindContentAsync(App, name, User, id, version); @@ -155,11 +211,26 @@ namespace Squidex.Areas.Api.Controllers.Contents return Ok(response.Data); } + /// + /// Create a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The full data for the content item. + /// Indicates whether the content should be published immediately. + /// + /// 201 => Content created. + /// 404 => Content, schema or app not found. + /// 400 => Content data is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppEditor] [HttpPost] [Route("content/{app}/{name}/")] [ApiCosts(1)] - public async Task PostContent(string name, [FromBody] NamedContentData request, [FromQuery] bool publish = false) + public async Task PostContent(string app, string name, [FromBody] NamedContentData request, [FromQuery] bool publish = false) { await contentQuery.FindSchemaAsync(App, name); @@ -173,11 +244,26 @@ namespace Squidex.Areas.Api.Controllers.Contents return CreatedAtAction(nameof(GetContent), new { id = command.ContentId }, response); } + /// + /// Update a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to update. + /// The full data for the content item. + /// + /// 200 => Content updated. + /// 404 => Content, schema or app not found. + /// 400 => Content data is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppEditor] [HttpPut] [Route("content/{app}/{name}/{id}/")] [ApiCosts(1)] - public async Task PutContent(string name, Guid id, [FromBody] NamedContentData request) + public async Task PutContent(string app, string name, Guid id, [FromBody] NamedContentData request) { await contentQuery.FindSchemaAsync(App, name); @@ -191,11 +277,26 @@ namespace Squidex.Areas.Api.Controllers.Contents return Ok(response); } + /// + /// Patchs a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to patch. + /// The patch for the content item. + /// + /// 200 => Content patched. + /// 404 => Content, schema or app not found. + /// 400 => Content patch is not valid. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppEditor] [HttpPatch] [Route("content/{app}/{name}/{id}/")] [ApiCosts(1)] - public async Task PatchContent(string name, Guid id, [FromBody] NamedContentData request) + public async Task PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request) { await contentQuery.FindSchemaAsync(App, name); @@ -209,71 +310,144 @@ namespace Squidex.Areas.Api.Controllers.Contents return Ok(response); } + /// + /// Publish a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to publish. + /// The date and time when the content should be published. + /// + /// 204 => Content published. + /// 404 => Content, schema or app not found. + /// 400 => Content was already published. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppEditor] [HttpPut] [Route("content/{app}/{name}/{id}/publish/")] [ApiCosts(1)] - public async Task PublishContent(string name, Guid id) + public async Task PublishContent(string app, string name, Guid id, string dueTime = null) { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Published, ContentId = id }; + var command = CreateCommand(id, Status.Published, dueTime); await CommandBus.PublishAsync(command); return NoContent(); } + /// + /// Unpublish a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to unpublish. + /// The date and time when the content should be unpublished. + /// + /// 204 => Content unpublished. + /// 404 => Content, schema or app not found. + /// 400 => Content was not published. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppEditor] [HttpPut] [Route("content/{app}/{name}/{id}/unpublish/")] [ApiCosts(1)] - public async Task UnpublishContent(string name, Guid id) + public async Task UnpublishContent(string app, string name, Guid id, string dueTime = null) { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Draft, ContentId = id }; + var command = CreateCommand(id, Status.Draft, dueTime); await CommandBus.PublishAsync(command); return NoContent(); } + /// + /// Archive a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to archive. + /// The date and time when the content should be archived. + /// + /// 204 => Content archived. + /// 404 => Content, schema or app not found. + /// 400 => Content was already archived. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppEditor] [HttpPut] [Route("content/{app}/{name}/{id}/archive/")] [ApiCosts(1)] - public async Task ArchiveContent(string name, Guid id) + public async Task ArchiveContent(string app, string name, Guid id, string dueTime = null) { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Archived, ContentId = id }; + var command = CreateCommand(id, Status.Archived, dueTime); await CommandBus.PublishAsync(command); return NoContent(); } + /// + /// Restore a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to restore. + /// The date and time when the content should be restored. + /// + /// 204 => Content restored. + /// 404 => Content, schema or app not found. + /// 400 => Content was not archived. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppEditor] [HttpPut] [Route("content/{app}/{name}/{id}/restore/")] [ApiCosts(1)] - public async Task RestoreContent(string name, Guid id) + public async Task RestoreContent(string app, string name, Guid id, string dueTime = null) { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Draft, ContentId = id }; + var command = CreateCommand(id, Status.Draft, dueTime); await CommandBus.PublishAsync(command); return NoContent(); } + /// + /// Delete a content item. + /// + /// The name of the app. + /// The name of the schema. + /// The id of the content item to delete. + /// + /// 204 => Content has been deleted. + /// 404 => Content, schema or app not found. + /// + /// + /// You can create an generated documentation for your app at /api/content/{appName}/docs + /// [MustBeAppEditor] [HttpDelete] [Route("content/{app}/{name}/{id}/")] [ApiCosts(1)] - public async Task DeleteContent(string name, Guid id) + public async Task DeleteContent(string app, string name, Guid id) { await contentQuery.FindSchemaAsync(App, name); @@ -283,5 +457,22 @@ namespace Squidex.Areas.Api.Controllers.Contents return NoContent(); } + + private static ChangeContentStatus CreateCommand(Guid id, Status status, string dueTime) + { + Instant? dt = null; + + if (!string.IsNullOrWhiteSpace(dueTime)) + { + var parseResult = InstantPattern.General.Parse(dueTime); + + if (parseResult.Success) + { + dt = parseResult.Value; + } + } + + return new ChangeContentStatus { Status = status, ContentId = id, DueTime = dt }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs index 010f411c0..03acbbdad 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs @@ -148,7 +148,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); operation.AddQueryParameter("publish", JsonObjectType.Boolean, "Set to true to autopublish content."); - operation.AddResponse("201", $"{schemaName} created.", contentSchema); + operation.AddResponse("201", $"{schemaName} content created.", contentSchema); }); } @@ -162,7 +162,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); - operation.AddResponse("201", $"{schemaName} item updated.", dataSchema); + operation.AddResponse("200", $"{schemaName} content updated.", dataSchema); }); } @@ -176,7 +176,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); - operation.AddResponse("201", $"{schemaName} item patched.", dataSchema); + operation.AddResponse("200", $"{schemaName} content patched.", dataSchema); }); } @@ -188,7 +188,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.Summary = $"Publish a {schemaName} content."; operation.Security = EditorSecurity; - operation.AddResponse("204", $"{schemaName} item published."); + operation.AddResponse("204", $"{schemaName} content published."); }); } @@ -200,7 +200,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.Summary = $"Unpublish a {schemaName} content."; operation.Security = EditorSecurity; - operation.AddResponse("204", $"{schemaName} item unpublished."); + operation.AddResponse("204", $"{schemaName} content unpublished."); }); } @@ -212,7 +212,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.Summary = $"Archive a {schemaName} content."; operation.Security = EditorSecurity; - operation.AddResponse("204", $"{schemaName} item restored."); + operation.AddResponse("204", $"{schemaName} content restored."); }); } @@ -224,7 +224,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator operation.Summary = $"Restore a {schemaName} content."; operation.Security = EditorSecurity; - operation.AddResponse("204", $"{schemaName} item restored."); + operation.AddResponse("204", $"{schemaName} content restored."); }); } diff --git a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs index eee19d42f..2be029606 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentDto.cs @@ -40,6 +40,21 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models [Required] public object Data { get; set; } + /// + /// The scheduled status. + /// + public Status? ScheduledTo { get; set; } + + /// + /// The scheduled date. + /// + public Instant? ScheduledAt { get; set; } + + /// + /// The user that has scheduled the content. + /// + public RefToken ScheduledBy { get; set; } + /// /// The date and time when the content item has been created. /// diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs index 80b0d9a3b..cb6a04ae7 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateSchemaDto.cs @@ -28,5 +28,10 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// Optional fields. /// public List Fields { get; set; } + + /// + /// Set it to true to autopublish the schema. + /// + public bool Publish { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs index 92fec91b3..d77397666 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs @@ -24,6 +24,7 @@ namespace Squidex.Areas.Api.Controllers.Users { [ApiAuthorize] [ApiExceptionFilter] + [ApiModelValidation] [MustBeAdministrator] [SwaggerIgnore] public sealed class UserManagementController : ApiController diff --git a/src/Squidex/Config/Domain/InfrastructureServices.cs b/src/Squidex/Config/Domain/InfrastructureServices.cs index 6781b36e7..9a182bf87 100644 --- a/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -89,7 +89,7 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingletonAs() + services.AddSingletonAs() .As(); services.AddSingletonAs() diff --git a/src/Squidex/Config/Domain/ReadServices.cs b/src/Squidex/Config/Domain/ReadServices.cs index 97dfe5c6e..1ce8db5b4 100644 --- a/src/Squidex/Config/Domain/ReadServices.cs +++ b/src/Squidex/Config/Domain/ReadServices.cs @@ -42,6 +42,8 @@ namespace Squidex.Config.Domain { services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); } var exposeSourceUrl = config.GetOptionalValue("assetStore:exposeSourceUrl", true); diff --git a/src/Squidex/Config/Domain/SerializationServices.cs b/src/Squidex/Config/Domain/SerializationServices.cs index c8f3cbb12..f783f2601 100644 --- a/src/Squidex/Config/Domain/SerializationServices.cs +++ b/src/Squidex/Config/Domain/SerializationServices.cs @@ -28,10 +28,10 @@ namespace Squidex.Config.Domain { private static readonly TypeNameRegistry TypeNameRegistry = new TypeNameRegistry() - .MapUnmapped(typeof(Migration01_FromCqrs).Assembly) - .MapUnmapped(typeof(SquidexCoreModel).Assembly) - .MapUnmapped(typeof(SquidexEvents).Assembly) - .MapUnmapped(typeof(SquidexInfrastructure).Assembly); + .MapUnmapped(SquidexCoreModel.Assembly) + .MapUnmapped(SquidexEvents.Assembly) + .MapUnmapped(SquidexInfrastructure.Assembly) + .MapUnmapped(SquidexMigrations.Assembly); private static readonly FieldRegistry FieldRegistry = new FieldRegistry(TypeNameRegistry); diff --git a/src/Squidex/Config/Domain/WriteServices.cs b/src/Squidex/Config/Domain/WriteServices.cs index 7c890b235..a40bd1c87 100644 --- a/src/Squidex/Config/Domain/WriteServices.cs +++ b/src/Squidex/Config/Domain/WriteServices.cs @@ -9,6 +9,7 @@ using System; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Migrate_01; +using Migrate_01.Migrations; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Apps; @@ -67,16 +68,22 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddTransientAs() + services.AddTransientAs() + .As(); + + services.AddTransientAs() + .As(); + + services.AddTransientAs() .As(); - services.AddTransientAs() + services.AddTransientAs() .As(); - services.AddTransientAs() + services.AddTransientAs() .As(); - services.AddTransientAs() + services.AddTransientAs() .As(); services.AddTransientAs() diff --git a/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs b/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs index ae8ec9899..42c2ba178 100644 --- a/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs +++ b/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs @@ -64,27 +64,6 @@ namespace Squidex.Pipeline return new ObjectResult(error) { StatusCode = statusCode }; } - public override void OnActionExecuting(ActionExecutingContext context) - { - if (!context.ModelState.IsValid) - { - var errors = new List(); - - foreach (var m in context.ModelState) - { - foreach (var e in m.Value.Errors) - { - if (!string.IsNullOrWhiteSpace(e.ErrorMessage)) - { - errors.Add(new ValidationError(e.ErrorMessage, m.Key)); - } - } - } - - throw new ValidationException("The model is not valid.", errors); - } - } - public void OnException(ExceptionContext context) { IActionResult result = null; diff --git a/src/Squidex/Pipeline/ApiModelValidationAttribute.cs b/src/Squidex/Pipeline/ApiModelValidationAttribute.cs new file mode 100644 index 000000000..8b62de32f --- /dev/null +++ b/src/Squidex/Pipeline/ApiModelValidationAttribute.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Filters; +using Squidex.Infrastructure; + +namespace Squidex.Pipeline +{ + public sealed class ApiModelValidationAttribute : ActionFilterAttribute + { + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!context.ModelState.IsValid) + { + var errors = new List(); + + foreach (var m in context.ModelState) + { + foreach (var e in m.Value.Errors) + { + if (!string.IsNullOrWhiteSpace(e.ErrorMessage)) + { + errors.Add(new ValidationError(e.ErrorMessage, m.Key)); + } + } + } + + throw new ValidationException("The model is not valid.", errors); + } + } + } +} diff --git a/src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs index 25cbebe80..8ee2b62da 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs @@ -25,6 +25,11 @@ namespace Squidex.Pipeline.CommandMiddlewares public async Task HandleAsync(CommandContext context, Func next) { + if (httpContextAccessor.HttpContext == null) + { + return; + } + var headers = httpContextAccessor.HttpContext.Request.Headers; var headerMatch = headers["If-Match"].ToString(); diff --git a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs index ce1b72ced..f8e16d354 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs @@ -27,6 +27,11 @@ namespace Squidex.Pipeline.CommandMiddlewares public Task HandleAsync(CommandContext context, Func next) { + if (httpContextAccessor.HttpContext == null) + { + return next(); + } + if (context.Command is SquidexCommand squidexCommand) { if (squidexCommand.Actor == null) diff --git a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs index 27d93b073..1a063c723 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs @@ -25,7 +25,12 @@ namespace Squidex.Pipeline.CommandMiddlewares public Task HandleAsync(CommandContext context, Func next) { - if (context.Command is AppCommand appCommand && appCommand.AppId == null) + if (httpContextAccessor.HttpContext == null) + { + return next(); + } + + if (context.Command is IAppCommand appCommand && appCommand.AppId == null) { var appFeature = httpContextAccessor.HttpContext.Features.Get(); diff --git a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs index 15a20857f..495131855 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs @@ -29,8 +29,35 @@ namespace Squidex.Pipeline.CommandMiddlewares public async Task HandleAsync(CommandContext context, Func next) { - if (context.Command is SchemaCommand schemaCommand && schemaCommand.SchemaId == null) + if (actionContextAccessor.ActionContext == null) { + await next(); + } + + if (context.Command is ISchemaCommand schemaCommand && schemaCommand.SchemaId == null) + { + NamedId appId = null; + + if (context.Command is IAppCommand appCommand) + { + appId = appCommand.AppId; + } + + if (appId == null) + { + var appFeature = actionContextAccessor.ActionContext.HttpContext.Features.Get(); + + if (appFeature != null && appFeature.App != null) + { + appId = new NamedId(appFeature.App.Id, appFeature.App.Name); + } + } + + if (appId == null) + { + return; + } + var routeValues = actionContextAccessor.ActionContext.RouteData.Values; if (routeValues.ContainsKey("name")) @@ -41,11 +68,11 @@ namespace Squidex.Pipeline.CommandMiddlewares if (Guid.TryParse(schemaName, out var id)) { - schema = await appProvider.GetSchemaAsync(schemaCommand.AppId.Id, id); + schema = await appProvider.GetSchemaAsync(appId.Id, id); } else { - schema = await appProvider.GetSchemaAsync(schemaCommand.AppId.Id, schemaName); + schema = await appProvider.GetSchemaAsync(appId.Id, schemaName); } if (schema == null) diff --git a/src/Squidex/app-config/webpack.run.prod.js b/src/Squidex/app-config/webpack.run.prod.js index d5f989ab5..55677f21e 100644 --- a/src/Squidex/app-config/webpack.run.prod.js +++ b/src/Squidex/app-config/webpack.run.prod.js @@ -1,6 +1,7 @@  var webpack = require('webpack'), webpackMerge = require('webpack-merge'), ExtractTextPlugin = require('extract-text-webpack-plugin'), + UglifyJsPlugin = require('uglifyjs-webpack-plugin'), ngToolsWebpack = require('@ngtools/webpack'), runConfig = require('./webpack.run.base.js'), helpers = require('./helpers'); @@ -86,6 +87,7 @@ module.exports = webpackMerge(runConfig, { plugins: [ new webpack.NoEmitOnErrorsPlugin(), new webpack.DefinePlugin({ 'process.env': { 'ENV': JSON.stringify(ENV) } }), + new webpack.optimize.ModuleConcatenationPlugin(), /* * Puts each bundle into a file and appends the hash of the file to the path. @@ -93,16 +95,20 @@ module.exports = webpackMerge(runConfig, { * See: https://github.com/webpack/extract-text-webpack-plugin */ new ExtractTextPlugin('[name].css'), - - new webpack.optimize.UglifyJsPlugin({ - beautify: false, - mangle: { - screw_ie8: true, keep_fnames: true - }, - compress: { - screw_ie8: true, warnings: false - }, - comments: false + + new UglifyJsPlugin({ + cache: true, + uglifyOptions: { + mangle: { + safari10: true, + }, + compress: { + pure_getters: true, passes: 3 + }, + output: { + ascii_only: true, comments: false,webkit: true, + } + } }), new ngToolsWebpack.AngularCompilerPlugin({ diff --git a/src/Squidex/app/app.routes.ts b/src/Squidex/app/app.routes.ts index c6ca613b6..00674bff9 100644 --- a/src/Squidex/app/app.routes.ts +++ b/src/Squidex/app/app.routes.ts @@ -6,7 +6,7 @@ */ import { ModuleWithProviders } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; import { AppAreaComponent, @@ -96,4 +96,4 @@ export const routes: Routes = [ } ]; -export const routing: ModuleWithProviders = RouterModule.forRoot(routes, { useHash: false }); \ No newline at end of file +export const routing: ModuleWithProviders = RouterModule.forRoot(routes, { useHash: false, preloadingStrategy: PreloadAllModules }); \ No newline at end of file diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss index f1563c37b..afdde04bc 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss @@ -9,7 +9,8 @@ .graphiql-container > * { box-sizing: content-box; - + + // sass-lint:disable class-name-format & .editorWrap { overflow: hidden; } diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.ts b/src/Squidex/app/features/content/pages/content/content-page.component.ts index 0b6467631..9ba93a9e9 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-page.component.ts @@ -12,9 +12,8 @@ import { Observable, Subscription } from 'rxjs'; import { ContentCreated, - ContentPublished, ContentRemoved, - ContentUnpublished, + ContentStatusChanged, ContentUpdated, ContentVersionSelected } from './../messages'; @@ -39,8 +38,7 @@ import { ] }) export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, OnInit { - private contentPublishedSubscription: Subscription; - private contentUnpublishedSubscription: Subscription; + private contentStatusChangedSubscription: Subscription; private contentDeletedSubscription: Subscription; private contentVersionSelectedSubscription: Subscription; @@ -63,8 +61,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, public ngOnDestroy() { this.contentVersionSelectedSubscription.unsubscribe(); - this.contentUnpublishedSubscription.unsubscribe(); - this.contentPublishedSubscription.unsubscribe(); + this.contentStatusChangedSubscription.unsubscribe(); this.contentDeletedSubscription.unsubscribe(); } @@ -75,27 +72,25 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy, this.loadVersion(message.version); }); - this.contentPublishedSubscription = - this.ctx.bus.of(ContentPublished) - .subscribe(message => { - if (this.content && message.content.id === this.content.id) { - this.content = this.content.publish(message.content.lastModifiedBy, message.content.version, message.content.lastModified); - } - }); - - this.contentUnpublishedSubscription = - this.ctx.bus.of(ContentUnpublished) + this.contentDeletedSubscription = + this.ctx.bus.of(ContentRemoved) .subscribe(message => { if (this.content && message.content.id === this.content.id) { - this.content = this.content.unpublish(message.content.lastModifiedBy, message.content.version, message.content.lastModified); + this.router.navigate(['../'], { relativeTo: this.ctx.route }); } }); - this.contentDeletedSubscription = - this.ctx.bus.of(ContentRemoved) + this.contentStatusChangedSubscription = + this.ctx.bus.of(ContentStatusChanged) .subscribe(message => { if (this.content && message.content.id === this.content.id) { - this.router.navigate(['../'], { relativeTo: this.ctx.route }); + this.content = + this.content.changeStatus( + message.content.scheduledTo || message.content.status, + message.content.scheduledAt, + message.content.lastModifiedBy, + message.content.version, + message.content.lastModified); } }); diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-page.component.html index fa9efefed..f00ddc40a 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.html @@ -28,7 +28,7 @@ Search for content using full text search over all fields and languages! -
- {{selectionCount}} items selected: + {{selectionCount}} items selected:   - - - - -
- \ No newline at end of file + + + diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts index a00c846b8..689d817ec 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts @@ -11,9 +11,8 @@ import { Observable, Subscription } from 'rxjs'; import { ContentCreated, - ContentPublished, ContentRemoved, - ContentUnpublished, + ContentStatusChanged, ContentUpdated } from './../messages'; @@ -23,6 +22,7 @@ import { AppLanguageDto, ContentDto, ContentsService, + DateTime, FieldDto, ImmutableArray, ModalView, @@ -52,6 +52,12 @@ export class ContentsPageComponent implements OnDestroy, OnInit { public contentsQuery = ''; public contentsPager = new Pager(0); + public dueTimeDialog = new ModalView(); + public dueTime: string | null = ''; + public dueTimeFunction: Function | null; + public dueTimeAction: string | null = ''; + public dueTimeMode = 'Immediately'; + public selectedItems: { [id: string]: boolean; } = {}; public selectionCount = 0; @@ -118,116 +124,89 @@ export class ContentsPageComponent implements OnDestroy, OnInit { } public publishContent(content: ContentDto) { - this.publishContentItem(content).subscribe(); - } - - public publishSelected() { - Observable.forkJoin( - this.contentItems.values - .filter(c => this.selectedItems[c.id]) - .filter(c => c.status !== 'Published') - .map(c => this.publishContentItem(c))) - .finally(() => { - this.updateSelectionSummary(); - }) - .subscribe(); + this.changeContentItems([content], 'Publish', 'Published', false); } - private publishContentItem(content: ContentDto): Observable { - return this.contentsService.publishContent(this.ctx.appName, this.schema.name, content.id, content.version) - .catch(error => { - this.ctx.notifyError(error); + public publishSelected(scheduled: boolean) { + const contents = this.contentItems.filter(c => c.status !== 'Published' && this.selectedItems[c.id]).values; - return Observable.throw(error); - }) - .do(dto => { - this.contentItems = this.contentItems.replaceBy('id', content.publish(this.ctx.userToken, dto.version)); - - this.emitContentPublished(content); - }); + this.changeContentItems(contents, 'Publish', 'Published', false); } public unpublishContent(content: ContentDto) { - this.unpublishContentItem(content).subscribe(); + this.changeContentItems([content], 'Unpublish', 'Draft', false); } - public unpublishSelected() { - Observable.forkJoin( - this.contentItems.values - .filter(c => this.selectedItems[c.id]) - .filter(c => c.status !== 'Unpublished') - .map(c => this.unpublishContentItem(c))) - .finally(() => { - this.updateSelectionSummary(); - }) - .subscribe(); - } - - private unpublishContentItem(content: ContentDto): Observable { - return this.contentsService.unpublishContent(this.ctx.appName, this.schema.name, content.id, content.version) - .catch(error => { - this.ctx.notifyError(error); - - return Observable.throw(error); - }) - .do(dto => { - this.contentItems = this.contentItems.replaceBy('id', content.unpublish(this.ctx.userToken, dto.version)); + public unpublishSelected(scheduled: boolean) { + const contents = this.contentItems.filter(c => c.status === 'Published' && this.selectedItems[c.id]).values; - this.emitContentUnpublished(content); - }); - } - - public archiveSelected() { - Observable.forkJoin( - this.contentItems.values.filter(c => this.selectedItems[c.id]) - .map(c => this.archiveContentItem(c))) - .finally(() => { - this.load(); - }) - .subscribe(); + this.changeContentItems(contents, 'Unpublish', 'Draft', false); } public archiveContent(content: ContentDto) { - this.archiveContentItem(content) - .finally(() => { - this.load(); - }) - .subscribe(); + this.changeContentItems([content], 'Archive', 'Archived', true); } - public archiveContentItem(content: ContentDto): Observable { - return this.contentsService.archiveContent(this.ctx.appName, this.schema.name, content.id, content.version) - .catch(error => { - this.ctx.notifyError(error); + public archiveSelected(scheduled: boolean) { + const contents = this.contentItems.filter(c => this.selectedItems[c.id]).values; - return Observable.throw(error); - }); + this.changeContentItems(contents, 'Archive', 'Archived', true); } - public restoreSelected() { - Observable.forkJoin( - this.contentItems.values.filter(c => this.selectedItems[c.id]) - .map(c => this.restoreContentItem(c))) - .finally(() => { - this.load(); - }) - .subscribe(); + public restoreContent(content: ContentDto) { + this.changeContentItems([content], 'Restore', 'Draft', true); } - public restoreContent(content: ContentDto) { - this.restoreContentItem(content) - .finally(() => { - this.load(); - }) - .subscribe(); + public restoreSelected(scheduled: boolean) { + const contents = this.contentItems.filter(c => this.selectedItems[c.id]).values; + + this.changeContentItems(contents, 'Restore', 'Draft', true); } - public restoreContentItem(content: ContentDto): Observable { - return this.contentsService.restoreContent(this.ctx.appName, this.schema.name, content.id, content.version) + private changeContentItems(contents: ContentDto[], action: string, status: string, reload: boolean) { + if (contents.length === 0) { + return; + } + + this.dueTimeFunction = () => { + if (this.dueTime) { + reload = false; + } + Observable.forkJoin( + contents + .map(c => this.changeContentItem(c, action, status, this.dueTime, reload))) + .finally(() => { + if (reload) { + this.load(); + } else { + this.updateSelectionSummary(); + } + }) + .subscribe(); + }; + + this.dueTimeAction = action; + this.dueTimeDialog.show(); + } + + private changeContentItem(content: ContentDto, action: string, status: string, dueTime: string | null, reload: boolean): Observable { + return this.contentsService.changeContentStatus(this.ctx.appName, this.schema.name, content.id, action, dueTime, content.version) .catch(error => { this.ctx.notifyError(error); return Observable.throw(error); + }) + .do(dto => { + if (!reload) { + const dt = + dueTime ? + DateTime.parseISO_UTC(dueTime) : + null; + + this.contentItems = this.contentItems.replaceBy('id', content.changeStatus(status, dt, this.ctx.userToken, dto.version)); + + this.emitContentStatusChanged(content); + } }); } @@ -359,12 +338,8 @@ export class ContentsPageComponent implements OnDestroy, OnInit { this.languageSelected = language; } - private emitContentPublished(content: ContentDto) { - this.ctx.bus.emit(new ContentPublished(content)); - } - - private emitContentUnpublished(content: ContentDto) { - this.ctx.bus.emit(new ContentUnpublished(content)); + private emitContentStatusChanged(content: ContentDto) { + this.ctx.bus.emit(new ContentStatusChanged(content)); } private emitContentRemoved(content: ContentDto) { @@ -393,5 +368,18 @@ export class ContentsPageComponent implements OnDestroy, OnInit { this.contentFields = [{}]; } } + + public confirmStatusChange() { + this.dueTimeFunction!(); + + this.cancelStatusChange(); + } + + public cancelStatusChange() { + this.dueTimeMode = 'Immediately'; + this.dueTimeDialog.hide(); + this.dueTimeFunction = null; + this.dueTime = null; + } } diff --git a/src/Squidex/app/features/content/pages/messages.ts b/src/Squidex/app/features/content/pages/messages.ts index 896f677e2..112827d57 100644 --- a/src/Squidex/app/features/content/pages/messages.ts +++ b/src/Squidex/app/features/content/pages/messages.ts @@ -35,14 +35,7 @@ export class ContentVersionSelected { } } -export class ContentPublished { - constructor( - public readonly content: ContentDto - ) { - } -} - -export class ContentUnpublished { +export class ContentStatusChanged { constructor( public readonly content: ContentDto ) { diff --git a/src/Squidex/app/features/content/shared/content-item.component.html b/src/Squidex/app/features/content/shared/content-item.component.html index e3bb20cdf..051ed7162 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.html +++ b/src/Squidex/app/features/content/shared/content-item.component.html @@ -10,7 +10,21 @@ - + + + + + + {{content.status}} + + + + + + + + Will be set to '{{content.scheduledTo}}' at {{content.scheduledAt | sqxFullDateTime}} + {{content.lastModified | sqxFromNow}} @@ -22,7 +36,7 @@ -