diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..5383c8731 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +## v1.1.7 - 2018-02-06 + +### Bugfixes + +* **UI**: Checkbox style fixed. + + +## v1.1.6 - 2018-02-06 + +### Features + +* **Rules**: Allow content triggers to catch all content events. +* **Rules**: Ensure that the events for an aggregate are handled sequentially. +* **UI**: History stream in the dashboard. +* **UI**: Better UI for apps overview. +* **Apps**: Added a ready to use blog sample. + +### Bugfixes + +* **UI**: History UI was throwing an exception when a user was referenced in the message. +* **UI**: A lot of style fixes. + +## v1.1.5 - 2018-02-03 + +### Features + +* **Content**: Slugify function for custom scripts. + +### Bugfixes + +* **Migration**: Assets and schemas were not removed before recreation. +* **Content**: OData queries only worked for data fields. +* **Assets**: OData queries did not work at all and included too many fields (e.g. AppId, Id). + +## v1.1.4 - 2018-02-03 + +### Features + +* **Login**: Consent screen to inform the user about privacy policies. + +## v1.1.3 - 2018-02-03 + +### Features + +* **Rules**: Trigger when asset has changed +* **Rules**: Action to purge cache items in fastly +* **Rules**: Action to push events to Azure storage queues. + +### Bugfixes + +* **Rules**: Layout fixes. + +### Refactorings + +* Freeze action, triggers and field properties using Fody. +* Fetch derived types automatically for Swagger generation. + +## v1.1.2 - 2018-01-31 + +### Features + +* **Assets**: OData support, except full text search (`$search`) +* **Rules**: Slack action +* **Rules**: Algolia action + +### Bugixes + +* **Rules**: Color corrections for actions. + +### Breaking Changes + +* Asset structure has changed: Migration will update the ocllection automatically. +* Asset endpoint: + * `take` query parameter renamed to `$top` for OData compatibility. + * `skip` query parameter renamed to `$skip` for OData compatibility. + * `query` query parameter replaced with OData. Use `$query=contains(fileName, 'MyQuery')` instead. \ No newline at end of file diff --git a/README.md b/README.md index d4fed770f..9c83fa2af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Squidex Logo](https://raw.githubusercontent.com/Squidex/squidex/master/media/logo-wide.png "Squidex") -# What is Squidex? +# What is Squidex?? Squidex is an open source headless CMS and content management hub. In contrast to a traditional CMS Squidex provides a rich API with OData filter and Swagger definitions. It is up to you to build your UI on top of it. It can be website, a native app or just another server. We build it with ASP.NET Core and CQRS and is tested for Windows and Linux on modern browsers. @@ -10,7 +10,7 @@ Read the docs at [https://docs.squidex.io/](https://docs.squidex.io/) (work in p ## Status -Current Version 1.0-beta3. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap +Current Version 1.1. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap ## Prerequisites @@ -22,7 +22,7 @@ Current Version 1.0-beta3. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadma ## Contributors -### Core Team +### Core Team and Founders * [Qaisar Ahmad](http://www.qaisarahmad.com/) Interaction Designer, Pakistan * [Sebastian Stehle](https://github.com/SebastianStehle) Software Engineer, Germany (currently Sweden) diff --git a/Squidex.sln.DotSettings b/Squidex.sln.DotSettings index 235489b1a..ecdd59c86 100644 --- a/Squidex.sln.DotSettings +++ b/Squidex.sln.DotSettings @@ -36,12 +36,15 @@ <?xml version="1.0" encoding="utf-16"?><Profile name="Header"><CSUpdateFileHeader>True</CSUpdateFileHeader></Profile> <?xml version="1.0" encoding="utf-16"?><Profile name="Namespaces"><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSUpdateFileHeader>True</CSUpdateFileHeader></Profile> <?xml version="1.0" encoding="utf-16"?><Profile name="Typescript"><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs></Profile> + False ========================================================================== - $FILENAME$ Squidex Headless CMS ========================================================================== - Copyright (c) Squidex Group - All rights reserved. + Copyright (c) Squidex UG (haftungsbeschraenkt) + All rights reserved. Licensed under the MIT license. ========================================================================== + True + True + True \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 000000000..5e33380b8 --- /dev/null +++ b/build.sh @@ -0,0 +1,11 @@ +# Build the image +docker build . -t squidex-build-image -f dockerfile.build + +# Open the image +docker create --name squidex-build-container squidex-build-image + +# Copy the output to the host file system +docker cp squidex-build-container:/out ./publish + +# Cleanup +docker rm squidex-build-container \ No newline at end of file diff --git a/libs/keep b/libs/keep new file mode 100644 index 000000000..e69de29bb diff --git a/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.2.0.0-beta1-fix.nupkg b/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.2.0.0-beta1-fix.nupkg deleted file mode 100644 index c719a733a..000000000 Binary files a/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.2.0.0-beta1-fix.nupkg and /dev/null differ diff --git a/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.2.0.0-beta1-fix.nupkg.sha512 b/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.2.0.0-beta1-fix.nupkg.sha512 deleted file mode 100644 index 11c0b5549..000000000 --- a/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.2.0.0-beta1-fix.nupkg.sha512 +++ /dev/null @@ -1 +0,0 @@ -5W20j9jiNog4dHUEt+cCnePb8z6jFEMnkwO4XilajM7FCnen3KTnN/G8PAUGuQieSlTI9MRe0sRYcafLJl900w== \ No newline at end of file diff --git a/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.nuspec b/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.nuspec deleted file mode 100644 index d685caf66..000000000 --- a/libs/microsoft.orleans.orleanscodegenerator.build/2.0.0-beta1-fix/microsoft.orleans.orleanscodegenerator.build.nuspec +++ /dev/null @@ -1,23 +0,0 @@ - - - - Microsoft.Orleans.OrleansCodeGenerator.Build - 2.0.0-beta1-fix - Microsoft Orleans Build-time Code Generator - Microsoft - Microsoft - false - true - https://github.com/dotnet/Orleans#license - https://github.com/dotnet/Orleans - https://raw.githubusercontent.com/dotnet/orleans/gh-pages/assets/logo_128.png - Microsoft Orleans build-time code generator to install in all grain interface & implementation projects. - © Microsoft Corporation. All rights reserved. - Orleans Cloud-Computing Actor-Model Actors Distributed-Systems C# .NET - - - - - - - \ No newline at end of file diff --git a/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.2.0.0-beta3.nupkg b/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.2.0.0-beta3.nupkg deleted file mode 100644 index a90f02b9d..000000000 Binary files a/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.2.0.0-beta3.nupkg and /dev/null differ diff --git a/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.2.0.0-beta3.nupkg.sha512 b/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.2.0.0-beta3.nupkg.sha512 deleted file mode 100644 index 6500d397d..000000000 --- a/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.2.0.0-beta3.nupkg.sha512 +++ /dev/null @@ -1 +0,0 @@ -mBHlGWl+bNTPP463JBEB/dftmdZKQRD8X72F7lsTFqYWddW5Ytp1gbzChCxW0d/Pt71KLF6XrVmyecbFlNdFBA== \ No newline at end of file diff --git a/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.nuspec b/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.nuspec deleted file mode 100644 index cf2043a3f..000000000 --- a/libs/orleansdashboard/2.0.0-beta3/orleansdashboard.nuspec +++ /dev/null @@ -1,25 +0,0 @@ - - - - OrleansDashboard - 2.0.0-beta3 - OrleansContrib - OrleansContrib - false - https://opensource.org/licenses/MIT - https://github.com/OrleansContrib/OrleansDashboard - http://dotnet.github.io/orleans/assets/logo.png - An admin dashboard for Microsoft Orleans - Copyright © 2017 - orleans dashboard metrics monitor - - - - - - - - - - - \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/FodyWeavers.xml b/src/Squidex.Domain.Apps.Core.Model/FodyWeavers.xml new file mode 100644 index 000000000..0444e1d26 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/FodyWeavers.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Freezable.cs b/src/Squidex.Domain.Apps.Core.Model/Freezable.cs similarity index 60% rename from src/Squidex.Infrastructure/Freezable.cs rename to src/Squidex.Domain.Apps.Core.Model/Freezable.cs index a71d35175..9fc282430 100644 --- a/src/Squidex.Infrastructure/Freezable.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Freezable.cs @@ -6,24 +6,30 @@ // ========================================================================== using System; +using Squidex.Infrastructure; -namespace Squidex.Infrastructure +namespace Squidex.Domain.Apps.Core { - public abstract class Freezable + public abstract class Freezable : IFreezable { - public bool IsFrozen { get; private set; } + private bool isFrozen; - protected void ThrowIfFrozen() + public bool IsFrozen { - if (IsFrozen) + get { return isFrozen; } + } + + protected void CheckIfFrozen() + { + if (isFrozen) { throw new InvalidOperationException("Object is frozen"); } } - public void Freeze() + public virtual void Freeze() { - IsFrozen = true; + isFrozen = true; } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AlgoliaAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AlgoliaAction.cs new file mode 100644 index 000000000..33295be4a --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AlgoliaAction.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Rules.Actions +{ + [TypeName(nameof(AlgoliaAction))] + public sealed class AlgoliaAction : RuleAction + { + public string AppId { get; set; } + + public string ApiKey { get; set; } + + public string IndexName { get; set; } + + public override T Accept(IRuleActionVisitor visitor) + { + return visitor.Visit(this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AzureQueueAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AzureQueueAction.cs new file mode 100644 index 000000000..fc9178243 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/AzureQueueAction.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Rules.Actions +{ + [TypeName(nameof(AzureQueueAction))] + public sealed class AzureQueueAction : RuleAction + { + public string ConnectionString { get; set; } + + public string Queue { get; set; } + + public override T Accept(IRuleActionVisitor visitor) + { + return visitor.Visit(this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/FastlyAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/FastlyAction.cs new file mode 100644 index 000000000..2d459d500 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/FastlyAction.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Rules.Actions +{ + [TypeName(nameof(FastlyAction))] + public sealed class FastlyAction : RuleAction + { + public string ApiKey { get; set; } + + public string ServiceId { get; set; } + + public override T Accept(IRuleActionVisitor visitor) + { + return visitor.Visit(this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/SlackAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/SlackAction.cs new file mode 100644 index 000000000..b669fe104 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/SlackAction.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Rules.Actions +{ + [TypeName(nameof(SlackAction))] + public sealed class SlackAction : RuleAction + { + public Uri WebhookUrl { get; set; } + + public string Text { get; set; } + + public override T Accept(IRuleActionVisitor visitor) + { + return visitor.Visit(this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/WebhookAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/WebhookAction.cs index 849c87a6e..30a6c0707 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/WebhookAction.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/WebhookAction.cs @@ -13,36 +13,9 @@ namespace Squidex.Domain.Apps.Core.Rules.Actions [TypeName(nameof(WebhookAction))] public sealed class WebhookAction : RuleAction { - private Uri url; - private string sharedSecret; + public Uri Url { get; set; } - public Uri Url - { - get - { - return url; - } - set - { - ThrowIfFrozen(); - - url = value; - } - } - - public string SharedSecret - { - get - { - return sharedSecret; - } - set - { - ThrowIfFrozen(); - - sharedSecret = value; - } - } + public string SharedSecret { get; set; } public override T Accept(IRuleActionVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs index 41f68b54e..00fa80744 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs @@ -11,6 +11,14 @@ namespace Squidex.Domain.Apps.Core.Rules { public interface IRuleActionVisitor { + T Visit(AlgoliaAction action); + + T Visit(AzureQueueAction action); + + T Visit(FastlyAction action); + + T Visit(SlackAction action); + T Visit(WebhookAction action); } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs index 10d10fd0a..0793615af 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs @@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Core.Rules { public interface IRuleTriggerVisitor { + T Visit(AssetChangedTrigger trigger); + T Visit(ContentChangedTrigger trigger); } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleAction.cs index 02fb9e85d..caaf409d0 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleAction.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleAction.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; - namespace Squidex.Domain.Apps.Core.Rules { public abstract class RuleAction : Freezable diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs index 030ff718f..963832075 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleJob.cs @@ -16,6 +16,8 @@ namespace Squidex.Domain.Apps.Core.Rules public Guid AppId { get; set; } + public Guid AggregateId { get; set; } + public string EventName { get; set; } public string ActionName { get; set; } diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs index 1640b81ab..95c1deb0f 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/RuleTrigger.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; - namespace Squidex.Domain.Apps.Core.Rules { public abstract class RuleTrigger : Freezable diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTrigger.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTrigger.cs new file mode 100644 index 000000000..67f099873 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTrigger.cs @@ -0,0 +1,28 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Rules.Triggers +{ + [TypeName(nameof(AssetChangedTrigger))] + public sealed class AssetChangedTrigger : RuleTrigger + { + public bool SendCreate { get; set; } + + public bool SendUpdate { get; set; } + + public bool SendRename { get; set; } + + public bool SendDelete { get; set; } + + public override T Accept(IRuleTriggerVisitor visitor) + { + return visitor.Visit(this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTrigger.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTrigger.cs index 2ad2d5110..e62138920 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTrigger.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTrigger.cs @@ -13,25 +13,26 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers [TypeName(nameof(ContentChangedTrigger))] public sealed class ContentChangedTrigger : RuleTrigger { - private ImmutableList schemas; + public ImmutableList Schemas { get; set; } - public ImmutableList Schemas - { - get - { - return schemas; - } - set - { - ThrowIfFrozen(); - - schemas = value; - } - } + public bool HandleAll { get; set; } public override T Accept(IRuleTriggerVisitor visitor) { return visitor.Visit(this); } + + public override void Freeze() + { + base.Freeze(); + + if (Schemas != null) + { + foreach (var schema in Schemas) + { + schema.Freeze(); + } + } + } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs index f5e0bf7ea..33d073f5a 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs @@ -9,7 +9,7 @@ using System; namespace Squidex.Domain.Apps.Core.Rules.Triggers { - public sealed class ContentChangedTriggerSchema + public sealed class ContentChangedTriggerSchema : Freezable { public Guid SchemaId { get; set; } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs index 022890352..d3ed6270a 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/AssetsFieldProperties.cs @@ -13,186 +13,29 @@ namespace Squidex.Domain.Apps.Core.Schemas [TypeName(nameof(AssetsField))] public sealed class AssetsFieldProperties : FieldProperties { - private bool mustBeImage; - private int? minItems; - private int? maxItems; - private int? minWidth; - private int? maxWidth; - private int? minHeight; - private int? maxHeight; - private int? minSize; - private int? maxSize; - private int? aspectWidth; - private int? aspectHeight; - private ImmutableList allowedExtensions; + public bool MustBeImage { get; set; } - public bool MustBeImage - { - get - { - return mustBeImage; - } - set - { - ThrowIfFrozen(); + public int? MinItems { get; set; } - mustBeImage = value; - } - } + public int? MaxItems { get; set; } - public int? MinItems - { - get - { - return minItems; - } - set - { - ThrowIfFrozen(); + public int? MinWidth { get; set; } - minItems = value; - } - } + public int? MaxWidth { get; set; } - public int? MaxItems - { - get - { - return maxItems; - } - set - { - ThrowIfFrozen(); + public int? MinHeight { get; set; } - maxItems = value; - } - } + public int? MaxHeight { get; set; } - public int? MinWidth - { - get - { - return minWidth; - } - set - { - ThrowIfFrozen(); + public int? MinSize { get; set; } - minWidth = value; - } - } + public int? MaxSize { get; set; } - public int? MaxWidth - { - get - { - return maxWidth; - } - set - { - ThrowIfFrozen(); + public int? AspectWidth { get; set; } - maxWidth = value; - } - } + public int? AspectHeight { get; set; } - public int? MinHeight - { - get - { - return minHeight; - } - set - { - ThrowIfFrozen(); - - minHeight = value; - } - } - - public int? MaxHeight - { - get - { - return maxHeight; - } - set - { - ThrowIfFrozen(); - - maxHeight = value; - } - } - - public int? MinSize - { - get - { - return minSize; - } - set - { - ThrowIfFrozen(); - - minSize = value; - } - } - - public int? MaxSize - { - get - { - return maxSize; - } - set - { - ThrowIfFrozen(); - - maxSize = value; - } - } - - public int? AspectWidth - { - get - { - return aspectWidth; - } - set - { - ThrowIfFrozen(); - - aspectWidth = value; - } - } - - public int? AspectHeight - { - get - { - return aspectHeight; - } - set - { - ThrowIfFrozen(); - - aspectHeight = value; - } - } - - public ImmutableList AllowedExtensions - { - get - { - return allowedExtensions; - } - set - { - ThrowIfFrozen(); - - allowedExtensions = value; - } - } + public ImmutableList AllowedExtensions { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs index 06c2f2fe8..2785eceba 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/BooleanFieldProperties.cs @@ -12,36 +12,9 @@ namespace Squidex.Domain.Apps.Core.Schemas [TypeName(nameof(BooleanField))] public sealed class BooleanFieldProperties : FieldProperties { - private BooleanFieldEditor editor; - private bool? defaultValue; + public bool? DefaultValue { get; set; } - public bool? DefaultValue - { - get - { - return defaultValue; - } - set - { - ThrowIfFrozen(); - - defaultValue = value; - } - } - - public BooleanFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); - - editor = value; - } - } + public BooleanFieldEditor Editor { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs index 6456e9604..8dcdd85d5 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/DateTimeFieldProperties.cs @@ -13,81 +13,15 @@ namespace Squidex.Domain.Apps.Core.Schemas [TypeName(nameof(DateTimeField))] public sealed class DateTimeFieldProperties : FieldProperties { - private DateTimeFieldEditor editor; - private DateTimeCalculatedDefaultValue? calculatedDefaultValue; - private Instant? maxValue; - private Instant? minValue; - private Instant? defaultValue; + public Instant? MaxValue { get; set; } - public Instant? MaxValue - { - get - { - return maxValue; - } - set - { - ThrowIfFrozen(); - - maxValue = value; - } - } - - public Instant? MinValue - { - get - { - return minValue; - } - set - { - ThrowIfFrozen(); - - minValue = value; - } - } - - public Instant? DefaultValue - { - get - { - return defaultValue; - } - set - { - ThrowIfFrozen(); - - defaultValue = value; - } - } - - public DateTimeCalculatedDefaultValue? CalculatedDefaultValue - { - get - { - return calculatedDefaultValue; - } - set - { - ThrowIfFrozen(); + public Instant? MinValue { get; set; } - calculatedDefaultValue = value; - } - } + public Instant? DefaultValue { get; set; } - public DateTimeFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); + public DateTimeCalculatedDefaultValue? CalculatedDefaultValue { get; set; } - editor = value; - } - } + public DateTimeFieldEditor Editor { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs index 3c0fc63f2..3820bd6c5 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldProperties.cs @@ -9,51 +9,11 @@ namespace Squidex.Domain.Apps.Core.Schemas { public abstract class FieldProperties : NamedElementPropertiesBase { - private bool isRequired; - private bool isListField; - private string placeholder; + public bool IsRequired { get; set; } - public bool IsRequired - { - get - { - return isRequired; - } - set - { - ThrowIfFrozen(); + public bool IsListField { get; set; } - isRequired = value; - } - } - - public bool IsListField - { - get - { - return isListField; - } - set - { - ThrowIfFrozen(); - - isListField = value; - } - } - - public string Placeholder - { - get - { - return placeholder; - } - set - { - ThrowIfFrozen(); - - placeholder = value; - } - } + public string Placeholder { get; set; } public abstract T Accept(IFieldPropertiesVisitor visitor); diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs index 985f75b5a..4cc7b239b 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/GeolocationFieldProperties.cs @@ -12,21 +12,7 @@ namespace Squidex.Domain.Apps.Core.Schemas [TypeName(nameof(GeolocationField))] public sealed class GeolocationFieldProperties : FieldProperties { - private GeolocationFieldEditor editor; - - public GeolocationFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); - - editor = value; - } - } + public GeolocationFieldEditor Editor { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NamedElementPropertiesBase.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NamedElementPropertiesBase.cs index f4f1b7497..9b3b92aba 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/NamedElementPropertiesBase.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NamedElementPropertiesBase.cs @@ -5,41 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; - namespace Squidex.Domain.Apps.Core.Schemas { public abstract class NamedElementPropertiesBase : Freezable { - private string label; - private string hints; - - public string Label - { - get - { - return label; - } - set - { - ThrowIfFrozen(); - - label = value; - } - } - - public string Hints - { - get - { - return hints; - } - set - { - ThrowIfFrozen(); + public string Label { get; set; } - hints = value; - } - } + public string Hints { get; set; } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs index cf6107222..bd86a2a74 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/NumberFieldProperties.cs @@ -13,81 +13,15 @@ namespace Squidex.Domain.Apps.Core.Schemas [TypeName(nameof(NumberField))] public sealed class NumberFieldProperties : FieldProperties { - private double? maxValue; - private double? minValue; - private double? defaultValue; - private ImmutableList allowedValues; - private NumberFieldEditor editor; + public ImmutableList AllowedValues { get; set; } - public double? MaxValue - { - get - { - return maxValue; - } - set - { - ThrowIfFrozen(); - - maxValue = value; - } - } - - public double? MinValue - { - get - { - return minValue; - } - set - { - ThrowIfFrozen(); - - minValue = value; - } - } - - public double? DefaultValue - { - get - { - return defaultValue; - } - set - { - ThrowIfFrozen(); - - defaultValue = value; - } - } - - public ImmutableList AllowedValues - { - get - { - return allowedValues; - } - set - { - ThrowIfFrozen(); + public double? MaxValue { get; set; } - allowedValues = value; - } - } + public double? MinValue { get; set; } - public NumberFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); + public double? DefaultValue { get; set; } - editor = value; - } - } + public NumberFieldEditor Editor { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs index 368ec7a2b..cc3740bda 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ReferencesFieldProperties.cs @@ -13,51 +13,11 @@ namespace Squidex.Domain.Apps.Core.Schemas [TypeName(nameof(ReferencesField))] public sealed class ReferencesFieldProperties : FieldProperties { - private int? minItems; - private int? maxItems; - private Guid schemaId; + public int? MinItems { get; set; } - public int? MinItems - { - get - { - return minItems; - } - set - { - ThrowIfFrozen(); - - minItems = value; - } - } - - public int? MaxItems - { - get - { - return maxItems; - } - set - { - ThrowIfFrozen(); + public int? MaxItems { get; set; } - maxItems = value; - } - } - - public Guid SchemaId - { - get - { - return schemaId; - } - set - { - ThrowIfFrozen(); - - schemaId = value; - } - } + public Guid SchemaId { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs index cdb515d5d..c7130e1de 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldEditor.cs @@ -14,6 +14,7 @@ namespace Squidex.Domain.Apps.Core.Schemas Dropdown, Radio, RichText, + Slug, TextArea } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs index 1129c646b..008c18c58 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/StringFieldProperties.cs @@ -13,111 +13,19 @@ namespace Squidex.Domain.Apps.Core.Schemas [TypeName(nameof(StringField))] public sealed class StringFieldProperties : FieldProperties { - private int? minLength; - private int? maxLength; - private string pattern; - private string patternMessage; - private string defaultValue; - private ImmutableList allowedValues; - private StringFieldEditor editor; + public ImmutableList AllowedValues { get; set; } - public int? MinLength - { - get - { - return minLength; - } - set - { - ThrowIfFrozen(); - - minLength = value; - } - } - - public int? MaxLength - { - get - { - return maxLength; - } - set - { - ThrowIfFrozen(); - - maxLength = value; - } - } - - public string DefaultValue - { - get - { - return defaultValue; - } - set - { - ThrowIfFrozen(); - - defaultValue = value; - } - } + public int? MinLength { get; set; } - public string Pattern - { - get - { - return pattern; - } - set - { - ThrowIfFrozen(); - - pattern = value; - } - } + public int? MaxLength { get; set; } - public string PatternMessage - { - get - { - return patternMessage; - } - set - { - ThrowIfFrozen(); + public string DefaultValue { get; set; } - patternMessage = value; - } - } + public string Pattern { get; set; } - public ImmutableList AllowedValues - { - get - { - return allowedValues; - } - set - { - ThrowIfFrozen(); + public string PatternMessage { get; set; } - allowedValues = value; - } - } - - public StringFieldEditor Editor - { - get - { - return editor; - } - set - { - ThrowIfFrozen(); - - editor = value; - } - } + public StringFieldEditor Editor { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs index ab4e3b305..a87e2fd44 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/TagsFieldProperties.cs @@ -12,36 +12,9 @@ namespace Squidex.Domain.Apps.Core.Schemas [TypeName(nameof(TagsField))] public sealed class TagsFieldProperties : FieldProperties { - private int? minItems; - private int? maxItems; + public int? MinItems { get; set; } - public int? MinItems - { - get - { - return minItems; - } - set - { - ThrowIfFrozen(); - - minItems = value; - } - } - - public int? MaxItems - { - get - { - return maxItems; - } - set - { - ThrowIfFrozen(); - - maxItems = value; - } - } + public int? MaxItems { get; set; } public override T Accept(IFieldPropertiesVisitor visitor) { diff --git a/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj b/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj index 701e56aa9..91ab5c926 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj +++ b/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj @@ -8,6 +8,8 @@ True + + diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs new file mode 100644 index 000000000..3a2194a9c --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs @@ -0,0 +1,157 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Algolia.Search; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.HandleRules.Actions +{ + public sealed class AlgoliaActionHandler : RuleActionHandler + { + private const string SchemaNamePlaceholder = "$SCHEMA_NAME"; + private readonly ClientPool<(string AppId, string ApiKey, string IndexName), Index> clients; + private readonly RuleEventFormatter formatter; + + public AlgoliaActionHandler(RuleEventFormatter formatter) + { + Guard.NotNull(formatter, nameof(formatter)); + + this.formatter = formatter; + + clients = new ClientPool<(string AppId, string ApiKey, string IndexName), Index>(key => + { + var client = new AlgoliaClient(key.AppId, key.ApiKey); + + return client.InitIndex(key.IndexName); + }); + } + + protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, AlgoliaAction action) + { + var ruleDescription = string.Empty; + var ruleData = new RuleJobData + { + ["AppId"] = action.AppId, + ["ApiKey"] = action.ApiKey + }; + + if (@event.Payload is ContentEvent contentEvent) + { + ruleData["ContentId"] = contentEvent.ContentId.ToString(); + ruleData["Operation"] = "Upsert"; + ruleData["IndexName"] = formatter.FormatString(action.IndexName, @event); + + var timestamp = @event.Headers.Timestamp().ToString(); + + switch (@event.Payload) + { + case ContentCreated created: + { + ruleDescription = $"Add entry to Algolia index: {action.IndexName}"; + ruleData["Content"] = new JObject( + new JProperty("id", contentEvent.ContentId), + new JProperty("created", timestamp), + new JProperty("createdBy", created.Actor.ToString()), + new JProperty("lastModified", timestamp), + new JProperty("lastModifiedBy", created.Actor.ToString()), + new JProperty("status", Status.Draft.ToString()), + new JProperty("data", formatter.ToRouteData(created.Data))); + break; + } + + case ContentUpdated updated: + { + ruleDescription = $"Update entry in Algolia index: {action.IndexName}"; + ruleData["Content"] = new JObject( + new JProperty("lastModified", timestamp), + new JProperty("lastModifiedBy", updated.Actor.ToString()), + new JProperty("data", formatter.ToRouteData(updated.Data))); + break; + } + + case ContentStatusChanged statusChanged: + { + ruleDescription = $"Update entry in Algolia index: {action.IndexName}"; + ruleData["Content"] = new JObject( + new JProperty("status", statusChanged.Status.ToString())); + break; + } + + case ContentDeleted deleted: + { + ruleDescription = $"Delete entry from Index: {action.IndexName}"; + ruleData["Content"] = new JObject(); + break; + } + } + } + + return (ruleDescription, ruleData); + } + + public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) + { + if (!job.TryGetValue("Operation", out var operationToken)) + { + return (null, new InvalidOperationException("The action cannot handle this event.")); + } + + var appId = job["AppId"].Value(); + var apiKey = job["ApiKey"].Value(); + var indexName = job["IndexName"].Value(); + + var index = clients.GetClient((appId, apiKey, indexName)); + + var operation = operationToken.Value(); + var content = job["Content"].Value(); + var contentId = job["ContentId"].Value(); + + try + { + switch (operation) + { + case "Upsert": + { + content["objectID"] = contentId; + + var resonse = await index.PartialUpdateObjectAsync(content); + + return (resonse.ToString(Formatting.Indented), null); + } + + case "Delete": + { + var resonse = await index.DeleteObjectAsync(contentId); + + return (resonse.ToString(Formatting.Indented), null); + } + + default: + return (null, null); + } + } + catch (AlgoliaException ex) + { + return (ex.Message, ex); + } + catch (Exception ex) + { + return (null, ex); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs new file mode 100644 index 000000000..56dd3154a --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.WindowsAzure.Storage; +using Microsoft.WindowsAzure.Storage.Queue; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.HandleRules.Actions +{ + public sealed class AzureQueueActionHandler : RuleActionHandler + { + private readonly ClientPool<(string ConnectionString, string QueueName), CloudQueue> clients; + private readonly RuleEventFormatter formatter; + + public AzureQueueActionHandler(RuleEventFormatter formatter) + { + Guard.NotNull(formatter, nameof(formatter)); + + this.formatter = formatter; + + clients = new ClientPool<(string ConnectionString, string QueueName), CloudQueue>(key => + { + var storageAccount = CloudStorageAccount.Parse(key.ConnectionString); + + var queueClient = storageAccount.CreateCloudQueueClient(); + var queueRef = queueClient.GetQueueReference(key.QueueName); + + return queueRef; + }); + } + + protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, AzureQueueAction action) + { + var body = formatter.ToRouteData(@event, eventName); + + var ruleDescription = $"Send event to azure queue '{action.Queue}'"; + var ruleData = new RuleJobData + { + ["QueueConnectionString"] = action.ConnectionString, + ["QueueName"] = action.Queue, + ["MessageBody"] = body + }; + + return (ruleDescription, ruleData); + } + + public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) + { + var queueConnectionString = job["QueueConnectionString"].Value(); + var queueName = job["QueueName"].Value(); + + var queue = clients.GetClient((queueConnectionString, queueName)); + + var messageBody = job["MessageBody"].ToString(Formatting.Indented); + + await queue.AddMessageAsync(new CloudQueueMessage(messageBody)); + + return ("Completed", null); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs new file mode 100644 index 000000000..5a8a252bd --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs @@ -0,0 +1,89 @@ +// ========================================================================== +// 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.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Http; + +namespace Squidex.Domain.Apps.Core.HandleRules.Actions +{ + public sealed class FastlyActionHandler : RuleActionHandler + { + protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, FastlyAction action) + { + var ruleDescription = "Purge key in fastly"; + var ruleData = new RuleJobData + { + ["FastlyApiKey"] = action.ApiKey, + ["FastlyServiceID"] = action.ServiceId + }; + + if (@event.Headers.Contains(CommonHeaders.AggregateId)) + { + ruleData["Key"] = @event.Headers.AggregateId().ToString(); + } + + return (ruleDescription, ruleData); + } + + public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) + { + if (!job.TryGetValue("Key", out var keyToken)) + { + return (null, new InvalidOperationException("The action cannot handle this event.")); + } + + var requestMsg = BuildRequest(job, keyToken.Value()); + + HttpResponseMessage response = null; + + try + { + response = await HttpClientPool.GetHttpClient().SendAsync(requestMsg); + + var responseString = await response.Content.ReadAsStringAsync(); + var requestDump = DumpFormatter.BuildDump(requestMsg, response, null, responseString, TimeSpan.Zero, false); + + return (requestDump, null); + } + catch (Exception ex) + { + if (requestMsg != null) + { + var requestDump = DumpFormatter.BuildDump(requestMsg, response, null, ex.ToString(), TimeSpan.Zero, false); + + return (requestDump, ex); + } + else + { + var requestDump = ex.ToString(); + + return (requestDump, ex); + } + } + } + + private static HttpRequestMessage BuildRequest(Dictionary job, string key) + { + var serviceId = job["FastlyServiceID"].Value(); + + var requestUrl = $"https://api.fastly.com/service/{serviceId}/purge/{key}"; + var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); + + request.Headers.Add("Fastly-Key", job["FastlyApiKey"].Value()); + + return request; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs new file mode 100644 index 000000000..d5becff40 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs @@ -0,0 +1,99 @@ +// ========================================================================== +// 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.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Http; + +namespace Squidex.Domain.Apps.Core.HandleRules.Actions +{ + public sealed class SlackActionHandler : RuleActionHandler + { + private readonly RuleEventFormatter formatter; + + public SlackActionHandler(RuleEventFormatter formatter) + { + Guard.NotNull(formatter, nameof(formatter)); + + this.formatter = formatter; + } + + protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, SlackAction action) + { + var body = CreatePayload(@event, action.Text); + + var ruleDescription = "Send message to slack"; + var ruleData = new RuleJobData + { + ["RequestUrl"] = action.WebhookUrl, + ["RequestBody"] = body + }; + + return (ruleDescription, ruleData); + } + + private JObject CreatePayload(Envelope @event, string text) + { + return new JObject(new JProperty("text", formatter.FormatString(text, @event))); + } + + public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) + { + var requestBody = job["RequestBody"].ToString(Formatting.Indented); + var requestMsg = BuildRequest(job, requestBody); + + HttpResponseMessage response = null; + + try + { + response = await HttpClientPool.GetHttpClient().SendAsync(requestMsg); + + var responseString = await response.Content.ReadAsStringAsync(); + var requestDump = DumpFormatter.BuildDump(requestMsg, response, requestBody, responseString, TimeSpan.Zero, false); + + return (requestDump, null); + } + catch (Exception ex) + { + if (requestMsg != null) + { + var requestDump = DumpFormatter.BuildDump(requestMsg, response, requestBody, ex.ToString(), TimeSpan.Zero, false); + + return (requestDump, ex); + } + else + { + var requestDump = ex.ToString(); + + return (requestDump, ex); + } + } + } + + private static HttpRequestMessage BuildRequest(Dictionary job, string requestBody) + { + var requestUrl = job["RequestUrl"].Value(); + + var request = new HttpRequestMessage(HttpMethod.Post, requestUrl) + { + Content = new StringContent(requestBody, Encoding.UTF8, "application/json") + }; + + return request; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs index 8ccb20c5d..9fbecd68d 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs @@ -23,24 +23,22 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions { public sealed class WebhookActionHandler : RuleActionHandler { - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2); + private readonly RuleEventFormatter formatter; - private readonly JsonSerializer serializer; - - public WebhookActionHandler(JsonSerializer serializer) + public WebhookActionHandler(RuleEventFormatter formatter) { - Guard.NotNull(serializer, nameof(serializer)); + Guard.NotNull(formatter, nameof(formatter)); - this.serializer = serializer; + this.formatter = formatter; } protected override (string Description, RuleJobData Data) CreateJob(Envelope @event, string eventName, WebhookAction action) { - var body = CreatePayload(@event, eventName); + var body = formatter.ToRouteData(@event, eventName); var signature = $"{body.ToString(Formatting.Indented)}{action.SharedSecret}".Sha256Base64(); - var ruleDescription = $"Send event to webhook {action.Url}"; + var ruleDescription = $"Send event to webhook '{action.Url}'"; var ruleData = new RuleJobData { ["RequestUrl"] = action.Url, @@ -51,38 +49,27 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions return (ruleDescription, ruleData); } - private JObject CreatePayload(Envelope @event, string eventName) - { - return new JObject( - new JProperty("type", eventName), - new JProperty("payload", JObject.FromObject(@event.Payload, serializer)), - new JProperty("timestamp", @event.Headers.Timestamp().ToString())); - } - public override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(RuleJobData job) { var requestBody = job["RequestBody"].ToString(Formatting.Indented); - var request = BuildRequest(job, requestBody); + var requestMsg = BuildRequest(job, requestBody); HttpResponseMessage response = null; try { - using (var client = new HttpClient { Timeout = Timeout }) - { - response = await client.SendAsync(request); + response = await HttpClientPool.GetHttpClient().SendAsync(requestMsg); - var responseString = await response.Content.ReadAsStringAsync(); - var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString, TimeSpan.Zero, false); + var responseString = await response.Content.ReadAsStringAsync(); + var requestDump = DumpFormatter.BuildDump(requestMsg, response, requestBody, responseString, TimeSpan.Zero, false); - return (requestDump, null); - } + return (requestDump, null); } catch (Exception ex) { - if (request != null) + if (requestMsg != null) { - var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString(), TimeSpan.Zero, false); + var requestDump = DumpFormatter.BuildDump(requestMsg, response, requestBody, ex.ToString(), TimeSpan.Zero, false); return (requestDump, ex); } @@ -97,15 +84,15 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions private static HttpRequestMessage BuildRequest(Dictionary job, string requestBody) { - var requestUrl = job["RequestUrl"].ToString(); - var requestSignature = job["RequestSignature"].ToString(); + var requestUrl = job["RequestUrl"].Value(); + var requestSig = job["RequestSignature"].Value(); var request = new HttpRequestMessage(HttpMethod.Post, requestUrl) { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") }; - request.Headers.Add("X-Signature", requestSignature); + request.Headers.Add("X-Signature", requestSig); request.Headers.Add("User-Agent", "Squidex Webhook"); return request; diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs new file mode 100644 index 000000000..b126e4bf3 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +#pragma warning disable RECS0108 // Warns about static fields in generic types + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + internal sealed class ClientPool + { + private static readonly TimeSpan TTL = TimeSpan.FromMinutes(30); + private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + private readonly Func factory; + + public ClientPool(Func factory) + { + this.factory = factory; + } + + public TClient GetClient(TKey key) + { + if (!memoryCache.TryGetValue(key, out var client)) + { + client = factory(key); + + memoryCache.Set(key, client, TTL); + } + + return client; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs new file mode 100644 index 000000000..231920699 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public static class HttpClientPool + { + private static readonly ClientPool Pool = new ClientPool(key => + { + return new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + }); + + public static HttpClient GetHttpClient() + { + return Pool.GetClient(string.Empty); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs new file mode 100644 index 000000000..0e64960f9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs @@ -0,0 +1,170 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// =========================================-================================= + +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public class RuleEventFormatter + { + private const string Undefined = "UNDEFINED"; + private const string AppIdPlaceholder = "$APP_ID"; + private const string AppNamePlaceholder = "$APP_NAME"; + private const string SchemaIdPlaceholder = "$SCHEMA_ID"; + private const string SchemaNamePlaceholder = "$SCHEMA_NAME"; + private const string TimestampDatePlaceholder = "$TIMESTAMP_DATE"; + private const string TimestampDateTimePlaceholder = "$TIMESTAMP_DATETIME"; + private const string ContentActionPlaceholder = "$CONTENT_ACTION"; + private static readonly Regex ContentDataPlaceholder = new Regex(@"\$CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}", RegexOptions.Compiled); + private readonly JsonSerializer serializer; + + public RuleEventFormatter(JsonSerializer serializer) + { + Guard.NotNull(serializer, nameof(serializer)); + + this.serializer = serializer; + } + + public virtual JToken ToRouteData(object value) + { + return JToken.FromObject(value, serializer); + } + + public virtual JToken ToRouteData(Envelope @event, string eventName) + { + return new JObject( + new JProperty("type", eventName), + new JProperty("payload", JToken.FromObject(@event.Payload, serializer)), + new JProperty("timestamp", @event.Headers.Timestamp().ToString())); + } + + public virtual string FormatString(string text, Envelope @event) + { + var sb = new StringBuilder(text); + + if (@event.Headers.Contains(CommonHeaders.Timestamp)) + { + var timestamp = @event.Headers.Timestamp().ToDateTimeUtc(); + + sb.Replace(TimestampDateTimePlaceholder, timestamp.ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture)); + sb.Replace(TimestampDatePlaceholder, timestamp.ToString("yyy-MM-dd", CultureInfo.InvariantCulture)); + } + + if (@event.Payload.AppId != null) + { + sb.Replace(AppIdPlaceholder, @event.Payload.AppId.Id.ToString()); + sb.Replace(AppNamePlaceholder, @event.Payload.AppId.Name); + } + + if (@event.Payload is SchemaEvent schemaEvent && schemaEvent.SchemaId != null) + { + sb.Replace(SchemaIdPlaceholder, schemaEvent.SchemaId.Id.ToString()); + sb.Replace(SchemaNamePlaceholder, schemaEvent.SchemaId.Name); + } + + FormatContentAction(@event, sb); + + var result = sb.ToString(); + + if (@event.Payload is ContentCreated contentCreated && contentCreated.Data != null) + { + result = ReplaceData(contentCreated.Data, result); + } + + if (@event.Payload is ContentUpdated contentUpdated && contentUpdated.Data != null) + { + result = ReplaceData(contentUpdated.Data, result); + } + + return result; + } + + private static void FormatContentAction(Envelope @event, StringBuilder sb) + { + switch (@event.Payload) + { + case ContentCreated contentCreated: + sb.Replace(ContentActionPlaceholder, "created"); + break; + + case ContentUpdated contentUpdated: + sb.Replace(ContentActionPlaceholder, "updated"); + break; + + case ContentStatusChanged contentStatusChanged: + sb.Replace(ContentActionPlaceholder, $"set to {contentStatusChanged.Status.ToString().ToLowerInvariant()}"); + break; + + case ContentDeleted contentDeleted: + sb.Replace(ContentActionPlaceholder, "deleted"); + break; + } + } + + private static string ReplaceData(NamedContentData data, string text) + { + return ContentDataPlaceholder.Replace(text, match => + { + var captures = match.Groups[2].Captures; + + var path = new string[captures.Count]; + + for (var i = 0; i < path.Length; i++) + { + path[i] = captures[i].Value; + } + + if (!data.TryGetValue(path[0], out var field)) + { + return Undefined; + } + + if (!field.TryGetValue(path[1], out var value)) + { + return Undefined; + } + + for (var j = 2; j < path.Length; j++) + { + if (value is JObject obj && obj.TryGetValue(path[j], out value)) + { + continue; + } + if (value is JArray arr && int.TryParse(path[j], out var idx) && idx >= 0 && idx < arr.Count) + { + value = arr[idx]; + } + else + { + return Undefined; + } + } + + if (value == null || value.Type == JTokenType.Null || value.Type == JTokenType.Undefined) + { + return Undefined; + } + + if (value is JValue jValue && jValue != null) + { + return jValue.Value.ToString(); + } + + return value?.ToString(Formatting.Indented) ?? Undefined; + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index 4b1be3d3e..ecac1cf7a 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -87,16 +87,17 @@ namespace Squidex.Domain.Apps.Core.HandleRules @event.Headers.Timestamp() : now; - var eventGuid = - @event.Headers.Contains(CommonHeaders.EventId) ? - @event.Headers.EventId() : + var aggregateId = + @event.Headers.Contains(CommonHeaders.AggregateId) ? + @event.Headers.AggregateId() : Guid.NewGuid(); var job = new RuleJob { - JobId = eventGuid, + JobId = Guid.NewGuid(), ActionName = actionName, ActionData = actionData.Data, + AggregateId = aggregateId, AppId = appEvent.AppId.Id, Created = now, EventName = eventName, diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs new file mode 100644 index 000000000..22555b6e2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.HandleRules.Triggers +{ + public sealed class AssetChangedTriggerHandler : RuleTriggerHandler + { + protected override bool Triggers(Envelope @event, AssetChangedTrigger trigger) + { + return @event.Payload is AssetEvent assetEvent && MatchsType(trigger, assetEvent); + } + + private static bool MatchsType(AssetChangedTrigger trigger, AssetEvent @event) + { + return + (trigger.SendCreate && @event is AssetCreated) || + (trigger.SendUpdate && @event is AssetUpdated) || + (trigger.SendDelete && @event is AssetDeleted) || + (trigger.SendRename && @event is AssetRenamed); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs index 2194abae6..13ca5f459 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs @@ -17,6 +17,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers { protected override bool Triggers(Envelope @event, ContentChangedTrigger trigger) { + if (trigger.HandleAll) + { + return true; + } + if (trigger.Schemas != null && @event.Payload is SchemaEvent schemaEvent) { foreach (var schema in trigger.Schemas) 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 5e0772860..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 syntaxs 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)); } } @@ -150,6 +150,7 @@ namespace Squidex.Domain.Apps.Core.Scripting } engine.SetValue("ctx", contextInstance); + engine.SetValue("slugify", new Func(x => x.Slugify())); return engine; } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index d69665a81..8a5057653 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -13,15 +13,17 @@ + - + - + - + + ..\..\Squidex.ruleset diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index 7a39e0329..2f2729858 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs @@ -8,24 +8,74 @@ using System; using MongoDB.Bson; using MongoDB.Bson.Serialization.Attributes; -using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Domain.Apps.Core.ValidateContent; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { - public sealed class MongoAssetEntity : IVersionedEntity + public sealed class MongoAssetEntity : + MongoEntity, + IAssetEntity, + IUpdateableEntityWithVersion, + IUpdateableEntityWithCreatedBy, + IUpdateableEntityWithLastModifiedBy { - [BsonId] + [BsonRequired] + [BsonElement] + public Guid AppIdId { get; set; } + + [BsonRequired] + [BsonElement] + public NamedId AppId { get; set; } + + [BsonRequired] + [BsonElement] + public string MimeType { get; set; } + + [BsonRequired] [BsonElement] - [BsonRepresentation(BsonType.String)] - public Guid Id { get; set; } + public string FileName { get; set; } + [BsonRequired] [BsonElement] + public long FileSize { get; set; } + [BsonRequired] - public AssetState State { get; set; } + [BsonElement] + public long FileVersion { get; set; } + [BsonRequired] [BsonElement] + public bool IsImage { get; set; } + [BsonRequired] + [BsonElement] public long Version { get; set; } + + [BsonRequired] + [BsonElement] + public int? PixelWidth { get; set; } + + [BsonRequired] + [BsonElement] + public int? PixelHeight { get; set; } + + [BsonRequired] + [BsonElement] + public RefToken CreatedBy { get; set; } + + [BsonRequired] + [BsonElement] + public RefToken LastModifiedBy { get; set; } + + [BsonElement] + public bool IsDeleted { get; set; } + + Guid IAssetInfo.AssetId + { + get { return Id; } + } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 1b3abce9c..737c7288c 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -9,10 +9,11 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using MongoDB.Bson; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.Edm; using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; @@ -34,44 +35,63 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { return collection.Indexes.CreateOneAsync( Index - .Ascending(x => x.State.AppId) - .Ascending(x => x.State.IsDeleted) - .Ascending(x => x.State.FileName) - .Ascending(x => x.State.MimeType) - .Descending(x => x.State.LastModified)); + .Ascending(x => x.AppId) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.FileName) + .Descending(x => x.LastModified)); } - public async Task> QueryAsync(Guid appId, HashSet mimeTypes = null, HashSet ids = null, string query = null, int take = 10, int skip = 0) + public async Task> QueryAsync(Guid appId, string query = null) { - var filters = new List> + try { - Filter.Eq(x => x.State.AppId, appId), - Filter.Eq(x => x.State.IsDeleted, false) - }; + var odataQuery = EdmAssetModel.Edm.ParseQuery(query); - if (ids != null && ids.Count > 0) + var filter = FindExtensions.BuildQuery(odataQuery, appId); + + var contentCount = Collection.Find(filter).CountAsync(); + var contentItems = + Collection.Find(filter) + .AssetTake(odataQuery) + .AssetSkip(odataQuery) + .AssetSort(odataQuery) + .ToListAsync(); + + await Task.WhenAll(contentItems, contentCount); + + return ResultList.Create(contentItems.Result, contentCount.Result); + } + catch (NotSupportedException) { - filters.Add(Filter.In(x => x.Id, ids)); + throw new ValidationException("This odata operation is not supported."); } - - if (mimeTypes != null && mimeTypes.Count > 0) + catch (NotImplementedException) { - filters.Add(Filter.In(x => x.State.MimeType, mimeTypes)); + throw new ValidationException("This odata operation is not supported."); } - - if (!string.IsNullOrWhiteSpace(query)) + catch (MongoQueryException ex) { - filters.Add(Filter.Regex(x => x.State.FileName, new BsonRegularExpression(query, "i"))); + if (ex.Message.Contains("17406")) + { + throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); + } + else + { + throw; + } } + } - var filter = Filter.And(filters); + public async Task> QueryAsync(Guid appId, HashSet ids) + { + var find = Collection.Find(Filter.In(x => x.Id, ids)).SortByDescending(x => x.LastModified); - var assetItems = Collection.Find(filter).Skip(skip).Limit(take).SortByDescending(x => x.State.LastModified).ToListAsync(); - var assetCount = Collection.Find(filter).CountAsync(); + var assetItems = find.ToListAsync(); + var assetCount = find.CountAsync(); await Task.WhenAll(assetItems, assetCount); - return ResultList.Create(assetItems.Result.Select(x => x.State), assetCount.Result); + return ResultList.Create(assetItems.Result.OfType().ToList(), assetCount.Result); } public async Task FindAssetAsync(Guid id) @@ -80,7 +100,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets await Collection.Find(x => x.Id == id) .FirstOrDefaultAsync(); - return assetEntity?.State; + return assetEntity; } } } 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 d181e97ca..a4056d0a7 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs @@ -10,7 +10,7 @@ using System.Threading.Tasks; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Infrastructure; -using Squidex.Infrastructure.MongoDb; +using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.MongoDb.Assets @@ -25,15 +25,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets if (existing != null) { - return (existing.State, existing.Version); + return (SimpleMapper.Map(existing, new AssetState()), existing.Version); } return (null, EtagVersion.NotFound); } - public Task WriteAsync(Guid key, AssetState value, long oldVersion, long newVersion) + public async Task WriteAsync(Guid key, AssetState value, long oldVersion, long newVersion) { - return Collection.UpsertVersionedAsync(key, oldVersion, newVersion, u => u.Set(x => x.State, value)); + 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 new file mode 100644 index 000000000..af3a75764 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs @@ -0,0 +1,86 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Microsoft.OData.UriParser; +using MongoDB.Bson; +using MongoDB.Driver; +using Squidex.Infrastructure; +using Squidex.Infrastructure.MongoDb.OData; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Assets.Visitors +{ + public static class FindExtensions + { + private static readonly FilterDefinitionBuilder Filter = Builders.Filter; + private static readonly PropertyCalculator PropertyCalculator = propertyNames => + { + if (propertyNames.Length > 0) + { + propertyNames[0] = propertyNames[0].ToPascalCase(); + } + + var propertyName = string.Join(".", propertyNames); + + return propertyName; + }; + + public static IFindFluent AssetSort(this IFindFluent cursor, ODataUriParser query) + { + var sort = query.BuildSort(PropertyCalculator); + + return sort != null ? cursor.Sort(sort) : cursor.SortByDescending(x => x.LastModified); + } + + public static IFindFluent AssetTake(this IFindFluent cursor, ODataUriParser query) + { + return cursor.Take(query, 200, 20); + } + + public static IFindFluent AssetSkip(this IFindFluent cursor, ODataUriParser query) + { + return cursor.Skip(query); + } + + public static FilterDefinition BuildQuery(ODataUriParser query, Guid appId) + { + var filters = new List> + { + Filter.Eq(x => x.AppIdId, appId), + Filter.Eq(x => x.IsDeleted, false) + }; + + var filter = query.BuildFilter(PropertyCalculator, false); + + if (filter.Filter != null) + { + if (filter.Last) + { + filters.Add(filter.Filter); + } + else + { + filters.Insert(0, filter.Filter); + } + } + + if (filters.Count > 1) + { + return Filter.And(filters); + } + else if (filters.Count == 1) + { + return filters[0]; + } + else + { + return new BsonDocument(); + } + } + } +} 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 eba2e0b88..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; @@ -49,6 +50,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents protected override async Task SetupCollectionAsync(IMongoCollection collection) { + 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) @@ -56,30 +63,45 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await collection.Indexes.CreateOneAsync( Index - .Ascending(x => x.SchemaId) + .Text(x => x.DataText) + .Ascending(x => x.SchemaIdId) .Ascending(x => x.Status) - .Ascending(x => x.IsDeleted) - .Text(x => x.DataText)); - - await collection.Indexes.CreateOneAsync( - Index - .Ascending(x => x.Id) .Ascending(x => x.IsDeleted)); await collection.Indexes.CreateOneAsync( Index + .Ascending(x => x.SchemaIdId) .Ascending(x => x.Id) - .Ascending(x => x.Version)); + .Ascending(x => x.IsDeleted) + .Ascending(x => x.Status)); await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.ReferencedIds)); } public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery) { - FilterDefinition filter; try { - filter = FindExtensions.BuildQuery(odataQuery, schema.Id, schema.SchemaDef, status); + var propertyCalculator = FindExtensions.CreatePropertyCalculator(schema.SchemaDef); + + var filter = FindExtensions.BuildQuery(odataQuery, schema.Id, status, propertyCalculator); + + var contentCount = Collection.Find(filter).CountAsync(); + var contentItems = + Collection.Find(filter) + .ContentTake(odataQuery) + .ContentSkip(odataQuery) + .ContentSort(odataQuery, propertyCalculator) + .ToListAsync(); + + await Task.WhenAll(contentItems, contentCount); + + foreach (var entity in contentItems.Result) + { + entity.ParseData(schema.SchemaDef); + } + + return ResultList.Create(contentItems.Result, contentCount.Result); } catch (NotSupportedException) { @@ -89,23 +111,22 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { throw new ValidationException("This odata operation is not supported."); } - - var contentItems = Collection.Find(filter).Take(odataQuery).Skip(odataQuery).Sort(odataQuery, schema.SchemaDef).ToListAsync(); - var contentCount = Collection.Find(filter).CountAsync(); - - await Task.WhenAll(contentItems, contentCount); - - foreach (var entity in contentItems.Result) + catch (MongoQueryException ex) { - entity.ParseData(schema.SchemaDef); + if (ex.Message.Contains("17406")) + { + throw new DomainException("Result set is too large to be retrieved. Use $top parameter to reduce the number of items."); + } + else + { + throw; + } } - - return ResultList.Create(contentItems.Result, contentCount.Result); } public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids) { - var find = Collection.Find(x => ids.Contains(x.Id)); + 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(); @@ -120,13 +141,13 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents return ResultList.Create(contentItems.Result, contentCount.Result); } - public async Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList contentIds) + public async Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids) { var contentEntities = - await Collection.Find(x => contentIds.Contains(x.Id) && x.AppId == appId).Only(x => x.Id) + await Collection.Find(x => x.SchemaIdId == schemaId && ids.Contains(x.Id) && x.IsDeleted == false).Only(x => x.Id) .ToListAsync(); - return contentIds.Except(contentEntities.Select(x => Guid.Parse(x["id"].AsString))).ToList(); + return ids.Except(contentEntities.Select(x => Guid.Parse(x["id"].AsString))).ToList(); } public async Task FindContentAsync(IAppEntity app, ISchemaEntity schema, Guid id, long version) @@ -143,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.Id == id && !x.IsDeleted) + await Collection.Find(x => x.SchemaIdId == schema.Id && x.Id == id && x.IsDeleted == false) .FirstOrDefaultAsync(); contentEntity?.ParseData(schema.SchemaDef); @@ -151,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_EventHandling.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs index 2f787186e..8a923d361 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_EventHandling.cs @@ -10,6 +10,7 @@ using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure.Dispatching; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Tasks; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { @@ -47,5 +48,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents Filter.AnyNe(x => x.ReferencedIdsDeleted, @event.ContentId)), Update.AddToSet(x => x.ReferencedIdsDeleted, @event.ContentId)); } + + Task IEventConsumer.ClearAsync() + { + return TaskHelper.Done; + } } } \ No newline at end of file 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 606cd1a28..cdfaff9b8 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs @@ -7,10 +7,15 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using Microsoft.OData.UriParser; +using MongoDB.Bson.Serialization.Attributes; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.GenerateEdmSchema; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure.MongoDb.OData; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors { @@ -18,64 +23,80 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors { private static readonly FilterDefinitionBuilder Filter = Builders.Filter; - public static IFindFluent Sort(this IFindFluent cursor, ODataUriParser query, Schema schema) + private static readonly Dictionary PropertyMap = + typeof(MongoContentEntity).GetProperties() + .ToDictionary(x => x.Name, x => x.GetCustomAttribute()?.ElementName ?? x.Name, StringComparer.OrdinalIgnoreCase); + + static FindExtensions() { - return cursor.Sort(SortBuilder.BuildSort(query, schema)); + PropertyMap["Data"] = "do"; } - public static IFindFluent Take(this IFindFluent cursor, ODataUriParser query) + public static PropertyCalculator CreatePropertyCalculator(Schema schema) { - var top = query.ParseTop(); - - if (top.HasValue) - { - cursor = cursor.Limit(Math.Min((int)top.Value, 200)); - } - else + return propertyNames => { - cursor = cursor.Limit(20); - } + if (propertyNames.Length > 1) + { + var edmName = propertyNames[1].UnescapeEdmField(); + + if (!schema.FieldsByName.TryGetValue(edmName, out var field)) + { + throw new NotSupportedException(); + } + + propertyNames[1] = field.Id.ToString(); + } + + if (propertyNames.Length > 0) + { + propertyNames[0] = PropertyMap[propertyNames[0]]; + } + + var propertyName = string.Join(".", propertyNames); - return cursor; + return propertyName; + }; } - public static IFindFluent Skip(this IFindFluent cursor, ODataUriParser query) + public static IFindFluent ContentSort(this IFindFluent cursor, ODataUriParser query, PropertyCalculator propertyCalculator) { - var skip = query.ParseSkip(); + var sort = query.BuildSort(propertyCalculator); - if (skip.HasValue) - { - cursor = cursor.Skip((int)skip.Value); - } - else - { - cursor = cursor.Skip(null); - } - - return cursor; + return sort != null ? cursor.Sort(sort) : cursor.SortByDescending(x => x.LastModified); } - public static IFindFluent Find(this IMongoCollection cursor, ODataUriParser query, Guid schemaId, Schema schema, Status[] status) + public static IFindFluent ContentTake(this IFindFluent cursor, ODataUriParser query) { - var filter = BuildQuery(query, schemaId, schema, status); + return cursor.Take(query, 200, 20); + } - return cursor.Find(filter); + public static IFindFluent ContentSkip(this IFindFluent cursor, ODataUriParser query) + { + return cursor.Skip(query); } - public static FilterDefinition BuildQuery(ODataUriParser query, Guid schemaId, Schema schema, Status[] status) + public static FilterDefinition BuildQuery(ODataUriParser query, Guid schemaId, Status[] status, PropertyCalculator propertyCalculator) { 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) }; - var filter = FilterBuilder.Build(query, schema); + var filter = query.BuildFilter(propertyCalculator); - if (filter != null) + if (filter.Filter != null) { - filters.Add(filter); + if (filter.Last) + { + filters.Add(filter.Filter); + } + else + { + filters.Insert(0, filter.Filter); + } } if (filters.Count == 1) 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/History/MongoHistoryEventRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs index dd76dbd6b..55ce5a471 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/History/MongoHistoryEventRepository.cs @@ -66,9 +66,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.History public async Task> QueryByChannelAsync(Guid appId, string channelPrefix, int count) { - var historyEventEntities = - await Collection.Find(x => x.AppId == appId && x.Channel == channelPrefix).SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count) - .ToListAsync(); + List historyEventEntities; + + if (!string.IsNullOrWhiteSpace(channelPrefix)) + { + historyEventEntities = + await Collection.Find(x => x.AppId == appId && x.Channel == channelPrefix).SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count) + .ToListAsync(); + } + else + { + historyEventEntities = + await Collection.Find(x => x.AppId == appId).SortByDescending(x => x.Created).ThenByDescending(x => x.Version).Limit(count) + .ToListAsync(); + } return historyEventEntities.Select(x => (IHistoryEventEntity)new ParsedHistoryEvent(x, texts)).ToList(); } 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.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj b/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj index bce4941ad..1095818b8 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj @@ -15,9 +15,9 @@ - + - + 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 51c460837..829c1388c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs @@ -45,9 +45,9 @@ namespace Squidex.Domain.Apps.Entities.Apps this.appPlansBillingManager = appPlansBillingManager; } - protected Task On(CreateApp command, CommandContext context) + protected async Task On(CreateApp command, CommandContext context) { - return handler.CreateSyncedAsync(context, async a => + var app = await handler.CreateSyncedAsync(context, async a => { await GuardApp.CanCreate(command, appProvider); @@ -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) { @@ -193,10 +193,8 @@ namespace Squidex.Domain.Apps.Entities.Apps public async Task HandleAsync(CommandContext context, Func next) { - if (!await this.DispatchActionAsync(context.Command, context)) - { - await next(); - } + await this.DispatchActionAsync(context.Command, context); + await next(); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs index f22d2ef2f..e722f8d06 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs @@ -133,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.Apps protected override Task CreateEventCoreAsync(Envelope @event) { - return this.DispatchFuncAsync(@event.Payload, @event.Headers, (HistoryEventToStore)null); + return this.DispatchFuncAsync(@event.Payload, (HistoryEventToStore)null); } private static string ClientName(AppClientRenamed @event) 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 b49d54c59..d4dc2528b 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/CreateApp.cs @@ -10,16 +10,11 @@ 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; } - Guid IAggregateCommand.AggregateId - { - get { return AppId; } - } + public string Template { get; set; } public CreateApp() { 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 new file mode 100644 index 000000000..adea21e4c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs @@ -0,0 +1,237 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates +{ + public sealed class CreateBlogCommandMiddleware : ICommandMiddleware + { + private const string TemplateName = "Blog"; + private const string SlugScript = @" + var data = ctx.data; + + data.slug = { iv: slugify(data.title.iv) }; + + replace(data);"; + + public Task HandleAsync(CommandContext context, Func next) + { + if (context.IsCompleted && context.Command is CreateApp createApp && IsRightTemplate(createApp)) + { + var appId = new NamedId(createApp.AppId, createApp.Name); + + return Task.WhenAll( + CreatePagesAsync(context.CommandBus, appId), + CreatePostsAsync(context.CommandBus, appId), + CreateClientAsync(context.CommandBus, appId)); + } + + return TaskHelper.Done; + } + + private static bool IsRightTemplate(CreateApp createApp) + { + return string.Equals(createApp.Template, TemplateName, StringComparison.OrdinalIgnoreCase); + } + + private static async Task CreateClientAsync(ICommandBus bus, NamedId appId) + { + await bus.PublishAsync(new AttachClient { Id = "sample-client" }); + } + + private async Task CreatePostsAsync(ICommandBus bus, NamedId appId) + { + var postsId = await CreatePostsSchema(bus, appId); + + await bus.PublishAsync(new CreateContent + { + SchemaId = postsId, + Data = + new NamedContentData() + .AddField("title", + new ContentFieldData() + .AddValue("iv", "My first post with Squidex")) + .AddField("text", + new ContentFieldData() + .AddValue("iv", "Just created a blog with Squidex. I love it!")), + Publish = true, + }); + } + + private async Task CreatePagesAsync(ICommandBus bus, NamedId appId) + { + var pagesId = await CreatePagesSchema(bus, appId); + + await bus.PublishAsync(new CreateContent + { + SchemaId = pagesId, + Data = + new NamedContentData() + .AddField("title", + new ContentFieldData() + .AddValue("iv", "About Me")) + .AddField("text", + new ContentFieldData() + .AddValue("iv", "I love Squidex and SciFi!")), + Publish = true + }); + } + + 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 + { + Name = "title", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties + { + Editor = StringFieldEditor.Input, + IsRequired = true, + IsListField = true, + MaxLength = 100, + MinLength = 0, + Label = "Title" + } + }, + new CreateSchemaField + { + Name = "slug", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties + { + Editor = StringFieldEditor.Slug, + IsRequired = false, + IsListField = true, + MaxLength = 100, + MinLength = 0, + Label = "Slug (Autogenerated)" + }, + IsDisabled = true + }, + new CreateSchemaField + { + Name = "text", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties + { + Editor = StringFieldEditor.RichText, + IsRequired = true, + IsListField = false, + Label = "Text" + } + } + }, + AppId = appId + }; + + await bus.PublishAsync(command); + + var schemaId = new NamedId(command.SchemaId, command.Name); + + await bus.PublishAsync(new ConfigureScripts + { + SchemaId = schemaId.Id, + ScriptCreate = SlugScript, + ScriptUpdate = SlugScript + }); + + return schemaId; + } + + private async Task> CreatePagesSchema(ICommandBus bus, NamedId appId) + { + var command = new CreateSchema + { + Name = "pages", + Properties = new SchemaProperties + { + Label = "Pages" + }, + Fields = new List + { + new CreateSchemaField + { + Name = "title", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties + { + Editor = StringFieldEditor.Input, + IsRequired = true, + IsListField = true, + MaxLength = 100, + MinLength = 0, + Label = "Title" + } + }, + new CreateSchemaField + { + Name = "slug", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties + { + Editor = StringFieldEditor.Slug, + IsRequired = false, + IsListField = true, + MaxLength = 100, + MinLength = 0, + Label = "Slug (Autogenerated)" + }, + IsDisabled = true + }, + new CreateSchemaField + { + Name = "text", + Partitioning = Partitioning.Invariant.Key, + Properties = new StringFieldProperties + { + Editor = StringFieldEditor.RichText, + IsRequired = true, + IsListField = false, + Label = "Text" + } + } + }, + AppId = appId + }; + + await bus.PublishAsync(command); + + var schemaId = new NamedId(command.SchemaId, command.Name); + + await bus.PublishAsync(new ConfigureScripts + { + SchemaId = schemaId.Id, + ScriptCreate = SlugScript, + ScriptUpdate = SlugScript + }); + + return schemaId; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs index 360f9134c..7cc351c83 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetDomainObject.cs @@ -7,6 +7,7 @@ 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.EventSourcing; @@ -73,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/Edm/EdmAssetModel.cs b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs new file mode 100644 index 000000000..ffb58c065 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.OData.Edm; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Assets.Edm +{ + public static class EdmAssetModel + { + public static readonly IEdmModel Edm; + + static EdmAssetModel() + { + var entityType = new EdmEntityType("Squidex", "Asset"); + + entityType.AddStructuralProperty(nameof(IAssetEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty(nameof(IAssetEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IAssetEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty(nameof(IAssetEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IAssetEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int64); + entityType.AddStructuralProperty(nameof(IAssetEntity.FileName).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IAssetEntity.FileSize).ToCamelCase(), EdmPrimitiveTypeKind.Int64); + entityType.AddStructuralProperty(nameof(IAssetEntity.FileVersion).ToCamelCase(), EdmPrimitiveTypeKind.Int64); + entityType.AddStructuralProperty(nameof(IAssetEntity.IsImage).ToCamelCase(), EdmPrimitiveTypeKind.Boolean); + entityType.AddStructuralProperty(nameof(IAssetEntity.MimeType).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IAssetEntity.PixelHeight).ToCamelCase(), EdmPrimitiveTypeKind.Int32); + entityType.AddStructuralProperty(nameof(IAssetEntity.PixelWidth).ToCamelCase(), EdmPrimitiveTypeKind.Int32); + + var container = new EdmEntityContainer("Squidex", "Container"); + + container.AddEntitySet("AssetSet", entityType); + + var model = new EdmModel(); + + model.AddElement(container); + model.AddElement(entityType); + + Edm = model; + } + } +} 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/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index c2bd7eca1..f748b4936 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -14,7 +14,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { public interface IAssetRepository { - Task> QueryAsync(Guid appId, HashSet mimeTypes = null, HashSet ids = null, string query = null, int take = 10, int skip = 0); + Task> QueryAsync(Guid appId, string query = null); + + Task> QueryAsync(Guid appId, HashSet ids); Task FindAssetAsync(Guid id); } 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 06b65cfc1..8e15d2a7b 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs @@ -6,15 +6,12 @@ // ========================================================================== using System; -using System.Security.Claims; using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public abstract class ContentCommand : SchemaCommand, IAggregateCommand + public abstract class ContentCommand : SquidexCommand, IAggregateCommand { - public ClaimsPrincipal User { get; set; } - public Guid ContentId { get; set; } Guid IAggregateCommand.AggregateId diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs index 1837b977f..2eec65f73 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/CreateContent.cs @@ -5,10 +5,22 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +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() + { + ContentId = Guid.NewGuid(); + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs index 6923e5005..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); }); @@ -133,10 +136,8 @@ namespace Squidex.Domain.Apps.Entities.Contents public async Task HandleAsync(CommandContext context, Func next) { - if (!await this.DispatchActionAsync(context.Command, context)) - { - await next(); - } + await this.DispatchActionAsync(context.Command, context); + await next(); } private async Task CreateContext(ContentCommand command, ContentDomainObject content, Func message) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs index d9eb1fa1d..47db34ab1 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentDomainObject.cs @@ -8,6 +8,7 @@ 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.EventSourcing; @@ -44,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; } @@ -79,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 new file mode 100644 index 000000000..27e1c1895 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -0,0 +1,64 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// 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; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentEntity : IContentEntity + { + public Guid Id { get; set; } + + public NamedId AppId { get; set; } + + public NamedId SchemaId { get; set; } + + public long Version { get; set; } + + public Instant Created { get; set; } + + 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 static ContentEntity Create(CreateContent command, EntityCreatedResult result) + { + var now = SystemClock.Instance.GetCurrentInstant(); + + var response = new ContentEntity + { + Id = command.ContentId, + Data = result.IdOrValue, + Version = result.Version, + Created = now, + CreatedBy = command.Actor, + LastModified = now, + LastModifiedBy = command.Actor, + Status = command.Publish ? Status.Published : Status.Draft + }; + + return response; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs index 63adc8214..6a0237831 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.History; +using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; @@ -19,16 +20,16 @@ namespace Squidex.Domain.Apps.Entities.Contents : base(typeNameRegistry) { AddEventMessage( - "created content item."); + "created {[Schema]} content item to."); AddEventMessage( - "updated content item."); + "updated {[Schema]} content item."); AddEventMessage( - "deleted content item."); + "deleted {[Schema]} content item."); AddEventMessage( - "changed status of content item to {[Status]}."); + "changed status of {[Schema]} content item to {[Status]}."); } protected override Task CreateEventCoreAsync(Envelope @event) @@ -37,6 +38,11 @@ namespace Squidex.Domain.Apps.Entities.Contents var result = ForEvent(@event.Payload, channel); + if (@event.Payload is SchemaEvent schemaEvent) + { + result = result.AddParameter("Schema", schemaEvent.SchemaId.Name); + } + if (@event.Payload is ContentStatusChanged contentStatusChanged) { result = result.AddParameter("Status", contentStatusChanged.Status); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentOperationContext.cs index d06df5e7c..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) @@ -106,7 +113,7 @@ namespace Squidex.Domain.Apps.Entities.Contents private async Task> QueryAssetsAsync(Guid appId, IEnumerable assetIds) { - return await assetRepository.QueryAsync(appId, null, new HashSet(assetIds), null, int.MaxValue, 0); + return await assetRepository.QueryAsync(appId, new HashSet(assetIds)); } private async Task> QueryContentsAsync(Guid appId, Guid schemaId, IEnumerable contentIds) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index ae01ee691..ebd65e858 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -12,7 +12,6 @@ using System.Security.Claims; using System.Threading.Tasks; using Microsoft.OData; using Microsoft.OData.UriParser; -using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Apps; @@ -122,7 +121,7 @@ namespace Squidex.Domain.Apps.Entities.Contents foreach (var content in contents) { var contentData = scriptEngine.Transform(new ScriptContext { User = user, Data = content.Data, ContentId = content.Id }, scriptText); - var contentResult = SimpleMapper.Map(content, new Content()); + var contentResult = SimpleMapper.Map(content, new ContentEntity()); contentResult.Data = contentData; @@ -199,23 +198,5 @@ namespace Squidex.Domain.Apps.Entities.Contents return status; } - - private sealed class Content : IContentEntity - { - public Guid Id { get; set; } - public Guid AppId { get; set; } - - public long Version { get; set; } - - public Instant Created { get; set; } - public Instant LastModified { get; set; } - - public RefToken CreatedBy { get; set; } - public RefToken LastModifiedBy { get; set; } - - public NamedContentData Data { get; set; } - - public Status Status { get; set; } - } } } 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/Edm/EdmModelBuilder.cs b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs index 5a73c8c59..ea8ffbe18 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs @@ -44,8 +44,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.Edm { var model = new EdmModel(); - var container = new EdmEntityContainer("Squidex", "Container"); - var schemaType = schema.BuildEdmType(partitionResolver, x => { model.AddElement(x); @@ -54,19 +52,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.Edm }); var entityType = new EdmEntityType("Squidex", schema.Name); - entityType.AddStructuralProperty("data", new EdmComplexTypeReference(schemaType, false)); - entityType.AddStructuralProperty("version", EdmPrimitiveTypeKind.Int32); - entityType.AddStructuralProperty("created", EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty("createdBy", EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty("lastModified", EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty("lastModifiedBy", EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IContentEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty(nameof(IContentEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IContentEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); + entityType.AddStructuralProperty(nameof(IContentEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); + entityType.AddStructuralProperty(nameof(IContentEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int32); + entityType.AddStructuralProperty(nameof(IContentEntity.Data).ToCamelCase(), new EdmComplexTypeReference(schemaType, false)); + + var container = new EdmEntityContainer("Squidex", "Container"); + + container.AddEntitySet("ContentSet", entityType); model.AddElement(container); model.AddElement(schemaType); model.AddElement(entityType); - container.AddEntitySet("ContentSet", entityType); - return model; } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index d970ca7e6..eb3d9f315 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Caching.Memory; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { @@ -20,6 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); private readonly IContentQueryService contentQuery; + private readonly ICommandBus commandBus; private readonly IGraphQLUrlGenerator urlGenerator; private readonly IAssetRepository assetRepository; private readonly IAppProvider appProvider; @@ -27,17 +29,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL public CachingGraphQLService(IMemoryCache cache, IAppProvider appProvider, IAssetRepository assetRepository, + ICommandBus commandBus, IContentQueryService contentQuery, IGraphQLUrlGenerator urlGenerator) : base(cache) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(assetRepository, nameof(assetRepository)); - Guard.NotNull(contentQuery, nameof(urlGenerator)); + Guard.NotNull(commandBus, nameof(commandBus)); Guard.NotNull(contentQuery, nameof(contentQuery)); + Guard.NotNull(urlGenerator, nameof(urlGenerator)); this.appProvider = appProvider; this.assetRepository = assetRepository; + this.commandBus = commandBus; this.contentQuery = contentQuery; this.urlGenerator = urlGenerator; } @@ -53,9 +58,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } var modelContext = await GetModelAsync(app); - var queryContext = new GraphQLQueryContext(app, assetRepository, contentQuery, user, urlGenerator); - return await modelContext.ExecuteAsync(queryContext, query); + var ctx = new GraphQLExecutionContext(app, assetRepository, commandBus, contentQuery, user, urlGenerator); + + return await modelContext.ExecuteAsync(ctx, query); } private async Task GetModelAsync(IAppEntity app) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs similarity index 84% rename from src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs rename to src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index b050ab970..f73f3ab6d 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLQueryContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -13,17 +13,22 @@ using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Repositories; +using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public sealed class GraphQLQueryContext : QueryContext + public sealed class GraphQLExecutionContext : QueryContext { + public ICommandBus CommandBus { get; } + public IGraphQLUrlGenerator UrlGenerator { get; } - public GraphQLQueryContext(IAppEntity app, IAssetRepository assetRepository, IContentQueryService contentQuery, ClaimsPrincipal user, + public GraphQLExecutionContext(IAppEntity app, IAssetRepository assetRepository, ICommandBus commandBus, IContentQueryService contentQuery, ClaimsPrincipal user, IGraphQLUrlGenerator urlGenerator) : base(app, assetRepository, contentQuery, user) { + CommandBus = commandBus; + UrlGenerator = urlGenerator; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs index e2915016a..03ceeec54 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -24,15 +24,17 @@ using GraphQLSchema = GraphQL.Types.Schema; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public sealed class GraphQLModel : IGraphQLContext + public sealed class GraphQLModel : IGraphModel { private readonly Dictionary> fieldInfos; - private readonly Dictionary schemaTypes = new Dictionary(); + private readonly Dictionary inputFieldInfos; + private readonly Dictionary contentTypes = new Dictionary(); + private readonly Dictionary contentDataTypes = new Dictionary(); private readonly Dictionary schemas; private readonly PartitionResolver partitionResolver; private readonly IAppEntity app; - private readonly IGraphType assetType; private readonly IGraphType assetListType; + private readonly IComplexGraphType assetType; private readonly GraphQLSchema graphQLSchema; public bool CanGenerateAssetSourceUrl { get; } @@ -48,35 +50,71 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL assetType = new AssetGraphType(this); assetListType = new ListGraphType(new NonNullGraphType(assetType)); - fieldInfos = new Dictionary> + inputFieldInfos = new Dictionary { { typeof(StringField), - field => ResolveDefault("String") + AllTypes.String }, { typeof(BooleanField), - field => ResolveDefault("Boolean") + AllTypes.Boolean }, { typeof(NumberField), - field => ResolveDefault("Float") + AllTypes.Boolean }, { typeof(DateTimeField), - field => ResolveDefault("Date") + AllTypes.Date }, { - typeof(JsonField), - field => ResolveDefault("Json") + typeof(GeolocationField), + AllTypes.GeolocationInput }, { typeof(TagsField), - field => ResolveDefault("String") + AllTypes.ListOfNonNullString + }, + { + typeof(AssetsField), + AllTypes.ListOfNonNullGuid + }, + { + typeof(ReferencesField), + AllTypes.ListOfNonNullGuid + } + }; + + fieldInfos = new Dictionary> + { + { + typeof(StringField), + field => ResolveDefault(AllTypes.NoopString) + }, + { + typeof(BooleanField), + field => ResolveDefault(AllTypes.NoopBoolean) + }, + { + typeof(NumberField), + field => ResolveDefault(AllTypes.NoopFloat) + }, + { + typeof(DateTimeField), + field => ResolveDefault(AllTypes.NoopDate) + }, + { + typeof(JsonField), + field => ResolveDefault(AllTypes.NoopJson) }, { typeof(GeolocationField), - field => ResolveDefault("Geolocation") + field => ResolveDefault(AllTypes.NoopGeolocation) + }, + { + typeof(TagsField), + field => ResolveDefault(AllTypes.NoopTags) }, { typeof(AssetsField), @@ -90,24 +128,32 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL this.schemas = schemas.ToDictionary(x => x.Id); - graphQLSchema = new GraphQLSchema { Query = new ContentQueryGraphType(this, this.schemas.Values) }; + var m = new AppMutationsGraphType(this, this.schemas.Values); + var q = new AppQueriesGraphType(this, this.schemas.Values); + + graphQLSchema = new GraphQLSchema { Query = q, Mutation = m }; - foreach (var schemaType in schemaTypes.Values) + foreach (var kvp in contentDataTypes) { - schemaType.Initialize(); + kvp.Value.Initialize(this, kvp.Key); + } + + foreach (var kvp in contentTypes) + { + kvp.Value.Initialize(this, kvp.Key, contentDataTypes[kvp.Key]); } } - private static (IGraphType ResolveType, IFieldResolver Resolver) ResolveDefault(string name) + private static (IGraphType ResolveType, IFieldResolver Resolver) ResolveDefault(IGraphType type) { - return (new NoopGraphType(name), new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName))); + return (type, new FuncFieldResolver(c => c.Source.GetOrDefault(c.FieldName))); } public IFieldResolver ResolveAssetUrl() { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; return context.UrlGenerator.GenerateAssetUrl(app, c.Source); }); @@ -119,7 +165,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; return context.UrlGenerator.GenerateAssetSourceUrl(app, c.Source); }); @@ -131,7 +177,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; return context.UrlGenerator.GenerateAssetThumbnailUrl(app, c.Source); }); @@ -143,7 +189,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; return context.UrlGenerator.GenerateContentUrl(app, schema, c.Source); }); @@ -155,7 +201,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; var contentIds = c.Source.GetOrDefault(c.FieldName); return context.GetReferencedAssetsAsync(contentIds); @@ -167,27 +213,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private ValueTuple ResolveReferences(Field field) { var schemaId = ((ReferencesField)field).Properties.SchemaId; - var schemaType = GetSchemaType(schemaId); - if (schemaType == null) + var contentType = GetContentType(schemaId); + + if (contentType == null) { return (null, null); } var resolver = new FuncFieldResolver(c => { - var context = (GraphQLQueryContext)c.UserContext; + var context = (GraphQLExecutionContext)c.UserContext; var contentIds = c.Source.GetOrDefault(c.FieldName); return context.GetReferencedContentsAsync(schemaId, contentIds); }); - var schemaFieldType = new ListGraphType(new NonNullGraphType(GetSchemaType(schemaId))); + var schemaFieldType = new ListGraphType(new NonNullGraphType(contentType)); return (schemaFieldType, resolver); } - public async Task<(object Data, object[] Errors)> ExecuteAsync(GraphQLQueryContext context, GraphQLQuery query) + public async Task<(object Data, object[] Errors)> ExecuteAsync(GraphQLExecutionContext context, GraphQLQuery query) { Guard.NotNull(context, nameof(context)); @@ -208,7 +255,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return partitionResolver(key); } - public IGraphType GetAssetType() + public IComplexGraphType GetAssetType() { return assetType; } @@ -218,11 +265,33 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return fieldInfos[field.GetType()](field); } - public IGraphType GetSchemaType(Guid schemaId) + public IComplexGraphType GetContentDataType(Guid schemaId) { var schema = schemas.GetOrDefault(schemaId); - return schema != null ? schemaTypes.GetOrAdd(schemaId, k => new ContentGraphType(schema, this)) : null; + if (schema == null) + { + return null; + } + + return schema != null ? contentDataTypes.GetOrAdd(schema, s => new ContentDataGraphType()) : null; + } + + public IComplexGraphType GetContentType(Guid schemaId) + { + var schema = schemas.GetOrDefault(schemaId); + + if (schema == null) + { + return null; + } + + return contentTypes.GetOrAdd(schema, s => new ContentGraphType()); + } + + public IGraphType GetInputGraphType(Field field) + { + return inputFieldInfos.GetOrAddDefault(field.GetType()); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs similarity index 80% rename from src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs rename to src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs index 45e00873a..29834fa71 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs @@ -14,15 +14,17 @@ using Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public interface IGraphQLContext + public interface IGraphModel { bool CanGenerateAssetSourceUrl { get; } IFieldPartitioning ResolvePartition(Partitioning key); - IGraphType GetAssetType(); + IComplexGraphType GetAssetType(); - IGraphType GetSchemaType(Guid schemaId); + IComplexGraphType GetContentType(Guid schemaId); + + IComplexGraphType GetContentDataType(Guid schemaId); IFieldResolver ResolveAssetUrl(); @@ -32,6 +34,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL IFieldResolver ResolveContentUrl(ISchemaEntity schema); + IGraphType GetInputGraphType(Field field); + (IGraphType ResolveType, IFieldResolver Resolver) GetGraphType(Field field); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs new file mode 100644 index 000000000..dac8e7e07 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public static class AllTypes + { + public static readonly Type None = typeof(NoopGraphType); + + public static readonly IGraphType Int = new IntGraphType(); + + public static readonly IGraphType Guid = new GuidGraphType(); + + public static readonly IGraphType Date = new DateGraphType(); + + public static readonly IGraphType Float = new FloatGraphType(); + + public static readonly IGraphType String = new StringGraphType(); + + public static readonly IGraphType Boolean = new BooleanGraphType(); + + public static readonly IGraphType NonNullInt = new NonNullGraphType(new IntGraphType()); + + public static readonly IGraphType NonNullGuid = new NonNullGraphType(new GuidGraphType()); + + public static readonly IGraphType NonNullDate = new NonNullGraphType(new DateGraphType()); + + public static readonly IGraphType NonNullFloat = new NonNullGraphType(new FloatGraphType()); + + public static readonly IGraphType NonNullString = new NonNullGraphType(new StringGraphType()); + + public static readonly IGraphType NonNullBoolean = new NonNullGraphType(new BooleanGraphType()); + + public static readonly IGraphType ListOfNonNullGuid = new ListGraphType(new NonNullGraphType(new GuidGraphType())); + + public static readonly IGraphType ListOfNonNullString = new ListGraphType(new NonNullGraphType(new StringGraphType())); + + public static readonly IGraphType NoopInt = new NoopGraphType("Int"); + + public static readonly IGraphType NoopGuid = new NoopGraphType("Guid"); + + public static readonly IGraphType NoopDate = new NoopGraphType("Date"); + + public static readonly IGraphType NoopJson = new NoopGraphType("Json"); + + public static readonly IGraphType NoopTags = new NoopGraphType("Tags"); + + public static readonly IGraphType NoopFloat = new NoopGraphType("Float"); + + public static readonly IGraphType NoopString = new NoopGraphType("String"); + + public static readonly IGraphType NoopBoolean = new NoopGraphType("Boolean"); + + public static readonly IGraphType NoopGeolocation = new NoopGraphType("Geolocation"); + + public static readonly IGraphType CommandVersion = new CommandVersionGraphType(); + + public static readonly IGraphType GeolocationInput = new GeolocationInputGraphType(); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs new file mode 100644 index 000000000..58ec5d018 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs @@ -0,0 +1,344 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GraphQL; +using GraphQL.Resolvers; +using GraphQL.Types; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class AppMutationsGraphType : ObjectGraphType + { + public AppMutationsGraphType(IGraphModel model, IEnumerable schemas) + { + foreach (var schema in schemas) + { + var schemaId = schema.NamedId(); + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); + + var contentType = model.GetContentType(schema.Id); + var contentDataType = model.GetContentDataType(schema.Id); + + var resultType = new ContentDataChangedResultGraphType(schemaType, schemaName, contentDataType); + + var inputType = new ContentDataGraphInputType(model, schema); + + AddContentCreate(schemaId, schemaType, schemaName, inputType, contentDataType, contentType); + 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."; + } + + private void AddContentCreate(NamedId schemaId, string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType contentDataType, IComplexGraphType contentType) + { + AddField(new FieldType + { + Name = $"create{schemaType}Content", + Arguments = new QueryArguments + { + new QueryArgument(AllTypes.None) + { + Name = "data", + Description = $"The data for the {schemaName} content.", + DefaultValue = null, + ResolvedType = new NonNullGraphType(inputType), + }, + new QueryArgument(AllTypes.None) + { + Name = "publish", + Description = "Set to true to autopublish content.", + DefaultValue = false, + ResolvedType = AllTypes.Boolean + }, + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } + }, + ResolvedType = new NonNullGraphType(contentType), + Resolver = ResolveAsync(async (c, publish) => + { + var argPublish = c.GetArgument("publish"); + + var contentData = GetContentData(c); + + var command = new CreateContent { SchemaId = schemaId, Data = contentData, Publish = argPublish }; + var commandContext = await publish(command); + + var result = commandContext.Result>(); + var response = ContentEntity.Create(command, result); + + return (IContentEntity)ContentEntity.Create(command, result); + }), + Description = $"Creates an {schemaName} content." + }); + } + + private void AddContentUpdate(string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType resultType) + { + AddField(new FieldType + { + Name = $"update{schemaType}Content", + Arguments = new QueryArguments + { + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = $"The id of the {schemaName} content (GUID)", + DefaultValue = string.Empty, + ResolvedType = AllTypes.NonNullGuid + }, + new QueryArgument(AllTypes.None) + { + Name = "data", + Description = $"The data for the {schemaName} content.", + DefaultValue = null, + ResolvedType = new NonNullGraphType(inputType), + }, + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } + }, + ResolvedType = new NonNullGraphType(resultType), + Resolver = ResolveAsync(async (c, publish) => + { + var contentId = c.GetArgument("id"); + var contentData = GetContentData(c); + + var command = new UpdateContent { ContentId = contentId, Data = contentData }; + var commandContext = await publish(command); + + var result = commandContext.Result(); + + return result; + }), + Description = $"Update an {schemaName} content by id." + }); + } + + private void AddContentPatch(string schemaType, string schemaName, ContentDataGraphInputType inputType, IComplexGraphType resultType) + { + AddField(new FieldType + { + Name = $"patch{schemaType}Content", + Arguments = new QueryArguments + { + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = $"The id of the {schemaName} content (GUID)", + DefaultValue = string.Empty, + ResolvedType = AllTypes.NonNullGuid + }, + new QueryArgument(AllTypes.None) + { + Name = "data", + Description = $"The data for the {schemaName} content.", + DefaultValue = null, + ResolvedType = new NonNullGraphType(inputType), + }, + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } + }, + ResolvedType = new NonNullGraphType(resultType), + Resolver = ResolveAsync(async (c, publish) => + { + var contentId = c.GetArgument("id"); + var contentData = GetContentData(c); + + var command = new PatchContent { ContentId = contentId, Data = contentData }; + var commandContext = await publish(command); + + var result = commandContext.Result(); + + return result; + }), + Description = $"Patch a {schemaName} content." + }); + } + + private void AddContentPublish(string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"publish{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = AllTypes.CommandVersion, + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Published }; + + return publish(command); + }), + Description = $"Publish a {schemaName} content." + }); + } + + private void AddContentUnpublish(string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"unpublish{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = AllTypes.CommandVersion, + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Draft }; + + return publish(command); + }), + Description = $"Unpublish a {schemaName} content." + }); + } + + private void AddContentArchive(string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"archive{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = AllTypes.CommandVersion, + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Archived }; + + return publish(command); + }), + Description = $"Archive a {schemaName} content." + }); + } + + private void AddContentRestore(string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"restore{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = AllTypes.CommandVersion, + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new ChangeContentStatus { ContentId = contentId, Status = Status.Draft }; + + return publish(command); + }), + Description = $"Restore a {schemaName} content." + }); + } + + private void AddContentDelete(string schemaType, string schemaName) + { + AddField(new FieldType + { + Name = $"delete{schemaType}Content", + Arguments = CreateIdArguments(schemaName), + ResolvedType = AllTypes.CommandVersion, + Resolver = ResolveAsync((c, publish) => + { + var contentId = c.GetArgument("id"); + + var command = new DeleteContent { ContentId = contentId }; + + return publish(command); + }), + Description = $"Delete an {schemaName} content." + }); + } + + private static QueryArguments CreateIdArguments(string schemaName) + { + return new QueryArguments + { + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = $"The id of the {schemaName} content (GUID)", + DefaultValue = string.Empty, + ResolvedType = AllTypes.NonNullGuid + }, + new QueryArgument(AllTypes.None) + { + Name = "expectedVersion", + Description = "The expected version", + DefaultValue = EtagVersion.Any, + ResolvedType = AllTypes.Int + } + }; + } + + private static IFieldResolver ResolveAsync(Func>, Task> action) + { + return new FuncFieldResolver>(async c => + { + var e = (GraphQLExecutionContext)c.UserContext; + + try + { + return await action(c, command => + { + command.ExpectedVersion = c.GetArgument("expectedVersion", EtagVersion.Any); + + return e.CommandBus.PublishAsync(command); + }); + } + catch (ValidationException ex) + { + c.Errors.Add(new ExecutionError(ex.Message)); + + throw; + } + catch (DomainException ex) + { + c.Errors.Add(new ExecutionError(ex.Message)); + + throw; + } + }); + } + + private static NamedContentData GetContentData(ResolveFieldContext c) + { + return JObject.FromObject(c.GetArgument("data")).ToObject(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs new file mode 100644 index 000000000..fcc0f6110 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs @@ -0,0 +1,257 @@ +// ========================================================================== +// 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.Linq; +using System.Threading.Tasks; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class AppQueriesGraphType : ObjectGraphType + { + public AppQueriesGraphType(IGraphModel model, IEnumerable schemas) + { + var assetType = model.GetAssetType(); + + AddAssetFind(assetType); + AddAssetsQueries(assetType); + + foreach (var schema in schemas) + { + var schemaId = schema.Id; + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); + + var contentType = model.GetContentType(schema.Id); + + AddContentFind(schemaId, schemaType, schemaName, contentType); + AddContentQueries(schemaId, schemaType, schemaName, contentType); + } + + Description = "The app queries."; + } + + private void AddAssetFind(IGraphType assetType) + { + AddField(new FieldType + { + Name = "findAsset", + Arguments = CreateAssetFindArguments(), + ResolvedType = assetType, + Resolver = ResolveAsync((c, e) => + { + var assetId = c.GetArgument("id"); + + return e.FindAssetAsync(assetId); + }), + Description = "Find an asset by id." + }); + } + + private void AddContentFind(Guid schemaId, string schemaType, string schemaName, IGraphType contentType) + { + AddField(new FieldType + { + Name = $"find{schemaType}Content", + Arguments = CreateContentFindTypes(schemaName), + ResolvedType = contentType, + Resolver = ResolveAsync((c, e) => + { + var contentId = c.GetArgument("id"); + + return e.FindContentAsync(schemaId, contentId); + }), + Description = $"Find an {schemaName} content by id." + }); + } + + private void AddAssetsQueries(IComplexGraphType assetType) + { + AddField(new FieldType + { + Name = "queryAssets", + Arguments = CreateAssetQueryArguments(), + ResolvedType = new ListGraphType(new NonNullGraphType(assetType)), + Resolver = ResolveAsync((c, e) => + { + var assetQuery = BuildODataQuery(c); + + return e.QueryAssetsAsync(assetQuery); + }), + Description = "Get assets." + }); + + AddField(new FieldType + { + Name = "queryAssetsWithTotal", + Arguments = CreateAssetQueryArguments(), + ResolvedType = new AssetsResultGraphType(assetType), + Resolver = ResolveAsync((c, e) => + { + var assetQuery = BuildODataQuery(c); + + return e.QueryAssetsAsync(assetQuery); + }), + Description = "Get assets and total count." + }); + } + + private void AddContentQueries(Guid schemaId, string schemaType, string schemaName, IComplexGraphType contentType) + { + AddField(new FieldType + { + Name = $"query{schemaType}Contents", + Arguments = CreateContentQueryArguments(), + ResolvedType = new ListGraphType(new NonNullGraphType(contentType)), + Resolver = ResolveAsync((c, e) => + { + var contentQuery = BuildODataQuery(c); + + return e.QueryContentsAsync(schemaId.ToString(), contentQuery); + }), + Description = $"Query {schemaName} content items." + }); + + AddField(new FieldType + { + Name = $"query{schemaType}ContentsWithTotal", + Arguments = CreateContentQueryArguments(), + ResolvedType = new ContentsResultGraphType(schemaType, schemaName, contentType), + Resolver = ResolveAsync((c, e) => + { + var contentQuery = BuildODataQuery(c); + + return e.QueryContentsAsync(schemaId.ToString(), contentQuery); + }), + Description = $"Query {schemaName} content items with total count." + }); + } + + private static QueryArguments CreateAssetFindArguments() + { + return new QueryArguments + { + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = "The id of the asset (GUID).", + DefaultValue = string.Empty, + ResolvedType = AllTypes.NonNullGuid + } + }; + } + + private static QueryArguments CreateContentFindTypes(string schemaName) + { + return new QueryArguments + { + new QueryArgument(AllTypes.None) + { + Name = "id", + Description = $"The id of the {schemaName} content (GUID)", + DefaultValue = string.Empty, + ResolvedType = AllTypes.NonNullGuid + } + }; + } + + private static QueryArguments CreateAssetQueryArguments() + { + return new QueryArguments + { + new QueryArgument(AllTypes.None) + { + Name = "take", + Description = "Optional number of assets to take (Default: 20).", + DefaultValue = 20, + ResolvedType = AllTypes.Int + }, + new QueryArgument(AllTypes.None) + { + Name = "skip", + Description = "Optional number of assets to skip.", + DefaultValue = 0, + ResolvedType = AllTypes.Int + }, + new QueryArgument(AllTypes.None) + { + Name = "search", + Description = "Optional query to limit the files by name.", + DefaultValue = string.Empty, + ResolvedType = AllTypes.String + } + }; + } + + private static QueryArguments CreateContentQueryArguments() + { + return new QueryArguments + { + new QueryArgument(AllTypes.None) + { + Name = "top", + Description = "Optional number of contents to take (Default: 20).", + DefaultValue = 20, + ResolvedType = AllTypes.Int + }, + new QueryArgument(AllTypes.None) + { + Name = "skip", + Description = "Optional number of contents to skip.", + DefaultValue = 0, + ResolvedType = AllTypes.Int + }, + new QueryArgument(AllTypes.None) + { + Name = "filter", + Description = "Optional OData filter.", + DefaultValue = string.Empty, + ResolvedType = AllTypes.String + }, + new QueryArgument(AllTypes.None) + { + Name = "search", + Description = "Optional OData full text search.", + DefaultValue = string.Empty, + ResolvedType = AllTypes.String + }, + new QueryArgument(AllTypes.None) + { + Name = "orderby", + Description = "Optional OData order definition.", + DefaultValue = string.Empty, + ResolvedType = AllTypes.String + } + }; + } + + private static string BuildODataQuery(ResolveFieldContext c) + { + var odataQuery = "?" + + string.Join("&", + c.Arguments + .Select(x => new { x.Key, Value = x.Value.ToString() }).Where(x => !string.IsNullOrWhiteSpace(x.Value)) + .Select(x => $"${x.Key}={x.Value}")); + + return odataQuery; + } + + private static IFieldResolver ResolveAsync(Func> action) + { + return new FuncFieldResolver>(c => + { + var e = (GraphQLExecutionContext)c.UserContext; + + return action(c, e); + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs index 7795a5a62..47e32ed04 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs @@ -15,145 +15,145 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class AssetGraphType : ObjectGraphType { - public AssetGraphType(IGraphQLContext context) + public AssetGraphType(IGraphModel model) { Name = "AssetDto"; AddField(new FieldType { Name = "id", - Resolver = Resolver(x => x.Id.ToString()), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullGuid, + Resolver = Resolve(x => x.Id.ToString()), Description = "The id of the asset." }); AddField(new FieldType { Name = "version", - Resolver = Resolver(x => x.Version), - ResolvedType = new NonNullGraphType(new IntGraphType()), + ResolvedType = AllTypes.NonNullInt, + Resolver = Resolve(x => x.Version), Description = "The version of the asset." }); AddField(new FieldType { Name = "created", - Resolver = Resolver(x => x.Created.ToDateTimeUtc()), - ResolvedType = new NonNullGraphType(new DateGraphType()), + ResolvedType = AllTypes.NonNullDate, + Resolver = Resolve(x => x.Created.ToDateTimeUtc()), Description = "The date and time when the asset has been created." }); AddField(new FieldType { Name = "createdBy", - Resolver = Resolver(x => x.CreatedBy.ToString()), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.CreatedBy.ToString()), Description = "The user that has created the asset." }); AddField(new FieldType { Name = "lastModified", - Resolver = Resolver(x => x.LastModified.ToDateTimeUtc()), - ResolvedType = new NonNullGraphType(new DateGraphType()), + ResolvedType = AllTypes.NonNullDate, + Resolver = Resolve(x => x.LastModified.ToDateTimeUtc()), Description = "The date and time when the asset has been modified last." }); AddField(new FieldType { Name = "lastModifiedBy", - Resolver = Resolver(x => x.LastModifiedBy.ToString()), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.LastModifiedBy.ToString()), Description = "The user that has updated the asset last." }); AddField(new FieldType { Name = "mimeType", - Resolver = Resolver(x => x.MimeType), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.MimeType), Description = "The mime type." }); AddField(new FieldType { Name = "url", - Resolver = context.ResolveAssetUrl(), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = model.ResolveAssetUrl(), Description = "The url to the asset." }); AddField(new FieldType { Name = "thumbnailUrl", - Resolver = context.ResolveAssetThumbnailUrl(), - ResolvedType = new StringGraphType(), + ResolvedType = AllTypes.String, + Resolver = model.ResolveAssetThumbnailUrl(), Description = "The thumbnail url to the asset." }); AddField(new FieldType { Name = "fileName", - Resolver = Resolver(x => x.FileName), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.FileName), Description = "The file name." }); AddField(new FieldType { Name = "fileType", - Resolver = Resolver(x => x.FileName.FileType()), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.FileName.FileType()), Description = "The file type." }); AddField(new FieldType { Name = "fileSize", - Resolver = Resolver(x => x.FileSize), - ResolvedType = new NonNullGraphType(new IntGraphType()), + ResolvedType = AllTypes.NonNullInt, + Resolver = Resolve(x => x.FileSize), Description = "The size of the file in bytes." }); AddField(new FieldType { Name = "fileVersion", - Resolver = Resolver(x => x.FileVersion), - ResolvedType = new NonNullGraphType(new IntGraphType()), + ResolvedType = AllTypes.NonNullInt, + Resolver = Resolve(x => x.FileVersion), Description = "The version of the file." }); AddField(new FieldType { Name = "isImage", - Resolver = Resolver(x => x.IsImage), - ResolvedType = new NonNullGraphType(new BooleanGraphType()), + ResolvedType = AllTypes.NonNullBoolean, + Resolver = Resolve(x => x.IsImage), Description = "Determines of the created file is an image." }); AddField(new FieldType { Name = "pixelWidth", - Resolver = Resolver(x => x.PixelWidth), - ResolvedType = new IntGraphType(), + ResolvedType = AllTypes.Int, + Resolver = Resolve(x => x.PixelWidth), Description = "The width of the image in pixels if the asset is an image." }); AddField(new FieldType { Name = "pixelHeight", - Resolver = Resolver(x => x.PixelHeight), - ResolvedType = new IntGraphType(), + ResolvedType = AllTypes.Int, + Resolver = Resolve(x => x.PixelHeight), Description = "The height of the image in pixels if the asset is an image." }); - if (context.CanGenerateAssetSourceUrl) + if (model.CanGenerateAssetSourceUrl) { AddField(new FieldType { Name = "sourceUrl", - Resolver = context.ResolveAssetSourceUrl(), - ResolvedType = new StringGraphType(), + ResolvedType = AllTypes.NonNullString, + Resolver = model.ResolveAssetSourceUrl(), Description = "The source url of the asset." }); } @@ -161,7 +161,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = "An asset"; } - private static IFieldResolver Resolver(Func action) + private static IFieldResolver Resolve(Func action) { return new FuncFieldResolver(c => action(c.Source)); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs new file mode 100644 index 000000000..b86685071 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetsResultGraphType.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class AssetsResultGraphType : ObjectGraphType> + { + public AssetsResultGraphType(IComplexGraphType assetType) + { + Name = $"AssetResultDto"; + + AddField(new FieldType + { + Name = "total", + ResolvedType = AllTypes.Int, + Resolver = Resolve(x => x.Total), + Description = $"The total count of assets." + }); + + AddField(new FieldType + { + Name = "items", + Resolver = Resolve(x => x), + ResolvedType = new ListGraphType(new NonNullGraphType(assetType)), + Description = $"The assets." + }); + + Description = "List of assets and total count of assets."; + } + + private static IFieldResolver Resolve(Func, object> action) + { + return new FuncFieldResolver, object>(c => action(c.Source)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs new file mode 100644 index 000000000..9fdc792f2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/CommandVersionGraphType.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class CommandVersionGraphType : ObjectGraphType + { + public CommandVersionGraphType() + { + Name = "CommandVersionDto"; + + AddField(new FieldType + { + Name = "version", + ResolvedType = AllTypes.Int, + Resolver = ResolveVersion(), + Description = "The new version of the item." + }); + + Description = "The result of a mutation"; + } + + private static IFieldResolver ResolveVersion() + { + return new FuncFieldResolver(x => + { + if (x.Source.Result() is EntitySavedResult result) + { + return (int)result.Version; + } + + return null; + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataChangedResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataChangedResultGraphType.cs new file mode 100644 index 000000000..ebe00aadb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataChangedResultGraphType.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using GraphQL.Resolvers; +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class ContentDataChangedResultGraphType : ObjectGraphType + { + public ContentDataChangedResultGraphType(string schemaType, string schemaName, IComplexGraphType contentDataType) + { + Name = $"{schemaName}DataChangedResultDto"; + + AddField(new FieldType + { + Name = "version", + ResolvedType = AllTypes.Int, + Resolver = Resolve(x => x.Version), + Description = $"The new version of the {schemaName} content." + }); + + AddField(new FieldType + { + Name = "data", + ResolvedType = new NonNullGraphType(contentDataType), + Resolver = Resolve(x => x.Data), + Description = $"The new data of the {schemaName} content." + }); + + Description = $"The result of the {schemaName} mutation"; + } + + private static IFieldResolver Resolve(Func action) + { + return new FuncFieldResolver(c => action(c.Source)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs new file mode 100644 index 000000000..42512c861 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphInputType.cs @@ -0,0 +1,74 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class ContentDataGraphInputType : InputObjectGraphType + { + public ContentDataGraphInputType(IGraphModel model, ISchemaEntity schema) + { + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); + + Name = $"{schemaType}InputDto"; + + foreach (var field in schema.SchemaDef.Fields.Where(x => !x.IsHidden)) + { + var inputType = model.GetInputGraphType(field); + + if (inputType != null) + { + if (field.RawProperties.IsRequired) + { + inputType = new NonNullGraphType(inputType); + } + + var fieldName = field.RawProperties.Label.WithFallback(field.Name); + + var fieldGraphType = new InputObjectGraphType + { + Name = $"{schemaType}Data{field.Name.ToPascalCase()}InputDto" + }; + + var partition = model.ResolvePartition(field.Partitioning); + + foreach (var partitionItem in partition) + { + fieldGraphType.AddField(new FieldType + { + Name = partitionItem.Key, + ResolvedType = inputType, + Resolver = null, + Description = field.RawProperties.Hints + }); + } + + fieldGraphType.Description = $"The input structure of the {fieldName} of a {schemaName} content type."; + + var fieldResolver = new FuncFieldResolver(c => c.Source.GetOrDefault(field.Name)); + + AddField(new FieldType + { + Name = field.Name.ToCamelCase(), + Resolver = fieldResolver, + ResolvedType = fieldGraphType, + Description = $"The {fieldName} field." + }); + } + } + + Description = $"The structure of a {schemaName} content type."; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs index def6b2251..d74c6e383 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs @@ -9,22 +9,23 @@ using System.Linq; using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; -using Schema = Squidex.Domain.Apps.Core.Schemas.Schema; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class ContentDataGraphType : ObjectGraphType { - public ContentDataGraphType(Schema schema, IGraphQLContext context) + public void Initialize(IGraphModel model, ISchemaEntity schema) { - var schemaName = schema.Properties.Label.WithFallback(schema.Name); + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); - Name = $"{schema.Name.ToPascalCase()}DataDto"; + Name = $"{schemaType}DataDto"; - foreach (var field in schema.Fields.Where(x => !x.IsHidden)) + foreach (var field in schema.SchemaDef.Fields.Where(x => !x.IsHidden)) { - var fieldInfo = context.GetGraphType(field); + var fieldInfo = model.GetGraphType(field); if (fieldInfo.ResolveType != null) { @@ -32,10 +33,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types var fieldGraphType = new ObjectGraphType { - Name = $"{schema.Name.ToPascalCase()}Data{field.Name.ToPascalCase()}Dto" + Name = $"{schemaType}Data{field.Name.ToPascalCase()}Dto" }; - var partition = context.ResolvePartition(field.Partitioning); + var partition = model.ResolvePartition(field.Partitioning); foreach (var partitionItem in partition) { @@ -56,7 +57,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { Name = field.Name.ToCamelCase(), Resolver = fieldResolver, - ResolvedType = fieldGraphType + ResolvedType = fieldGraphType, + Description = $"The {fieldName} field." }); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs index 1d0348db9..c23eaeaea 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs @@ -10,92 +10,81 @@ using System.Linq; using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class ContentGraphType : ObjectGraphType { - private readonly ISchemaEntity schema; - private readonly IGraphQLContext context; - - public ContentGraphType(ISchemaEntity schema, IGraphQLContext context) + public void Initialize(IGraphModel model, ISchemaEntity schema, IComplexGraphType contentDataType) { - this.context = context; - this.schema = schema; + var schemaType = schema.TypeName(); + var schemaName = schema.DisplayName(); - Name = $"{schema.Name.ToPascalCase()}Dto"; - } - - public void Initialize() - { - var schemaName = schema.SchemaDef.Properties.Label.WithFallback(schema.Name); + Name = $"{schemaType}Dto"; AddField(new FieldType { Name = "id", - Resolver = Resolver(x => x.Id.ToString()), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullGuid, + Resolver = Resolve(x => x.Id), Description = $"The id of the {schemaName} content." }); AddField(new FieldType { Name = "version", - Resolver = Resolver(x => x.Version), - ResolvedType = new NonNullGraphType(new IntGraphType()), + ResolvedType = AllTypes.NonNullInt, + Resolver = Resolve(x => x.Version), Description = $"The version of the {schemaName} content." }); AddField(new FieldType { Name = "created", - Resolver = Resolver(x => x.Created.ToDateTimeUtc()), - ResolvedType = new NonNullGraphType(new DateGraphType()), + ResolvedType = AllTypes.NonNullDate, + Resolver = Resolve(x => x.Created.ToDateTimeUtc()), Description = $"The date and time when the {schemaName} content has been created." }); AddField(new FieldType { Name = "createdBy", - Resolver = Resolver(x => x.CreatedBy.ToString()), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.CreatedBy.ToString()), Description = $"The user that has created the {schemaName} content." }); AddField(new FieldType { Name = "lastModified", - Resolver = Resolver(x => x.LastModified.ToDateTimeUtc()), - ResolvedType = new NonNullGraphType(new DateGraphType()), + ResolvedType = AllTypes.NonNullDate, + Resolver = Resolve(x => x.LastModified.ToDateTimeUtc()), Description = $"The date and time when the {schemaName} content has been modified last." }); AddField(new FieldType { Name = "lastModifiedBy", - Resolver = Resolver(x => x.LastModifiedBy.ToString()), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.LastModifiedBy.ToString()), Description = $"The user that has updated the {schemaName} content last." }); AddField(new FieldType { Name = "url", - Resolver = context.ResolveContentUrl(schema), - ResolvedType = new NonNullGraphType(new StringGraphType()), + ResolvedType = AllTypes.NonNullString, + Resolver = model.ResolveContentUrl(schema), Description = $"The url to the the {schemaName} content." }); - var dataType = new ContentDataGraphType(schema.SchemaDef, context); - - if (dataType.Fields.Any()) + if (contentDataType.Fields.Any()) { AddField(new FieldType { Name = "data", - Resolver = Resolver(x => x.Data), - ResolvedType = new NonNullGraphType(dataType), + ResolvedType = new NonNullGraphType(contentDataType), + Resolver = Resolve(x => x.Data), Description = $"The data of the {schemaName} content." }); } @@ -103,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = $"The structure of a {schemaName} content type."; } - private static IFieldResolver Resolver(Func action) + private static IFieldResolver Resolve(Func action) { return new FuncFieldResolver(c => action(c.Source)); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentQueryGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentQueryGraphType.cs deleted file mode 100644 index d6604ed50..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentQueryGraphType.cs +++ /dev/null @@ -1,191 +0,0 @@ -// ========================================================================== -// 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.Linq; -using GraphQL.Resolvers; -using GraphQL.Types; -using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; - -namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types -{ - public sealed class ContentQueryGraphType : ObjectGraphType - { - public ContentQueryGraphType(IGraphQLContext graphQLContext, IEnumerable schemas) - { - AddAssetFind(graphQLContext); - AddAssetsQuery(graphQLContext); - - foreach (var schema in schemas) - { - var schemaName = schema.SchemaDef.Properties.Label.WithFallback(schema.SchemaDef.Name); - var schemaType = graphQLContext.GetSchemaType(schema.Id); - - AddContentFind(schema, schemaType, schemaName); - AddContentQuery(schema, schemaType, schemaName); - } - - Description = "The app queries."; - } - - private void AddAssetFind(IGraphQLContext graphQLContext) - { - AddField(new FieldType - { - Name = "findAsset", - Arguments = new QueryArguments - { - new QueryArgument(typeof(StringGraphType)) - { - Name = "id", - Description = "The id of the asset.", - DefaultValue = string.Empty - } - }, - ResolvedType = graphQLContext.GetAssetType(), - Resolver = new FuncFieldResolver(c => - { - var context = (GraphQLQueryContext)c.UserContext; - var contentId = Guid.Parse(c.GetArgument("id", Guid.Empty.ToString())); - - return context.FindAssetAsync(contentId); - }), - Description = "Find an asset by id." - }); - } - - private void AddContentFind(ISchemaEntity schema, IGraphType schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"find{schema.Name.ToPascalCase()}Content", - Arguments = new QueryArguments - { - new QueryArgument(typeof(StringGraphType)) - { - Name = "id", - Description = $"The id of the {schemaName} content.", - DefaultValue = string.Empty - } - }, - ResolvedType = schemaType, - Resolver = new FuncFieldResolver(c => - { - var context = (GraphQLQueryContext)c.UserContext; - var contentId = Guid.Parse(c.GetArgument("id", Guid.Empty.ToString())); - - return context.FindContentAsync(schema.Id, contentId); - }), - Description = $"Find an {schemaName} content by id." - }); - } - - private void AddAssetsQuery(IGraphQLContext graphQLContext) - { - AddField(new FieldType - { - Name = "queryAssets", - Arguments = new QueryArguments - { - new QueryArgument(typeof(IntGraphType)) - { - Name = "top", - Description = "Optional number of assets to take.", - DefaultValue = 20 - }, - new QueryArgument(typeof(IntGraphType)) - { - Name = "skip", - Description = "Optional number of assets to skip.", - DefaultValue = 0 - }, - new QueryArgument(typeof(StringGraphType)) - { - Name = "search", - Description = "Optional query.", - DefaultValue = string.Empty - } - }, - ResolvedType = new ListGraphType(new NonNullGraphType(graphQLContext.GetAssetType())), - Resolver = new FuncFieldResolver(c => - { - var context = (GraphQLQueryContext)c.UserContext; - - var argTop = c.GetArgument("top", 20); - var argSkip = c.GetArgument("skip", 0); - var argQuery = c.GetArgument("search", string.Empty); - - return context.QueryAssetsAsync(argQuery, argSkip, argTop); - }), - Description = "Query assets items." - }); - } - - private void AddContentQuery(ISchemaEntity schema, IGraphType schemaType, string schemaName) - { - AddField(new FieldType - { - Name = $"query{schema.Name.ToPascalCase()}Contents", - Arguments = new QueryArguments - { - new QueryArgument(typeof(IntGraphType)) - { - Name = "top", - Description = "Optional number of contents to take.", - DefaultValue = 20 - }, - new QueryArgument(typeof(IntGraphType)) - { - Name = "skip", - Description = "Optional number of contents to skip.", - DefaultValue = 0 - }, - new QueryArgument(typeof(StringGraphType)) - { - Name = "filter", - Description = "Optional OData filter.", - DefaultValue = string.Empty - }, - new QueryArgument(typeof(StringGraphType)) - { - Name = "search", - Description = "Optional OData full text search.", - DefaultValue = string.Empty - }, - new QueryArgument(typeof(StringGraphType)) - { - Name = "orderby", - Description = "Optional OData order definition.", - DefaultValue = string.Empty - } - }, - ResolvedType = new ListGraphType(new NonNullGraphType(schemaType)), - Resolver = new FuncFieldResolver(c => - { - var context = (GraphQLQueryContext)c.UserContext; - var contentQuery = BuildODataQuery(c); - - return context.QueryContentsAsync(schema.Id.ToString(), contentQuery); - }), - Description = $"Query {schemaName} content items." - }); - } - - private static string BuildODataQuery(ResolveFieldContext c) - { - var odataQuery = "?" + - string.Join("&", - c.Arguments - .Select(x => new { x.Key, Value = x.Value.ToString() }).Where(x => !string.IsNullOrWhiteSpace(x.Value)) - .Select(x => $"${x.Key}={x.Value}")); - - return odataQuery; - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs new file mode 100644 index 000000000..c3c052d06 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentsResultGraphType.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class ContentsResultGraphType : ObjectGraphType> + { + public ContentsResultGraphType(string schemaType, string schemaName, IComplexGraphType contentType) + { + Name = $"{schemaType}ResultDto"; + + AddField(new FieldType + { + Name = "total", + Resolver = Resolver(x => x.Total), + ResolvedType = AllTypes.NonNullInt, + Description = $"The total number of {schemaName} items." + }); + + AddField(new FieldType + { + Name = "items", + Resolver = Resolver(x => x), + ResolvedType = new ListGraphType(new NonNullGraphType(contentType)), + Description = $"The {schemaName} items." + }); + + Description = $"List of {schemaName} items and total count."; + } + + private static IFieldResolver Resolver(Func, object> action) + { + return new FuncFieldResolver, object>(c => action(c.Source)); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs new file mode 100644 index 000000000..73ba49b5c --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GeolocationInputGraphType.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class GeolocationInputGraphType : InputObjectGraphType + { + public GeolocationInputGraphType() + { + Name = "GeolocationInputDto"; + + AddField(new FieldType + { + Name = "latitude", + ResolvedType = AllTypes.NonNullFloat + }); + + AddField(new FieldType + { + Name = "longitude", + ResolvedType = AllTypes.NonNullFloat + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs new file mode 100644 index 000000000..196c8ed44 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/GuidGraphType.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using GraphQL.Language.AST; +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public sealed class GuidGraphType : ScalarGraphType + { + public GuidGraphType() + { + Name = "Guid"; + + Description = "The `Guid` scalar type global unique identifier"; + } + + public override object Serialize(object value) + { + return ParseValue(value)?.ToString(); + } + + public override object ParseValue(object value) + { + if (value is Guid guid) + { + return guid; + } + + var inputValue = value?.ToString().Trim('"'); + + if (Guid.TryParse(inputValue, out guid)) + { + return guid; + } + + return null; + } + + public override object ParseLiteral(IValue value) + { + if (value is StringValue stringValue) + { + return ParseValue(stringValue.Value); + } + + return null; + } + } +} 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/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs index cf306c0ea..bf5dbd7ab 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/QueryContext.cs @@ -80,9 +80,9 @@ namespace Squidex.Domain.Apps.Entities.Contents return content; } - public async Task> QueryAssetsAsync(string query, int skip = 0, int take = 10) + public async Task> QueryAssetsAsync(string query) { - var assets = await assetRepository.QueryAsync(app.Id, null, null, query, take, skip); + var assets = await assetRepository.QueryAsync(app.Id, query); foreach (var asset in assets) { @@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return assets; } - public async Task> QueryContentsAsync(string schemaIdOrName, string query) + public async Task> QueryContentsAsync(string schemaIdOrName, string query) { var result = await contentQuery.QueryAsync(app, schemaIdOrName, user, false, query); @@ -112,7 +112,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (notLoadedAssets.Count > 0) { - var assets = await assetRepository.QueryAsync(app.Id, null, notLoadedAssets, null, int.MaxValue); + var assets = await assetRepository.QueryAsync(app.Id, notLoadedAssets); foreach (var asset in assets) { diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index 4cdbc2fd6..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; @@ -22,10 +23,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, ODataUriParser odataQuery); - Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList contentIds); + Task> QueryNotFoundAsync(Guid appId, Guid schemaId, IList ids); 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/Contents/Edm/EdmModelExtensions.cs b/src/Squidex.Domain.Apps.Entities/EdmModelExtensions.cs similarity index 95% rename from src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelExtensions.cs rename to src/Squidex.Domain.Apps.Entities/EdmModelExtensions.cs index 03c97b8f7..afb38c598 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities/EdmModelExtensions.cs @@ -10,7 +10,7 @@ using System.Linq; using Microsoft.OData.Edm; using Microsoft.OData.UriParser; -namespace Squidex.Domain.Apps.Entities.Contents.Edm +namespace Squidex.Domain.Apps.Entities { public static class EdmModelExtensions { 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 a871182b2..3b794922d 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules.Actions; @@ -24,13 +25,85 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards return action.Accept(visitor); } + public Task> Visit(AlgoliaAction action) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(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 is required.", nameof(action.AppId))); + } + + if (string.IsNullOrWhiteSpace(action.IndexName)) + { + errors.Add(new ValidationError("Index name is required.", nameof(action.IndexName))); + } + + return Task.FromResult>(errors); + } + + public Task> Visit(AzureQueueAction action) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(action.ConnectionString)) + { + errors.Add(new ValidationError("Connection string is required.", nameof(action.ConnectionString))); + } + + if (string.IsNullOrWhiteSpace(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]+)*$")) + { + errors.Add(new ValidationError("Queue must be valid azure queue name.", nameof(action.Queue))); + } + + return Task.FromResult>(errors); + } + + public Task> Visit(FastlyAction action) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(action.ApiKey)) + { + errors.Add(new ValidationError("Api key is required.", nameof(action.ApiKey))); + } + + if (string.IsNullOrWhiteSpace(action.ServiceId)) + { + errors.Add(new ValidationError("Service ID is required.", nameof(action.ServiceId))); + } + + return Task.FromResult>(errors); + } + + public Task> Visit(SlackAction action) + { + var errors = new List(); + + if (action.WebhookUrl == null || !action.WebhookUrl.IsAbsoluteUri) + { + errors.Add(new ValidationError("Webhook Url is required and must be an absolute URL.", nameof(action.WebhookUrl))); + } + + return Task.FromResult>(errors); + } + public Task> Visit(WebhookAction action) { var errors = new List(); if (action.Url == null || !action.Url.IsAbsoluteUri) { - errors.Add(new ValidationError("Url must be specified and absolute.", nameof(action.Url))); + 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/Guards/RuleTriggerValidator.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs index f4e8f1b0e..ea513751d 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs @@ -35,6 +35,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards return action.Accept(visitor); } + public Task> Visit(AssetChangedTrigger trigger) + { + return Task.FromResult(Enumerable.Empty()); + } + public async Task> Visit(ContentChangedTrigger trigger) { if (trigger.Schemas != null) 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/RuleCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleCommandMiddleware.cs index e252b5632..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); }); @@ -82,10 +82,8 @@ namespace Squidex.Domain.Apps.Entities.Rules public async Task HandleAsync(CommandContext context, Func next) { - if (!await this.DispatchActionAsync(context.Command, context)) - { - await next(); - } + await this.DispatchActionAsync(context.Command, context); + await next(); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs index 6c0926902..693d661bb 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuer.cs @@ -16,13 +16,14 @@ using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Tasks; using Squidex.Infrastructure.Timers; namespace Squidex.Domain.Apps.Entities.Rules { public class RuleDequeuer : DisposableObjectBase, IRunnable { - private readonly ActionBlock requestBlock; + private readonly ITargetBlock requestBlock; private readonly IRuleEventRepository ruleEventRepository; private readonly RuleService ruleService; private readonly CompletionTimer timer; @@ -45,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Rules this.log = log; requestBlock = - new ActionBlock(HandleAsync, + new PartitionedActionBlock(HandleAsync, x => x.Job.AggregateId.GetHashCode(), new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 }); timer = new CompletionTimer(5000, QueryAsync); diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs index 706b3e352..54c9afc2d 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleDomainObject.cs @@ -7,6 +7,7 @@ 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.EventSourcing; @@ -51,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/SchemaCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaCommandMiddleware.cs index 18c468c0f..b58f2c815 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaCommandMiddleware.cs @@ -187,10 +187,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas public async Task HandleAsync(CommandContext context, Func next) { - if (!await this.DispatchActionAsync(context.Command, context)) - { - await next(); - } + await this.DispatchActionAsync(context.Command, context); + await next(); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs index d4c862953..b57b31333 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaDomainObject.cs @@ -10,6 +10,7 @@ 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.EventSourcing; @@ -189,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/SchemaExtensions.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs new file mode 100644 index 000000000..5d86e5682 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaExtensions.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// 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 static class SchemaExtensions + { + public static NamedId NamedId(this ISchemaEntity schema) + { + return new NamedId(schema.Id, schema.Name); + } + + public static string TypeName(this ISchemaEntity schema) + { + return schema.SchemaDef.Name.ToPascalCase(); + } + + public static string DisplayName(this ISchemaEntity schema) + { + return schema.SchemaDef.Properties.Label.WithFallback(schema.TypeName()); + } + + public static string TypeName(this Schema schema) + { + return schema.Name.ToPascalCase(); + } + + public static string DisplayName(this Schema schema) + { + return schema.Properties.Label.WithFallback(schema.TypeName()); + } + } +} 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/Squidex.Domain.Apps.Entities.csproj b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 740cccf99..431c71718 100644 --- a/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -15,7 +15,7 @@ - + diff --git a/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs b/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs index a481289bf..a2c642e16 100644 --- a/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/SquidexCommand.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Security.Claims; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -14,6 +15,8 @@ namespace Squidex.Domain.Apps.Entities { public RefToken Actor { get; set; } + public ClaimsPrincipal User { get; set; } + public long ExpectedVersion { get; set; } } } 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/Squidex.Domain.Apps.Events.csproj b/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj index 3fa2fa5db..456d82e06 100644 --- a/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj +++ b/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Squidex.Domain.Apps.Events/SquidexEvent.cs b/src/Squidex.Domain.Apps.Events/SquidexEvent.cs index a9a71bdc1..8d42986bf 100644 --- a/src/Squidex.Domain.Apps.Events/SquidexEvent.cs +++ b/src/Squidex.Domain.Apps.Events/SquidexEvent.cs @@ -12,8 +12,6 @@ namespace Squidex.Domain.Apps.Events { public abstract class SquidexEvent : IEvent { - public string Username { get; set; } - public RefToken Actor { get; set; } } } diff --git a/src/Squidex.Domain.Users.MongoDb/MongoUser.cs b/src/Squidex.Domain.Users.MongoDb/MongoUser.cs index dff095d15..57e7528ca 100644 --- a/src/Squidex.Domain.Users.MongoDb/MongoUser.cs +++ b/src/Squidex.Domain.Users.MongoDb/MongoUser.cs @@ -111,7 +111,7 @@ namespace Squidex.Domain.Users.MongoDb Id = ObjectId.GenerateNewId().ToString(); } - public void UpdateEmail(string email) + public void SetEmail(string email) { Email = UserName = email; } diff --git a/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj b/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj index 9d061d44c..50c66cb45 100644 --- a/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj +++ b/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj @@ -13,11 +13,11 @@ - + - + diff --git a/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj b/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj index 96bbaab76..10b6e916a 100644 --- a/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj +++ b/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Squidex.Domain.Users/UserExtensions.cs b/src/Squidex.Domain.Users/UserExtensions.cs index 065eb605f..cc87e88c7 100644 --- a/src/Squidex.Domain.Users/UserExtensions.cs +++ b/src/Squidex.Domain.Users/UserExtensions.cs @@ -35,19 +35,64 @@ namespace Squidex.Domain.Users user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email)); } + public static void SetConsent(this IUser user) + { + user.SetClaim(SquidexClaimTypes.SquidexConsent, "true"); + } + + public static void SetConsentForEmails(this IUser user, bool value) + { + user.SetClaim(SquidexClaimTypes.SquidexConsentForEmails, value.ToString()); + } + + public static bool HasConsent(this IUser user) + { + return user.HasClaimValue(SquidexClaimTypes.SquidexConsent, "true"); + } + + public static bool HasConsentForEmails(this IUser user) + { + return user.HasClaimValue(SquidexClaimTypes.SquidexConsentForEmails, "true"); + } + + public static bool HasDisplayName(this IUser user) + { + return user.HasClaim(SquidexClaimTypes.SquidexDisplayName); + } + + public static bool HasPictureUrl(this IUser user) + { + return user.HasClaim(SquidexClaimTypes.SquidexPictureUrl); + } + public static bool IsPictureUrlStored(this IUser user) { - return string.Equals(user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)?.Value, "store", StringComparison.OrdinalIgnoreCase); + return user.HasClaimValue(SquidexClaimTypes.SquidexPictureUrl, "store"); } public static string PictureUrl(this IUser user) { - return user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)?.Value; + return user.GetClaimValue(SquidexClaimTypes.SquidexPictureUrl); } public static string DisplayName(this IUser user) { - return user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexDisplayName)?.Value; + return user.GetClaimValue(SquidexClaimTypes.SquidexDisplayName); + } + + public static string GetClaimValue(this IUser user, string claim) + { + return user.Claims.FirstOrDefault(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase))?.Value; + } + + public static bool HasClaim(this IUser user, string claim) + { + return user.Claims.Any(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase)); + } + + public static bool HasClaimValue(this IUser user, string claim, string value) + { + return user.Claims.Any(x => string.Equals(x.Type, claim, StringComparison.OrdinalIgnoreCase) && string.Equals(x.Value, value, StringComparison.OrdinalIgnoreCase)); } public static string PictureNormalizedUrl(this IUser user) diff --git a/src/Squidex.Domain.Users/UserManagerExtensions.cs b/src/Squidex.Domain.Users/UserManagerExtensions.cs index 525823d32..302b025d1 100644 --- a/src/Squidex.Domain.Users/UserManagerExtensions.cs +++ b/src/Squidex.Domain.Users/UserManagerExtensions.cs @@ -73,7 +73,7 @@ namespace Squidex.Domain.Users public static Task UpdateAsync(this UserManager userManager, IUser user, string email, string displayName) { - user.UpdateEmail(email); + user.SetEmail(email); user.SetDisplayName(displayName); return userManager.UpdateAsync(user); diff --git a/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj b/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj index 2a6b10e30..7f009bb3d 100644 --- a/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj +++ b/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj @@ -4,7 +4,7 @@ Squidex.Infrastructure - + diff --git a/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj b/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj index 9d576f112..4ebcb4f28 100644 --- a/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj +++ b/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Squidex.Infrastructure.GoogleCloud/Squidex.Infrastructure.GoogleCloud.csproj b/src/Squidex.Infrastructure.GoogleCloud/Squidex.Infrastructure.GoogleCloud.csproj index 23bbc623d..0872cb47e 100644 --- a/src/Squidex.Infrastructure.GoogleCloud/Squidex.Infrastructure.GoogleCloud.csproj +++ b/src/Squidex.Infrastructure.GoogleCloud/Squidex.Infrastructure.GoogleCloud.csproj @@ -8,8 +8,8 @@ True - - + + diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs index 9d39e33e7..1db3eff76 100644 --- a/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs @@ -12,6 +12,8 @@ using MongoDB.Bson; using MongoDB.Driver; using Squidex.Infrastructure.States; +#pragma warning disable RECS0022 // A catch clause that catches System.Exception and has an empty body + namespace Squidex.Infrastructure.MongoDb { public static class MongoExtensions @@ -37,6 +39,18 @@ namespace Squidex.Infrastructure.MongoDb return true; } + public static async Task TryDropOneAsync(this IMongoIndexManager indexes, string name) + { + try + { + await indexes.DropOneAsync(name); + } + catch + { + /* NOOP */ + } + } + public static IFindFluent Only(this IFindFluent find, Expression> include) { diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/ConstantVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ConstantVisitor.cs similarity index 66% rename from src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/ConstantVisitor.cs rename to src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ConstantVisitor.cs index 04f31ee48..482d6a7c1 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/ConstantVisitor.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/ConstantVisitor.cs @@ -11,10 +11,14 @@ using Microsoft.OData.UriParser; using NodaTime; using NodaTime.Text; -namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors +namespace Squidex.Infrastructure.MongoDb.OData { public sealed class ConstantVisitor : QueryNodeVisitor { + private static readonly IEdmPrimitiveType BooleanType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Boolean); + private static readonly IEdmPrimitiveType DateTimeType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.DateTimeOffset); + private static readonly IEdmPrimitiveType GuidType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Guid); + private static readonly ConstantVisitor Instance = new ConstantVisitor(); private ConstantVisitor() @@ -28,16 +32,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors public override object Visit(ConvertNode nodeIn) { - var booleanType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.Boolean); - - if (nodeIn.TypeReference.Definition == booleanType) + if (nodeIn.TypeReference.Definition == BooleanType) { return bool.Parse(Visit(nodeIn.Source).ToString()); } - var dateTimeType = EdmCoreModel.Instance.GetPrimitiveType(EdmPrimitiveTypeKind.DateTimeOffset); + if (nodeIn.TypeReference.Definition == GuidType) + { + return Guid.Parse(Visit(nodeIn.Source).ToString()); + } - if (nodeIn.TypeReference.Definition == dateTimeType) + if (nodeIn.TypeReference.Definition == DateTimeType) { var value = Visit(nodeIn.Source); diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs similarity index 66% rename from src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterBuilder.cs rename to src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs index 36771266e..ffc82c1dc 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterBuilder.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterBuilder.cs @@ -8,16 +8,12 @@ using Microsoft.OData; using Microsoft.OData.UriParser; using MongoDB.Driver; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors +namespace Squidex.Infrastructure.MongoDb.OData { public static class FilterBuilder { - private static readonly FilterDefinitionBuilder Filter = Builders.Filter; - - public static FilterDefinition Build(ODataUriParser query, Schema schema) + public static (FilterDefinition Filter, bool Last) BuildFilter(this ODataUriParser query, PropertyCalculator propertyCalculator = null, bool supportsSearch = true) { SearchClause search; try @@ -31,7 +27,12 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors if (search != null) { - return Filter.Text(SearchTermVisitor.Visit(search.Expression).ToString()); + if (!supportsSearch) + { + throw new ValidationException("Query $search clause not supported."); + } + + return (Builders.Filter.Text(SearchTermVisitor.Visit(search.Expression).ToString()), false); } FilterClause filter; @@ -46,10 +47,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors if (filter != null) { - return FilterVisitor.Visit(filter.Expression, schema); + return (FilterVisitor.Visit(filter.Expression, propertyCalculator), true); } - return null; + return (null, false); } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs similarity index 80% rename from src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterVisitor.cs rename to src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs index 1463f0343..cfe498c4e 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterVisitor.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/FilterVisitor.cs @@ -10,33 +10,32 @@ using System.Linq; using Microsoft.OData.UriParser; using MongoDB.Bson; using MongoDB.Driver; -using Squidex.Domain.Apps.Core.Schemas; -namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors +namespace Squidex.Infrastructure.MongoDb.OData { - public class FilterVisitor : QueryNodeVisitor> + public sealed class FilterVisitor : QueryNodeVisitor> { - private static readonly FilterDefinitionBuilder Filter = Builders.Filter; - private readonly Schema schema; + private static readonly FilterDefinitionBuilder Filter = Builders.Filter; + private readonly PropertyCalculator propertyCalculator; - private FilterVisitor(Schema schema) + private FilterVisitor(PropertyCalculator propertyCalculator) { - this.schema = schema; + this.propertyCalculator = propertyCalculator; } - public static FilterDefinition Visit(QueryNode node, Schema schema) + public static FilterDefinition Visit(QueryNode node, PropertyCalculator propertyCalculator) { - var visitor = new FilterVisitor(schema); + var visitor = new FilterVisitor(propertyCalculator); return node.Accept(visitor); } - public override FilterDefinition Visit(ConvertNode nodeIn) + public override FilterDefinition Visit(ConvertNode nodeIn) { return nodeIn.Source.Accept(this); } - public override FilterDefinition Visit(UnaryOperatorNode nodeIn) + public override FilterDefinition Visit(UnaryOperatorNode nodeIn) { if (nodeIn.OperatorKind == UnaryOperatorKind.Not) { @@ -46,7 +45,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors throw new NotSupportedException(); } - public override FilterDefinition Visit(SingleValueFunctionCallNode nodeIn) + public override FilterDefinition Visit(SingleValueFunctionCallNode nodeIn) { var fieldNode = nodeIn.Parameters.ElementAt(0); var valueNode = nodeIn.Parameters.ElementAt(1); @@ -75,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors throw new NotSupportedException(); } - public override FilterDefinition Visit(BinaryOperatorNode nodeIn) + public override FilterDefinition Visit(BinaryOperatorNode nodeIn) { if (nodeIn.OperatorKind == BinaryOperatorKind.And) { @@ -149,9 +148,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors return new BsonRegularExpression(formatter(BuildValue(node).ToString()), "i"); } - private FieldDefinition BuildFieldDefinition(QueryNode nodeIn) + private FieldDefinition BuildFieldDefinition(QueryNode nodeIn) { - return PropertyVisitor.Visit(nodeIn, schema); + return nodeIn.BuildFieldDefinition(propertyCalculator); } private static object BuildValue(QueryNode nodeIn) diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/LimitExtensions.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/LimitExtensions.cs new file mode 100644 index 000000000..8296a4476 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/LimitExtensions.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.OData.UriParser; +using MongoDB.Driver; + +namespace Squidex.Infrastructure.MongoDb.OData +{ + public static class LimitExtensions + { + public static IFindFluent Take(this IFindFluent cursor, ODataUriParser query, int maxValue = 200, int defaultValue = 20) + { + var top = query.ParseTop(); + + if (top.HasValue) + { + cursor = cursor.Limit(Math.Min((int)top.Value, maxValue)); + } + else + { + cursor = cursor.Limit(defaultValue); + } + + return cursor; + } + + public static IFindFluent Skip(this IFindFluent cursor, ODataUriParser query) + { + var skip = query.ParseSkip(); + + if (skip.HasValue) + { + cursor = cursor.Skip((int)skip.Value); + } + else + { + cursor = cursor.Skip(null); + } + + return cursor; + } + } +} diff --git a/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs new file mode 100644 index 000000000..ede103c64 --- /dev/null +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyBuilder.cs @@ -0,0 +1,33 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Linq; +using Microsoft.OData.UriParser; +using MongoDB.Driver; + +namespace Squidex.Infrastructure.MongoDb.OData +{ + public delegate string PropertyCalculator(string[] parts); + + public static class PropertyBuilder + { + private static readonly PropertyCalculator DefaultCalculator = parts => + { + return string.Join(".", parts).ToPascalCase(); + }; + + public static StringFieldDefinition BuildFieldDefinition(this QueryNode node, PropertyCalculator propertyCalculator) + { + propertyCalculator = propertyCalculator ?? DefaultCalculator; + + var propertyParts = node.Accept(PropertyNameVisitor.Instance).ToArray(); + var propertyName = propertyCalculator(propertyParts); + + return new StringFieldDefinition(propertyName); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/PropertyVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyNameVisitor.cs similarity index 55% rename from src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/PropertyVisitor.cs rename to src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyNameVisitor.cs index e72959bc1..116fc4e98 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/PropertyVisitor.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/PropertyNameVisitor.cs @@ -5,39 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Immutable; -using System.Linq; using Microsoft.OData.UriParser; -using MongoDB.Driver; -using Squidex.Domain.Apps.Core.GenerateEdmSchema; -using Squidex.Domain.Apps.Core.Schemas; -namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors +namespace Squidex.Infrastructure.MongoDb.OData { - public sealed class PropertyVisitor : QueryNodeVisitor> + public sealed class PropertyNameVisitor : QueryNodeVisitor> { - private static readonly PropertyVisitor Instance = new PropertyVisitor(); + public static readonly PropertyNameVisitor Instance = new PropertyNameVisitor(); - public static StringFieldDefinition Visit(QueryNode node, Schema schema) + private PropertyNameVisitor() { - var propertyNames = node.Accept(Instance).ToArray(); - - if (propertyNames.Length == 3) - { - var edmName = propertyNames[1].UnescapeEdmField(); - - if (!schema.FieldsByName.TryGetValue(edmName, out var field)) - { - throw new NotSupportedException(); - } - - propertyNames[1] = field.Id.ToString(); - } - - var propertyName = $"do.{string.Join(".", propertyNames.Skip(1))}"; - - return new StringFieldDefinition(propertyName); } public override ImmutableList Visit(ConvertNode nodeIn) diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/SearchTermVisitor.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SearchTermVisitor.cs similarity index 94% rename from src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/SearchTermVisitor.cs rename to src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SearchTermVisitor.cs index 823d77768..85f897c80 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/SearchTermVisitor.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SearchTermVisitor.cs @@ -8,7 +8,7 @@ using System; using Microsoft.OData.UriParser; -namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors +namespace Squidex.Infrastructure.MongoDb.OData { public class SearchTermVisitor : QueryNodeVisitor { diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/SortBuilder.cs b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs similarity index 52% rename from src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/SortBuilder.cs rename to src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs index c0d63c8e4..c19ca4305 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/SortBuilder.cs +++ b/src/Squidex.Infrastructure.MongoDb/MongoDb/OData/SortBuilder.cs @@ -1,60 +1,57 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System.Collections.Generic; using Microsoft.OData.UriParser; using MongoDB.Driver; -using Squidex.Domain.Apps.Core.Schemas; -namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors +namespace Squidex.Infrastructure.MongoDb.OData { public static class SortBuilder { - private static readonly SortDefinitionBuilder Sort = Builders.Sort; - - public static SortDefinition BuildSort(ODataUriParser query, Schema schema) + public static SortDefinition BuildSort(this ODataUriParser query, PropertyCalculator propertyCalculator = null) { var orderBy = query.ParseOrderBy(); if (orderBy != null) { - var sorts = new List>(); + var sorts = new List>(); while (orderBy != null) { - sorts.Add(OrderBy(orderBy, schema)); + sorts.Add(OrderBy(orderBy, propertyCalculator)); orderBy = orderBy.ThenBy; } if (sorts.Count > 1) { - return Sort.Combine(sorts); + return Builders.Sort.Combine(sorts); } else { return sorts[0]; } } - else - { - return Sort.Descending(x => x.LastModified); - } + + return null; } - public static SortDefinition OrderBy(OrderByClause clause, Schema schema) + public static SortDefinition OrderBy(OrderByClause clause, PropertyCalculator propertyCalculator = null) { + var propertyName = clause.Expression.BuildFieldDefinition(propertyCalculator); + if (clause.Direction == OrderByDirection.Ascending) { - return Sort.Ascending(PropertyVisitor.Visit(clause.Expression, schema)); + return Builders.Sort.Ascending(propertyName); } else { - return Sort.Descending(PropertyVisitor.Visit(clause.Expression, schema)); + return Builders.Sort.Descending(propertyName); } } } diff --git a/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj b/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj index 5db5f7158..43fd73478 100644 --- a/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj +++ b/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj @@ -11,8 +11,9 @@ + - + diff --git a/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj b/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj index 8c7ffe98d..043f04732 100644 --- a/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj +++ b/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj b/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj index b811c6667..fee1b24ed 100644 --- a/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj +++ b/src/Squidex.Infrastructure.Redis/Squidex.Infrastructure.Redis.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Squidex.Infrastructure/Commands/CommandContext.cs b/src/Squidex.Infrastructure/Commands/CommandContext.cs index 278c7eebf..a83ba04fb 100644 --- a/src/Squidex.Infrastructure/Commands/CommandContext.cs +++ b/src/Squidex.Infrastructure/Commands/CommandContext.cs @@ -12,6 +12,7 @@ namespace Squidex.Infrastructure.Commands public sealed class CommandContext { private readonly ICommand command; + private readonly ICommandBus commandBus; private readonly Guid contextId = Guid.NewGuid(); private Tuple result; @@ -20,6 +21,11 @@ namespace Squidex.Infrastructure.Commands get { return command; } } + public ICommandBus CommandBus + { + get { return commandBus; } + } + public Guid ContextId { get { return contextId; } @@ -30,11 +36,13 @@ namespace Squidex.Infrastructure.Commands get { return result != null; } } - public CommandContext(ICommand command) + public CommandContext(ICommand command, ICommandBus commandBus) { Guard.NotNull(command, nameof(command)); + Guard.NotNull(commandBus, nameof(commandBus)); this.command = command; + this.commandBus = commandBus; } public void Complete(object resultValue = null) diff --git a/src/Squidex.Infrastructure/Commands/InMemoryCommandBus.cs b/src/Squidex.Infrastructure/Commands/InMemoryCommandBus.cs index 498a41a28..7c72e8fba 100644 --- a/src/Squidex.Infrastructure/Commands/InMemoryCommandBus.cs +++ b/src/Squidex.Infrastructure/Commands/InMemoryCommandBus.cs @@ -15,24 +15,24 @@ namespace Squidex.Infrastructure.Commands { public sealed class InMemoryCommandBus : ICommandBus { - private readonly List handlers; + private readonly List middlewares; - public InMemoryCommandBus(IEnumerable handlers) + public InMemoryCommandBus(IEnumerable middlewares) { - Guard.NotNull(handlers, nameof(handlers)); + Guard.NotNull(middlewares, nameof(middlewares)); - this.handlers = handlers.Reverse().ToList(); + this.middlewares = middlewares.Reverse().ToList(); } public async Task PublishAsync(ICommand command) { Guard.NotNull(command, nameof(command)); - var context = new CommandContext(command); + var context = new CommandContext(command, this); var next = new Func(() => TaskHelper.Done); - foreach (var handler in handlers) + foreach (var handler in middlewares) { next = Join(handler, context, next); } diff --git a/src/Squidex.Infrastructure/Http/DumpFormatter.cs b/src/Squidex.Infrastructure/Http/DumpFormatter.cs index 4063f0105..5f0c0dff1 100644 --- a/src/Squidex.Infrastructure/Http/DumpFormatter.cs +++ b/src/Squidex.Infrastructure/Http/DumpFormatter.cs @@ -66,7 +66,7 @@ namespace Squidex.Infrastructure.Http writer.AppendLine(responseBody); } - if (response != null) + if (response != null && elapsed != TimeSpan.Zero) { writer.AppendLine(); writer.AppendLine($"Elapsed: {elapsed}"); diff --git a/src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs b/src/Squidex.Infrastructure/IFreezable.cs similarity index 65% rename from src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs rename to src/Squidex.Infrastructure/IFreezable.cs index bf899acef..c7095fccd 100644 --- a/src/Squidex.Domain.Apps.Entities/IEntityWithAppRef.cs +++ b/src/Squidex.Infrastructure/IFreezable.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.Infrastructure { - public interface IEntityWithAppRef + public interface IFreezable { - Guid AppId { get; } + void Freeze(); } -} \ No newline at end of file +} diff --git a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index fdab3f5d8..ba723caf1 100644 --- a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Squidex.Infrastructure/StringExtensions.cs b/src/Squidex.Infrastructure/StringExtensions.cs index 111d5ca6e..98cf38e9a 100644 --- a/src/Squidex.Infrastructure/StringExtensions.cs +++ b/src/Squidex.Infrastructure/StringExtensions.cs @@ -357,7 +357,7 @@ namespace Squidex.Infrastructure } } - public static string Simplify(this string value, ISet preserveHash = null, bool singleCharDiactric = false, char separator = '-') + public static string Slugify(this string value, ISet preserveHash = null, bool singleCharDiactric = false, char separator = '-') { var result = new StringBuilder(value.Length); diff --git a/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs b/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs new file mode 100644 index 000000000..16fd0c779 --- /dev/null +++ b/src/Squidex.Infrastructure/Tasks/PartitionedActionBlock.cs @@ -0,0 +1,110 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Infrastructure.Tasks +{ + public class PartitionedActionBlock : ITargetBlock + { + private readonly ITargetBlock distributor; + private readonly ActionBlock[] workers; + + public Task Completion + { + get { return Task.WhenAll(workers.Select(x => x.Completion)); } + } + + public PartitionedActionBlock(Action action, Func partitioner) + : this (ToAsync(action), partitioner, new ExecutionDataflowBlockOptions()) + { + } + + public PartitionedActionBlock(Func action, Func partitioner) + : this(action, partitioner, new ExecutionDataflowBlockOptions()) + { + } + + public PartitionedActionBlock(Action action, Func partitioner, ExecutionDataflowBlockOptions dataflowBlockOptions) + : this(ToAsync(action), partitioner, dataflowBlockOptions) + { + } + + public PartitionedActionBlock(Func action, Func partitioner, ExecutionDataflowBlockOptions dataflowBlockOptions) + { + Guard.NotNull(action, nameof(action)); + Guard.NotNull(partitioner, nameof(partitioner)); + Guard.NotNull(dataflowBlockOptions, nameof(dataflowBlockOptions)); + Guard.GreaterThan(dataflowBlockOptions.MaxDegreeOfParallelism, 1, nameof(dataflowBlockOptions.MaxDegreeOfParallelism)); + + workers = new ActionBlock[dataflowBlockOptions.MaxDegreeOfParallelism]; + + for (var i = 0; i < dataflowBlockOptions.MaxDegreeOfParallelism; i++) + { + var workerOption = SimpleMapper.Map(dataflowBlockOptions, new ExecutionDataflowBlockOptions()); + + workerOption.MaxDegreeOfParallelism = 1; + workerOption.MaxMessagesPerTask = 1; + + workers[i] = new ActionBlock(action, workerOption); + } + + var distributorOption = new ExecutionDataflowBlockOptions + { + MaxDegreeOfParallelism = 1, + MaxMessagesPerTask = 1, + BoundedCapacity = 1 + }; + + distributor = new ActionBlock(x => + { + var partition = Math.Abs(partitioner(x)) % workers.Length; + + return workers[partition].SendAsync(x); + }, distributorOption); + + distributor.Completion.ContinueWith(x => + { + foreach (var worker in workers) + { + worker.Complete(); + } + }); + } + + public DataflowMessageStatus OfferMessage(DataflowMessageHeader messageHeader, TInput messageValue, ISourceBlock source, bool consumeToAccept) + { + return distributor.OfferMessage(messageHeader, messageValue, source, consumeToAccept); + } + + public void Complete() + { + distributor.Complete(); + } + + public void Fault(Exception exception) + { + distributor.Fault(exception); + } + + private static Func ToAsync(Action action) + { + Guard.NotNull(action, nameof(action)); + + return x => + { + action(x); + + return TaskHelper.Done; + }; + } + } +} diff --git a/src/Squidex.Infrastructure/ValidationError.cs b/src/Squidex.Infrastructure/ValidationError.cs index eb1c6c651..69eca55d6 100644 --- a/src/Squidex.Infrastructure/ValidationError.cs +++ b/src/Squidex.Infrastructure/ValidationError.cs @@ -11,7 +11,7 @@ namespace Squidex.Infrastructure { public sealed class ValidationError { - private static readonly string[] FalbackProperties = new string[0]; + private static readonly string[] FallbackProperties = new string[0]; private readonly string message; private readonly string[] propertyNames; @@ -31,7 +31,7 @@ namespace Squidex.Infrastructure this.message = message; - this.propertyNames = propertyNames ?? FalbackProperties; + this.propertyNames = propertyNames ?? FallbackProperties; } } -} \ No newline at end of file +} diff --git a/src/Squidex.Infrastructure/ValidationException.cs b/src/Squidex.Infrastructure/ValidationException.cs index 29bf22532..163904187 100644 --- a/src/Squidex.Infrastructure/ValidationException.cs +++ b/src/Squidex.Infrastructure/ValidationException.cs @@ -9,11 +9,12 @@ using System; using System.Collections.Generic; using System.Linq; using System.Runtime.Serialization; +using System.Text; namespace Squidex.Infrastructure { [Serializable] - public class ValidationException : Exception + public class ValidationException : DomainException { private static readonly List FallbackErrors = new List(); private readonly IReadOnlyList errors; @@ -23,27 +24,29 @@ namespace Squidex.Infrastructure get { return errors; } } - public ValidationException(string message, params ValidationError[] errors) - : base(message) + public string Summary { get; } + + public ValidationException(string summary, params ValidationError[] errors) + : this(summary, null, errors?.ToList()) { - this.errors = errors != null ? errors.ToList() : FallbackErrors; } - public ValidationException(string message, IReadOnlyList errors) - : base(message) + public ValidationException(string summary, IReadOnlyList errors) + : this(summary, null, errors) { this.errors = errors ?? FallbackErrors; } - public ValidationException(string message, Exception inner, params ValidationError[] errors) - : base(message, inner) + public ValidationException(string summary, Exception inner, params ValidationError[] errors) + : this(summary, null, errors?.ToList()) { - this.errors = errors != null ? errors.ToList() : FallbackErrors; } - public ValidationException(string message, Exception inner, IReadOnlyList errors) - : base(message, inner) + public ValidationException(string summary, Exception inner, IReadOnlyList errors) + : base(FormatMessage(summary, errors), inner) { + Summary = summary; + this.errors = errors ?? FallbackErrors; } @@ -52,9 +55,39 @@ namespace Squidex.Infrastructure { } - public override string ToString() + private static string FormatMessage(string summary, IReadOnlyList errors) { - return string.Join(" ", Enumerable.Repeat(Message, 1).Union(Errors.Select(x => x.Message))); + var sb = new StringBuilder(); + + sb.Append(summary.TrimEnd(' ', '.', ':')); + + if (errors?.Count > 0) + { + sb.Append(": "); + + for (var i = 0; i < errors.Count; i++) + { + var error = errors[i].Message; + + sb.Append(error); + + if (!error.EndsWith(".", StringComparison.OrdinalIgnoreCase)) + { + sb.Append("."); + } + + if (i < errors.Count - 1) + { + sb.Append(" "); + } + } + } + else + { + sb.Append("."); + } + + return sb.ToString(); } } } diff --git a/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs b/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs new file mode 100644 index 000000000..32ad369d8 --- /dev/null +++ b/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; + +namespace Squidex.Shared.Identity +{ + public static class ClaimsPrincipalExtensions + { + public static void SetDisplayName(this ClaimsIdentity identity, string displayName) + { + identity.AddClaim(new Claim(SquidexClaimTypes.SquidexDisplayName, displayName)); + } + + public static void SetPictureUrl(this ClaimsIdentity identity, string pictureUrl) + { + identity.AddClaim(new Claim(SquidexClaimTypes.SquidexPictureUrl, pictureUrl)); + } + + public static IEnumerable GetSquidexClaims(this ClaimsPrincipal principal) + { + return principal.Claims.Where(c => c.Type.StartsWith(SquidexClaimTypes.Prefix, StringComparison.Ordinal)); + } + } +} diff --git a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs index f622d5bfa..b456cfd21 100644 --- a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs +++ b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs @@ -13,6 +13,10 @@ namespace Squidex.Shared.Identity public static readonly string SquidexPictureUrl = "urn:squidex:picture"; + public static readonly string SquidexConsent = "urn:squidex:consent"; + + public static readonly string SquidexConsentForEmails = "urn:squidex:consent:emails"; + public static readonly string Prefix = "urn:squidex:"; } } diff --git a/src/Squidex.Shared/Squidex.Shared.csproj b/src/Squidex.Shared/Squidex.Shared.csproj index bd58e3fe2..244e5b12e 100644 --- a/src/Squidex.Shared/Squidex.Shared.csproj +++ b/src/Squidex.Shared/Squidex.Shared.csproj @@ -7,7 +7,7 @@ True - + diff --git a/src/Squidex.Shared/Users/IUser.cs b/src/Squidex.Shared/Users/IUser.cs index 14f607177..14065247f 100644 --- a/src/Squidex.Shared/Users/IUser.cs +++ b/src/Squidex.Shared/Users/IUser.cs @@ -24,10 +24,10 @@ namespace Squidex.Shared.Users IReadOnlyList Logins { get; } - void UpdateEmail(string email); - - void AddClaim(Claim claim); + void SetEmail(string email); void SetClaim(string type, string value); + + void AddClaim(Claim claim); } } diff --git a/src/Squidex/Areas/Api/Config/Swagger/ODataQueryParamsProcessor.cs b/src/Squidex/Areas/Api/Config/Swagger/ODataQueryParamsProcessor.cs new file mode 100644 index 000000000..7a4ba6e8e --- /dev/null +++ b/src/Squidex/Areas/Api/Config/Swagger/ODataQueryParamsProcessor.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using NJsonSchema; +using NSwag.SwaggerGeneration.Processors; +using NSwag.SwaggerGeneration.Processors.Contexts; +using Squidex.Infrastructure.Tasks; +using Squidex.Pipeline.Swagger; + +namespace Squidex.Areas.Api.Config.Swagger +{ + public sealed class ODataQueryParamsProcessor : IOperationProcessor + { + private readonly string path; + private readonly string entity; + private readonly bool supportSearch; + + public ODataQueryParamsProcessor(string path, string entity, bool supportSearch) + { + this.path = path; + this.entity = entity; + this.supportSearch = supportSearch; + } + + public Task ProcessAsync(OperationProcessorContext context) + { + if (context.OperationDescription.Path == path) + { + if (supportSearch) + { + context.OperationDescription.Operation.AddQueryParameter("$search", JsonObjectType.String, "Optional OData full text search."); + } + + context.OperationDescription.Operation.AddQueryParameter("$top", JsonObjectType.Number, $"Optional number of {entity} to take."); + context.OperationDescription.Operation.AddQueryParameter("$skip", JsonObjectType.Number, $"Optional number of {entity} to skip."); + context.OperationDescription.Operation.AddQueryParameter("$orderby", JsonObjectType.String, "Optional OData order definition."); + context.OperationDescription.Operation.AddQueryParameter("$filter", JsonObjectType.String, "Optional OData filter definition."); + } + + return TaskHelper.True; + } + } +} diff --git a/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs b/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs index 7658b8011..f04c2e960 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs @@ -30,6 +30,7 @@ namespace Squidex.Areas.Api.Config.Swagger var settings = new SwaggerSettings { Title = "Squidex API", Version = "1.0", IsAspNetCore = false } + .AddAssetODataParams() .ConfigurePaths(urlOptions) .ConfigureSchemaSettings() .ConfigureIdentity(urlOptions); @@ -40,6 +41,13 @@ namespace Squidex.Areas.Api.Config.Swagger services.AddTransient(); } + private static SwaggerSettings AddAssetODataParams(this SwaggerSettings settings) + { + settings.OperationProcessors.Add(new ODataQueryParamsProcessor("/apps/{app}/assets", "assets", false)); + + return settings; + } + private static SwaggerSettings ConfigureIdentity(this SwaggerSettings settings, MyUrlsOptions urlOptions) { settings.DocumentProcessors.Add( diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs index 3606495dd..bd9b70cc3 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Primitives; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Domain.Apps.Core.Apps; @@ -53,7 +52,7 @@ namespace Squidex.Areas.Api.Controllers.Apps { var response = App.Clients.Select(x => SimpleMapper.Map(x.Value, new ClientDto { Id = x.Key })).ToList(); - Response.Headers["ETag"] = new StringValues(App.Version.ToString()); + Response.Headers["ETag"] = App.Version.ToString(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index db305d370..aa8cd6ac7 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Primitives; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Domain.Apps.Entities.Apps.Commands; @@ -55,7 +54,7 @@ namespace Squidex.Areas.Api.Controllers.Apps var response = new ContributorsDto { Contributors = contributors, MaxContributors = appPlansProvider.GetPlanForApp(App).MaxContributors }; - Response.Headers["ETag"] = new StringValues(App.Version.ToString()); + Response.Headers["ETag"] = App.Version.ToString(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs index 55bdac37e..53dff0972 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs @@ -10,7 +10,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Primitives; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Domain.Apps.Core.Apps; @@ -60,7 +59,7 @@ namespace Squidex.Areas.Api.Controllers.Apps Fallback = x.LanguageFallbacks.ToList() })).OrderByDescending(x => x.IsMaster).ThenBy(x => x.Iso2Code).ToList(); - Response.Headers["ETag"] = new StringValues(App.Version.ToString()); + Response.Headers["ETag"] = App.Version.ToString(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs index 125907e49..949e8951e 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs @@ -78,7 +78,7 @@ namespace Squidex.Areas.Api.Controllers.Apps var response = SimpleMapper.Map(request, new AppPatternDto { PatternId = command.PatternId }); - return CreatedAtAction(nameof(GetPatterns), new { app }, request); + return CreatedAtAction(nameof(GetPatterns), new { app }, response); } /// diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppDto.cs index 362932862..92cab9c82 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/CreateAppDto.cs @@ -17,5 +17,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models [Required] [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] public string Name { get; set; } + + /// + /// Initialize the app with the inbuilt template. + /// + public string Template { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index 1f11f7100..f0e4c4d03 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -62,24 +62,24 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(0.5)] public async Task GetAssetContent(string app, Guid id, [FromQuery] int version = -1, [FromQuery] int? width = null, [FromQuery] int? height = null, [FromQuery] string mode = null) { - var asset = await assetRepository.FindAssetAsync(id); + var entity = await assetRepository.FindAssetAsync(id); - if (asset == null || asset.FileVersion < version || width == 0 || height == 0) + if (entity == null || entity.FileVersion < version || width == 0 || height == 0) { return NotFound(); } - var assetId = asset.Id.ToString(); + var assetId = entity.Id.ToString(); - return new FileCallbackResult(asset.MimeType, asset.FileName, async bodyStream => + return new FileCallbackResult(entity.MimeType, entity.FileName, async bodyStream => { - if (asset.IsImage && (width.HasValue || height.HasValue)) + if (entity.IsImage && (width.HasValue || height.HasValue)) { var assetSuffix = $"{width}_{height}_{mode}"; try { - await assetStorage.DownloadAsync(assetId, asset.FileVersion, assetSuffix, bodyStream); + await assetStorage.DownloadAsync(assetId, entity.FileVersion, assetSuffix, bodyStream); } catch (AssetNotFoundException) { @@ -87,13 +87,13 @@ namespace Squidex.Areas.Api.Controllers.Assets { using (var destinationStream = GetTempStream()) { - await assetStorage.DownloadAsync(assetId, asset.FileVersion, null, sourceStream); + await assetStorage.DownloadAsync(assetId, entity.FileVersion, null, sourceStream); sourceStream.Position = 0; await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, destinationStream, width, height, mode); destinationStream.Position = 0; - await assetStorage.UploadAsync(assetId, asset.FileVersion, assetSuffix, destinationStream); + await assetStorage.UploadAsync(assetId, entity.FileVersion, assetSuffix, destinationStream); destinationStream.Position = 0; await destinationStream.CopyToAsync(bodyStream); @@ -103,7 +103,7 @@ namespace Squidex.Areas.Api.Controllers.Assets } else { - await assetStorage.DownloadAsync(assetId, asset.FileVersion, null, bodyStream); + await assetStorage.DownloadAsync(assetId, entity.FileVersion, null, bodyStream); } }); } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 0f2da0d0f..327a28e8f 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -12,7 +12,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Assets.Models; using Squidex.Domain.Apps.Entities.Apps.Services; @@ -60,38 +59,26 @@ namespace Squidex.Areas.Api.Controllers.Assets /// /// The name of the app. /// The optional asset ids. - /// The number of assets to skip. - /// The number of assets to take (Default: 20). - /// The query to limit the files by name. - /// Comma separated list of mime types to get. /// /// 200 => Assets returned. /// 404 => App not found. /// /// - /// Get all assets for the app. Mime types can be comma-separated, e.g. application/json,text/html. + /// Get all assets for the app. /// [MustBeAppReader] [HttpGet] [Route("apps/{app}/assets/")] [ProducesResponseType(typeof(AssetsDto), 200)] [ApiCosts(1)] - public async Task GetAssets(string app, [FromQuery] string query = null, [FromQuery] string mimeTypes = null, [FromQuery] string ids = null, [FromQuery] int skip = 0, [FromQuery] int take = 10) + public async Task GetAssets(string app, [FromQuery] string ids = null) { - var mimeTypeList = new HashSet(); - - if (!string.IsNullOrWhiteSpace(mimeTypes)) - { - foreach (var mimeType in mimeTypes.Split(',')) - { - mimeTypeList.Add(mimeType.Trim()); - } - } - - var idsList = new HashSet(); + HashSet idsList = null; if (!string.IsNullOrWhiteSpace(ids)) { + idsList = new HashSet(); + foreach (var id in ids.Split(',')) { if (Guid.TryParse(id, out var guid)) @@ -101,7 +88,10 @@ namespace Squidex.Areas.Api.Controllers.Assets } } - var assets = await assetRepository.QueryAsync(App.Id, mimeTypeList, idsList, query, take, skip); + var assets = + idsList?.Count > 0 ? + await assetRepository.QueryAsync(App.Id, idsList) : + await assetRepository.QueryAsync(App.Id, Request.QueryString.ToString()); var response = new AssetsDto { @@ -109,6 +99,8 @@ namespace Squidex.Areas.Api.Controllers.Assets Items = assets.Select(x => SimpleMapper.Map(x, new AssetDto { FileType = x.FileName.FileType() })).ToArray() }; + Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id)); + return Ok(response); } @@ -137,7 +129,8 @@ namespace Squidex.Areas.Api.Controllers.Assets var response = SimpleMapper.Map(entity, new AssetDto { FileType = entity.FileName.FileType() }); - Response.Headers["ETag"] = new StringValues(entity.Version.ToString()); + Response.Headers["ETag"] = entity.Version.ToString(); + Response.Headers["Surrogate-Key"] = entity.Id.ToString(); return Ok(response); } @@ -153,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] @@ -255,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 e9861fe66..6cc59d1b7 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/ContentsController.cs @@ -10,7 +10,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Primitives; +using NodaTime; +using NodaTime.Text; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Contents.Models; using Squidex.Domain.Apps.Core.Contents; @@ -86,11 +87,11 @@ namespace Squidex.Areas.Api.Controllers.Contents var isFrontendClient = User.IsFrontendClient(); var result = - idsList != null ? + idsList?.Count > 0 ? await contentQuery.QueryAsync(App, name, User, archived, idsList) : await contentQuery.QueryAsync(App, name, User, archived, Request.QueryString.ToString()); - var response = new AssetsDto + var response = new ContentsDto { Total = result.Contents.Total, Items = result.Contents.Take(200).Select(item => @@ -106,6 +107,8 @@ namespace Squidex.Areas.Api.Controllers.Contents }).ToArray() }; + Response.Headers["Surrogate-Key"] = string.Join(" ", response.Items.Select(x => x.Id)); + return Ok(response); } @@ -115,18 +118,19 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(1)] public async Task GetContent(string name, Guid id) { - var content = await contentQuery.FindContentAsync(App, name, User, id); + var (schema, entity) = await contentQuery.FindContentAsync(App, name, User, id); - var response = SimpleMapper.Map(content.Content, new ContentDto()); + var response = SimpleMapper.Map(entity, new ContentDto()); - if (content.Content.Data != null) + if (entity.Data != null) { var isFrontendClient = User.IsFrontendClient(); - response.Data = content.Content.Data.ToApiModel(content.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); + response.Data = entity.Data.ToApiModel(schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); } - Response.Headers["ETag"] = new StringValues(content.Content.Version.ToString()); + Response.Headers["ETag"] = entity.Version.ToString(); + Response.Headers["Surrogate-Key"] = entity.Id.ToString(); return Ok(response); } @@ -148,7 +152,7 @@ namespace Squidex.Areas.Api.Controllers.Contents response.Data = content.Content.Data.ToApiModel(content.Schema.SchemaDef, App.LanguagesConfig, !isFrontendClient); } - Response.Headers["ETag"] = new StringValues(version.ToString()); + Response.Headers["ETag"] = version.ToString(); return Ok(response.Data); } @@ -161,7 +165,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new CreateContent { ContentId = Guid.NewGuid(), User = User, Data = request.ToCleaned(), Publish = publish }; + var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish }; var context = await CommandBus.PublishAsync(command); @@ -179,7 +183,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new UpdateContent { ContentId = id, User = User, Data = request.ToCleaned() }; + var command = new UpdateContent { ContentId = id, Data = request.ToCleaned() }; var context = await CommandBus.PublishAsync(command); @@ -197,7 +201,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new PatchContent { ContentId = id, User = User, Data = request.ToCleaned() }; + var command = new PatchContent { ContentId = id, Data = request.ToCleaned() }; var context = await CommandBus.PublishAsync(command); @@ -211,11 +215,11 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPut] [Route("content/{app}/{name}/{id}/publish/")] [ApiCosts(1)] - public async Task PublishContent(string name, Guid id) + public async Task PublishContent(string name, Guid id, string dueTime = null) { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Published, ContentId = id, User = User }; + var command = CreateCommand(id, Status.Published, dueTime); await CommandBus.PublishAsync(command); @@ -226,11 +230,11 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPut] [Route("content/{app}/{name}/{id}/unpublish/")] [ApiCosts(1)] - public async Task UnpublishContent(string name, Guid id) + public async Task UnpublishContent(string name, Guid id, string dueTime = null) { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Draft, ContentId = id, User = User }; + var command = CreateCommand(id, Status.Draft, dueTime); await CommandBus.PublishAsync(command); @@ -241,11 +245,11 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPut] [Route("content/{app}/{name}/{id}/archive/")] [ApiCosts(1)] - public async Task ArchiveContent(string name, Guid id) + public async Task ArchiveContent(string name, Guid id, string dueTime = null) { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Archived, ContentId = id, User = User }; + var command = CreateCommand(id, Status.Archived, dueTime); await CommandBus.PublishAsync(command); @@ -256,11 +260,11 @@ namespace Squidex.Areas.Api.Controllers.Contents [HttpPut] [Route("content/{app}/{name}/{id}/restore/")] [ApiCosts(1)] - public async Task RestoreContent(string name, Guid id) + public async Task RestoreContent(string name, Guid id, string dueTime = null) { await contentQuery.FindSchemaAsync(App, name); - var command = new ChangeContentStatus { Status = Status.Draft, ContentId = id, User = User }; + var command = CreateCommand(id, Status.Draft, dueTime); await CommandBus.PublishAsync(command); @@ -275,11 +279,28 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.FindSchemaAsync(App, name); - var command = new DeleteContent { ContentId = id, User = User }; + var command = new DeleteContent { ContentId = id }; await CommandBus.PublishAsync(command); 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 9236c96d5..010f411c0 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/Generator/SchemaSwaggerGenerator.cs @@ -14,6 +14,7 @@ using Squidex.Config; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.GenerateJsonSchema; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Pipeline.Swagger; using Squidex.Shared.Identity; @@ -32,7 +33,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator private readonly JsonSchema4 dataSchema; private readonly string schemaPath; private readonly string schemaName; - private readonly string schemaKey; + private readonly string schemaType; private readonly string appPath; static SchemaSwaggerGenerator() @@ -68,12 +69,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator appPath = path; schemaPath = schema.Name; - schemaName = schema.Properties.Label.WithFallback(schema.Name); - schemaKey = schema.Name.ToPascalCase(); + schemaName = schema.DisplayName(); + schemaType = schema.TypeName(); - dataSchema = schemaResolver($"{schemaKey}Dto", schema.BuildJsonSchema(partitionResolver, schemaResolver)); + dataSchema = schemaResolver($"{schemaType}Dto", schema.BuildJsonSchema(partitionResolver, schemaResolver)); - contentSchema = schemaResolver($"{schemaKey}ContentDto", schemaBuilder.CreateContentSchema(schema, dataSchema)); + contentSchema = schemaResolver($"{schemaType}ContentDto", schemaBuilder.CreateContentSchema(schema, dataSchema)); } public void GenerateSchemaOperations() @@ -108,13 +109,13 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Get, null, $"{appPath}/{schemaPath}", operation => { - operation.OperationId = $"Query{schemaKey}Contents"; + operation.OperationId = $"Query{schemaType}Contents"; operation.Summary = $"Queries {schemaName} contents."; operation.Security = ReaderSecurity; operation.Description = SchemaQueryDescription; - operation.AddQueryParameter("$top", JsonObjectType.Number, "Optional number of contents to take."); + operation.AddQueryParameter("$top", JsonObjectType.Number, "Optional number of contents to take (Default: 20)."); operation.AddQueryParameter("$skip", JsonObjectType.Number, "Optional number of contents to skip."); operation.AddQueryParameter("$filter", JsonObjectType.String, "Optional OData filter."); operation.AddQueryParameter("$search", JsonObjectType.String, "Optional OData full text search."); @@ -128,7 +129,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Get, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { - operation.OperationId = $"Get{schemaKey}Content"; + operation.OperationId = $"Get{schemaType}Content"; operation.Summary = $"Get a {schemaName} content."; operation.Security = ReaderSecurity; @@ -140,7 +141,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Post, null, $"{appPath}/{schemaPath}", operation => { - operation.OperationId = $"Create{schemaKey}Content"; + operation.OperationId = $"Create{schemaType}Content"; operation.Summary = $"Create a {schemaName} content."; operation.Security = EditorSecurity; @@ -155,7 +156,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { - operation.OperationId = $"Update{schemaKey}Content"; + operation.OperationId = $"Update{schemaType}Content"; operation.Summary = $"Update a {schemaName} content."; operation.Security = EditorSecurity; @@ -169,8 +170,8 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Patch, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { - operation.OperationId = $"Path{schemaKey}Content"; - operation.Summary = $"Patchs a {schemaName} content."; + operation.OperationId = $"Path{schemaType}Content"; + operation.Summary = $"Patch a {schemaName} content."; operation.Security = EditorSecurity; operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); @@ -183,7 +184,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/publish", operation => { - operation.OperationId = $"Publish{schemaKey}Content"; + operation.OperationId = $"Publish{schemaType}Content"; operation.Summary = $"Publish a {schemaName} content."; operation.Security = EditorSecurity; @@ -195,7 +196,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/unpublish", operation => { - operation.OperationId = $"Unpublish{schemaKey}Content"; + operation.OperationId = $"Unpublish{schemaType}Content"; operation.Summary = $"Unpublish a {schemaName} content."; operation.Security = EditorSecurity; @@ -207,7 +208,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/archive", operation => { - operation.OperationId = $"Archive{schemaKey}Content"; + operation.OperationId = $"Archive{schemaType}Content"; operation.Summary = $"Archive a {schemaName} content."; operation.Security = EditorSecurity; @@ -219,7 +220,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/restore", operation => { - operation.OperationId = $"Restore{schemaKey}Content"; + operation.OperationId = $"Restore{schemaType}Content"; operation.Summary = $"Restore a {schemaName} content."; operation.Security = EditorSecurity; @@ -231,7 +232,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator { return AddOperation(SwaggerOperationMethod.Delete, schemaName, $"{appPath}/{schemaPath}/{{id}}/", operation => { - operation.OperationId = $"Delete{schemaKey}Content"; + operation.OperationId = $"Delete{schemaType}Content"; operation.Summary = $"Delete a {schemaName} content."; operation.Security = EditorSecurity; 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/Content/Models/AssetsDto.cs b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs similarity index 95% rename from src/Squidex/Areas/Api/Controllers/Content/Models/AssetsDto.cs rename to src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs index 6be8cbf15..12c19cd9c 100644 --- a/src/Squidex/Areas/Api/Controllers/Content/Models/AssetsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Content/Models/ContentsDto.cs @@ -7,7 +7,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models { - public sealed class AssetsDto + public sealed class ContentsDto { /// /// The total number of content items. diff --git a/src/Squidex/Areas/Api/Controllers/JsonInheritanceConverter.cs b/src/Squidex/Areas/Api/Controllers/JsonInheritanceConverter.cs index fe5f23cf3..af9b07000 100644 --- a/src/Squidex/Areas/Api/Controllers/JsonInheritanceConverter.cs +++ b/src/Squidex/Areas/Api/Controllers/JsonInheritanceConverter.cs @@ -6,9 +6,9 @@ // ========================================================================== using System; +using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Runtime.Serialization; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NJsonSchema.Annotations; @@ -20,6 +20,8 @@ namespace Squidex.Areas.Api.Controllers public sealed class JsonInheritanceConverter : JsonConverter { private readonly string discriminator; + private readonly Dictionary mapNameToType = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary mapTypeToName = new Dictionary(); [ThreadStatic] private static bool IsReading; @@ -53,9 +55,17 @@ namespace Squidex.Areas.Api.Controllers } } - public JsonInheritanceConverter(string discriminator) + public JsonInheritanceConverter(string discriminator, Type baseType) { this.discriminator = discriminator; + + foreach (var type in baseType.Assembly.GetTypes().Where(x => x != baseType && baseType.IsAssignableFrom(x))) + { + var name = type.GetTypeInfo().GetCustomAttribute()?.Name ?? type.Name; + + mapTypeToName[type] = name; + mapNameToType[name] = type; + } } public override bool CanConvert(Type objectType) @@ -70,7 +80,7 @@ namespace Squidex.Areas.Api.Controllers { var jsonObject = JObject.FromObject(value, serializer); - jsonObject.AddFirst(new JProperty(discriminator, GetSchemaName(value.GetType()))); + jsonObject.AddFirst(new JProperty(discriminator, mapTypeToName[value.GetType()])); writer.WriteToken(jsonObject.CreateReader()); } @@ -94,11 +104,9 @@ namespace Squidex.Areas.Api.Controllers return null; } - var subType = GetObjectSubtype(objectType, subName); - - if (subType == null) + if (subName == null || !mapNameToType.TryGetValue(subName, out var subType)) { - return null; + throw new InvalidOperationException($"Could not find subtype of '{objectType.Name}' with discriminator '{subName}'."); } return serializer.Deserialize(jsonObject.CreateReader(), subType); @@ -108,28 +116,5 @@ namespace Squidex.Areas.Api.Controllers IsReading = false; } } - - private static Type GetObjectSubtype(Type objectType, string discriminatorValue) - { - var knownTypeAttribute = - objectType.GetTypeInfo().GetCustomAttributes() - .FirstOrDefault(a => IsKnownType(a, discriminatorValue)); - - return knownTypeAttribute?.Type; - } - - private static bool IsKnownType(KnownTypeAttribute attribute, string discriminator) - { - var type = attribute.Type; - - return type != null && GetSchemaName(type) == discriminator; - } - - private static string GetSchemaName(Type type) - { - var schenaName = type.GetTypeInfo().GetCustomAttribute()?.Name; - - return schenaName ?? type.Name; - } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index 72e23b3cb..b0d406c9b 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Primitives; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Plans.Models; using Squidex.Domain.Apps.Entities.Apps.Commands; @@ -65,7 +64,7 @@ namespace Squidex.Areas.Api.Controllers.Plans HasPortal = appPlansBillingManager.HasPortal }; - Response.Headers["ETag"] = new StringValues(App.Version.ToString()); + Response.Headers["ETag"] = App.Version.ToString(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AlgoliaActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AlgoliaActionDto.cs new file mode 100644 index 000000000..e561386c9 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AlgoliaActionDto.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using NJsonSchema.Annotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions +{ + [JsonSchema("Algolia")] + public sealed class AlgoliaActionDto : RuleActionDto + { + /// + /// The application ID. + /// + [Required] + public string AppId { get; set; } + + /// + /// The API key to grant access to Squidex. + /// + [Required] + public string ApiKey { get; set; } + + /// + /// The name of the index. + /// + [Required] + public string IndexName { get; set; } + + public override RuleAction ToAction() + { + return SimpleMapper.Map(this, new AlgoliaAction()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AzureQueueActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AzureQueueActionDto.cs new file mode 100644 index 000000000..bfcafb1ac --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/AzureQueueActionDto.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using NJsonSchema.Annotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions +{ + [JsonSchema("AzureQueue")] + public class AzureQueueActionDto : RuleActionDto + { + /// + /// The connection string to the storage account. + /// + [Required] + public string ConnectionString { get; set; } + + /// + /// The queue name. + /// + [Required] + public string Queue { get; set; } + + public override RuleAction ToAction() + { + return SimpleMapper.Map(this, new AzureQueueAction()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/FastlyActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/FastlyActionDto.cs new file mode 100644 index 000000000..c9431f02e --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/FastlyActionDto.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using NJsonSchema.Annotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions +{ + [JsonSchema("Fastly")] + public sealed class FastlyActionDto : RuleActionDto + { + /// + /// The ID of the fastly service. + /// + [Required] + public string ServiceId { get; set; } + + /// + /// The API key to grant access to Squidex. + /// + [Required] + public string ApiKey { get; set; } + + public override RuleAction ToAction() + { + return SimpleMapper.Map(this, new FastlyAction()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/SlackActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/SlackActionDto.cs new file mode 100644 index 000000000..553db29d1 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/SlackActionDto.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using NJsonSchema.Annotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions +{ + [JsonSchema("Slack")] + public sealed class SlackActionDto : RuleActionDto + { + /// + /// The slack webhook url. + /// + [Required] + public Uri WebhookUrl { get; set; } + + /// + /// The text that is sent as message to slack. + /// + public string Text { get; set; } + + public override RuleAction ToAction() + { + return SimpleMapper.Map(this, new SlackAction()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs index 264298fee..705bf8935 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs @@ -25,6 +25,26 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters return properties.Accept(Instance); } + public RuleActionDto Visit(AlgoliaAction action) + { + return SimpleMapper.Map(action, new AlgoliaActionDto()); + } + + public RuleActionDto Visit(AzureQueueAction action) + { + return SimpleMapper.Map(action, new AzureQueueActionDto()); + } + + public RuleActionDto Visit(FastlyAction action) + { + return SimpleMapper.Map(action, new FastlyActionDto()); + } + + public RuleActionDto Visit(SlackAction action) + { + return SimpleMapper.Map(action, new SlackActionDto()); + } + public RuleActionDto Visit(WebhookAction action) { return SimpleMapper.Map(action, new WebhookActionDto()); diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs index 6d40e73b1..0c58dc1e9 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs @@ -26,12 +26,16 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters return properties.Accept(Instance); } + public RuleTriggerDto Visit(AssetChangedTrigger trigger) + { + return SimpleMapper.Map(trigger, new AssetChangedTriggerDto()); + } + public RuleTriggerDto Visit(ContentChangedTrigger trigger) { - return new ContentChangedTriggerDto - { - Schemas = trigger.Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedTriggerSchemaDto())).ToList() - }; + var schemas = trigger.Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedTriggerSchemaDto())).ToList(); + + return new ContentChangedTriggerDto { Schemas = schemas, HandleAll = trigger.HandleAll }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionDto.cs index 629781317..5cf41aa29 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionDto.cs @@ -5,17 +5,25 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; -using Squidex.Areas.Api.Controllers.Rules.Models.Actions; using Squidex.Domain.Apps.Core.Rules; namespace Squidex.Areas.Api.Controllers.Rules.Models { - [JsonConverter(typeof(JsonInheritanceConverter), "actionType")] - [KnownType(typeof(WebhookActionDto))] + [JsonConverter(typeof(JsonInheritanceConverter), "actionType", typeof(RuleActionDto))] + [KnownType(nameof(Subtypes))] public abstract class RuleActionDto { public abstract RuleAction ToAction(); + + public static Type[] Subtypes() + { + var type = typeof(RuleActionDto); + + return type.Assembly.GetTypes().Where(type.IsAssignableFrom).ToArray(); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs index b2ed18047..f30b5d5e0 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs @@ -5,17 +5,25 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; -using Squidex.Areas.Api.Controllers.Rules.Models.Triggers; using Squidex.Domain.Apps.Core.Rules; namespace Squidex.Areas.Api.Controllers.Rules.Models { - [JsonConverter(typeof(JsonInheritanceConverter), "triggerType")] - [KnownType(typeof(ContentChangedTriggerDto))] + [JsonConverter(typeof(JsonInheritanceConverter), "triggerType", typeof(RuleTriggerDto))] + [KnownType(nameof(Subtypes))] public abstract class RuleTriggerDto { public abstract RuleTrigger ToTrigger(); + + public static Type[] Subtypes() + { + var type = typeof(RuleTriggerDto); + + return type.Assembly.GetTypes().Where(type.IsAssignableFrom).ToArray(); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedTriggerDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedTriggerDto.cs new file mode 100644 index 000000000..740e13fea --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/AssetChangedTriggerDto.cs @@ -0,0 +1,43 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using NJsonSchema.Annotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Triggers; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers +{ + [JsonSchema("AssetChanged")] + public sealed class AssetChangedTriggerDto : RuleTriggerDto + { + /// + /// Determines whether to handle the event when an asset is created. + /// + public bool SendCreate { get; set; } + + /// + /// Determines whether to handle the event when an asset is updated. + /// + public bool SendUpdate { get; set; } + + /// + /// Determines whether to handle the event when an asset is renamed. + /// + public bool SendRename { get; set; } + + /// + /// Determines whether to handle the event when an asset is deleted. + /// + public bool SendDelete { get; set; } + + public override RuleTrigger ToTrigger() + { + return SimpleMapper.Map(this, new AssetChangedTrigger()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerDto.cs index 1a37562df..aa2b70386 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerDto.cs @@ -25,12 +25,16 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers [Required] public List Schemas { get; set; } + /// + /// Determines whether the trigger should handle all content changes events. + /// + public bool HandleAll { get; set; } + public override RuleTrigger ToTrigger() { - return new ContentChangedTrigger - { - Schemas = Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedTriggerSchema())).ToImmutableList() - }; + var schemas = Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedTriggerSchema())).ToImmutableList(); + + return new ContentChangedTrigger { HandleAll = HandleAll, Schemas = schemas }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs index dc8b503db..dacd359b3 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs @@ -17,22 +17,22 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers public Guid SchemaId { get; set; } /// - /// True, when to send a message for created events. + /// Determines whether to handle the event when a content is created. /// public bool SendCreate { get; set; } /// - /// True, when to send a message for updated events. + /// Determines whether to handle the event when a content is updated. /// public bool SendUpdate { get; set; } /// - /// True, when to send a message for deleted events. + /// Determines whether to handle the event when a content is deleted. /// public bool SendDelete { get; set; } /// - /// True, when to send a message for published events. + /// Determines whether to handle the event when a content is published. /// public bool SendPublish { get; set; } } 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/Schemas/Models/FieldPropertiesDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs index 4eff189b2..afd3e8094 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs @@ -5,24 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; -using Squidex.Areas.Api.Controllers.Schemas.Models.Fields; using Squidex.Domain.Apps.Core.Schemas; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - [JsonConverter(typeof(JsonInheritanceConverter), "fieldType")] - [KnownType(typeof(AssetsFieldPropertiesDto))] - [KnownType(typeof(BooleanFieldPropertiesDto))] - [KnownType(typeof(DateTimeFieldPropertiesDto))] - [KnownType(typeof(GeolocationFieldPropertiesDto))] - [KnownType(typeof(JsonFieldPropertiesDto))] - [KnownType(typeof(NumberFieldPropertiesDto))] - [KnownType(typeof(ReferencesFieldPropertiesDto))] - [KnownType(typeof(StringFieldPropertiesDto))] - [KnownType(typeof(TagsFieldPropertiesDto))] + [JsonConverter(typeof(JsonInheritanceConverter), "fieldType", typeof(FieldPropertiesDto))] + [KnownType(nameof(Subtypes))] public abstract class FieldPropertiesDto { /// @@ -59,5 +52,12 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models public string Partitioning { get; set; } public abstract FieldProperties ToProperties(); + + public static Type[] Subtypes() + { + var type = typeof(SchemaPropertiesDto); + + return type.Assembly.GetTypes().Where(type.IsAssignableFrom).ToArray(); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index 5181db196..7118cc2a3 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -9,7 +9,6 @@ using System; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Primitives; using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Schemas.Models; using Squidex.Areas.Api.Controllers.Schemas.Models.Converters; @@ -96,7 +95,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas var response = entity.ToDetailsModel(); - Response.Headers["ETag"] = new StringValues(entity.Version.ToString()); + Response.Headers["ETag"] = entity.Version.ToString(); return Ok(response); } 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/Areas/IdentityServer/Controllers/Account/AccountController.cs b/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs index b2bdf20c0..4ee48c856 100644 --- a/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs @@ -12,6 +12,7 @@ using System.Security; using System.Security.Claims; using System.Text; using System.Threading.Tasks; +using IdentityServer4.Models; using IdentityServer4.Services; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -88,21 +89,52 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account } [HttpGet] - [Route("account/logout/")] - public async Task Logout(string logoutId) + [Route("account/consent/")] + public IActionResult Consent(string returnUrl = null) { - var context = await interactions.GetLogoutContextAsync(logoutId); + return View(new ConsentVM { PrivacyUrl = identityOptions.Value.PrivacyUrl, ReturnUrl = returnUrl }); + } - await signInManager.SignOutAsync(); + [HttpPost] + [Route("account/consent/")] + public async Task Consent(ConsentModel model, string returnUrl = null) + { + if (!model.ConsentToCookies) + { + ModelState.AddModelError(nameof(model.ConsentToCookies), "You have to give consent."); + } - var logoutUrl = context.PostLogoutRedirectUri; + if (!model.ConsentToPersonalInformation) + { + ModelState.AddModelError(nameof(model.ConsentToPersonalInformation), "You have to give consent."); + } - if (string.IsNullOrWhiteSpace(logoutUrl)) + if (!ModelState.IsValid) { - logoutUrl = urlOptions.Value.BuildUrl("logout/"); + var vm = new ConsentVM { PrivacyUrl = identityOptions.Value.PrivacyUrl, ReturnUrl = returnUrl }; + + return View(vm); } - return Redirect(logoutUrl); + var user = await userManager.GetUserAsync(User); + + user.SetConsentForEmails(model.ConsentToAutomatedEmails); + user.SetConsent(); + + await userManager.UpdateAsync(user); + + return RedirectToReturnUrl(returnUrl); + } + + [HttpGet] + [Route("account/logout/")] + public async Task Logout(string logoutId) + { + var context = await interactions.GetLogoutContextAsync(logoutId); + + await signInManager.SignOutAsync(); + + return RedirectToLogoutUrl(context); } [HttpGet] @@ -143,13 +175,9 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account { return await LoginViewAsync(returnUrl, true, true); } - else if (!string.IsNullOrWhiteSpace(returnUrl)) - { - return Redirect(returnUrl); - } else { - return Redirect("~/../"); + return RedirectToReturnUrl(returnUrl); } } @@ -203,11 +231,13 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account var isLoggedIn = result.Succeeded; + IUser user = null; + if (!isLoggedIn) { var email = externalLogin.Principal.FindFirst(ClaimTypes.Email).Value; - var user = await userManager.FindByEmailAsync(email); + user = await userManager.FindByEmailAsync(email); if (user != null) { @@ -241,13 +271,13 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account { return RedirectToAction(nameof(Login)); } - else if (!string.IsNullOrWhiteSpace(returnUrl)) + else if (user != null && !user.HasConsent()) { - return Redirect(returnUrl); + return RedirectToAction(nameof(Consent), new { returnUrl }); } else { - return Redirect("~/../"); + return RedirectToReturnUrl(returnUrl); } } @@ -292,24 +322,48 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account { var user = userFactory.Create(email); - if (!externalLogin.Principal.HasClaim(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)) + foreach (var squidexClaim in externalLogin.Principal.GetSquidexClaims()) { - user.SetClaim(SquidexClaimTypes.SquidexPictureUrl, GravatarHelper.CreatePictureUrl(email)); + user.AddClaim(squidexClaim); } - if (!externalLogin.Principal.HasClaim(x => x.Type == SquidexClaimTypes.SquidexDisplayName)) + if (!user.HasPictureUrl()) { - user.SetClaim(SquidexClaimTypes.SquidexDisplayName, email); + user.SetPictureUrl(GravatarHelper.CreatePictureUrl(email)); } - foreach (var squidexClaim in externalLogin.Principal.Claims.Where(c => c.Type.StartsWith(SquidexClaimTypes.Prefix, StringComparison.Ordinal))) + if (!user.HasDisplayName()) { - user.AddClaim(squidexClaim); + user.SetDisplayName(email); } return user; } + private IActionResult RedirectToLogoutUrl(LogoutRequest context) + { + if (!string.IsNullOrWhiteSpace(context.PostLogoutRedirectUri)) + { + return Redirect(context.PostLogoutRedirectUri); + } + else + { + return Redirect("~/../"); + } + } + + private IActionResult RedirectToReturnUrl(string returnUrl) + { + if (!string.IsNullOrWhiteSpace(returnUrl)) + { + return Redirect(returnUrl); + } + else + { + return Redirect("~/../"); + } + } + private async Task MakeIdentityOperation(Func> action, [CallerMemberName] string operationName = null) { try diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentModel.cs b/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentModel.cs new file mode 100644 index 000000000..bcc77c7e5 --- /dev/null +++ b/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentModel.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.IdentityServer.Controllers.Account +{ + public sealed class ConsentModel + { + public bool ConsentToPersonalInformation { get; set; } + + public bool ConsentToAutomatedEmails { get; set; } + + public bool ConsentToCookies { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithAppRef.cs b/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentVM.cs similarity index 58% rename from src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithAppRef.cs rename to src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentVM.cs index c8ca9da7e..a8764ff13 100644 --- a/src/Squidex.Domain.Apps.Entities/IUpdateableEntityWithAppRef.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Account/ConsentVM.cs @@ -1,16 +1,16 @@ // ========================================================================== // 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.Areas.IdentityServer.Controllers.Account { - public interface IUpdateableEntityWithAppRef + public sealed class ConsentVM { - Guid AppId { get; set; } + public string ReturnUrl { get; set; } + + public string PrivacyUrl { get; set; } } } diff --git a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs index 3b0713066..90ea54e4f 100644 --- a/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs +++ b/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs @@ -75,12 +75,8 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Route("/account/profile/login-add-callback/")] public Task AddLoginCallback() { - return MakeChangeAsync(async user => - { - var externalLogin = await signInManager.GetExternalLoginInfoWithDisplayNameAsync(userManager.GetUserId(User)); - - return await userManager.AddLoginAsync(user, externalLogin); - }, "Login added successfully."); + return MakeChangeAsync(user => AddLoginAsync(user), + "Login added successfully."); } [HttpPost] @@ -119,31 +115,41 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Route("/account/profile/upload-picture/")] public Task UploadPicture(List file) { - return MakeChangeAsync(async user => + return MakeChangeAsync(user => UpdatePictureAsync(file, user), + "Picture uploaded successfully."); + } + + private async Task AddLoginAsync(IUser user) + { + var externalLogin = await signInManager.GetExternalLoginInfoWithDisplayNameAsync(userManager.GetUserId(User)); + + return await userManager.AddLoginAsync(user, externalLogin); + } + + private async Task UpdatePictureAsync(List file, IUser user) + { + if (file.Count != 1) { - if (file.Count != 1) - { - return IdentityResult.Failed(new IdentityError { Description = "Please upload a single file." }); - } + return IdentityResult.Failed(new IdentityError { Description = "Please upload a single file." }); + } - var thumbnailStream = new MemoryStream(); - try - { - await assetThumbnailGenerator.CreateThumbnailAsync(file[0].OpenReadStream(), thumbnailStream, 128, 128, "Crop"); + var thumbnailStream = new MemoryStream(); + try + { + await assetThumbnailGenerator.CreateThumbnailAsync(file[0].OpenReadStream(), thumbnailStream, 128, 128, "Crop"); - thumbnailStream.Position = 0; - } - catch - { - return IdentityResult.Failed(new IdentityError { Description = "Picture is not a valid image." }); - } + thumbnailStream.Position = 0; + } + catch + { + return IdentityResult.Failed(new IdentityError { Description = "Picture is not a valid image." }); + } - await userPictureStore.UploadAsync(user.Id, thumbnailStream); + await userPictureStore.UploadAsync(user.Id, thumbnailStream); - user.SetPictureUrlToStore(); + user.SetPictureUrlToStore(); - return await userManager.UpdateAsync(user); - }, "Picture uploaded successfully."); + return await userManager.UpdateAsync(user); } private async Task MakeChangeAsync(Func> action, string successMessage, ChangeProfileModel model = null) diff --git a/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml b/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml new file mode 100644 index 000000000..7e6a94ae2 --- /dev/null +++ b/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml @@ -0,0 +1,91 @@ +@model Squidex.Areas.IdentityServer.Controllers.Account.ConsentVM + +@{ + ViewBag.Theme = "white"; + ViewBag.Title = "Consent"; +} + +@functions { + public string ErrorClass(string error) + { + return ViewData.ModelState[error]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid ? "border-danger" : ""; + } +} + +
+
+ + +

We need your consent

+ +
+
+

Automated E-Mails (Optional)

+ +
+
+ +
+
+ I understand and agree that Squidex sends e-mails to inform me about new features, breaking changes and downtimes. +
+
+
+
+ +
+
+

Cookies & Analytics

+ +
+
+ +
+
+

+ I understand and agree that Squidex uses cookies to ensure you get the best experience on our platform and to store your login status. +

+

+ I understand and agree that Squidex has integrated Google Analytics (with the anonymizer function). Google Analytics is a web analytics service to gather and analyse data about the behavior of users. +

+

+ I have read the privacy policies and understand them. +

+
+
+
+
+ +
+
+

Personal Information

+ +
+
+ +
+
+ I understand and agree that Squidex collects the following private information that are retrieved from external authentication providers such as Google, Microsoft or Github. + +
    +
  • + Basic personal information (e-mail address, name and picture) are provided to all other users so that they can add you to their working space. +
  • +
  • + At anytime you have the option to change these information to anonymize your account. +
  • +
  • + Your user account has an unique identifier and for all your changes we track, that you made these changes and provide this information to other users. +
  • +
+ +
+
+
+
+ +
+ +
+
+
\ No newline at end of file diff --git a/src/Squidex/Config/Authentication/GoogleHandler.cs b/src/Squidex/Config/Authentication/GoogleHandler.cs index a0500b80a..ded513599 100644 --- a/src/Squidex/Config/Authentication/GoogleHandler.cs +++ b/src/Squidex/Config/Authentication/GoogleHandler.cs @@ -29,7 +29,7 @@ namespace Squidex.Config.Authentication var displayNameClaim = context.Identity.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Name); if (displayNameClaim != null) { - context.Identity.AddClaim(new Claim(SquidexClaimTypes.SquidexDisplayName, displayNameClaim.Value)); + context.Identity.SetDisplayName(displayNameClaim.Value); } var pictureUrl = context.User?.Value("picture"); @@ -46,7 +46,7 @@ namespace Squidex.Config.Authentication if (!string.IsNullOrWhiteSpace(pictureUrl)) { - context.Identity.AddClaim(new Claim(SquidexClaimTypes.SquidexPictureUrl, pictureUrl)); + context.Identity.SetPictureUrl(pictureUrl); } return base.CreatingTicket(context); diff --git a/src/Squidex/Config/Authentication/MicrosoftHandler.cs b/src/Squidex/Config/Authentication/MicrosoftHandler.cs index 1b06bdcd3..168995ad9 100644 --- a/src/Squidex/Config/Authentication/MicrosoftHandler.cs +++ b/src/Squidex/Config/Authentication/MicrosoftHandler.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication.OAuth; using Squidex.Shared.Identity; @@ -20,7 +19,7 @@ namespace Squidex.Config.Authentication if (!string.IsNullOrEmpty(displayName)) { - context.Identity.AddClaim(new Claim(SquidexClaimTypes.SquidexDisplayName, displayName)); + context.Identity.SetDisplayName(displayName); } var id = context.User.Value("id"); @@ -29,7 +28,7 @@ namespace Squidex.Config.Authentication { var pictureUrl = $"https://apis.live.net/v5.0/{id}/picture"; - context.Identity.AddClaim(new Claim(SquidexClaimTypes.SquidexPictureUrl, pictureUrl)); + context.Identity.SetPictureUrl(pictureUrl); } return base.CreatingTicket(context); diff --git a/src/Squidex/Config/Domain/ReadServices.cs b/src/Squidex/Config/Domain/ReadServices.cs index e1a4da864..16f925fff 100644 --- a/src/Squidex/Config/Domain/ReadServices.cs +++ b/src/Squidex/Config/Domain/ReadServices.cs @@ -46,6 +46,8 @@ namespace Squidex.Config.Domain .As(); services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); } var exposeSourceUrl = config.GetOptionalValue("assetStore:exposeSourceUrl", true); @@ -89,9 +91,27 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .AsSelf(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); diff --git a/src/Squidex/Config/Domain/WriteServices.cs b/src/Squidex/Config/Domain/WriteServices.cs index fd498d374..a208e0567 100644 --- a/src/Squidex/Config/Domain/WriteServices.cs +++ b/src/Squidex/Config/Domain/WriteServices.cs @@ -13,6 +13,7 @@ using Migrate_01.Migrations; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.Templates; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Rules; @@ -64,13 +65,16 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddTransientAs() .As(); - services.AddTransientAs() + services.AddTransientAs() .As(); - services.AddTransientAs() + services.AddTransientAs() .As(); services.AddTransientAs() @@ -79,6 +83,12 @@ namespace Squidex.Config.Domain services.AddTransientAs() .As(); + services.AddTransientAs() + .As(); + + services.AddTransientAs() + .As(); + services.AddTransientAs() .AsSelf(); diff --git a/src/Squidex/Config/MyIdentityOptions.cs b/src/Squidex/Config/MyIdentityOptions.cs index 8dade2b71..8ab795f53 100644 --- a/src/Squidex/Config/MyIdentityOptions.cs +++ b/src/Squidex/Config/MyIdentityOptions.cs @@ -23,6 +23,8 @@ namespace Squidex.Config public string AuthorityUrl { get; set; } + public string PrivacyUrl { get; set; } + public bool RequiresHttps { get; set; } public bool AllowPasswordAuth { get; set; } diff --git a/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs b/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs index a8f2adc05..42c2ba178 100644 --- a/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs +++ b/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs @@ -54,7 +54,7 @@ namespace Squidex.Pipeline private static IActionResult OnValidationException(ValidationException ex) { - return ErrorResult(400, new ErrorDto { Message = ex.Message, Details = ex.Errors.Select(e => e.Message).ToArray() }); + return ErrorResult(400, new ErrorDto { Message = ex.Summary, Details = ex.Errors.Select(e => e.Message).ToArray() }); } private static IActionResult ErrorResult(int statusCode, ErrorDto error) @@ -64,21 +64,6 @@ namespace Squidex.Pipeline return new ObjectResult(error) { StatusCode = statusCode }; } - public override void OnActionExecuting(ActionExecutingContext context) - { - if (!context.ModelState.IsValid) - { - var errors = - context.ModelState.SelectMany(m => - { - return m.Value.Errors.Where(e => !string.IsNullOrWhiteSpace(e.ErrorMessage)) - .Select(e => new ValidationError(e.ErrorMessage, m.Key)); - }).ToList(); - - 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 15960c0d2..8ee2b62da 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs @@ -9,7 +9,6 @@ using System; using System.Globalization; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Primitives; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -26,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(); @@ -42,7 +46,7 @@ namespace Squidex.Pipeline.CommandMiddlewares if (context.Result() is EntitySavedResult result) { - httpContextAccessor.HttpContext.Response.Headers["ETag"] = new StringValues(result.Version.ToString()); + httpContextAccessor.HttpContext.Response.Headers["ETag"] = result.Version.ToString(); } } } diff --git a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs index 28741a599..f8e16d354 100644 --- a/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs +++ b/src/Squidex/Pipeline/CommandMiddlewares/EnrichWithActorCommandMiddleware.cs @@ -27,13 +27,26 @@ namespace Squidex.Pipeline.CommandMiddlewares public Task HandleAsync(CommandContext context, Func next) { - if (context.Command is SquidexCommand squidexCommand && squidexCommand.Actor == null) + if (httpContextAccessor.HttpContext == null) { - var actorToken = - FindActorFromSubject() ?? - FindActorFromClient(); + return next(); + } + + if (context.Command is SquidexCommand squidexCommand) + { + if (squidexCommand.Actor == null) + { + var actorToken = + FindActorFromSubject() ?? + FindActorFromClient(); + + squidexCommand.Actor = actorToken ?? throw new SecurityException("No actor with subject or client id available."); + } - squidexCommand.Actor = actorToken ?? throw new SecurityException("No actor with subject or client id available."); + if (squidexCommand.User == null) + { + squidexCommand.User = httpContextAccessor.HttpContext.User; + } } return next(); 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/Pipeline/Swagger/SwaggerHelper.cs b/src/Squidex/Pipeline/Swagger/SwaggerHelper.cs index 50a2fb41a..2e4b36aa2 100644 --- a/src/Squidex/Pipeline/Swagger/SwaggerHelper.cs +++ b/src/Squidex/Pipeline/Swagger/SwaggerHelper.cs @@ -72,7 +72,7 @@ namespace Squidex.Pipeline.Swagger document.Host = context.Request.Host.Value; } - document.SecurityDefinitions.Add("OAuth2", CreateOAuthSchema(urlOptions)); + document.SecurityDefinitions.Add(Constants.SecurityDefinition, CreateOAuthSchema(urlOptions)); return document; } diff --git a/src/Squidex/Squidex.csproj b/src/Squidex/Squidex.csproj index 0f1cfd7b0..6a2adcef5 100644 --- a/src/Squidex/Squidex.csproj +++ b/src/Squidex/Squidex.csproj @@ -48,32 +48,37 @@ + + - - - + + + - - + + + + + - - - - + + + + - + - + - - + + diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html index fe1bca11d..917428b6e 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html @@ -4,7 +4,7 @@
- diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index 5a6aa0ec0..deddd7689 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -31,7 +31,7 @@ export class UserPageComponent implements OnInit { public userFormSubmitted = false; public userForm: FormGroup; - public userFormError? = ''; + public userFormError = ''; public isCurrentUser = false; public isNewMode = false; diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.html b/src/Squidex/app/features/administration/pages/users/users-page.component.html index 64803f59c..de7e0fdd9 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.html @@ -4,7 +4,7 @@
- @@ -92,10 +92,10 @@ diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.html b/src/Squidex/app/features/apps/pages/apps-page.component.html index aa3f2a039..9c65780f2 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.html +++ b/src/Squidex/app/features/apps/pages/apps-page.component.html @@ -1,17 +1,56 @@  -
-
+
+

Hi {{ctx.user.displayName}}

+ +
+ Welcome to Squidex. +
+
+ +
+

You are not collaborating to any app yet

+
- +
+
+

{{app.name}}

+ +
+ Edit +
+
+
-
-
-

{{app.name}}

+
+
+
+
+ +
- Edit +

New App

+ +
+ Create a new blank app without content and schemas. +
+
+
+ +
+
+
+ +
+ +

New Blog Sample

+ +
+
Start with our ready to use blog.
+
Sample Code: ASP.NET Core
+
@@ -21,7 +60,8 @@