diff --git a/.drone.yml b/.drone.yml index bc95a8247..d6abed99a 100644 --- a/.drone.yml +++ b/.drone.yml @@ -20,7 +20,7 @@ steps: image: docker commands: - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD - - docker build -t squidex/squidex:dev -t squidex/squidex:dev-$${DRONE_BUILD_NUMBER} . + - docker build -t squidex/squidex:dev -t squidex/squidex:dev-$${DRONE_BUILD_NUMBER} --build-arg SQUIDEX__VERSION=dev-$${DRONE_BUILD_NUMBER} . - docker push squidex/squidex:dev - docker push squidex/squidex:dev-$${DRONE_BUILD_NUMBER} volumes: @@ -43,7 +43,7 @@ steps: image: docker commands: - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD - - docker build -t squidex/squidex:latest -t squidex/squidex:$${DRONE_TAG} . + - docker build -t squidex/squidex:latest -t squidex/squidex:$${DRONE_TAG} --build-arg SQUIDEX__VERSION=$${DRONE_TAG} . - docker push squidex/squidex:latest - docker push squidex/squidex:$${DRONE_TAG} volumes: @@ -63,7 +63,7 @@ steps: - name: build_binaries image: docker commands: - - docker build . -t squidex-build-image -f Dockerfile.build + - docker build -t squidex-build-image -f Dockerfile.build --build-arg SQUIDEX__VERSION=$${DRONE_TAG} . - docker create --name squidex-build-container squidex-build-image - docker cp squidex-build-container:/out /build volumes: diff --git a/Dockerfile b/Dockerfile index e57b35d67..cabf22bc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,8 @@ # FROM squidex/dotnet:2.2-sdk-chromium-phantomjs-node as builder +ARG SQUIDEX__VERSION=1.0.0 + WORKDIR /src # Copy Node project files. @@ -35,7 +37,7 @@ RUN dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests. && dotnet test tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj # Publish -RUN dotnet publish src/Squidex/Squidex.csproj --output /out/alpine --configuration Release -r alpine.3.7-x64 +RUN dotnet publish src/Squidex/Squidex.csproj --output /out/alpine --configuration Release -r alpine.3.7-x64 -p:version=$SQUIDEX__VERSION # # Stage 2, Build runtime diff --git a/Dockerfile.build b/Dockerfile.build index 96debc8cd..c2002ae5c 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -1,5 +1,7 @@ FROM squidex/dotnet:2.2-sdk-chromium-phantomjs-node as builder +ARG SQUIDEX__VERSION=1.0.0 + WORKDIR /src # Copy Node project files. @@ -32,4 +34,4 @@ RUN dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests. && dotnet test tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj # Publish -RUN dotnet publish src/Squidex/Squidex.csproj --output /out/ --configuration Release \ No newline at end of file +RUN dotnet publish src/Squidex/Squidex.csproj --output /out/ --configuration Release -p:version=$SQUIDEX__VERSION \ No newline at end of file diff --git a/Squidex.ruleset b/Squidex.ruleset index 055510070..5aae5da01 100644 --- a/Squidex.ruleset +++ b/Squidex.ruleset @@ -63,6 +63,7 @@ + diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs index fd670011f..518ecd646 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs @@ -21,7 +21,7 @@ namespace Squidex.Domain.Apps.Core.Apps { Guard.NotNullOrEmpty(secret, nameof(secret)); Guard.NotNullOrEmpty(role, nameof(role)); - + Role = role; Secret = secret; diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs index 15ed0a51c..864961903 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Core.Apps : base(name) { Guard.NotNullOrEmpty(pattern, nameof(pattern)); - + Pattern = pattern; Message = message; diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs index 51b23d192..2a7be22e2 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs @@ -5,12 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Newtonsoft.Json; -using Squidex.Infrastructure.Json.Newtonsoft; -using Squidex.Infrastructure.Security; using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; +using Squidex.Infrastructure.Json.Newtonsoft; +using Squidex.Infrastructure.Security; namespace Squidex.Domain.Apps.Core.Apps.Json { diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs index 700fb4246..22f6f1b73 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs @@ -5,10 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; -using Squidex.Infrastructure.Security; +using System; using System.Collections.Generic; using System.Diagnostics.Contracts; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; using P = Squidex.Shared.Permissions; namespace Squidex.Domain.Apps.Core.Apps @@ -20,7 +21,7 @@ namespace Squidex.Domain.Apps.Core.Apps public const string Owner = "Owner"; public const string Reader = "Reader"; - private static readonly HashSet DefaultRolesSet = new HashSet + private static readonly HashSet DefaultRolesSet = new HashSet(StringComparer.OrdinalIgnoreCase) { Editor, Developer, @@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Core.Apps return role != null && DefaultRolesSet.Contains(role); } + public static bool IsRole(string name, string expected) + { + return name != null && string.Equals(name, expected, StringComparison.OrdinalIgnoreCase); + } + public static Role CreateOwner(string app) { return new Role(Owner, @@ -65,7 +71,8 @@ namespace Squidex.Domain.Apps.Core.Apps return new Role(Editor, P.ForApp(P.AppAssets, app), P.ForApp(P.AppCommon, app), - P.ForApp(P.AppContents, app)); + P.ForApp(P.AppContents, app), + P.ForApp(P.AppWorkflowsRead, app)); } public static Role CreateReader(string app) @@ -84,6 +91,7 @@ namespace Squidex.Domain.Apps.Core.Apps P.ForApp(P.AppCommon, app), P.ForApp(P.AppContents, app), P.ForApp(P.AppPatterns, app), + P.ForApp(P.AppWorkflows, app), P.ForApp(P.AppRules, app), P.ForApp(P.AppSchemas, app)); } diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs index 58e7c5bfa..4e3e1d066 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs @@ -5,12 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; -using Squidex.Infrastructure.Collections; using System; using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Linq; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; namespace Squidex.Domain.Apps.Core.Apps { diff --git a/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs b/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs index a9b2bb7cb..61a90f23c 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using NodaTime; using Squidex.Infrastructure; -using System; namespace Squidex.Domain.Apps.Core.Comments { diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs new file mode 100644 index 000000000..286b83d12 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Squidex.Infrastructure.Json.Newtonsoft; + +namespace Squidex.Domain.Apps.Core.Contents.Json +{ + public sealed class StatusConverter : JsonConverter, ISupportedTypes + { + public IEnumerable SupportedTypes + { + get { yield return typeof(Status); } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(value.ToString()); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType != JsonToken.String) + { + throw new JsonException($"Expected String, but got {reader.TokenType}."); + } + + return new Status(reader.Value.ToString()); + } + + public override bool CanConvert(Type objectType) + { + return objectType == typeof(Status); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs new file mode 100644 index 000000000..84e8092ee --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// 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 Newtonsoft.Json; +using Squidex.Infrastructure.Json.Newtonsoft; + +namespace Squidex.Domain.Apps.Core.Contents.Json +{ + public sealed class WorkflowConverter : JsonClassConverter + { + protected override void WriteValue(JsonWriter writer, Workflows value, JsonSerializer serializer) + { + var json = new Dictionary(value.Count); + + foreach (var workflow in value) + { + json.Add(workflow.Key, workflow.Value); + } + + serializer.Serialize(writer, json); + } + + protected override Workflows ReadValue(JsonReader reader, Type objectType, JsonSerializer serializer) + { + var json = serializer.Deserialize>(reader); + + return new Workflows(json.ToArray()); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs index c20e0c4eb..32026fc44 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs @@ -5,12 +5,58 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.ComponentModel; + namespace Squidex.Domain.Apps.Core.Contents { - public enum Status + [TypeConverter(typeof(StatusConverter))] + public struct Status : IEquatable { - Draft, - Archived, - Published + public static readonly Status Archived = new Status("Archived"); + public static readonly Status Draft = new Status("Draft"); + public static readonly Status Published = new Status("Published"); + + private readonly string name; + + public string Name + { + get { return name ?? "Unknown"; } + } + + public Status(string name) + { + this.name = name; + } + + public override bool Equals(object obj) + { + return obj is Status status && Equals(status); + } + + public bool Equals(Status other) + { + return string.Equals(name, other.name); + } + + public override int GetHashCode() + { + return name?.GetHashCode() ?? 0; + } + + public override string ToString() + { + return Name; + } + + public static bool operator ==(Status lhs, Status rhs) + { + return lhs.Equals(rhs); + } + + public static bool operator !=(Status lhs, Status rhs) + { + return !lhs.Equals(rhs); + } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs index 9e3900deb..eae462221 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs @@ -1,7 +1,7 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) +// Copyright (c) Squidex UG (haftungsbeschränkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== @@ -9,9 +9,8 @@ namespace Squidex.Domain.Apps.Core.Contents { public enum StatusChange { - Archived, + Change, Published, - Restored, Unpublished } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs new file mode 100644 index 000000000..0e64ea00b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusColors.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Contents +{ + public static class StatusColors + { + public const string Archived = "#eb3142"; + public const string Draft = "#8091a5"; + public const string Published = "#4bb958"; + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs new file mode 100644 index 000000000..a7ba559c7 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs @@ -0,0 +1,36 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel; +using System.Globalization; + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class StatusConverter : TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) + { + return sourceType == typeof(string); + } + + public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) + { + return destinationType == typeof(string); + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + return new Status(value?.ToString()); + } + + public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) + { + return value.ToString(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs deleted file mode 100644 index 005b2d4b3..000000000 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs +++ /dev/null @@ -1,32 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.Collections.Generic; -using System.Linq; - -namespace Squidex.Domain.Apps.Core.Contents -{ - public static class StatusFlow - { - private static readonly Dictionary Flow = new Dictionary - { - [Status.Draft] = new[] { Status.Published, Status.Archived }, - [Status.Archived] = new[] { Status.Draft }, - [Status.Published] = new[] { Status.Draft, Status.Archived } - }; - - public static bool Exists(Status status) - { - return Flow.ContainsKey(status); - } - - public static bool CanChange(Status status, Status toStatus) - { - return Flow.TryGetValue(status, out var state) && state.Contains(toStatus); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusInfo.cs similarity index 52% rename from src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs rename to src/Squidex.Domain.Apps.Core.Model/Contents/StatusInfo.cs index 9f3eda547..a444badaa 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusInfo.cs @@ -5,19 +5,19 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure.Commands; - -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Core.Contents { - public sealed class ContentDataChangedResult : EntitySavedResult + public sealed class StatusInfo { - public NamedContentData Data { get; } + public Status Status { get; } + + public string Color { get; } - public ContentDataChangedResult(NamedContentData data, long version) - : base(version) + public StatusInfo(Status status, string color) { - Data = data; + Status = status; + + Color = color; } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs new file mode 100644 index 000000000..dae5dfd26 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs @@ -0,0 +1,125 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class Workflow : Named + { + private const string DefaultName = "Unnamed"; + + public static readonly IReadOnlyDictionary EmptySteps = new Dictionary(); + public static readonly IReadOnlyList EmptySchemaIds = new List(); + public static readonly Workflow Default = CreateDefault(); + public static readonly Workflow Empty = new Workflow(default, EmptySteps); + + public IReadOnlyDictionary Steps { get; } = EmptySteps; + + public IReadOnlyList SchemaIds { get; } = EmptySchemaIds; + + public Status Initial { get; } + + public Workflow( + Status initial, + IReadOnlyDictionary steps, + IReadOnlyList schemaIds = null, + string name = null) + : base(name ?? DefaultName) + { + Initial = initial; + + if (steps != null) + { + Steps = steps; + } + + if (schemaIds != null) + { + SchemaIds = schemaIds; + } + } + + public static Workflow CreateDefault(string name = null) + { + return new Workflow( + Status.Draft, new Dictionary + { + [Status.Archived] = + new WorkflowStep( + new Dictionary + { + [Status.Draft] = new WorkflowTransition() + }, + StatusColors.Archived, true), + [Status.Draft] = + new WorkflowStep( + new Dictionary + { + [Status.Archived] = new WorkflowTransition(), + [Status.Published] = new WorkflowTransition() + }, + StatusColors.Draft), + [Status.Published] = + new WorkflowStep( + new Dictionary + { + [Status.Archived] = new WorkflowTransition(), + [Status.Draft] = new WorkflowTransition() + }, + StatusColors.Published) + }, null, name); + } + + public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status) + { + if (TryGetStep(status, out var step)) + { + foreach (var transition in step.Transitions) + { + yield return (transition.Key, Steps[transition.Key], transition.Value); + } + } + else if (TryGetStep(Initial, out var initial)) + { + yield return (Initial, initial, WorkflowTransition.Default); + } + } + + public bool TryGetTransition(Status from, Status to, out WorkflowTransition transition) + { + transition = null; + + if (TryGetStep(from, out var step)) + { + if (step.Transitions.TryGetValue(to, out transition)) + { + return true; + } + } + else if (to == Initial) + { + transition = WorkflowTransition.Default; + + return true; + } + + return false; + } + + public bool TryGetStep(Status status, out WorkflowStep step) + { + return Steps.TryGetValue(status, out step); + } + + public (Status Key, WorkflowStep) GetInitialStep() + { + return (Initial, Steps[Initial]); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs new file mode 100644 index 000000000..04eb595c5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class WorkflowStep + { + private static readonly IReadOnlyDictionary EmptyTransitions = new Dictionary(); + + public IReadOnlyDictionary Transitions { get; } + + public string Color { get; } + + public bool NoUpdate { get; } + + public WorkflowStep(IReadOnlyDictionary transitions = null, string color = null, bool noUpdate = false) + { + Transitions = transitions ?? EmptyTransitions; + + Color = color; + + NoUpdate = noUpdate; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs similarity index 50% rename from src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs rename to src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs index a43e109cc..a41c5ba73 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs @@ -5,21 +5,21 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure.Commands; - -namespace Squidex.Domain.Apps.Entities.Assets +namespace Squidex.Domain.Apps.Core.Contents { - public class AssetSavedResult : EntitySavedResult + public sealed class WorkflowTransition { - public long FileVersion { get; } + public static readonly WorkflowTransition Default = new WorkflowTransition(); + + public string Expression { get; } - public string FileHash { get; } + public string Role { get; } - public AssetSavedResult(long version, long fileVersion, string fileHash) - : base(version) + public WorkflowTransition(string expression = null, string role = null) { - FileVersion = fileVersion; - FileHash = fileHash; + Expression = expression; + + Role = role; } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs new file mode 100644 index 000000000..b5d86740c --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs @@ -0,0 +1,84 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; + +namespace Squidex.Domain.Apps.Core.Contents +{ + public sealed class Workflows : ArrayDictionary + { + public static readonly Workflows Empty = new Workflows(); + + private Workflows() + { + } + + public Workflows(KeyValuePair[] items) + : base(items) + { + } + + [Pure] + public Workflows Remove(Guid id) + { + return new Workflows(Without(id)); + } + + [Pure] + public Workflows Add(Guid workflowId, string name) + { + Guard.NotNullOrEmpty(name, nameof(name)); + + return new Workflows(With(workflowId, Workflow.CreateDefault(name))); + } + + [Pure] + public Workflows Set(Workflow workflow) + { + Guard.NotNull(workflow, nameof(workflow)); + + return new Workflows(With(Guid.Empty, workflow)); + } + + [Pure] + public Workflows Set(Guid id, Workflow workflow) + { + Guard.NotNull(workflow, nameof(workflow)); + + return new Workflows(With(id, workflow)); + } + + [Pure] + public Workflows Update(Guid id, Workflow workflow) + { + Guard.NotNull(workflow, nameof(workflow)); + + if (id == Guid.Empty) + { + return Set(workflow); + } + + if (!ContainsKey(id)) + { + return this; + } + + return new Workflows(With(id, workflow)); + } + + public Workflow GetFirst() + { + return Values.FirstOrDefault() ?? Workflow.Default; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs b/src/Squidex.Domain.Apps.Core.Model/Named.cs similarity index 93% rename from src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs rename to src/Squidex.Domain.Apps.Core.Model/Named.cs index 69ba9a3c1..fd76c4e8f 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Named.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Named.cs @@ -7,7 +7,7 @@ using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Core.Apps +namespace Squidex.Domain.Apps.Core { public abstract class Named { diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs index ed1d9b033..d9c958390 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs @@ -5,8 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System.Collections.ObjectModel; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Rules.Triggers { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs index 99d62bd4e..77cf55f72 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs @@ -5,10 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System; using System.Collections.Generic; using System.Diagnostics.Contracts; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs index f07114303..4450ef2d1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Core.Schemas public sealed class FieldCollection : Cloneable> where T : IField { public static readonly FieldCollection Empty = new FieldCollection(); - + private static readonly Dictionary EmptyById = new Dictionary(); private static readonly Dictionary EmptyByString = new Dictionary(); diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs index 88a11c699..76ba5da7d 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System.Collections.Generic; using System.Linq; +using Squidex.Infrastructure; using NamedIdStatic = Squidex.Infrastructure.NamedId; namespace Squidex.Domain.Apps.Core.Schemas diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs index 78e3d3f81..3a7a90900 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using Newtonsoft.Json; using Squidex.Infrastructure; -using System; using P = Squidex.Domain.Apps.Core.Partitioning; namespace Squidex.Domain.Apps.Core.Schemas.Json @@ -44,7 +44,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json if (Properties is ArrayFieldProperties arrayProperties) { - var nested = Children?.ToArray(n => n.ToNestedField()) ?? Array.Empty(); + var nested = Children?.Map(n => n.ToNestedField()) ?? Array.Empty(); return new ArrayField(Id, Name, partitioning, nested, arrayProperties, this); } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs index 83196b881..54c31c88f 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonSchemaModel.cs @@ -49,7 +49,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json SimpleMapper.Map(schema, this); Fields = - schema.Fields.ToArray(x => + schema.Fields.Select(x => new JsonFieldModel { Id = x.Id, @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json IsDisabled = x.IsDisabled, Partitioning = x.Partitioning.Key, Properties = x.RawProperties - }); + }).ToArray(); PreviewUrls = schema.PreviewUrls.ToDictionary(x => x.Key, x => x.Value); } @@ -69,7 +69,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json { if (field is ArrayField arrayField) { - return arrayField.Fields.ToArray(x => + return arrayField.Fields.Select(x => new JsonNestedFieldModel { Id = x.Id, @@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json IsLocked = x.IsLocked, IsDisabled = x.IsDisabled, Properties = x.RawProperties - }); + }).ToArray(); } return null; @@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Core.Schemas.Json public Schema ToSchema() { - var fields = Fields.ToArray(f => f.ToField()) ?? Array.Empty(); + var fields = Fields.Map(f => f.ToField()) ?? Array.Empty(); var schema = new Schema(Name, fields, Properties, IsPublished, IsSingleton); diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs index b7dfb78d4..5dc24c564 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== - namespace Squidex.Domain.Apps.Core.Schemas { public sealed class JsonFieldProperties : FieldProperties diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs index b1d672216..23989053a 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs @@ -5,8 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure; using System; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Schemas { 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 7a88572ce..f5b9cd862 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 @@ -14,6 +14,8 @@ runtime; build; native; contentfiles; analyzers + + diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs index 45148a8e2..565272ef6 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs @@ -9,12 +9,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents { public enum EnrichedContentEventType { - Archived, Created, Deleted, Published, - Restored, + StatusChanged, + Updated, Unpublished, - Updated } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs index 807210461..1d1922df0 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs @@ -47,6 +47,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules AddPattern("APP_ID", AppId); AddPattern("APP_NAME", AppName); AddPattern("CONTENT_ACTION", ContentAction); + AddPattern("CONTENT_STATUS", ContentStatus); AddPattern("CONTENT_URL", ContentUrl); AddPattern("SCHEMA_ID", SchemaId); AddPattern("SCHEMA_NAME", SchemaName); @@ -212,6 +213,16 @@ namespace Squidex.Domain.Apps.Core.HandleRules return Fallback; } + private static string ContentStatus(EnrichedEvent @event) + { + if (@event is EnrichedContentEvent contentEvent) + { + return contentEvent.Status.ToString(); + } + + return Fallback; + } + private string ContentUrl(EnrichedEvent @event) { if (@event is EnrichedContentEvent contentEvent) diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/DefaultConverter.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/DefaultConverter.cs index 5959acc30..4cd2a9007 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/DefaultConverter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/DefaultConverter.cs @@ -46,6 +46,9 @@ namespace Squidex.Domain.Apps.Core.Scripting case Instant instant: result = JsValue.FromObject(engine, instant.ToDateTimeUtc()); return true; + case Status status: + result = status.ToString(); + return true; case NamedContentData content: result = new ContentDataObject(engine, content); return true; diff --git a/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs index 3d2177d4b..d32a234bd 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs @@ -48,9 +48,9 @@ namespace Squidex.Domain.Apps.Core.Scripting private static ObjectWrapper CreateUser(Engine engine, string id, bool isClient, string email, string name, IEnumerable allClaims) { var claims = - allClaims.GroupBy(x => x.Type) + allClaims.GroupBy(x => x.Type.Split(ClaimSeparators).Last()) .ToDictionary( - x => x.Key.Split(ClaimSeparators).Last(), + x => x.Key, x => x.Select(y => y.Value).ToArray()); return new ObjectWrapper(engine, new { id, isClient, email, name, claims }); diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs index f0fc88a3a..ad819ba57 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/ITagService.cs @@ -19,11 +19,11 @@ namespace Squidex.Domain.Apps.Core.Tags Task> DenormalizeTagsAsync(Guid appId, string group, HashSet ids); - Task> GetTagsAsync(Guid appId, string group); + Task GetTagsAsync(Guid appId, string group); - Task GetExportableTagsAsync(Guid appId, string group); + Task GetExportableTagsAsync(Guid appId, string group); - Task RebuildTagsAsync(Guid appId, string group, TagSet tags); + Task RebuildTagsAsync(Guid appId, string group, TagsExport tags); Task ClearAsync(Guid appId, string group); } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs similarity index 88% rename from src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs rename to src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs index 530c28b00..d1f54ecf7 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagSet.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsExport.cs @@ -9,7 +9,7 @@ using System.Collections.Generic; namespace Squidex.Domain.Apps.Core.Tags { - public sealed class TagSet : Dictionary + public sealed class TagsExport : Dictionary { } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsSet.cs b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsSet.cs new file mode 100644 index 000000000..8e87ee8ab --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/Tags/TagsSet.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Core.Tags +{ + public sealed class TagsSet : Dictionary + { + public long Version { get; set; } + + public TagsSet() + { + } + + public TagsSet(IDictionary tags, long version) + : base(tags) + { + Version = version; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 6444ba638..b7ccaf0ed 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -137,7 +137,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets } } - public async Task> QueryByHashAsync(Guid appId, string hash) + public async Task> QueryByHashAsync(Guid appId, string hash) { using (Profiler.TraceMethod()) { diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs index 9c8f3eba7..fe2e0649c 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs @@ -51,7 +51,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents [BsonRequired] [BsonElement("ss")] - [BsonRepresentation(BsonType.String)] public Status Status { get; set; } [BsonIgnoreIfNull] diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 5dd3020e7..51e7ca5a9 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -36,6 +36,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents private readonly string typeContentDeleted; private readonly MongoContentCollection contents; + static MongoContentRepository() + { + StatusSerializer.Register(); + } + public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, IJsonSerializer serializer, ITextIndexer indexer, TypeNameRegistry typeNameRegistry) { Guard.NotNull(appProvider, nameof(appProvider)); @@ -64,7 +69,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { Guard.NotNull(app, nameof(app)); Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(status, nameof(status)); Guard.NotNull(query, nameof(query)); using (Profiler.TraceMethod("QueryAsyncByQuery")) @@ -73,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents if (fullTextIds?.Count == 0) { - return ResultList.Create(0); + return ResultList.CreateFrom(0); } return await contents.QueryAsync(schema, query, fullTextIds, status, inDraft, includeDraft); @@ -83,9 +87,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public async Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet ids, bool includeDraft = true) { Guard.NotNull(app, nameof(app)); - Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(status, nameof(status)); Guard.NotNull(ids, nameof(ids)); + Guard.NotNull(schema, nameof(schema)); using (Profiler.TraceMethod("QueryAsyncByIds")) { @@ -96,7 +99,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents public async Task> QueryAsync(IAppEntity app, Status[] status, HashSet ids, bool includeDraft = true) { Guard.NotNull(app, nameof(app)); - Guard.NotNull(status, nameof(status)); Guard.NotNull(ids, nameof(ids)); using (Profiler.TraceMethod("QueryAsyncByIdsWithoutSchema")) @@ -109,7 +111,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { Guard.NotNull(app, nameof(app)); Guard.NotNull(schema, nameof(schema)); - Guard.NotNull(status, nameof(status)); using (Profiler.TraceMethod()) { diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs new file mode 100644 index 000000000..5d59c836a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents +{ + public sealed class StatusSerializer : SerializerBase + { + private static volatile int isRegistered; + + public static void Register() + { + if (Interlocked.Increment(ref isRegistered) == 1) + { + BsonSerializer.RegisterSerializer(new StatusSerializer()); + } + } + + public override Status Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + var value = context.Reader.ReadString(); + + return new Status(value); + } + + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Status value) + { + context.Writer.WriteString(value.Name); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs index bbed5d51f..1dd0b1500 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs @@ -162,7 +162,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors } filters.Add(Filter.Ne(x => x.IsDeleted, true)); - filters.Add(Filter.In(x => x.Status, status)); + + if (status != null) + { + filters.Add(Filter.In(x => x.Status, status)); + } if (ids != null && ids.Count > 0) { diff --git a/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/src/Squidex.Domain.Apps.Entities/AppProvider.cs index a227d68bf..82507f4d9 100644 --- a/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -65,6 +65,17 @@ namespace Squidex.Domain.Apps.Entities }); } + public Task GetAppAsync(Guid appId) + { + return localCache.GetOrCreateAsync($"GetAppAsync({appId})", async () => + { + using (Profiler.TraceMethod()) + { + return await GetAppByIdAsync(appId); + } + }); + } + public Task GetAppAsync(string appName) { return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () => @@ -78,14 +89,7 @@ namespace Squidex.Domain.Apps.Entities return null; } - var app = await grainFactory.GetGrain(appId).GetStateAsync(); - - if (!IsExisting(app)) - { - return null; - } - - return app.Value; + return await GetAppByIdAsync(appId); } }); } @@ -184,6 +188,18 @@ namespace Squidex.Domain.Apps.Entities }); } + private async Task GetAppByIdAsync(Guid appId) + { + var app = await grainFactory.GetGrain(appId).GetStateAsync(); + + if (!IsExisting(app)) + { + return null; + } + + return app.Value; + } + private async Task> GetAppIdsByUserAsync(string userId) { using (Profiler.TraceMethod()) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 63ac53ab9..95ba1cefc 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -60,11 +60,13 @@ namespace Squidex.Domain.Apps.Entities.Apps switch (command) { case CreateApp createApp: - return CreateAsync(createApp, c => + return CreateReturn(createApp, c => { GuardApp.CanCreate(c); Create(c); + + return Snapshot; }); case AssignContributor assignContributor: @@ -74,111 +76,167 @@ namespace Squidex.Domain.Apps.Entities.Apps AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId)); - return EntityCreatedResult.Create(c.ContributorId, Version); + return Snapshot; }); case RemoveContributor removeContributor: - return UpdateAsync(removeContributor, c => + return UpdateReturn(removeContributor, c => { GuardAppContributors.CanRemove(Snapshot.Contributors, c); RemoveContributor(c); + + return Snapshot; }); case AttachClient attachClient: - return UpdateAsync(attachClient, c => + return UpdateReturn(attachClient, c => { GuardAppClients.CanAttach(Snapshot.Clients, c); AttachClient(c); + + return Snapshot; }); case UpdateClient updateClient: - return UpdateAsync(updateClient, c => + return UpdateReturn(updateClient, c => { GuardAppClients.CanUpdate(Snapshot.Clients, c, Snapshot.Roles); UpdateClient(c); + + return Snapshot; }); case RevokeClient revokeClient: - return UpdateAsync(revokeClient, c => + return UpdateReturn(revokeClient, c => { GuardAppClients.CanRevoke(Snapshot.Clients, c); RevokeClient(c); + + return Snapshot; + }); + + case AddWorkflow addWorkflow: + return UpdateReturn(addWorkflow, c => + { + GuardAppWorkflows.CanAdd(c); + + AddWorkflow(c); + + return Snapshot; + }); + + case UpdateWorkflow updateWorkflow: + return UpdateReturn(updateWorkflow, c => + { + GuardAppWorkflows.CanUpdate(Snapshot.Workflows, c); + + UpdateWorkflow(c); + + return Snapshot; + }); + + case DeleteWorkflow deleteWorkflow: + return UpdateReturn(deleteWorkflow, c => + { + GuardAppWorkflows.CanDelete(Snapshot.Workflows, c); + + DeleteWorkflow(c); + + return Snapshot; }); case AddLanguage addLanguage: - return UpdateAsync(addLanguage, c => + return UpdateReturn(addLanguage, c => { GuardAppLanguages.CanAdd(Snapshot.LanguagesConfig, c); AddLanguage(c); + + return Snapshot; }); case RemoveLanguage removeLanguage: - return UpdateAsync(removeLanguage, c => + return UpdateReturn(removeLanguage, c => { GuardAppLanguages.CanRemove(Snapshot.LanguagesConfig, c); RemoveLanguage(c); + + return Snapshot; }); case UpdateLanguage updateLanguage: - return UpdateAsync(updateLanguage, c => + return UpdateReturn(updateLanguage, c => { GuardAppLanguages.CanUpdate(Snapshot.LanguagesConfig, c); UpdateLanguage(c); + + return Snapshot; }); case AddRole addRole: - return UpdateAsync(addRole, c => + return UpdateReturn(addRole, c => { GuardAppRoles.CanAdd(Snapshot.Roles, c); AddRole(c); + + return Snapshot; }); case DeleteRole deleteRole: - return UpdateAsync(deleteRole, c => + return UpdateReturn(deleteRole, c => { GuardAppRoles.CanDelete(Snapshot.Roles, c, Snapshot.Contributors, Snapshot.Clients); DeleteRole(c); + + return Snapshot; }); case UpdateRole updateRole: - return UpdateAsync(updateRole, c => + return UpdateReturn(updateRole, c => { GuardAppRoles.CanUpdate(Snapshot.Roles, c); UpdateRole(c); + + return Snapshot; }); case AddPattern addPattern: - return UpdateAsync(addPattern, c => + return UpdateReturn(addPattern, c => { GuardAppPatterns.CanAdd(Snapshot.Patterns, c); AddPattern(c); + + return Snapshot; }); case DeletePattern deletePattern: - return UpdateAsync(deletePattern, c => + return UpdateReturn(deletePattern, c => { GuardAppPatterns.CanDelete(Snapshot.Patterns, c); DeletePattern(c); + + return Snapshot; }); case UpdatePattern updatePattern: - return UpdateAsync(updatePattern, c => + return UpdateReturn(updatePattern, c => { GuardAppPatterns.CanUpdate(Snapshot.Patterns, c); UpdatePattern(c); + + return Snapshot; }); case ChangePlan changePlan: @@ -291,6 +349,21 @@ namespace Squidex.Domain.Apps.Entities.Apps RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked())); } + public void AddWorkflow(AddWorkflow command) + { + RaiseEvent(SimpleMapper.Map(command, new AppWorkflowAdded())); + } + + public void UpdateWorkflow(UpdateWorkflow command) + { + RaiseEvent(SimpleMapper.Map(command, new AppWorkflowUpdated())); + } + + public void DeleteWorkflow(DeleteWorkflow command) + { + RaiseEvent(SimpleMapper.Map(command, new AppWorkflowDeleted())); + } + public void AddLanguage(AddLanguage command) { RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded())); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs index 9b79da894..901eb1e68 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppHistoryEventsCreator.cs @@ -19,15 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Apps public AppHistoryEventsCreator(TypeNameRegistry typeNameRegistry) : base(typeNameRegistry) { - AddEventMessage("AppContributorAssignedEvent", - "assigned {user:[Contributor]} as {[Role]}"); - - AddEventMessage("AppClientUpdatedEvent", - "updated client {[Id]}"); - - AddEventMessage("AppPlanChanged", - "changed plan to {[Plan]}"); - AddEventMessage( "assigned {user:[Contributor]} as {[Role]}"); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs new file mode 100644 index 000000000..54ca7b4bb --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddWorkflow.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class AddWorkflow : AppCommand + { + public Guid WorkflowId { get; set; } + + public string Name { get; set; } + + public AddWorkflow() + { + WorkflowId = Guid.NewGuid(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/StatusForFrontend.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs similarity index 70% rename from src/Squidex.Domain.Apps.Entities/Contents/StatusForFrontend.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs index b29257b0c..c21492e79 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/StatusForFrontend.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeleteWorkflow.cs @@ -5,12 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.Domain.Apps.Entities.Contents +using System; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public enum StatusForFrontend + public sealed class DeleteWorkflow : AppCommand { - PublishedDraft, - PublishedOnly, - Archived + public Guid WorkflowId { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs new file mode 100644 index 000000000..635936040 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateWorkflow.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class UpdateWorkflow : AppCommand + { + public Guid WorkflowId { get; set; } + + public Workflow Workflow { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs new file mode 100644 index 000000000..738b2f70a --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs @@ -0,0 +1,109 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps.Guards +{ + public static class GuardAppWorkflows + { + public static void CanAdd(AddWorkflow command) + { + Guard.NotNull(command, nameof(command)); + + Validate.It(() => "Cannot add workflow.", e => + { + if (string.IsNullOrWhiteSpace(command.Name)) + { + e(Not.Defined("Name"), nameof(command.Name)); + } + }); + } + + public static void CanUpdate(Workflows workflows, UpdateWorkflow command) + { + Guard.NotNull(command, nameof(command)); + + GetWorkflowOrThrow(workflows, command.WorkflowId); + + Validate.It(() => "Cannot update workflow.", e => + { + if (command.Workflow == null) + { + e(Not.Defined("Workflow"), nameof(command.Workflow)); + return; + } + + var workflow = command.Workflow; + + if (!workflow.Steps.ContainsKey(workflow.Initial)) + { + e(Not.Defined("Initial step"), $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}"); + } + + if (workflow.Initial == Status.Published) + { + e("Initial step cannot be published step.", $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}"); + } + + var stepsPrefix = $"{nameof(command.Workflow)}.{nameof(workflow.Steps)}"; + + if (!workflow.Steps.ContainsKey(Status.Published)) + { + e("Workflow must have a published step.", stepsPrefix); + } + + foreach (var step in workflow.Steps) + { + var stepPrefix = $"{stepsPrefix}.{step.Key}"; + + if (step.Value == null) + { + e(Not.Defined("Step"), stepPrefix); + } + else + { + foreach (var transition in step.Value.Transitions) + { + var transitionPrefix = $"{stepPrefix}.{nameof(step.Value.Transitions)}.{transition.Key}"; + + if (!workflow.Steps.ContainsKey(transition.Key)) + { + e("Transition has an invalid target.", transitionPrefix); + } + + if (transition.Value == null) + { + e(Not.Defined("Transition"), transitionPrefix); + } + } + } + } + }); + } + + public static void CanDelete(Workflows workflows, DeleteWorkflow command) + { + Guard.NotNull(command, nameof(command)); + + GetWorkflowOrThrow(workflows, command.WorkflowId); + } + + private static Workflow GetWorkflowOrThrow(Workflows workflows, Guid id) + { + if (!workflows.TryGetValue(id, out var workflow)) + { + throw new DomainObjectNotFoundException(id.ToString(), "Workflows", typeof(IAppEntity)); + } + + return workflow; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs index bf81d2612..a41e3368f 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Domain.Apps.Entities.Apps { @@ -29,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Apps LanguagesConfig LanguagesConfig { get; } + Workflows Workflows { get; } + bool IsArchived { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs index 3327fc914..5e2454fab 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs @@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes await Index(GetUserId(createApp)).AddAppAsync(createApp.AppId); break; case AssignContributor assignContributor: - await Index(GetUserId(context)).AddAppAsync(assignContributor.AppId); + await Index(GetUserId(assignContributor)).AddAppAsync(assignContributor.AppId); break; case RemoveContributor removeContributor: await Index(GetUserId(removeContributor)).RemoveAppAsync(removeContributor.AppId); @@ -57,19 +57,19 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes await next(); } - private static string GetUserId(RemoveContributor removeContributor) + private static string GetUserId(CreateApp createApp) { - return removeContributor.ContributorId; + return createApp.Actor.Identifier; } - private static string GetUserId(CreateApp createApp) + private static string GetUserId(AssignContributor assignContributor) { - return createApp.Actor.Identifier; + return assignContributor.ContributorId; } - private static string GetUserId(CommandContext context) + private static string GetUserId(RemoveContributor removeContributor) { - return context.Result>().IdOrValue; + return removeContributor.ContributorId; } private IAppsByUserIndex Index(string id) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs index 0bf99f271..7bde0a4cd 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs @@ -35,9 +35,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation await next(); - if (assignContributor.IsCreated && context.PlainResult is EntityCreatedResult id) + if (assignContributor.IsCreated && context.PlainResult is IAppEntity app) { - context.Complete(new InvitedResult { Id = id }); + context.Complete(new InvitedResult { App = app }); } return; diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs index 695be0a4b..45c6df7b9 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs @@ -5,12 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure.Commands; - namespace Squidex.Domain.Apps.Entities.Apps.Invitation { public sealed class InvitedResult { - public EntityCreatedResult Id { get; set; } + public IAppEntity App { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs b/src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs index 0e0eddad9..f6464d5dc 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs @@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { return id; } - })); + }).Where(x => x != "common")); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index 4f31dbdc0..7e870d56c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -7,6 +7,7 @@ using System.Runtime.Serialization; using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Apps; using Squidex.Infrastructure.Dispatching; @@ -42,6 +43,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.State [DataMember] public LanguagesConfig LanguagesConfig { get; set; } = LanguagesConfig.English; + [DataMember] + public Workflows Workflows { get; set; } = Workflows.Empty; + [DataMember] public bool IsArchived { get; set; } @@ -92,6 +96,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.State Clients = Clients.Revoke(@event.Id); } + protected void On(AppWorkflowAdded @event) + { + Workflows = Workflows.Add(@event.WorkflowId, @event.Name); + } + + protected void On(AppWorkflowUpdated @event) + { + Workflows = Workflows.Update(@event.WorkflowId, @event.Workflow); + } + + protected void On(AppWorkflowDeleted @event) + { + Workflows = Workflows.Remove(@event.WorkflowId); + } + protected void On(AppPatternAdded @event) { Patterns = Patterns.Add(@event.PatternId, @event.Name, @event.Pattern, @event.Message); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs index 5c93059f2..fe8da3714 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs @@ -21,27 +21,30 @@ namespace Squidex.Domain.Apps.Entities.Assets public sealed class AssetCommandMiddleware : GrainCommandMiddleware { private readonly IAssetStore assetStore; + private readonly IAssetEnricher assetEnricher; private readonly IAssetQueryService assetQuery; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IEnumerable> tagGenerators; public AssetCommandMiddleware( IGrainFactory grainFactory, + IAssetEnricher assetEnricher, IAssetQueryService assetQuery, IAssetStore assetStore, IAssetThumbnailGenerator assetThumbnailGenerator, IEnumerable> tagGenerators) : base(grainFactory) { + Guard.NotNull(assetEnricher, nameof(assetEnricher)); Guard.NotNull(assetStore, nameof(assetStore)); Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); Guard.NotNull(tagGenerators, nameof(tagGenerators)); this.assetStore = assetStore; + this.assetEnricher = assetEnricher; this.assetQuery = assetQuery; this.assetThumbnailGenerator = assetThumbnailGenerator; - this.tagGenerators = tagGenerators; } @@ -56,53 +59,37 @@ namespace Squidex.Domain.Apps.Entities.Assets createAsset.Tags = new HashSet(); } - createAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(createAsset.File.OpenRead()); - - createAsset.FileHash = await UploadAsync(context, createAsset.File); + await EnrichWithImageInfosAsync(createAsset); + await EnrichWithHashAndUploadAsync(createAsset, context); try { var existings = await assetQuery.QueryByHashAsync(createAsset.AppId.Id, createAsset.FileHash); - AssetCreatedResult result = null; - foreach (var existing in existings) { if (IsDuplicate(createAsset, existing)) { - result = new AssetCreatedResult( - existing.Id, - existing.Tags, - existing.Version, - existing.FileVersion, - existing.FileHash, - true); - } + var result = new AssetCreatedResult(existing, true); - break; + context.Complete(result); + await next(); + return; + } } - if (result == null) + foreach (var tagGenerator in tagGenerators) { - foreach (var tagGenerator in tagGenerators) - { - tagGenerator.GenerateTags(createAsset, createAsset.Tags); - } + tagGenerator.GenerateTags(createAsset, createAsset.Tags); + } - var commandResult = (AssetSavedResult)await ExecuteCommandAsync(createAsset); + await HandleCoreAsync(context, next); - result = new AssetCreatedResult( - createAsset.AssetId, - createAsset.Tags, - commandResult.Version, - commandResult.FileVersion, - commandResult.FileHash, - false); + var asset = context.PlainResult as IEnrichedAssetEntity; - await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null); - } + context.Complete(new AssetCreatedResult(asset, false)); - context.Complete(result); + await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), asset.FileVersion, null); } finally { @@ -114,16 +101,16 @@ namespace Squidex.Domain.Apps.Entities.Assets case UpdateAsset updateAsset: { - updateAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(updateAsset.File.OpenRead()); + await EnrichWithImageInfosAsync(updateAsset); + await EnrichWithHashAndUploadAsync(updateAsset, context); - updateAsset.FileHash = await UploadAsync(context, updateAsset.File); try { - var result = (AssetSavedResult)await ExecuteCommandAsync(updateAsset); + await HandleCoreAsync(context, next); - context.Complete(result); + var asset = context.PlainResult as IAssetEntity; - await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.FileVersion, null); + await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), asset.FileVersion, null); } finally { @@ -134,28 +121,42 @@ namespace Squidex.Domain.Apps.Entities.Assets } default: - await base.HandleAsync(context, next); + await HandleCoreAsync(context, next); + break; } } + private async Task HandleCoreAsync(CommandContext context, Func next) + { + await base.HandleAsync(context, next); + + if (context.PlainResult is IAssetEntity asset && !(context.PlainResult is IEnrichedAssetEntity)) + { + var enriched = await assetEnricher.EnrichAsync(asset); + + context.Complete(enriched); + } + } + private static bool IsDuplicate(CreateAsset createAsset, IAssetEntity asset) { return asset != null && asset.FileName == createAsset.File.FileName && asset.FileSize == createAsset.File.FileSize; } - private async Task UploadAsync(CommandContext context, AssetFile file) + private async Task EnrichWithImageInfosAsync(UploadAssetCommand command) { - string hash; + command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead()); + } - using (var hashStream = new HasherStream(file.OpenRead(), HashAlgorithmName.SHA256)) + private async Task EnrichWithHashAndUploadAsync(UploadAssetCommand command, CommandContext context) + { + using (var hashStream = new HasherStream(command.File.OpenRead(), HashAlgorithmName.SHA256)) { await assetStore.UploadAsync(context.ContextId.ToString(), hashStream); - hash = $"{hashStream.GetHashStringAndReset()}{file.FileName}{file.FileSize}".Sha256Base64(); + command.FileHash = $"{hashStream.GetHashStringAndReset()}{command.File.FileName}{command.File.FileSize}".Sha256Base64(); } - - return hash; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs index de8da5f23..aa932bf36 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs @@ -5,29 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Collections.Generic; -using Squidex.Infrastructure.Commands; - namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class AssetCreatedResult : EntityCreatedResult + public sealed class AssetCreatedResult { - public HashSet Tags { get; } - - public long FileVersion { get; } - - public string FileHash { get; } + public IEnrichedAssetEntity Asset { get; } public bool IsDuplicate { get; } - public AssetCreatedResult(Guid id, HashSet tags, long version, long fileVersion, string fileHash, bool isDuplicate) - : base(id, version) + public AssetCreatedResult(IEnrichedAssetEntity asset, bool isDuplicate) { - Tags = tags; - - FileVersion = fileVersion; - FileHash = fileHash; + Asset = asset; IsDuplicate = isDuplicate; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs new file mode 100644 index 000000000..3a1b802e8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetEnricher.cs @@ -0,0 +1,82 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Tags; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class AssetEnricher : IAssetEnricher + { + private readonly ITagService tagService; + + public AssetEnricher(ITagService tagService) + { + Guard.NotNull(tagService, nameof(tagService)); + + this.tagService = tagService; + } + + public async Task EnrichAsync(IAssetEntity asset) + { + Guard.NotNull(asset, nameof(asset)); + + var enriched = await EnrichAsync(Enumerable.Repeat(asset, 1)); + + return enriched[0]; + } + + public async Task> EnrichAsync(IEnumerable assets) + { + Guard.NotNull(assets, nameof(assets)); + + using (Profiler.TraceMethod()) + { + var results = new List(); + + foreach (var group in assets.GroupBy(x => x.AppId.Id)) + { + var tagsById = await CalculateTags(group); + + foreach (var asset in group) + { + var result = SimpleMapper.Map(asset, new AssetEntity()); + + result.TagNames = new HashSet(); + + if (asset.Tags != null) + { + foreach (var id in asset.Tags) + { + if (tagsById.TryGetValue(id, out var name)) + { + result.TagNames.Add(name); + } + } + } + + results.Add(result); + } + } + + return results; + } + } + + private async Task> CalculateTags(IGrouping group) + { + var uniqueIds = group.Where(x => x.Tags != null).SelectMany(x => x.Tags).ToHashSet(); + + return await tagService.DenormalizeTagsAsync(group.Key, TagGroups.Assets, uniqueIds); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs similarity index 86% rename from tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs rename to src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs index 2daf028de..150e53b78 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetEntity.cs @@ -1,19 +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 System.Collections.Generic; using NodaTime; -using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; -namespace Squidex.Domain.Apps.Entities.Contents.TestData +namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class FakeAssetEntity : IAssetEntity + public sealed class AssetEntity : IEnrichedAssetEntity { public NamedId AppId { get; set; } @@ -31,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData public HashSet Tags { get; set; } + public HashSet TagNames { get; set; } + public long Version { get; set; } public string MimeType { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs index b16b18ae4..c76515200 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs @@ -51,16 +51,27 @@ namespace Squidex.Domain.Apps.Entities.Assets Create(c, tagIds); - return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash); + return Snapshot; }); case UpdateAsset updateRule: - return UpdateAsync(updateRule, c => + return UpdateReturn(updateRule, c => { GuardAsset.CanUpdate(c); Update(c); - return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash); + return Snapshot; + }); + case AnnotateAsset annotateAsset: + return UpdateReturnAsync(annotateAsset, async c => + { + GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug); + + var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags); + + Annotate(c, tagIds); + + return Snapshot; }); case DeleteAsset deleteAsset: return UpdateAsync(deleteAsset, async c => @@ -71,15 +82,6 @@ namespace Squidex.Domain.Apps.Entities.Assets Delete(c); }); - case AnnotateAsset annotateAsset: - return UpdateAsync(annotateAsset, async c => - { - GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug); - - var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags); - - Annotate(c, tagIds); - }); default: throw new NotSupportedException(); } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs index 833920248..0dc427439 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Options; using Microsoft.OData; @@ -21,9 +20,10 @@ using Squidex.Infrastructure.Queries.OData; namespace Squidex.Domain.Apps.Entities.Assets { - public class AssetQueryService : IAssetQueryService + public sealed class AssetQueryService : IAssetQueryService { private readonly ITagService tagService; + private readonly IAssetEnricher assetEnricher; private readonly IAssetRepository assetRepository; private readonly AssetOptions options; @@ -32,79 +32,85 @@ namespace Squidex.Domain.Apps.Entities.Assets get { return options.DefaultPageSizeGraphQl; } } - public AssetQueryService(ITagService tagService, IAssetRepository assetRepository, IOptions options) + public AssetQueryService( + ITagService tagService, + IAssetEnricher assetEnricher, + IAssetRepository assetRepository, + IOptions options) { Guard.NotNull(tagService, nameof(tagService)); - Guard.NotNull(options, nameof(options)); + Guard.NotNull(assetEnricher, nameof(assetEnricher)); Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(options, nameof(options)); + this.tagService = tagService; + this.assetEnricher = assetEnricher; this.assetRepository = assetRepository; this.options = options.Value; - this.tagService = tagService; - } - - public Task FindAssetAsync(QueryContext context, Guid id) - { - Guard.NotNull(context, nameof(context)); - - return FindAssetAsync(context.App.Id, id); } - public async Task FindAssetAsync(Guid appId, Guid id) + public async Task FindAssetAsync( Guid id) { var asset = await assetRepository.FindAssetAsync(id); if (asset != null) { - await DenormalizeTagsAsync(appId, Enumerable.Repeat(asset, 1)); + return await assetEnricher.EnrichAsync(asset); } - return asset; + return null; } - public async Task> QueryByHashAsync(Guid appId, string hash) + public async Task> QueryByHashAsync(Guid appId, string hash) { Guard.NotNull(hash, nameof(hash)); var assets = await assetRepository.QueryByHashAsync(appId, hash); - await DenormalizeTagsAsync(appId, assets); - - return assets; + return await assetEnricher.EnrichAsync(assets); } - public async Task> QueryAsync(QueryContext context, Q query) + public async Task> QueryAsync(Context context, Q query) { Guard.NotNull(context, nameof(context)); Guard.NotNull(query, nameof(query)); IResultList assets; - if (query.Ids != null) + if (query.Ids != null && query.Ids.Count > 0) { - assets = await assetRepository.QueryAsync(context.App.Id, new HashSet(query.Ids)); - assets = Sort(assets, query.Ids); + assets = await QueryByIdsAsync(context, query); } else { - var parsedQuery = ParseQuery(context, query.ODataQuery); - - assets = await assetRepository.QueryAsync(context.App.Id, parsedQuery); + assets = await QueryByQueryAsync(context, query); } - await DenormalizeTagsAsync(context.App.Id, assets); + var enriched = await assetEnricher.EnrichAsync(assets); - return assets; + return ResultList.Create(assets.Total, enriched); } - private static IResultList Sort(IResultList assets, IReadOnlyList ids) + private async Task> QueryByQueryAsync(Context context, Q query) + { + var parsedQuery = ParseQuery(context, query.ODataQuery); + + return await assetRepository.QueryAsync(context.App.Id, parsedQuery); + } + + private async Task> QueryByIdsAsync(Context context, Q query) { - var sorted = ids.Select(id => assets.FirstOrDefault(x => x.Id == id)).Where(x => x != null); + var assets = await assetRepository.QueryAsync(context.App.Id, new HashSet(query.Ids)); - return ResultList.Create(assets.Total, sorted); + return Sort(assets, query.Ids); } - private Query ParseQuery(QueryContext context, string query) + private static IResultList Sort(IResultList assets, IReadOnlyList ids) + { + return assets.SortSet(x => x.Id, ids); + } + + private Query ParseQuery(Context context, string query) { try { @@ -140,34 +146,5 @@ namespace Squidex.Domain.Apps.Entities.Assets throw new ValidationException($"Failed to parse query: {ex.Message}", ex); } } - - private async Task DenormalizeTagsAsync(Guid appId, IEnumerable assets) - { - var tags = new HashSet(assets.Where(x => x.Tags != null).SelectMany(x => x.Tags).Distinct()); - - var tagsById = await tagService.DenormalizeTagsAsync(appId, TagGroups.Assets, tags); - - foreach (var asset in assets) - { - if (asset.Tags?.Count > 0) - { - var tagNames = asset.Tags.ToList(); - - asset.Tags.Clear(); - - foreach (var id in tagNames) - { - if (tagsById.TryGetValue(id, out var name)) - { - asset.Tags.Add(name); - } - } - } - else - { - asset.Tags?.Clear(); - } - } - } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs b/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs index 068a807de..44701ee16 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/BackupAssets.cs @@ -82,7 +82,7 @@ namespace Squidex.Domain.Apps.Entities.Assets private async Task RestoreTagsAsync(Guid appId, BackupReader reader) { - var tags = await reader.ReadJsonAttachmentAsync(TagsFile); + var tags = await reader.ReadJsonAttachmentAsync(TagsFile); await tagService.RebuildTagsAsync(appId, TagGroups.Assets, tags); } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs index 9c49e67bd..8e869ba40 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs @@ -8,22 +8,15 @@ using System; using System.Collections.Generic; using Squidex.Infrastructure; -using Squidex.Infrastructure.Assets; namespace Squidex.Domain.Apps.Entities.Assets.Commands { - public sealed class CreateAsset : AssetCommand, IAppCommand + public sealed class CreateAsset : UploadAssetCommand, IAppCommand { public NamedId AppId { get; set; } - public AssetFile File { get; set; } - - public ImageInfo ImageInfo { get; set; } - public HashSet Tags { get; set; } - public string FileHash { get; set; } - public CreateAsset() { AssetId = Guid.NewGuid(); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs index 1c998ac7a..16197164d 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs @@ -5,16 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure.Assets; - namespace Squidex.Domain.Apps.Entities.Assets.Commands { - public sealed class UpdateAsset : AssetCommand + public sealed class UpdateAsset : UploadAssetCommand { - public AssetFile File { get; set; } - - public ImageInfo ImageInfo { get; set; } - - public string FileHash { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs new file mode 100644 index 000000000..5ef0652cd --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Assets; + +namespace Squidex.Domain.Apps.Entities.Assets.Commands +{ + public abstract class UploadAssetCommand : AssetCommand + { + public AssetFile File { get; set; } + + public ImageInfo ImageInfo { get; set; } + + public string FileHash { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs new file mode 100644 index 000000000..1807af316 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetEnricher.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetEnricher + { + Task EnrichAsync(IAssetEntity asset); + + Task> EnrichAsync(IEnumerable assets); + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs index 501d690a9..bec9309a6 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs @@ -16,10 +16,10 @@ namespace Squidex.Domain.Apps.Entities.Assets { int DefaultPageSizeGraphQl { get; } - Task> QueryByHashAsync(Guid appId, string hash); + Task> QueryByHashAsync(Guid appId, string hash); - Task> QueryAsync(QueryContext contex, Q query); + Task> QueryAsync(Context contex, Q query); - Task FindAssetAsync(QueryContext context, Guid id); + Task FindAssetAsync(Guid id); } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs b/src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs new file mode 100644 index 000000000..eab0cde16 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/IEnrichedAssetEntity.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IEnrichedAssetEntity : IAssetEntity + { + HashSet TagNames { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index 12de8c72a..533ce993f 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { public interface IAssetRepository { - Task> QueryByHashAsync(Guid appId, string hash); + Task> QueryByHashAsync(Guid appId, string hash); Task> QueryAsync(Guid appId, Query query); diff --git a/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs b/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs index e95b25248..e50cc05f4 100644 --- a/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs @@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Comments switch (command) { case CreateComment createComment: - return UpsertAsync(createComment, c => + return UpsertReturn(createComment, c => { GuardComments.CanCreate(c); @@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Comments }); case UpdateComment updateComment: - return UpsertAsync(updateComment, c => + return Upsert(updateComment, c => { GuardComments.CanUpdate(events, c); @@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Comments }); case DeleteComment deleteComment: - return UpsertAsync(deleteComment, c => + return Upsert(deleteComment, c => { GuardComments.CanDelete(events, c); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs index 7f0842c16..f2eea4643 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentDataCommand.cs @@ -12,7 +12,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public abstract class ContentDataCommand : ContentCommand { public NamedContentData Data { get; set; } - - public bool AsDraft { get; set; } } } diff --git a/src/Squidex.Web/IAppFeature.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs similarity index 69% rename from src/Squidex.Web/IAppFeature.cs rename to src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs index a798da598..63bd8a400 100644 --- a/src/Squidex.Web/IAppFeature.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentUpdateCommand.cs @@ -5,12 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Entities.Apps; - -namespace Squidex.Web +namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public interface IAppFeature + public abstract class ContentUpdateCommand : ContentDataCommand { - IAppEntity App { get; } + public bool AsDraft { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs index 80206cebd..6654339d9 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/PatchContent.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class PatchContent : ContentDataCommand + public sealed class PatchContent : ContentUpdateCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs index 01f642d5c..aeb2ce59e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Commands/UpdateContent.cs @@ -7,7 +7,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { - public sealed class UpdateContent : ContentDataCommand + public sealed class UpdateContent : ContentUpdateCommand { } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs index dff657548..8132c6f28 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs @@ -69,11 +69,8 @@ namespace Squidex.Domain.Apps.Entities.Contents case StatusChange.Unpublished: result.Type = EnrichedContentEventType.Unpublished; break; - case StatusChange.Archived: - result.Type = EnrichedContentEventType.Archived; - break; - case StatusChange.Restored: - result.Type = EnrichedContentEventType.Restored; + default: + result.Type = EnrichedContentEventType.StatusChanged; break; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs new file mode 100644 index 000000000..63bc61a96 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentCommandMiddleware : GrainCommandMiddleware + { + private readonly IContentEnricher contentEnricher; + + public ContentCommandMiddleware(IGrainFactory grainFactory, IContentEnricher contentEnricher) + : base(grainFactory) + { + Guard.NotNull(contentEnricher, nameof(contentEnricher)); + + this.contentEnricher = contentEnricher; + } + + public override async Task HandleAsync(CommandContext context, Func next) + { + await base.HandleAsync(context, next); + + if (context.Command is SquidexCommand command && context.PlainResult is IContentEntity content && NotEnriched(context)) + { + var enriched = await contentEnricher.EnrichAsync(content, command.User); + + context.Complete(enriched); + } + } + + private static bool NotEnriched(CommandContext context) + { + return !(context.PlainResult is IEnrichedContentEntity); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs new file mode 100644 index 000000000..32d93b04d --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs @@ -0,0 +1,111 @@ +// ========================================================================== +// 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; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ContentEnricher : IContentEnricher + { + private const string DefaultColor = StatusColors.Draft; + private readonly IContentWorkflow contentWorkflow; + private readonly IContextProvider contextProvider; + + public ContentEnricher(IContentWorkflow contentWorkflow, IContextProvider contextProvider) + { + Guard.NotNull(contentWorkflow, nameof(contentWorkflow)); + Guard.NotNull(contextProvider, nameof(contextProvider)); + + this.contentWorkflow = contentWorkflow; + this.contextProvider = contextProvider; + } + + public async Task EnrichAsync(IContentEntity content, ClaimsPrincipal user) + { + Guard.NotNull(content, nameof(content)); + + var enriched = await EnrichAsync(Enumerable.Repeat(content, 1), user); + + return enriched[0]; + } + + public async Task> EnrichAsync(IEnumerable contents, ClaimsPrincipal user) + { + Guard.NotNull(contents, nameof(contents)); + Guard.NotNull(user, nameof(user)); + + using (Profiler.TraceMethod()) + { + var results = new List(); + + var cache = new Dictionary<(Guid, Status), StatusInfo>(); + + foreach (var content in contents) + { + var result = SimpleMapper.Map(content, new ContentEntity()); + + await ResolveColorAsync(content, result, cache); + + if (ShouldEnrichWithStatuses()) + { + await ResolveNextsAsync(content, result, user); + await ResolveCanUpdateAsync(content, result); + } + + results.Add(result); + } + + return results; + } + } + + private bool ShouldEnrichWithStatuses() + { + return contextProvider.Context.IsFrontendClient || contextProvider.Context.IsResolveFlow(); + } + + private async Task ResolveCanUpdateAsync(IContentEntity content, ContentEntity result) + { + result.CanUpdate = await contentWorkflow.CanUpdateAsync(content); + } + + private async Task ResolveNextsAsync(IContentEntity content, ContentEntity result, ClaimsPrincipal user) + { + result.Nexts = await contentWorkflow.GetNextsAsync(content, user); + } + + private async Task ResolveColorAsync(IContentEntity content, ContentEntity result, Dictionary<(Guid, Status), StatusInfo> cache) + { + result.StatusColor = await GetColorAsync(content, cache); + } + + private async Task GetColorAsync(IContentEntity content, Dictionary<(Guid, Status), StatusInfo> cache) + { + if (!cache.TryGetValue((content.SchemaId.Id, content.Status), out var info)) + { + info = await contentWorkflow.GetInfoAsync(content); + + if (info == null) + { + info = new StatusInfo(content.Status, DefaultColor); + } + + cache[(content.SchemaId.Id, content.Status)] = info; + } + + return info.Color; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 9c5a5f3f7..2e9f92115 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -8,13 +8,11 @@ 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 sealed class ContentEntity : IEnrichedContentEntity { public Guid Id { get; set; } @@ -40,25 +38,12 @@ namespace Squidex.Domain.Apps.Entities.Contents public Status Status { get; set; } - public bool IsPending { get; set; } + public StatusInfo[] Nexts { get; set; } + + public string StatusColor { 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; - } + public bool CanUpdate { get; set; } + + public bool IsPending { get; set; } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 5adf76236..60f425b5e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -32,6 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Contents private readonly IAssetRepository assetRepository; private readonly IContentRepository contentRepository; private readonly IScriptEngine scriptEngine; + private readonly IContentWorkflow contentWorkflow; public ContentGrain( IStore store, @@ -39,17 +40,20 @@ namespace Squidex.Domain.Apps.Entities.Contents IAppProvider appProvider, IAssetRepository assetRepository, IScriptEngine scriptEngine, + IContentWorkflow contentWorkflow, IContentRepository contentRepository) : base(store, log) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(contentWorkflow, nameof(contentWorkflow)); Guard.NotNull(contentRepository, nameof(contentRepository)); this.appProvider = appProvider; this.scriptEngine = scriptEngine; this.assetRepository = assetRepository; + this.contentWorkflow = contentWorkflow; this.contentRepository = contentRepository; } @@ -64,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, Guid.Empty, () => "Failed to create content."); - GuardContent.CanCreate(ctx.Schema, c); + await GuardContent.CanCreate(ctx.Schema, contentWorkflow, c); await ctx.ExecuteScriptAndTransformAsync(s => s.Create, "Create", c, c.Data); await ctx.EnrichAsync(c.Data); @@ -79,35 +83,43 @@ namespace Squidex.Domain.Apps.Entities.Contents await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data); } - Create(c); + var statusInfo = await contentWorkflow.GetInitialStatusAsync(ctx.Schema); - return EntityCreatedResult.Create(c.Data, Version); + Create(c, statusInfo.Status); + + return Snapshot; }); case UpdateContent updateContent: - return UpdateReturnAsync(updateContent, c => + return UpdateReturnAsync(updateContent, async c => { - GuardContent.CanUpdate(c); + var isProposal = c.AsDraft && Snapshot.Status == Status.Published; + + await GuardContent.CanUpdate(Snapshot, contentWorkflow, c, isProposal); - return UpdateAsync(c, x => c.Data, false); + return await UpdateAsync(c, x => c.Data, false, isProposal); }); case PatchContent patchContent: - return UpdateReturnAsync(patchContent, c => + return UpdateReturnAsync(patchContent, async c => { - GuardContent.CanPatch(c); + var isProposal = c.AsDraft && Snapshot.Status == Status.Published; + + await GuardContent.CanPatch(Snapshot, contentWorkflow, c, isProposal); - return UpdateAsync(c, c.Data.MergeInto, true); + return await UpdateAsync(c, c.Data.MergeInto, true, isProposal); }); case ChangeContentStatus changeContentStatus: - return UpdateAsync(changeContentStatus, async c => + return UpdateReturnAsync(changeContentStatus, async c => { try { + var isChangeConfirm = Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published; + var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, Snapshot.Id, () => "Failed to change content."); - GuardContent.CanChangeContentStatus(ctx.Schema, Snapshot.IsPending, Snapshot.Status, c); + await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c, isChangeConfirm); if (c.DueTime.HasValue) { @@ -115,7 +127,7 @@ namespace Squidex.Domain.Apps.Entities.Contents } else { - if (Snapshot.IsPending && Snapshot.Status == Status.Published && c.Status == Status.Published) + if (isChangeConfirm) { ConfirmChanges(c); } @@ -127,17 +139,13 @@ namespace Squidex.Domain.Apps.Entities.Contents { reason = StatusChange.Published; } - else if (c.Status == Status.Archived) - { - reason = StatusChange.Archived; - } else if (Snapshot.Status == Status.Published) { reason = StatusChange.Unpublished; } else { - reason = StatusChange.Restored; + reason = StatusChange.Change; } await ctx.ExecuteScriptAsync(s => s.Change, reason, c, Snapshot.Data); @@ -157,6 +165,18 @@ namespace Squidex.Domain.Apps.Entities.Contents throw; } } + + return Snapshot; + }); + + case DiscardChanges discardChanges: + return UpdateReturn(discardChanges, c => + { + GuardContent.CanDiscardChanges(Snapshot.IsPending, c); + + DiscardChanges(c); + + return Snapshot; }); case DeleteContent deleteContent: @@ -171,23 +191,13 @@ namespace Squidex.Domain.Apps.Entities.Contents Delete(c); }); - case DiscardChanges discardChanges: - return UpdateAsync(discardChanges, c => - { - GuardContent.CanDiscardChanges(Snapshot.IsPending, c); - - DiscardChanges(c); - }); - default: throw new NotSupportedException(); } } - private async Task UpdateAsync(ContentDataCommand c, Func newDataFunc, bool partial) + private async Task UpdateAsync(ContentUpdateCommand command, Func newDataFunc, bool partial, bool isProposal) { - var isProposal = c.AsDraft && Snapshot.Status == Status.Published; - var currentData = isProposal ? Snapshot.DataDraft : @@ -201,35 +211,35 @@ namespace Squidex.Domain.Apps.Entities.Contents if (partial) { - await ctx.ValidatePartialAsync(c.Data); + await ctx.ValidatePartialAsync(command.Data); } else { - await ctx.ValidateAsync(c.Data); + await ctx.ValidateAsync(command.Data); } - newData = await ctx.ExecuteScriptAndTransformAsync(s => s.Update, "Update", c, newData, Snapshot.Data); + newData = await ctx.ExecuteScriptAndTransformAsync(s => s.Update, "Update", command, newData, Snapshot.Data); if (isProposal) { - ProposeUpdate(c, newData); + ProposeUpdate(command, newData); } else { - Update(c, newData); + Update(command, newData); } } - return new ContentDataChangedResult(newData, Version); + return Snapshot; } - public void Create(CreateContent command) + public void Create(CreateContent command, Status status) { - RaiseEvent(SimpleMapper.Map(command, new ContentCreated())); + RaiseEvent(SimpleMapper.Map(command, new ContentCreated { Status = status })); if (command.Publish) { - RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })); + RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published })); } } @@ -268,9 +278,9 @@ namespace Squidex.Domain.Apps.Entities.Contents RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value })); } - public void ChangeStatus(ChangeContentStatus command, StatusChange reason) + public void ChangeStatus(ChangeContentStatus command, StatusChange change) { - RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = reason })); + RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = change })); } private void RaiseEvent(SchemaEvent @event) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs index f51138b5e..e88a08f67 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -8,7 +8,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Security.Claims; using System.Threading.Tasks; using Microsoft.Extensions.Options; using Microsoft.OData; @@ -23,9 +22,7 @@ using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries.OData; using Squidex.Infrastructure.Reflection; -using Squidex.Infrastructure.Security; using Squidex.Shared; -using Squidex.Shared.Identity; #pragma warning disable RECS0147 @@ -33,14 +30,13 @@ namespace Squidex.Domain.Apps.Entities.Contents { public sealed class ContentQueryService : IContentQueryService { - private static readonly Status[] StatusAll = { Status.Archived, Status.Draft, Status.Published }; - private static readonly Status[] StatusArchived = { Status.Archived }; private static readonly Status[] StatusPublishedOnly = { Status.Published }; - private static readonly Status[] StatusPublishedDraft = { Status.Published, Status.Draft }; - private readonly IContentRepository contentRepository; - private readonly IContentVersionLoader contentVersionLoader; + private static readonly IResultList EmptyContents = ResultList.CreateFrom(0); private readonly IAppProvider appProvider; private readonly IAssetUrlGenerator assetUrlGenerator; + private readonly IContentEnricher contentEnricher; + private readonly IContentRepository contentRepository; + private readonly IContentVersionLoader contentVersionLoader; private readonly IScriptEngine scriptEngine; private readonly ContentOptions options; private readonly EdmModelBuilder modelBuilder; @@ -53,6 +49,7 @@ namespace Squidex.Domain.Apps.Entities.Contents public ContentQueryService( IAppProvider appProvider, IAssetUrlGenerator assetUrlGenerator, + IContentEnricher contentEnricher, IContentRepository contentRepository, IContentVersionLoader contentVersionLoader, IScriptEngine scriptEngine, @@ -61,6 +58,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(assetUrlGenerator, nameof(assetUrlGenerator)); + Guard.NotNull(contentEnricher, nameof(contentEnricher)); Guard.NotNull(contentRepository, nameof(contentRepository)); Guard.NotNull(contentVersionLoader, nameof(contentVersionLoader)); Guard.NotNull(modelBuilder, nameof(modelBuilder)); @@ -69,6 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents this.appProvider = appProvider; this.assetUrlGenerator = assetUrlGenerator; + this.contentEnricher = contentEnricher; this.contentRepository = contentRepository; this.contentVersionLoader = contentVersionLoader; this.modelBuilder = modelBuilder; @@ -76,138 +75,126 @@ namespace Squidex.Domain.Apps.Entities.Contents this.scriptEngine = scriptEngine; } - public Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName) - { - return GetSchemaAsync(context, schemaIdOrName); - } - - public async Task FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1) + public async Task FindContentAsync(Context context, string schemaIdOrName, Guid id, long version = -1) { Guard.NotNull(context, nameof(context)); - var schema = await GetSchemaAsync(context, schemaIdOrName); + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); - CheckPermission(context.User, schema); + CheckPermission(context, schema); using (Profiler.TraceMethod()) { - var isVersioned = version > EtagVersion.Empty; + IContentEntity content; - var status = GetFindStatus(context); - - var content = - isVersioned ? - await FindContentByVersionAsync(id, version) : - await FindContentAsync(context, id, status, schema); + if (version > EtagVersion.Empty) + { + content = await FindByVersionAsync(id, version); + } + else + { + content = await FindCoreAsync(context, id, schema); + } if (content == null || (content.Status != Status.Published && !context.IsFrontendClient) || content.SchemaId.Id != schema.Id) { throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity)); } - return Transform(context, schema, content); + return await TransformAsync(context, schema, content); } } - public async Task> QueryAsync(QueryContext context, string schemaIdOrName, Q query) + public async Task> QueryAsync(Context context, string schemaIdOrName, Q query) { Guard.NotNull(context, nameof(context)); - var schema = await GetSchemaAsync(context, schemaIdOrName); + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); - CheckPermission(context.User, schema); + CheckPermission(context, schema); using (Profiler.TraceMethod()) { - var status = GetQueryStatus(context); - IResultList contents; - if (query.Ids?.Count > 0) + if (query.Ids != null && query.Ids.Count > 0) { - contents = await QueryAsync(context, schema, query.Ids.ToHashSet(), status); - contents = SortSet(contents, query.Ids); + contents = await QueryByIdsAsync(context, query, schema); } else { - var parsedQuery = ParseQuery(context, query.ODataQuery, schema); - - contents = await QueryAsync(context, schema, parsedQuery, status); + contents = await QueryByQueryAsync(context, query, schema); } - return Transform(context, schema, contents); + return await TransformAsync(context, schema, contents); } } - public async Task> QueryAsync(QueryContext context, IReadOnlyList ids) + public async Task> QueryAsync(Context context, IReadOnlyList ids) { Guard.NotNull(context, nameof(context)); using (Profiler.TraceMethod()) { - var status = GetQueryStatus(context); - - List result; - - if (ids?.Count > 0) + if (ids == null || ids.Count == 0) { - var contents = await QueryAsync(context, ids, status); + return EmptyContents; + } - var permissions = context.User.Permissions(); + var results = new List(); - contents = contents.Where(x => HasPermission(permissions, x.Schema)).ToList(); + var contents = await QueryCoreAsync(context, ids); - result = contents.Select(x => Transform(context, x.Schema, x.Content)).ToList(); - result = SortList(result, ids).ToList(); - } - else + foreach (var group in contents.GroupBy(x => x.Schema.Id)) { - result = new List(); + var schema = group.First().Schema; + + if (HasPermission(context, schema)) + { + var enriched = await TransformCoreAsync(context, schema, group.Select(x => x.Content)); + + results.AddRange(enriched); + } } - return result; + return ResultList.Create(results.Count, results.SortList(x => x.Id, ids)); } } - private IResultList Transform(QueryContext context, ISchemaEntity schema, IResultList contents) + private async Task> TransformAsync(Context context, ISchemaEntity schema, IResultList contents) { - var transformed = TransformCore(context, schema, contents); + var transformed = await TransformCoreAsync(context, schema, contents); return ResultList.Create(contents.Total, transformed); } - private IContentEntity Transform(QueryContext context, ISchemaEntity schema, IContentEntity content) - { - return TransformCore(context, schema, Enumerable.Repeat(content, 1)).FirstOrDefault(); - } - - private static IResultList SortSet(IResultList contents, IReadOnlyList ids) + private async Task TransformAsync(Context context, ISchemaEntity schema, IContentEntity content) { - return ResultList.Create(contents.Total, SortList(contents, ids)); - } + var transformed = await TransformCoreAsync(context, schema, Enumerable.Repeat(content, 1)); - private static IEnumerable SortList(IEnumerable contents, IReadOnlyList ids) - { - return ids.Select(id => contents.FirstOrDefault(x => x.Id == id)).Where(x => x != null); + return transformed[0]; } - private IEnumerable TransformCore(QueryContext context, ISchemaEntity schema, IEnumerable contents) + private async Task> TransformCoreAsync(Context context, ISchemaEntity schema, IEnumerable contents) { using (Profiler.TraceMethod()) { + var results = new List(); + var converters = GenerateConverters(context).ToArray(); var scriptText = schema.SchemaDef.Scripts.Query; + var scripting = !string.IsNullOrWhiteSpace(scriptText); - var isScripting = !string.IsNullOrWhiteSpace(scriptText); + var enriched = await contentEnricher.EnrichAsync(contents, context.User); - foreach (var content in contents) + foreach (var content in enriched) { var result = SimpleMapper.Map(content, new ContentEntity()); if (result.Data != null) { - if (!context.IsFrontendClient && isScripting) + if (!context.IsFrontendClient && scripting) { var ctx = new ScriptContext { User = context.User, Data = content.Data, ContentId = content.Id }; @@ -217,7 +204,7 @@ namespace Squidex.Domain.Apps.Entities.Contents result.Data = result.Data.ConvertName2Name(schema.SchemaDef, converters); } - if (result.DataDraft != null && (context.ApiStatus == StatusForApi.PublishedDraft || context.IsFrontendClient)) + if (result.DataDraft != null && (context.IsUnpublished() || context.IsFrontendClient)) { result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters); } @@ -226,12 +213,14 @@ namespace Squidex.Domain.Apps.Entities.Contents result.DataDraft = null; } - yield return result; + results.Add(result); } + + return results; } } - private IEnumerable GenerateConverters(QueryContext context) + private IEnumerable GenerateConverters(Context context) { if (!context.IsFrontendClient) { @@ -249,19 +238,23 @@ namespace Squidex.Domain.Apps.Entities.Contents { yield return FieldConverters.ResolveFallbackLanguages(context.App.LanguagesConfig); - if (context.Languages?.Any() == true) + var languages = context.Languages(); + + if (languages.Any()) { - yield return FieldConverters.FilterLanguages(context.App.LanguagesConfig, context.Languages); + yield return FieldConverters.FilterLanguages(context.App.LanguagesConfig, languages); } - if (context.AssetUrlsToResolve?.Any() == true) + var assetUrls = context.AssetUrls(); + + if (assetUrls.Any() == true) { - yield return FieldConverters.ResolveAssetUrls(context.AssetUrlsToResolve, assetUrlGenerator); + yield return FieldConverters.ResolveAssetUrls(assetUrls.ToList(), assetUrlGenerator); } } } - private Query ParseQuery(QueryContext context, string query, ISchemaEntity schema) + private Query ParseQuery(Context context, string query, ISchemaEntity schema) { using (Profiler.TraceMethod()) { @@ -298,7 +291,7 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public async Task GetSchemaAsync(QueryContext context, string schemaIdOrName) + public async Task GetSchemaOrThrowAsync(Context context, string schemaIdOrName) { ISchemaEntity schema = null; @@ -320,35 +313,29 @@ namespace Squidex.Domain.Apps.Entities.Contents return schema; } - private static void CheckPermission(ClaimsPrincipal user, params ISchemaEntity[] schemas) + private static void CheckPermission(Context context, params ISchemaEntity[] schemas) { - var permissions = user.Permissions(); - foreach (var schema in schemas) { - if (!HasPermission(permissions, schema)) + if (!HasPermission(context, schema)) { throw new DomainForbiddenException("You do not have permission for this schema."); } } } - private static bool HasPermission(PermissionSet permissions, ISchemaEntity schema) + private static bool HasPermission(Context context, ISchemaEntity schema) { var permission = Permissions.ForApp(Permissions.AppContentsRead, schema.AppId.Name, schema.SchemaDef.Name); - return permissions.Allows(permission); + return context.Permissions.Allows(permission); } - private static Status[] GetFindStatus(QueryContext context) + private static Status[] GetStatus(Context context) { - if (context.IsFrontendClient) - { - return StatusAll; - } - else if (context.ApiStatus == StatusForApi.PublishedDraft) + if (context.IsFrontendClient || context.IsUnpublished()) { - return StatusPublishedDraft; + return null; } else { @@ -356,60 +343,48 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private static Status[] GetQueryStatus(QueryContext context) + private async Task> QueryByQueryAsync(Context context, Q query, ISchemaEntity schema) { - if (context.IsFrontendClient) - { - switch (context.FrontendStatus) - { - case StatusForFrontend.Archived: - return StatusArchived; - case StatusForFrontend.PublishedOnly: - return StatusPublishedOnly; - default: - return StatusPublishedDraft; - } - } - else - { - switch (context.ApiStatus) - { - case StatusForApi.PublishedDraft: - return StatusPublishedDraft; - default: - return StatusPublishedOnly; - } - } + var parsedQuery = ParseQuery(context, query.ODataQuery, schema); + + return await QueryCoreAsync(context, schema, parsedQuery); + } + + private async Task> QueryByIdsAsync(Context context, Q query, ISchemaEntity schema) + { + var contents = await QueryCoreAsync(context, schema, query.Ids.ToHashSet()); + + return contents.SortSet(x => x.Id, query.Ids); } - private Task> QueryAsync(QueryContext context, IReadOnlyList ids, Status[] status) + private Task> QueryCoreAsync(Context context, IReadOnlyList ids) { - return contentRepository.QueryAsync(context.App, status, new HashSet(ids), ShouldIncludeDraft(context)); + return contentRepository.QueryAsync(context.App, GetStatus(context), new HashSet(ids), WithDraft(context)); } - private Task> QueryAsync(QueryContext context, ISchemaEntity schema, Query query, Status[] status) + private Task> QueryCoreAsync(Context context, ISchemaEntity schema, Query query) { - return contentRepository.QueryAsync(context.App, schema, status, context.IsFrontendClient, query, ShouldIncludeDraft(context)); + return contentRepository.QueryAsync(context.App, schema, GetStatus(context), context.IsFrontendClient, query, WithDraft(context)); } - private Task> QueryAsync(QueryContext context, ISchemaEntity schema, HashSet ids, Status[] status) + private Task> QueryCoreAsync(Context context, ISchemaEntity schema, HashSet ids) { - return contentRepository.QueryAsync(context.App, schema, status, ids, ShouldIncludeDraft(context)); + return contentRepository.QueryAsync(context.App, schema, GetStatus(context), ids, WithDraft(context)); } - private Task FindContentAsync(QueryContext context, Guid id, Status[] status, ISchemaEntity schema) + private Task FindCoreAsync(Context context, Guid id, ISchemaEntity schema) { - return contentRepository.FindContentAsync(context.App, schema, status, id, ShouldIncludeDraft(context)); + return contentRepository.FindContentAsync(context.App, schema, GetStatus(context), id, WithDraft(context)); } - private Task FindContentByVersionAsync(Guid id, long version) + private Task FindByVersionAsync(Guid id, long version) { return contentVersionLoader.LoadAsync(id, version); } - private static bool ShouldIncludeDraft(QueryContext context) + private static bool WithDraft(Context context) { - return context.ApiStatus == StatusForApi.PublishedDraft || context.IsFrontendClient; + return context.IsUnpublished() || context.IsFrontendClient; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs new file mode 100644 index 000000000..78f208424 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs @@ -0,0 +1,139 @@ +// ========================================================================== +// 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 Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public static class ContextExtensions + { + private const string HeaderUnpublished = "X-Unpublished"; + private const string HeaderFlatten = "X-Flatten"; + private const string HeaderLanguages = "X-Languages"; + private const string HeaderResolveFlow = "X-ResolveFlow"; + private const string HeaderResolveAssetUrls = "X-Resolve-Urls"; + private static readonly char[] Separators = { ',', ';' }; + + public static bool IsUnpublished(this Context context) + { + return context.Headers.ContainsKey(HeaderUnpublished); + } + + public static Context WithUnpublished(this Context context, bool value = true) + { + if (value) + { + context.Headers[HeaderUnpublished] = "1"; + } + else + { + context.Headers.Remove(HeaderUnpublished); + } + + return context; + } + + public static bool IsFlatten(this Context context) + { + return context.Headers.ContainsKey(HeaderFlatten); + } + + public static Context WithFlatten(this Context context, bool value = true) + { + if (value) + { + context.Headers[HeaderFlatten] = "1"; + } + else + { + context.Headers.Remove(HeaderFlatten); + } + + return context; + } + + public static bool IsResolveFlow(this Context context) + { + return context.Headers.ContainsKey(HeaderResolveFlow); + } + + public static Context WithResolveFlow(this Context context, bool value = true) + { + if (value) + { + context.Headers[HeaderResolveFlow] = "1"; + } + else + { + context.Headers.Remove(HeaderResolveFlow); + } + + return context; + } + + public static IEnumerable AssetUrls(this Context context) + { + if (context.Headers.TryGetValue(HeaderResolveAssetUrls, out var value)) + { + return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToHashSet(); + } + + return Enumerable.Empty(); + } + + public static Context WithAssetUrlsToResolve(this Context context, IEnumerable fieldNames) + { + if (fieldNames?.Any() == true) + { + context.Headers[HeaderResolveAssetUrls] = string.Join(",", fieldNames); + } + else + { + context.Headers.Remove(HeaderResolveAssetUrls); + } + + return context; + } + + public static IEnumerable Languages(this Context context) + { + if (context.Headers.TryGetValue(HeaderResolveAssetUrls, out var value)) + { + var languages = new HashSet(); + + foreach (var iso2Code in value.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) + { + if (Language.TryGetLanguage(iso2Code.Trim(), out var language)) + { + languages.Add(language); + } + } + + return languages; + } + + return Enumerable.Empty(); + } + + public static Context WithLanguages(this Context context, IEnumerable fieldNames) + { + if (fieldNames?.Any() == true) + { + context.Headers[HeaderLanguages] = string.Join(",", fieldNames); + } + else + { + context.Headers.Remove(HeaderLanguages); + } + + return context; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs new file mode 100644 index 000000000..47c76f4e0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs @@ -0,0 +1,96 @@ +// ========================================================================== +// 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.Security.Claims; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class DefaultContentWorkflow : IContentWorkflow + { + private static readonly StatusInfo InfoArchived = new StatusInfo(Status.Archived, StatusColors.Archived); + private static readonly StatusInfo InfoDraft = new StatusInfo(Status.Draft, StatusColors.Draft); + private static readonly StatusInfo InfoPublished = new StatusInfo(Status.Published, StatusColors.Published); + + private static readonly StatusInfo[] All = + { + InfoArchived, + InfoDraft, + InfoPublished + }; + + private static readonly Dictionary Flow = + new Dictionary + { + [Status.Archived] = (InfoArchived, new[] + { + InfoDraft + }), + [Status.Draft] = (InfoDraft, new[] + { + InfoArchived, + InfoPublished + }), + [Status.Published] = (InfoPublished, new[] + { + InfoDraft, + InfoArchived + }) + }; + + public Task GetInitialStatusAsync(ISchemaEntity schema) + { + var result = InfoDraft; + + return Task.FromResult(result); + } + + public Task CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user) + { + return TaskHelper.True; + } + + public Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) + { + var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next); + + return Task.FromResult(result); + } + + public Task CanUpdateAsync(IContentEntity content) + { + var result = content.Status != Status.Archived; + + return Task.FromResult(result); + } + + public Task GetInfoAsync(IContentEntity content) + { + var result = Flow[content.Status].Info; + + return Task.FromResult(result); + } + + public Task GetNextsAsync(IContentEntity content, ClaimsPrincipal user) + { + var result = Flow.TryGetValue(content.Status, out var step) ? step.Transitions : Array.Empty(); + + return Task.FromResult(result); + } + + public Task GetAllAsync(ISchemaEntity schema) + { + return Task.FromResult(All); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs b/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs new file mode 100644 index 000000000..75ddd704b --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// 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.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class DefaultWorkflowsValidator : IWorkflowsValidator + { + private readonly IAppProvider appProvider; + + public DefaultWorkflowsValidator(IAppProvider appProvider) + { + Guard.NotNull(appProvider, nameof(appProvider)); + + this.appProvider = appProvider; + } + + public async Task> ValidateAsync(Guid appId, Workflows workflows) + { + Guard.NotNull(workflows, nameof(workflows)); + + var errors = new List(); + + if (workflows.Values.Count(x => x.SchemaIds.Count == 0) > 1) + { + errors.Add("Multiple workflows cover all schemas."); + } + + var uniqueSchemaIds = workflows.Values.SelectMany(x => x.SchemaIds).Distinct().ToList(); + + foreach (var schemaId in uniqueSchemaIds) + { + if (workflows.Values.Count(x => x.SchemaIds.Contains(schemaId)) > 1) + { + var schema = await appProvider.GetSchemaAsync(appId, schemaId); + + if (schema != null) + { + errors.Add($"The schema `{schema.SchemaDef.Name}` is covered by multiple workflows."); + } + } + } + + return errors; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs new file mode 100644 index 000000000..6788f21e5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs @@ -0,0 +1,153 @@ +// ========================================================================== +// 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; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class DynamicContentWorkflow : IContentWorkflow + { + private readonly IScriptEngine scriptEngine; + private readonly IAppProvider appProvider; + + public DynamicContentWorkflow(IScriptEngine scriptEngine, IAppProvider appProvider) + { + Guard.NotNull(scriptEngine, nameof(scriptEngine)); + Guard.NotNull(appProvider, nameof(appProvider)); + + this.scriptEngine = scriptEngine; + + this.appProvider = appProvider; + } + + public async Task GetAllAsync(ISchemaEntity schema) + { + var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); + + return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray(); + } + + public async Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) + { + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); + + return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content.DataDraft, user); + } + + public async Task CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user) + { + var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); + + return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && CanUse(transition, data, user); + } + + public async Task CanUpdateAsync(IContentEntity content) + { + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); + + if (workflow.TryGetStep(content.Status, out var step)) + { + return !step.NoUpdate; + } + + return true; + } + + public async Task GetInfoAsync(IContentEntity content) + { + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); + + if (workflow.TryGetStep(content.Status, out var step)) + { + return new StatusInfo(content.Status, GetColor(step)); + } + + return new StatusInfo(content.Status, StatusColors.Draft); + } + + public async Task GetInitialStatusAsync(ISchemaEntity schema) + { + var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); + + var (status, step) = workflow.GetInitialStep(); + + return new StatusInfo(status, GetColor(step)); + } + + public async Task GetNextsAsync(IContentEntity content, ClaimsPrincipal user) + { + var result = new List(); + + var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); + + foreach (var (to, step, transition) in workflow.GetTransitions(content.Status)) + { + if (CanUse(transition, content.DataDraft, user)) + { + result.Add(new StatusInfo(to, GetColor(step))); + } + } + + return result.ToArray(); + } + + private bool CanUse(WorkflowTransition transition, NamedContentData data, ClaimsPrincipal user) + { + if (!string.IsNullOrWhiteSpace(transition.Role)) + { + if (!user.Claims.Any(x => x.Type == ClaimTypes.Role && x.Value == transition.Role)) + { + return false; + } + } + + if (!string.IsNullOrWhiteSpace(transition.Expression)) + { + return scriptEngine.Evaluate("data", data, transition.Expression); + } + + return true; + } + + private async Task GetWorkflowAsync(Guid appId, Guid schemaId) + { + Workflow result = null; + + var app = await appProvider.GetAppAsync(appId); + + if (app != null) + { + result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schemaId)); + + if (result == null) + { + result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0); + } + } + + if (result == null) + { + result = Workflow.Default; + } + + return result; + } + + private static string GetColor(WorkflowStep step) + { + return step.Color ?? StatusColors.Draft; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index 1a01b229a..1fa48486c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL this.resolver = resolver; } - public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries) + public async Task<(bool HasError, object Response)> QueryAsync(Context context, params GraphQLQuery[] queries) { Guard.NotNull(context, nameof(context)); Guard.NotNull(queries, nameof(queries)); @@ -40,10 +40,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q))); - return (result.Any(x => x.HasError), result.ToArray(x => x.Response)); + return (result.Any(x => x.HasError), result.Map(x => x.Response)); } - public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query) + public async Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query) { Guard.NotNull(context, nameof(context)); Guard.NotNull(query, nameof(query)); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index c5dbf6024..3841ac148 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public sealed class GraphQLExecutionContext : QueryExecutionContext { - private static readonly List EmptyAssets = new List(); + private static readonly List EmptyAssets = new List(); private static readonly List EmptyContents = new List(); private readonly IDataLoaderContextAccessor dataLoaderContextAccessor; private readonly IDependencyResolver resolver; @@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL public ISemanticLog Log { get; } - public GraphQLExecutionContext(QueryContext context, IDependencyResolver resolver) + public GraphQLExecutionContext(Context context, IDependencyResolver resolver) : base(context, resolver.Resolve(), resolver.Resolve()) @@ -53,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL execution.UserContext = this; } - public override Task FindAssetAsync(Guid id) + public override Task FindAssetAsync(Guid id) { var dataLoader = GetAssetsLoader(); @@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return dataLoader.LoadAsync(id); } - public async Task> GetReferencedAssetsAsync(IJsonValue value) + public async Task> GetReferencedAssetsAsync(IJsonValue value) { var ids = ParseIds(value); @@ -95,9 +95,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return await dataLoader.LoadManyAsync(ids); } - private IDataLoader GetAssetsLoader() + private IDataLoader GetAssetsLoader() { - return dataLoaderContextAccessor.Context.GetOrAddBatchLoader("Assets", + return dataLoaderContextAccessor.Context.GetOrAddBatchLoader("Assets", async batch => { var result = await GetReferencedAssetsAsync(new List(batch)); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs index 693f1fabf..65760a321 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs @@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public interface IGraphQLService { - Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries); + Task<(bool HasError, object Response)> QueryAsync(Context context, params GraphQLQuery[] queries); - Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query); + Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs index 5e1b6c973..c8ee5d2f2 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs @@ -7,7 +7,6 @@ using System; using GraphQL.Types; -using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Utils; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types @@ -28,8 +27,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public static readonly IGraphType Float = new FloatGraphType(); - public static readonly IGraphType Status = new EnumerationGraphType(); - public static readonly IGraphType String = new StringGraphType(); public static readonly IGraphType Boolean = new BooleanGraphType(); @@ -46,8 +43,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public static readonly IGraphType NonNullBoolean = new NonNullGraphType(Boolean); - public static readonly IGraphType NonNullStatusType = new NonNullGraphType(Status); - public static readonly IGraphType NoopDate = new NoopGraphType(Date); public static readonly IGraphType NoopJson = new NoopGraphType(Json); 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 ecdcb3620..801f44c81 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - public sealed class AssetGraphType : ObjectGraphType + public sealed class AssetGraphType : ObjectGraphType { public AssetGraphType(IGraphModel model) { @@ -167,8 +167,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { Name = "tags", ResolvedType = null, - Resolver = Resolve(x => x.Tags), - Description = "The height of the image in pixels if the asset is an image.", + Resolver = Resolve(x => x.TagNames), + Description = "The asset tags.", Type = AllTypes.NonNullTagsType }); @@ -186,9 +186,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = "An asset"; } - private static IFieldResolver Resolve(Func action) + private static IFieldResolver Resolve(Func action) { - return new FuncFieldResolver(c => action(c.Source)); + return new FuncFieldResolver(c => action(c.Source)); } } } 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 ec8878874..63f571c1a 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs @@ -13,7 +13,7 @@ using Squidex.Domain.Apps.Entities.Schemas; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - public sealed class ContentGraphType : ObjectGraphType + public sealed class ContentGraphType : ObjectGraphType { public void Initialize(IGraphModel model, ISchemaEntity schema, IComplexGraphType contentDataType) { @@ -73,11 +73,19 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types AddField(new FieldType { Name = "status", - ResolvedType = AllTypes.NonNullStatusType, - Resolver = Resolve(x => x.Status), + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.Status.Name.ToUpperInvariant()), Description = $"The the status of the {schemaName} content." }); + AddField(new FieldType + { + Name = "statusColor", + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.StatusColor), + Description = $"The color status of the {schemaName} content." + }); + AddField(new FieldType { Name = "url", @@ -108,9 +116,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = $"The structure of a {schemaName} content type."; } - private static IFieldResolver Resolve(Func action) + private static IFieldResolver Resolve(Func action) { - return new FuncFieldResolver(c => action(c.Source)); + return new FuncFieldResolver(c => action(c.Source)); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index 379bd601f..9feaadbd4 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Threading.Tasks; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; @@ -15,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { public static class GuardContent { - public static void CanCreate(ISchemaEntity schema, CreateContent command) + public static async Task CanCreate(ISchemaEntity schema, IContentWorkflow contentWorkflow, CreateContent command) { Guard.NotNull(command, nameof(command)); @@ -28,9 +29,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { throw new DomainException("Singleton content cannot be created."); } + + if (command.Publish && !await contentWorkflow.CanPublishOnCreateAsync(schema, command.Data, command.User)) + { + throw new DomainException("Content workflow prevents publishing."); + } } - public static void CanUpdate(UpdateContent command) + public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command, bool isProposal) { Guard.NotNull(command, nameof(command)); @@ -38,9 +44,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { ValidateData(command, e); }); + + if (!isProposal) + { + await ValidateCanUpdate(content, contentWorkflow); + } } - public static void CanPatch(PatchContent command) + public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command, bool isProposal) { Guard.NotNull(command, nameof(command)); @@ -48,6 +59,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { ValidateData(command, e); }); + + if (!isProposal) + { + await ValidateCanUpdate(content, contentWorkflow); + } } public static void CanDiscardChanges(bool isPending, DiscardChanges command) @@ -60,35 +76,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards } } - public static void CanChangeContentStatus(ISchemaEntity schema, bool isPending, Status status, ChangeContentStatus command) + public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command, bool isChangeConfirm) { Guard.NotNull(command, nameof(command)); if (schema.SchemaDef.IsSingleton && command.Status != Status.Published) { - throw new DomainException("Singleton content archived or unpublished."); + throw new DomainException("Singleton content cannot be changed."); } - Validate.It(() => "Cannot change status.", e => + return Validate.It(() => "Cannot change status.", async e => { - if (!StatusFlow.Exists(command.Status)) - { - e(Not.Valid("Status"), nameof(command.Status)); - } - else if (!StatusFlow.CanChange(status, command.Status)) + if (isChangeConfirm) { - if (status == command.Status && status == Status.Published) - { - if (!isPending) - { - e("Content has no changes to publish.", nameof(command.Status)); - } - } - else + if (!content.IsPending) { - e($"Cannot change status from {status} to {command.Status}.", nameof(command.Status)); + e("Content has no changes to publish.", nameof(command.Status)); } } + else if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User)) + { + e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status)); + } if (command.DueTime.HasValue && command.DueTime.Value < SystemClock.Instance.GetCurrentInstant()) { @@ -114,5 +123,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards e(Not.Defined("Data"), nameof(command.Data)); } } + + private static async Task ValidateCanUpdate(IContentEntity content, IContentWorkflow contentWorkflow) + { + if (!await contentWorkflow.CanUpdateAsync(content)) + { + throw new DomainException($"The workflow does not allow updates at status {content.Status}"); + } + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs new file mode 100644 index 000000000..e73e49c16 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IContentEnricher + { + Task EnrichAsync(IContentEntity content, ClaimsPrincipal user); + + Task> EnrichAsync(IEnumerable contents, ClaimsPrincipal user); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs index 75d89c115..de784f55d 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents @@ -16,12 +17,12 @@ namespace Squidex.Domain.Apps.Entities.Contents { int DefaultPageSizeGraphQl { get; } - Task> QueryAsync(QueryContext context, IReadOnlyList ids); + Task> QueryAsync(Context context, IReadOnlyList ids); - Task> QueryAsync(QueryContext context, string schemaIdOrName, Q query); + Task> QueryAsync(Context context, string schemaIdOrName, Q query); - Task FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any); + Task FindContentAsync(Context context, string schemaIdOrName, Guid id, long version = EtagVersion.Any); - Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName); + Task GetSchemaOrThrowAsync(Context context, string schemaIdOrName); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs new file mode 100644 index 000000000..b9acaffc9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Security.Claims; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Schemas; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IContentWorkflow + { + Task GetInitialStatusAsync(ISchemaEntity schema); + + Task CanPublishOnCreateAsync(ISchemaEntity schema, NamedContentData data, ClaimsPrincipal user); + + Task CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user); + + Task CanUpdateAsync(IContentEntity content); + + Task GetInfoAsync(IContentEntity content); + + Task GetNextsAsync(IContentEntity content, ClaimsPrincipal user); + + Task GetAllAsync(ISchemaEntity schema); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs new file mode 100644 index 000000000..45b3506a4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IEnrichedContentEntity : IContentEntity + { + bool CanUpdate { get; } + + string StatusColor { get; } + + StatusInfo[] Nexts { get; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs b/src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs new file mode 100644 index 000000000..01c8574b4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IWorkflowsValidator.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// 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.Contents; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public interface IWorkflowsValidator + { + Task> ValidateAsync(Guid appId, Workflows workflows); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs index 8b186cbd4..2c50a0d1e 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs @@ -18,12 +18,12 @@ namespace Squidex.Domain.Apps.Entities.Contents public class QueryExecutionContext { private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); - private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); + private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); private readonly IContentQueryService contentQuery; private readonly IAssetQueryService assetQuery; - private readonly QueryContext context; + private readonly Context context; - public QueryExecutionContext(QueryContext context, IAssetQueryService assetQuery, IContentQueryService contentQuery) + public QueryExecutionContext(Context context, IAssetQueryService assetQuery, IContentQueryService contentQuery) { Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(contentQuery, nameof(contentQuery)); @@ -34,13 +34,13 @@ namespace Squidex.Domain.Apps.Entities.Contents this.context = context; } - public virtual async Task FindAssetAsync(Guid id) + public virtual async Task FindAssetAsync(Guid id) { var asset = cachedAssets.GetOrDefault(id); if (asset == null) { - asset = await assetQuery.FindAssetAsync(context, id); + asset = await assetQuery.FindAssetAsync(id); if (asset != null) { @@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return result; } - public virtual async Task> GetReferencedAssetsAsync(ICollection ids) + public virtual async Task> GetReferencedAssetsAsync(ICollection ids) { Guard.NotNull(ids, nameof(ids)); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs b/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs index fe48b89e4..6d0c7799c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs @@ -16,12 +16,12 @@ namespace Squidex.Domain.Apps.Entities.Contents { public Guid Id { get; } + public Instant DueTime { get; } + public Status Status { get; } public RefToken ScheduledBy { get; } - public Instant DueTime { get; } - public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime) { Id = id; diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index 1f2169431..e413b9117 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs @@ -63,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.State { ScheduleJob = null; - Status = @event.Status; + SimpleMapper.Map(@event, this); if (@event.Status == Status.Published) { diff --git a/src/Squidex.Domain.Apps.Entities/Context.cs b/src/Squidex.Domain.Apps.Entities/Context.cs new file mode 100644 index 000000000..106b54f15 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Context.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Security.Claims; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Shared.Identity; + +namespace Squidex.Domain.Apps.Entities +{ + public sealed class Context + { + public IDictionary Headers { get; } = new Dictionary(); + + public IAppEntity App { get; set; } + + public ClaimsPrincipal User { get; set; } + + public PermissionSet Permissions + { + get { return User?.Permissions() ?? PermissionSet.Empty; } + } + + public Context() + { + } + + public Context(ClaimsPrincipal user, IAppEntity app) + { + User = user; + + App = app; + } + + public bool IsFrontendClient + { + get { return User != null && User.IsInClient(DefaultClients.Frontend); } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs b/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs index 92f18d5a2..7e5c018b3 100644 --- a/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs +++ b/src/Squidex.Domain.Apps.Entities/History/ParsedHistoryEvent.cs @@ -53,14 +53,17 @@ namespace Squidex.Domain.Apps.Entities.History message = new Lazy(() => { - var result = texts[item.Message]; - - foreach (var kvp in item.Parameters) + if (texts.TryGetValue(item.Message, out var result)) { - result = result.Replace("[" + kvp.Key + "]", kvp.Value); + foreach (var kvp in item.Parameters) + { + result = result.Replace("[" + kvp.Key + "]", kvp.Value); + } + + return result; } - return result; + return null; }); } } diff --git a/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/src/Squidex.Domain.Apps.Entities/IAppProvider.cs index f40ca30ca..114ac3384 100644 --- a/src/Squidex.Domain.Apps.Entities/IAppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/IAppProvider.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities { Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id); + Task GetAppAsync(Guid appId); + Task GetAppAsync(string appName); Task GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs b/src/Squidex.Domain.Apps.Entities/IContextProvider.cs similarity index 75% rename from src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs rename to src/Squidex.Domain.Apps.Entities/IContextProvider.cs index 4d17ea6a6..e6e2c67cf 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs +++ b/src/Squidex.Domain.Apps.Entities/IContextProvider.cs @@ -5,11 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Domain.Apps.Entities { - public enum StatusForApi + public interface IContextProvider { - PublishedOnly, - PublishedDraft, + Context Context { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/QueryContext.cs deleted file mode 100644 index c6c552be9..000000000 --- a/src/Squidex.Domain.Apps.Entities/QueryContext.cs +++ /dev/null @@ -1,133 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.Collections.Generic; -using System.Security.Claims; -using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Security; - -namespace Squidex.Domain.Apps.Entities -{ - public sealed class QueryContext : Cloneable - { - private static readonly char[] Separators = { ',', ';' }; - - public ClaimsPrincipal User { get; private set; } - - public IAppEntity App { get; private set; } - - public bool Flatten { get; set; } - - public StatusForApi ApiStatus { get; private set; } - - public StatusForFrontend FrontendStatus { get; private set; } - - public IReadOnlyCollection AssetUrlsToResolve { get; private set; } - - public IReadOnlyCollection Languages { get; private set; } - - private QueryContext() - { - } - - public static QueryContext Create(IAppEntity app, ClaimsPrincipal user) - { - return new QueryContext { App = app, User = user }; - } - - public QueryContext WithFlatten(bool flatten) - { - return Clone(c => c.Flatten = flatten); - } - - public QueryContext WithUnpublished(bool unpublished) - { - return WithApiStatus(unpublished ? StatusForApi.PublishedDraft : StatusForApi.PublishedOnly); - } - - public QueryContext WithApiStatus(StatusForApi status) - { - return Clone(c => c.ApiStatus = status); - } - - public QueryContext WithFrontendStatus(StatusForFrontend status) - { - return Clone(c => c.FrontendStatus = status); - } - - public QueryContext WithFrontendStatus(string status) - { - if (status != null && Enum.TryParse(status, out var result)) - { - return WithFrontendStatus(result); - } - - return this; - } - - public QueryContext WithAssetUrlsToResolve(IEnumerable fieldNames) - { - if (fieldNames != null) - { - return Clone(c => - { - var fields = new HashSet(StringComparer.OrdinalIgnoreCase); - - c.AssetUrlsToResolve?.Foreach(x => fields.Add(x)); - - foreach (var part in fieldNames) - { - foreach (var fieldName in part.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) - { - fields.Add(fieldName.Trim()); - } - } - - c.AssetUrlsToResolve = fields; - }); - } - - return this; - } - - public QueryContext WithLanguages(IEnumerable languageCodes) - { - if (languageCodes != null) - { - return Clone(c => - { - var languages = new HashSet(); - - c.Languages?.Foreach(x => languages.Add(x)); - - foreach (var part in languageCodes) - { - foreach (var iso2Code in part.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) - { - if (Language.TryGetLanguage(iso2Code.Trim(), out var language)) - { - languages.Add(language); - } - } - } - - c.Languages = languages; - }); - } - - return this; - } - - public bool IsFrontendClient - { - get { return User.IsInClient("squidex-frontend"); } - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs index f6073a4f6..a44d6144d 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs @@ -41,35 +41,43 @@ namespace Squidex.Domain.Apps.Entities.Rules switch (command) { case CreateRule createRule: - return CreateAsync(createRule, async c => + return CreateReturnAsync(createRule, async c => { await GuardRule.CanCreate(c, appProvider); Create(c); + + return Snapshot; }); case UpdateRule updateRule: - return UpdateAsync(updateRule, async c => + return UpdateReturnAsync(updateRule, async c => { await GuardRule.CanUpdate(c, Snapshot.AppId.Id, appProvider); Update(c); + + return Snapshot; }); case EnableRule enableRule: - return UpdateAsync(enableRule, c => + return UpdateReturn(enableRule, c => { GuardRule.CanEnable(c, Snapshot.RuleDef); Enable(c); + + return Snapshot; }); case DisableRule disableRule: - return UpdateAsync(disableRule, c => + return UpdateReturn(disableRule, c => { GuardRule.CanDisable(c, Snapshot.RuleDef); Disable(c); + + return Snapshot; }); case DeleteRule deleteRule: - return UpdateAsync(deleteRule, c => + return Update(deleteRule, c => { GuardRule.CanDelete(deleteRule); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs index b0a2b8d5d..760a6086e 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs @@ -12,21 +12,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { public static class GuardHelper { - public static IArrayField GetArrayFieldOrThrow(Schema schema, long parentId) + public static IArrayField GetArrayFieldOrThrow(Schema schema, long parentId, bool allowLocked) { if (!schema.FieldsById.TryGetValue(parentId, out var rootField) || !(rootField is IArrayField arrayField)) { throw new DomainObjectNotFoundException(parentId.ToString(), "Fields", typeof(Schema)); } + if (!allowLocked) + { + EnsureNotLocked(arrayField); + } + return arrayField; } - public static IField GetFieldOrThrow(Schema schema, long fieldId, long? parentId) + public static IField GetFieldOrThrow(Schema schema, long fieldId, long? parentId, bool allowLocked) { if (parentId.HasValue) { - var arrayField = GetArrayFieldOrThrow(schema, parentId.Value); + var arrayField = GetArrayFieldOrThrow(schema, parentId.Value, allowLocked); if (!arrayField.FieldsById.TryGetValue(fieldId, out var nestedField)) { @@ -41,7 +46,20 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards throw new DomainObjectNotFoundException(fieldId.ToString(), "Fields", typeof(Schema)); } + if (!allowLocked) + { + EnsureNotLocked(field); + } + return field; } + + private static void EnsureNotLocked(IField field) + { + if (field.IsLocked) + { + throw new DomainException("Schema field is locked."); + } + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs index 4e937c96e..1a87bdf21 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs @@ -56,7 +56,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards if (command.ParentFieldId.HasValue) { - arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value); + arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value, false); } Validate.It(() => "Cannot reorder schema fields.", error => diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs index ed93453bc..92ccf8142 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs @@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards if (command.ParentFieldId.HasValue) { - var arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value); + var arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value, false); if (arrayField.FieldsByName.ContainsKey(command.Name)) { @@ -64,12 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - - if (field.IsLocked) - { - throw new DomainException("Schema field is already locked."); - } + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false); Validate.It(() => "Cannot update field.", e => { @@ -90,12 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - - if (field.IsLocked) - { - throw new DomainException("Schema field is locked."); - } + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false); if (field.IsHidden) { @@ -108,18 +98,30 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards } } + public static void CanShow(Schema schema, ShowField command) + { + Guard.NotNull(command, nameof(command)); + + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false); + + if (!field.IsHidden) + { + throw new DomainException("Schema field is already visible."); + } + } + public static void CanDisable(Schema schema, DisableField command) { Guard.NotNull(command, nameof(command)); - var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false); if (field.IsDisabled) { throw new DomainException("Schema field is already disabled."); } - if (!field.IsForApi()) + if (!field.IsForApi(true)) { throw new DomainException("UI field cannot be disabled."); } @@ -129,7 +131,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false); if (field.IsLocked) { @@ -137,23 +139,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards } } - public static void CanShow(Schema schema, ShowField command) - { - Guard.NotNull(command, nameof(command)); - - var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); - - if (!field.IsHidden) - { - throw new DomainException("Schema field is already visible."); - } - } - public static void CanEnable(Schema schema, EnableField command) { Guard.NotNull(command, nameof(command)); - var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false); if (!field.IsDisabled) { @@ -165,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards { Guard.NotNull(command, nameof(command)); - var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); + var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false); if (field.IsLocked) { diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs index 44e96fa7f..f1d2ae8c5 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs @@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas switch (command) { case AddField addField: - return UpdateAsync(addField, c => + return UpdateReturn(addField, c => { GuardSchemaField.CanAdd(Snapshot.SchemaDef, c); @@ -65,139 +65,171 @@ namespace Squidex.Domain.Apps.Entities.Schemas id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id; } - return EntityCreatedResult.Create(id, Version); + return Snapshot; }); case CreateSchema createSchema: - return CreateAsync(createSchema, async c => + return CreateReturnAsync(createSchema, async c => { await GuardSchema.CanCreate(c, appProvider); Create(c); + + return Snapshot; }); case SynchronizeSchema synchronizeSchema: - return UpdateAsync(synchronizeSchema, c => + return UpdateReturn(synchronizeSchema, c => { GuardSchema.CanSynchronize(c); Synchronize(c); + + return Snapshot; }); case DeleteField deleteField: - return UpdateAsync(deleteField, c => + return UpdateReturn(deleteField, c => { GuardSchemaField.CanDelete(Snapshot.SchemaDef, deleteField); DeleteField(c); + + return Snapshot; }); case LockField lockField: - return UpdateAsync(lockField, c => + return UpdateReturn(lockField, c => { GuardSchemaField.CanLock(Snapshot.SchemaDef, lockField); LockField(c); + + return Snapshot; }); case HideField hideField: - return UpdateAsync(hideField, c => + return UpdateReturn(hideField, c => { GuardSchemaField.CanHide(Snapshot.SchemaDef, c); HideField(c); + + return Snapshot; }); case ShowField showField: - return UpdateAsync(showField, c => + return UpdateReturn(showField, c => { GuardSchemaField.CanShow(Snapshot.SchemaDef, c); ShowField(c); + + return Snapshot; }); case DisableField disableField: - return UpdateAsync(disableField, c => + return UpdateReturn(disableField, c => { GuardSchemaField.CanDisable(Snapshot.SchemaDef, c); DisableField(c); + + return Snapshot; }); case EnableField enableField: - return UpdateAsync(enableField, c => + return UpdateReturn(enableField, c => { GuardSchemaField.CanEnable(Snapshot.SchemaDef, c); EnableField(c); + + return Snapshot; }); case UpdateField updateField: - return UpdateAsync(updateField, c => + return UpdateReturn(updateField, c => { GuardSchemaField.CanUpdate(Snapshot.SchemaDef, c); UpdateField(c); + + return Snapshot; }); case ReorderFields reorderFields: - return UpdateAsync(reorderFields, c => + return UpdateReturn(reorderFields, c => { GuardSchema.CanReorder(Snapshot.SchemaDef, c); Reorder(c); + + return Snapshot; }); case UpdateSchema updateSchema: - return UpdateAsync(updateSchema, c => + return UpdateReturn(updateSchema, c => { GuardSchema.CanUpdate(Snapshot.SchemaDef, c); Update(c); + + return Snapshot; }); case PublishSchema publishSchema: - return UpdateAsync(publishSchema, c => + return UpdateReturn(publishSchema, c => { GuardSchema.CanPublish(Snapshot.SchemaDef, c); Publish(c); + + return Snapshot; }); case UnpublishSchema unpublishSchema: - return UpdateAsync(unpublishSchema, c => + return UpdateReturn(unpublishSchema, c => { GuardSchema.CanUnpublish(Snapshot.SchemaDef, c); Unpublish(c); + + return Snapshot; }); case ConfigureScripts configureScripts: - return UpdateAsync(configureScripts, c => + return UpdateReturn(configureScripts, c => { GuardSchema.CanConfigureScripts(Snapshot.SchemaDef, c); ConfigureScripts(c); + + return Snapshot; }); case ChangeCategory changeCategory: - return UpdateAsync(changeCategory, c => + return UpdateReturn(changeCategory, c => { GuardSchema.CanChangeCategory(Snapshot.SchemaDef, c); ChangeCategory(c); + + return Snapshot; }); case ConfigurePreviewUrls configurePreviewUrls: - return UpdateAsync(configurePreviewUrls, c => + return UpdateReturn(configurePreviewUrls, c => { GuardSchema.CanConfigurePreviewUrls(c); ConfigurePreviewUrls(c); + + return Snapshot; }); case DeleteSchema deleteSchema: - return UpdateAsync(deleteSchema, c => + return Update(deleteSchema, c => { GuardSchema.CanDelete(Snapshot.SchemaDef, c); diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs index f640cf173..21721c416 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs @@ -19,12 +19,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas public SchemaHistoryEventsCreator(TypeNameRegistry typeNameRegistry) : base(typeNameRegistry) { - AddEventMessage("SchemaCreatedEvent", - "created schema {[Name]}."); - - AddEventMessage("ScriptsConfiguredEvent", - "configured script of schema {[Name]}."); - AddEventMessage( "reordered fields of schema {[Name]}."); diff --git a/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs index ad8c37457..08f1ff835 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/GrainTagService.cs @@ -45,17 +45,17 @@ namespace Squidex.Domain.Apps.Entities.Tags return GetGrain(appId, group).DenormalizeTagsAsync(ids); } - public Task> GetTagsAsync(Guid appId, string group) + public Task GetTagsAsync(Guid appId, string group) { return GetGrain(appId, group).GetTagsAsync(); } - public Task GetExportableTagsAsync(Guid appId, string group) + public Task GetExportableTagsAsync(Guid appId, string group) { return GetGrain(appId, group).GetExportableTagsAsync(); } - public Task RebuildTagsAsync(Guid appId, string group, TagSet tags) + public Task RebuildTagsAsync(Guid appId, string group, TagsExport tags) { return GetGrain(appId, group).RebuildAsync(tags); } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs index d43b6f022..be9a5bdfb 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/ITagGrain.cs @@ -20,12 +20,12 @@ namespace Squidex.Domain.Apps.Entities.Tags Task> DenormalizeTagsAsync(HashSet ids); - Task> GetTagsAsync(); + Task GetTagsAsync(); - Task GetExportableTagsAsync(); + Task GetExportableTagsAsync(); Task ClearAsync(); - Task RebuildAsync(TagSet tags); + Task RebuildAsync(TagsExport tags); } } diff --git a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs index 9062e3366..3053bf1a6 100644 --- a/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Tags [CollectionName("Index_Tags")] public sealed class GrainState { - public TagSet Tags { get; set; } = new TagSet(); + public TagsExport Tags { get; set; } = new TagsExport(); } public TagGrain(IStore store) @@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Tags return ClearStateAsync(); } - public Task RebuildAsync(TagSet tags) + public Task RebuildAsync(TagsExport tags) { State.Tags = tags; @@ -132,12 +132,14 @@ namespace Squidex.Domain.Apps.Entities.Tags return Task.FromResult(result); } - public Task> GetTagsAsync() + public Task GetTagsAsync() { - return Task.FromResult(State.Tags.Values.ToDictionary(x => x.Name, x => x.Count)); + var tags = State.Tags.Values.ToDictionary(x => x.Name, x => x.Count); + + return Task.FromResult(new TagsSet(tags, Persistence.Version)); } - public Task GetExportableTagsAsync() + public Task GetExportableTagsAsync() { return Task.FromResult(State.Tags); } diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowAdded.cs b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowAdded.cs new file mode 100644 index 000000000..3e5627bee --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowAdded.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppWorkflowAdded))] + public sealed class AppWorkflowAdded : AppEvent + { + public Guid WorkflowId { get; set; } + + public string Name { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowDeleted.cs b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowDeleted.cs new file mode 100644 index 000000000..15d418994 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowDeleted.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppWorkflowDeleted))] + public sealed class AppWorkflowDeleted : AppEvent + { + public Guid WorkflowId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowUpdated.cs b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowUpdated.cs new file mode 100644 index 000000000..672242ed8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppWorkflowUpdated.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppWorkflowUpdated))] + public sealed class AppWorkflowUpdated : AppEvent + { + public Guid WorkflowId { get; set; } + + public Workflow Workflow { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs index ed06a71ec..0aeb82997 100644 --- a/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs @@ -10,9 +10,11 @@ using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Contents { - [EventType(nameof(ContentCreated))] + [EventType(nameof(ContentCreated), 2)] public sealed class ContentCreated : ContentEvent { + public Status Status { get; set; } + public NamedContentData Data { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs index 81b95e5fb..86f782df4 100644 --- a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs @@ -10,10 +10,10 @@ using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Contents { - [EventType(nameof(ContentStatusChanged))] + [EventType(nameof(ContentStatusChanged), 2)] public sealed class ContentStatusChanged : ContentEvent { - public StatusChange? Change { get; set; } + public StatusChange Change { get; set; } public Status Status { get; set; } } diff --git a/src/Squidex.Domain.Users/UserManagerExtensions.cs b/src/Squidex.Domain.Users/UserManagerExtensions.cs index 8fa0115a5..2db94237f 100644 --- a/src/Squidex.Domain.Users/UserManagerExtensions.cs +++ b/src/Squidex.Domain.Users/UserManagerExtensions.cs @@ -115,7 +115,7 @@ namespace Squidex.Domain.Users return result; } - public static async Task CreateAsync(this UserManager userManager, IUserFactory factory, UserValues values) + public static async Task CreateAsync(this UserManager userManager, IUserFactory factory, UserValues values) { var user = factory.Create(values.Email); @@ -142,10 +142,10 @@ namespace Squidex.Domain.Users throw; } - return user; + return await userManager.ResolveUserAsync(user); } - public static async Task UpdateAsync(this UserManager userManager, string id, UserValues values) + public static async Task UpdateAsync(this UserManager userManager, string id, UserValues values) { var user = await userManager.FindByIdAsync(id); @@ -155,6 +155,8 @@ namespace Squidex.Domain.Users } await UpdateAsync(userManager, user, values); + + return await userManager.ResolveUserAsync(user); } public static async Task UpdateSafeAsync(this UserManager userManager, IdentityUser user, UserValues values) @@ -193,7 +195,7 @@ namespace Squidex.Domain.Users } } - public static async Task LockAsync(this UserManager userManager, string id) + public static async Task LockAsync(this UserManager userManager, string id) { var user = await userManager.FindByIdAsync(id); @@ -203,9 +205,11 @@ namespace Squidex.Domain.Users } await DoChecked(() => userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)), "Cannot lock user."); + + return await userManager.ResolveUserAsync(user); } - public static async Task UnlockAsync(this UserManager userManager, string id) + public static async Task UnlockAsync(this UserManager userManager, string id) { var user = await userManager.FindByIdAsync(id); @@ -215,6 +219,8 @@ namespace Squidex.Domain.Users } await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null), "Cannot unlock user."); + + return await userManager.ResolveUserAsync(user); } private static async Task DoChecked(Func> action, string message) diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index 76be0fd06..248197dc1 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -13,6 +13,24 @@ namespace Squidex.Infrastructure { public static class CollectionExtensions { + public static IResultList SortSet(this IResultList input, Func idProvider, IReadOnlyList ids) where T : class + { + return ResultList.Create(input.Total, SortList(input, idProvider, ids)); + } + + public static IEnumerable SortList(this IEnumerable input, Func idProvider, IReadOnlyList ids) where T : class + { + return ids.Select(id => input.FirstOrDefault(x => Equals(idProvider(x), id))).Where(x => x != null); + } + + public static void AddRange(this ICollection target, IEnumerable source) + { + foreach (var value in source) + { + target.Add(value); + } + } + public static IEnumerable Shuffle(this IEnumerable enumerable) { var random = new Random(); @@ -35,7 +53,7 @@ namespace Squidex.Infrastructure return source.Concat(Enumerable.Repeat(value, 1)); } - public static TResult[] ToArray(this T[] value, Func convert) + public static TResult[] Map(this T[] value, Func convert) { var result = new TResult[value.Length]; @@ -47,20 +65,6 @@ namespace Squidex.Infrastructure return result; } - public static TResult[] ToArray(this IReadOnlyCollection value, Func convert) - { - var result = new TResult[value.Count]; - var i = 0; - - foreach (var v in value) - { - result[i] = convert(v); - i++; - } - - return result; - } - public static int SequentialHashCode(this IEnumerable collection) { return collection.SequentialHashCode(EqualityComparer.Default); diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs index 5c3b0d717..23f482b77 100644 --- a/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs +++ b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs @@ -93,7 +93,7 @@ namespace Squidex.Infrastructure.Commands return InvokeAsync(command, handler, Mode.Create); } - protected Task CreateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + protected Task CreateReturn(TCommand command, Func handler) where TCommand : class, IAggregateCommand { return InvokeAsync(command, handler?.ToAsync(), Mode.Create); } @@ -103,7 +103,7 @@ namespace Squidex.Infrastructure.Commands return InvokeAsync(command, handler.ToDefault(), Mode.Create); } - protected Task CreateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand + protected Task Create(TCommand command, Action handler) where TCommand : class, IAggregateCommand { return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), Mode.Create); } @@ -113,7 +113,7 @@ namespace Squidex.Infrastructure.Commands return InvokeAsync(command, handler, Mode.Update); } - protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + protected Task UpdateReturn(TCommand command, Func handler) where TCommand : class, IAggregateCommand { return InvokeAsync(command, handler?.ToAsync(), Mode.Update); } @@ -123,7 +123,7 @@ namespace Squidex.Infrastructure.Commands return InvokeAsync(command, handler?.ToDefault(), Mode.Update); } - protected Task UpdateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand + protected Task Update(TCommand command, Action handler) where TCommand : class, IAggregateCommand { return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), Mode.Update); } @@ -133,7 +133,7 @@ namespace Squidex.Infrastructure.Commands return InvokeAsync(command, handler, Mode.Upsert); } - protected Task UpsertAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + protected Task UpsertReturn(TCommand command, Func handler) where TCommand : class, IAggregateCommand { return InvokeAsync(command, handler?.ToAsync(), Mode.Upsert); } @@ -143,7 +143,7 @@ namespace Squidex.Infrastructure.Commands return InvokeAsync(command, handler?.ToDefault(), Mode.Upsert); } - protected Task UpsertAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand + protected Task Upsert(TCommand command, Action handler) where TCommand : class, IAggregateCommand { return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), Mode.Upsert); } diff --git a/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs b/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs index bbf07c80e..e3f800f4b 100644 --- a/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs +++ b/src/Squidex.Infrastructure/EventSourcing/DefaultEventDataFormatter.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Diagnostics; using Squidex.Infrastructure.Json; namespace Squidex.Infrastructure.EventSourcing @@ -33,6 +34,11 @@ namespace Squidex.Infrastructure.EventSourcing if (payloadObj is IMigrated migratedEvent) { payloadObj = migratedEvent.Migrate(); + + if (ReferenceEquals(migratedEvent, payloadObj)) + { + Debug.WriteLine("Migration should return new event."); + } } var envelope = new Envelope(payloadObj, eventData.Headers); @@ -47,6 +53,11 @@ namespace Squidex.Infrastructure.EventSourcing if (migrate && eventPayload is IMigrated migratedEvent) { eventPayload = migratedEvent.Migrate(); + + if (ReferenceEquals(migratedEvent, eventPayload)) + { + Debug.WriteLine("Migration should return new event."); + } } var payloadType = typeNameRegistry.GetName(eventPayload.GetType()); diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs index f37a9200f..bc5ada68d 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs @@ -58,7 +58,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains public Task> GetStateAsync() { - return Task.FromResult(State.ToInfo(eventConsumer.Name).AsImmutable()); + return Task.FromResult(CreateInfo()); + } + + private Immutable CreateInfo() + { + return State.ToInfo(eventConsumer.Name).AsImmutable(); } public Task OnEventAsync(Immutable subscription, Immutable storedEvent) @@ -109,39 +114,43 @@ namespace Squidex.Infrastructure.EventSourcing.Grains return TaskHelper.Done; } - public Task StartAsync() + public async Task> StartAsync() { if (!State.IsStopped) { - return TaskHelper.Done; + return CreateInfo(); } - return DoAndUpdateStateAsync(() => + await DoAndUpdateStateAsync(() => { Subscribe(State.Position); State = State.Started(); }); + + return CreateInfo(); } - public Task StopAsync() + public async Task> StopAsync() { if (State.IsStopped) { - return TaskHelper.Done; + return CreateInfo(); } - return DoAndUpdateStateAsync(() => + await DoAndUpdateStateAsync(() => { Unsubscribe(); State = State.Stopped(); }); + + return CreateInfo(); } - public Task ResetAsync() + public async Task> ResetAsync() { - return DoAndUpdateStateAsync(async () => + await DoAndUpdateStateAsync(async () => { Unsubscribe(); @@ -151,6 +160,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains State = State.Reset(); }); + + return CreateInfo(); } private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string caller = null) diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs index ca9097142..4952088c0 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs @@ -74,33 +74,31 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { return Task.WhenAll( eventConsumers - .Select(c => GrainFactory.GetGrain(c.Name)) - .Select(c => c.StartAsync())); + .Select(c => StartAsync(c.Name))); } public Task StopAllAsync() { return Task.WhenAll( eventConsumers - .Select(c => GrainFactory.GetGrain(c.Name)) - .Select(c => c.StopAsync())); + .Select(c => StopAsync(c.Name))); } - public Task ResetAsync(string consumerName) + public Task> ResetAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); return eventConsumer.ResetAsync(); } - public Task StartAsync(string consumerName) + public Task> StartAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); return eventConsumer.StartAsync(); } - public Task StopAsync(string consumerName) + public Task> StopAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs index 58b7bf2fb..fb7d82811 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs @@ -16,11 +16,11 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { Task> GetStateAsync(); - Task StopAsync(); + Task> StopAsync(); - Task StartAsync(); + Task> StartAsync(); - Task ResetAsync(); + Task> ResetAsync(); Task OnEventAsync(Immutable subscription, Immutable storedEvent); diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs index c0b53d403..397db21f4 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs @@ -16,15 +16,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { Task ActivateAsync(string streamName); - Task StopAllAsync(); + Task StartAllAsync(); - Task StopAsync(string consumerName); + Task StopAllAsync(); - Task StartAllAsync(); + Task> StopAsync(string consumerName); - Task StartAsync(string consumerName); + Task> StartAsync(string consumerName); - Task ResetAsync(string consumerName); + Task> ResetAsync(string consumerName); Task>> GetConsumersAsync(); } diff --git a/src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs b/src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs index a42d030f5..a560e4abc 100644 --- a/src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs +++ b/src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs @@ -36,6 +36,30 @@ namespace Squidex.Infrastructure.Json.Newtonsoft } } + protected override JsonArrayContract CreateArrayContract(Type objectType) + { + if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyList<>)) + { + var implementationType = typeof(List<>).MakeGenericType(objectType.GetGenericArguments()); + + return base.CreateArrayContract(implementationType); + } + + return base.CreateArrayContract(objectType); + } + + protected override JsonDictionaryContract CreateDictionaryContract(Type objectType) + { + if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)) + { + var implementationType = typeof(Dictionary<,>).MakeGenericType(objectType.GetGenericArguments()); + + return base.CreateDictionaryContract(implementationType); + } + + return base.CreateDictionaryContract(objectType); + } + protected override JsonConverter ResolveContractConverter(Type objectType) { var result = base.ResolveContractConverter(objectType); diff --git a/src/Squidex.Infrastructure/Language.cs b/src/Squidex.Infrastructure/Language.cs index 981d7ee47..0d096fc31 100644 --- a/src/Squidex.Infrastructure/Language.cs +++ b/src/Squidex.Infrastructure/Language.cs @@ -16,7 +16,7 @@ namespace Squidex.Infrastructure private static readonly Regex CultureRegex = new Regex("^([a-z]{2})(\\-[a-z]{2})?$", RegexOptions.IgnoreCase); private static readonly Dictionary AllLanguagesField = new Dictionary(StringComparer.OrdinalIgnoreCase); - private static Language AddLanguage(string iso2Code, string englishName) + internal static Language AddLanguage(string iso2Code, string englishName) { return AllLanguagesField.GetOrAdd(iso2Code, englishName, (c, n) => new Language(c, n)); } diff --git a/src/Squidex.Infrastructure/LanguagesInitializer.cs b/src/Squidex.Infrastructure/LanguagesInitializer.cs new file mode 100644 index 000000000..214e7aa60 --- /dev/null +++ b/src/Squidex.Infrastructure/LanguagesInitializer.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure +{ + public sealed class LanguagesInitializer : IInitializable + { + private readonly LanguagesOptions options; + + public LanguagesInitializer(IOptions options) + { + Guard.NotNull(options, nameof(options)); + + this.options = options.Value; + } + + public Task InitializeAsync(CancellationToken ct = default) + { + foreach (var kvp in options) + { + if (!string.IsNullOrWhiteSpace(kvp.Key) && !string.IsNullOrWhiteSpace(kvp.Value)) + { + Language.AddLanguage(kvp.Key, kvp.Value); + } + } + + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex.Infrastructure/LanguagesOptions.cs b/src/Squidex.Infrastructure/LanguagesOptions.cs new file mode 100644 index 000000000..7a7e66ce3 --- /dev/null +++ b/src/Squidex.Infrastructure/LanguagesOptions.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; + +namespace Squidex.Infrastructure +{ + public sealed class LanguagesOptions : Dictionary + { + } +} diff --git a/src/Squidex.Infrastructure/ResultList.cs b/src/Squidex.Infrastructure/ResultList.cs index 957ecc48b..7f835704f 100644 --- a/src/Squidex.Infrastructure/ResultList.cs +++ b/src/Squidex.Infrastructure/ResultList.cs @@ -27,7 +27,7 @@ namespace Squidex.Infrastructure return new Impl(items, total); } - public static IResultList Create(long total, params T[] items) + public static IResultList CreateFrom(long total, params T[] items) { return new Impl(items, total); } diff --git a/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs b/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs index 48a30f22c..9d684fc59 100644 --- a/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs +++ b/src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs @@ -190,7 +190,7 @@ namespace Squidex.Infrastructure.States private EventData[] GetEventData(Envelope[] events, Guid commitId) { - return events.ToArray(x => eventDataFormatter.ToEventData(x, commitId, true)); + return events.Map(x => eventDataFormatter.ToEventData(x, commitId, true)); } private string GetStreamName() diff --git a/src/Squidex.Shared/DefaultClients.cs b/src/Squidex.Shared/DefaultClients.cs new file mode 100644 index 000000000..554685565 --- /dev/null +++ b/src/Squidex.Shared/DefaultClients.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Shared +{ + public static class DefaultClients + { + public const string Frontend = "squidex-frontend"; + } +} diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 964925efb..10ceb8fef 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -37,8 +37,6 @@ namespace Squidex.Shared public const string AdminAppCreate = "squidex.admin.apps.create"; public const string AdminRestore = "squidex.admin.restore"; - public const string AdminRestoreRead = "squidex.admin.restore.read"; - public const string AdminRestoreCreate = "squidex.admin.restore.create"; public const string AdminEvents = "squidex.admin.events"; public const string AdminEventsRead = "squidex.admin.events.read"; @@ -79,11 +77,16 @@ namespace Squidex.Shared public const string AppRolesDelete = "squidex.apps.{app}.roles.delete"; public const string AppPatterns = "squidex.apps.{app}.patterns"; - public const string AppPatternsRead = "squidex.apps.{app}.patterns.read"; public const string AppPatternsCreate = "squidex.apps.{app}.patterns.create"; public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update"; public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete"; + public const string AppWorkflows = "squidex.apps.{app}.workflows"; + public const string AppWorkflowsRead = "squidex.apps.{app}.workflows.read"; + public const string AppWorkflowsCreate = "squidex.apps.{app}.workflows.create"; + public const string AppWorkflowsUpdate = "squidex.apps.{app}.workflows.update"; + public const string AppWorkflowsDelete = "squidex.apps.{app}.workflows.delete"; + public const string AppBackups = "squidex.apps.{app}.backups"; public const string AppBackupsRead = "squidex.apps.{app}.backups.read"; public const string AppBackupsCreate = "squidex.apps.{app}.backups.create"; @@ -108,7 +111,6 @@ namespace Squidex.Shared public const string AppRulesDelete = "squidex.apps.{app}.rules.delete"; public const string AppSchemas = "squidex.apps.{app}.schemas.{name}"; - public const string AppSchemasRead = "squidex.apps.{app}.schemas.{name}.read"; public const string AppSchemasCreate = "squidex.apps.{app}.schemas.{name}.create"; public const string AppSchemasUpdate = "squidex.apps.{app}.schemas.{name}.update"; public const string AppSchemasScripts = "squidex.apps.{app}.schemas.{name}.scripts"; @@ -117,14 +119,10 @@ namespace Squidex.Shared public const string AppContents = "squidex.apps.{app}.contents.{name}"; public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read"; - public const string AppContentsGraphQL = "squidex.apps.{app}.contents.{name}.graphql"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; - public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard"; - public const string AppContentsArchive = "squidex.apps.{app}.contents.{name}.archive"; - public const string AppContentsRestore = "squidex.apps.{app}.contents.{name}.restore"; - public const string AppContentsPublish = "squidex.apps.{app}.contents.{name}.publish"; - public const string AppContentsUnpublish = "squidex.apps.{app}.contents.{name}.unpublish"; + public const string AppContentsDraftDiscard = "squidex.apps.{app}.contents.{name}.draft.discard"; + public const string AppContentsDraftPublish = "squidex.apps.{app}.contents.{name}.draft.publish"; public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete"; public const string AppApi = "squidex.apps.{app}.api"; diff --git a/src/Squidex.Web/ApiController.cs b/src/Squidex.Web/ApiController.cs index 2d4a8d517..a7ead50d9 100644 --- a/src/Squidex.Web/ApiController.cs +++ b/src/Squidex.Web/ApiController.cs @@ -8,6 +8,7 @@ using System; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -26,17 +27,22 @@ namespace Squidex.Web { get { - var appFeature = HttpContext.Features.Get(); + var app = HttpContext.Context().App; - if (appFeature == null) + if (app == null) { throw new InvalidOperationException("Not in a app context."); } - return appFeature.App; + return app; } } + protected Context Context + { + get { return HttpContext.Context(); } + } + protected Guid AppId { get { return App.Id; } diff --git a/src/Squidex.Web/ApiExceptionFilterAttribute.cs b/src/Squidex.Web/ApiExceptionFilterAttribute.cs index 3e195c0be..ce1b22b55 100644 --- a/src/Squidex.Web/ApiExceptionFilterAttribute.cs +++ b/src/Squidex.Web/ApiExceptionFilterAttribute.cs @@ -93,7 +93,7 @@ namespace Squidex.Web private static string[] ToDetails(ValidationException ex) { - return ex.Errors?.ToArray(e => + return ex.Errors?.Select(e => { if (e.PropertyNames?.Any() == true) { @@ -103,7 +103,7 @@ namespace Squidex.Web { return e.Message; } - }); + }).ToArray(); } } } diff --git a/src/Squidex.Web/ApiPermissionAttribute.cs b/src/Squidex.Web/ApiPermissionAttribute.cs index d515c76ce..f655b2c6f 100644 --- a/src/Squidex.Web/ApiPermissionAttribute.cs +++ b/src/Squidex.Web/ApiPermissionAttribute.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Tasks; -using Squidex.Shared.Identity; namespace Squidex.Web { @@ -36,23 +35,26 @@ namespace Squidex.Web { if (permissionIds.Length > 0) { - var set = context.HttpContext.User.Permissions(); + var permissions = context.HttpContext.Context().Permissions; var hasPermission = false; - foreach (var permissionId in permissionIds) + if (permissions != null) { - var id = permissionId; - - foreach (var routeParam in context.RouteData.Values) + foreach (var permissionId in permissionIds) { - id = id.Replace($"{{{routeParam.Key}}}", routeParam.Value?.ToString()); - } + var id = permissionId; - if (set.Allows(new Permission(id))) - { - hasPermission = true; - break; + foreach (var routeParam in context.RouteData.Values) + { + id = id.Replace($"{{{routeParam.Key}}}", routeParam.Value?.ToString()); + } + + if (permissions.Allows(new Permission(id))) + { + hasPermission = true; + break; + } } } diff --git a/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs b/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs index 0b7d872e5..394ee4aa7 100644 --- a/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs +++ b/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs @@ -7,7 +7,6 @@ using System; using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; @@ -17,20 +16,15 @@ namespace Squidex.Web.CommandMiddlewares { public sealed class EnrichWithAppIdCommandMiddleware : ICommandMiddleware { - private readonly IHttpContextAccessor httpContextAccessor; + private readonly IContextProvider contextProvider; - public EnrichWithAppIdCommandMiddleware(IHttpContextAccessor httpContextAccessor) + public EnrichWithAppIdCommandMiddleware(IContextProvider contextProvider) { - this.httpContextAccessor = httpContextAccessor; + this.contextProvider = contextProvider; } public Task HandleAsync(CommandContext context, Func next) { - if (httpContextAccessor.HttpContext == null) - { - return next(); - } - if (context.Command is IAppCommand appCommand && appCommand.AppId == null) { var appId = GetAppId(); @@ -50,14 +44,14 @@ namespace Squidex.Web.CommandMiddlewares private NamedId GetAppId() { - var appFeature = httpContextAccessor.HttpContext.Features.Get(); + var context = contextProvider.Context; - if (appFeature?.App == null) + if (context.App == null) { throw new InvalidOperationException("Cannot resolve app."); } - return appFeature.App.NamedId(); + return context.App.NamedId(); } } } \ No newline at end of file diff --git a/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs b/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs index a64798783..c837b5dd0 100644 --- a/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs +++ b/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs @@ -65,12 +65,7 @@ namespace Squidex.Web.CommandMiddlewares if (appId == null) { - var appFeature = actionContextAccessor.ActionContext.HttpContext.Features.Get(); - - if (appFeature?.App != null) - { - appId = appFeature.App.NamedId(); - } + appId = actionContextAccessor.ActionContext.HttpContext.Context().App?.NamedId(); } if (appId != null) diff --git a/src/Squidex.Web/Constants.cs b/src/Squidex.Web/Constants.cs index 770c8a435..0c10bd520 100644 --- a/src/Squidex.Web/Constants.cs +++ b/src/Squidex.Web/Constants.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Infrastructure; +using Squidex.Shared; namespace Squidex.Web { @@ -31,7 +32,7 @@ namespace Squidex.Web public static readonly string ProfileScope = "squidex-profile"; - public static readonly string FrontendClient = "squidex-frontend"; + public static readonly string FrontendClient = DefaultClients.Frontend; public static readonly string InternalClientId = "squidex-internal"; diff --git a/src/Squidex.Web/ContextExtensions.cs b/src/Squidex.Web/ContextExtensions.cs new file mode 100644 index 000000000..7548e3c98 --- /dev/null +++ b/src/Squidex.Web/ContextExtensions.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Http; +using Squidex.Domain.Apps.Entities; + +namespace Squidex.Web +{ + public static class ContextExtensions + { + public static Context Context(this HttpContext httpContext) + { + var context = httpContext.Features.Get(); + + if (context == null) + { + context = new Context { User = httpContext.User }; + + foreach (var header in httpContext.Request.Headers) + { + if (header.Key.StartsWith("X-", System.StringComparison.Ordinal)) + { + context.Headers.Add(header.Key, header.Value.ToString()); + } + } + + httpContext.Features.Set(context); + } + + return context; + } + } +} diff --git a/src/Squidex.Web/ContextProvider.cs b/src/Squidex.Web/ContextProvider.cs new file mode 100644 index 000000000..44f1f0a08 --- /dev/null +++ b/src/Squidex.Web/ContextProvider.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Http; +using Squidex.Domain.Apps.Entities; +using Squidex.Infrastructure; + +namespace Squidex.Web +{ + public sealed class ContextProvider : IContextProvider + { + private readonly IHttpContextAccessor httpContextAccessor; + + public Context Context + { + get { return httpContextAccessor.HttpContext.Context(); } + } + + public ContextProvider(IHttpContextAccessor httpContextAccessor) + { + Guard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); + + this.httpContextAccessor = httpContextAccessor; + } + } +} diff --git a/src/Squidex.Web/Deferred.cs b/src/Squidex.Web/Deferred.cs new file mode 100644 index 000000000..717182f49 --- /dev/null +++ b/src/Squidex.Web/Deferred.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Infrastructure; + +namespace Squidex.Web +{ + public struct Deferred + { + private readonly Lazy> value; + + public Task Value + { + get { return value.Value; } + } + + private Deferred(Func> value) + { + this.value = new Lazy>(value); + } + + public static Deferred Response(Func factory) + { + Guard.NotNull(factory, nameof(factory)); + + return new Deferred(() => Task.FromResult(factory())); + } + + public static Deferred AsyncResponse(Func> factory) + { + Guard.NotNull(factory, nameof(factory)); + + return new Deferred(async () => await factory()); + } + } +} diff --git a/src/Squidex.Web/ETagExtensions.cs b/src/Squidex.Web/ETagExtensions.cs index 5ee961a9d..034a9b958 100644 --- a/src/Squidex.Web/ETagExtensions.cs +++ b/src/Squidex.Web/ETagExtensions.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Text; +using Squidex.Domain.Apps.Entities; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; @@ -17,40 +18,54 @@ namespace Squidex.Web { private static readonly int GuidLength = Guid.Empty.ToString().Length; - public static string ToManyEtag(this IReadOnlyList items, long total = 0) where T : IGenerateETag + public static string ToEtag(this IReadOnlyList items, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion { using (Profiler.Trace("CalculateEtag")) { - var unhashed = Unhashed(items, total); + var unhashed = Unhashed(items, 0, app); return unhashed.Sha256Base64(); } } - private static string Unhashed(IReadOnlyList items, long total) where T : IGenerateETag + public static string ToEtag(this IResultList items, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion { - var sb = new StringBuilder((items.Count * (GuidLength + 4)) + 10); + using (Profiler.Trace("CalculateEtag")) + { + var unhashed = Unhashed(items, items.Total, app); + + return unhashed.Sha256Base64(); + } + } + + private static string Unhashed(IReadOnlyList items, long total, IEntityWithVersion app) where T : IEntity, IEntityWithVersion + { + var sb = new StringBuilder((items.Count * (GuidLength + 8)) + 10); + + for (var i = 0; i < items.Count; i++) + { + sb.Append(";"); + sb.Append(items[i].ToEtag()); + } - sb.Append(total); sb.Append("_"); + sb.Append(total); - if (items.Count > 0) + if (app != null) { - sb.Append(items[0].Id.ToString()); - sb.Append(items[0].Version); - - for (var i = 1; i < items.Count; i++) - { - sb.Append(";"); - sb.Append(items[i].Id.ToString()); - sb.Append(items[i].Version); - } + sb.Append("_"); + sb.Append(app.Version); } - return sb.ToString().Sha256Base64(); + return sb.ToString(); + } + + public static string ToSurrogateKey(this T item) where T : IEntity + { + return item.Id.ToString(); } - public static string ToSurrogateKeys(this IReadOnlyList items) where T : IGenerateETag + public static string ToSurrogateKeys(this IReadOnlyList items) where T : IEntity { if (items.Count == 0) { @@ -70,9 +85,17 @@ namespace Squidex.Web return sb.ToString(); } - public static string ToEtag(this T item) where T : IGenerateETag + public static string ToEtag(this T item, IEntityWithVersion app = null) where T : IEntity, IEntityWithVersion { - return item.Version.ToString(); + var result = $"{item.Id};{item.Version}"; + + if (app != null) + { + result += ";"; + result += app.Version; + } + + return result; } } } diff --git a/src/Squidex.Web/EntityCreatedDto.cs b/src/Squidex.Web/EntityCreatedDto.cs index 95738823d..754f33f77 100644 --- a/src/Squidex.Web/EntityCreatedDto.cs +++ b/src/Squidex.Web/EntityCreatedDto.cs @@ -15,7 +15,7 @@ namespace Squidex.Web [Required] [Display(Description = "Id of the created entity.")] public string Id { get; set; } - + [Display(Description = "The new version of the entity.")] public long Version { get; set; } diff --git a/src/Squidex.Web/ErrorDto.cs b/src/Squidex.Web/ErrorDto.cs index 2d3e8f6be..2a8bda377 100644 --- a/src/Squidex.Web/ErrorDto.cs +++ b/src/Squidex.Web/ErrorDto.cs @@ -14,10 +14,10 @@ namespace Squidex.Web [Required] [Display(Description = "Error message.")] public string Message { get; set; } - + [Display(Description = "Detailed error messages.")] public string[] Details { get; set; } - + [Display(Description = "Status code of the http response.")] public int? StatusCode { get; set; } = 400; } diff --git a/src/Squidex.Web/IGenerateEtag.cs b/src/Squidex.Web/ExposedConfiguration.cs similarity index 79% rename from src/Squidex.Web/IGenerateEtag.cs rename to src/Squidex.Web/ExposedConfiguration.cs index 6986f1acc..169cfc9fb 100644 --- a/src/Squidex.Web/IGenerateEtag.cs +++ b/src/Squidex.Web/ExposedConfiguration.cs @@ -5,14 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using System.Collections.Generic; namespace Squidex.Web { - public interface IGenerateETag + public sealed class ExposedConfiguration : Dictionary { - Guid Id { get; } - - long Version { get; } } } diff --git a/src/Squidex.Web/ExposedValues.cs b/src/Squidex.Web/ExposedValues.cs new file mode 100644 index 000000000..4b56935cd --- /dev/null +++ b/src/Squidex.Web/ExposedValues.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using Microsoft.Extensions.Configuration; +using Squidex.Infrastructure; + +namespace Squidex.Web +{ + public sealed class ExposedValues : Dictionary + { + public ExposedValues() + { + } + + public ExposedValues(ExposedConfiguration configured, IConfiguration configuration, Assembly assembly = null) + { + Guard.NotNull(configured, nameof(configured)); + Guard.NotNull(configuration, nameof(configuration)); + + foreach (var kvp in configured) + { + var value = configuration.GetValue(kvp.Value); + + if (!string.IsNullOrWhiteSpace(value)) + { + this[kvp.Key] = value; + } + } + + if (assembly != null) + { + if (!ContainsKey("version")) + { + this["version"] = assembly.GetName().Version.ToString(); + } + } + } + + public override string ToString() + { + var sb = new StringBuilder(); + + foreach (var kvp in this) + { + if (sb.Length > 0) + { + sb.Append(", "); + } + + sb.Append(kvp.Key); + sb.Append(": "); + sb.Append(kvp.Value); + } + + return sb.ToString(); + } + } +} diff --git a/src/Squidex.Web/Extensions.cs b/src/Squidex.Web/Extensions.cs index b7f7594bf..4ab57d830 100644 --- a/src/Squidex.Web/Extensions.cs +++ b/src/Squidex.Web/Extensions.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Security.Claims; using Squidex.Infrastructure.Security; @@ -40,5 +41,12 @@ namespace Squidex.Web return (null, null); } + + public static bool IsUser(this ApiController controller, string userId) + { + var subject = controller.User.OpenIdSubject(); + + return string.Equals(subject, userId, StringComparison.OrdinalIgnoreCase); + } } } diff --git a/src/Squidex.Web/MyJsonInheritanceConverter.cs b/src/Squidex.Web/Json/TypedJsonInheritanceConverter.cs similarity index 80% rename from src/Squidex.Web/MyJsonInheritanceConverter.cs rename to src/Squidex.Web/Json/TypedJsonInheritanceConverter.cs index ff3a1854e..f87d632fd 100644 --- a/src/Squidex.Web/MyJsonInheritanceConverter.cs +++ b/src/Squidex.Web/Json/TypedJsonInheritanceConverter.cs @@ -16,17 +16,16 @@ using Squidex.Infrastructure; #pragma warning disable RECS0108 // Warns about static fields in generic types -namespace Squidex.Web +namespace Squidex.Web.Json { - public class MyJsonInheritanceConverter : JsonInheritanceConverter + public class TypedJsonInheritanceConverter : JsonInheritanceConverter { - private static readonly Dictionary DefaultMapping = new Dictionary(); - private readonly IReadOnlyDictionary maping; - - static MyJsonInheritanceConverter() + private static readonly Lazy> DefaultMapping = new Lazy>(() => { var baseName = typeof(T).Name; + var result = new Dictionary(); + void AddType(Type type) { var discriminator = type.Name; @@ -36,7 +35,7 @@ namespace Squidex.Web discriminator = discriminator.Substring(0, discriminator.Length - baseName.Length); } - DefaultMapping[discriminator] = type; + result[discriminator] = type; } foreach (var attribute in typeof(T).GetCustomAttributes()) @@ -66,17 +65,21 @@ namespace Squidex.Web } } } - } - public MyJsonInheritanceConverter(string discriminator) - : this(discriminator, DefaultMapping) + return result; + }); + + private readonly IReadOnlyDictionary maping; + + public TypedJsonInheritanceConverter(string discriminator) + : this(discriminator, DefaultMapping.Value) { } - public MyJsonInheritanceConverter(string discriminator, IReadOnlyDictionary mapping) + public TypedJsonInheritanceConverter(string discriminator, IReadOnlyDictionary mapping) : base(typeof(T), discriminator) { - maping = mapping ?? DefaultMapping; + maping = mapping ?? DefaultMapping.Value; } protected override Type GetDiscriminatorType(JObject jObject, Type objectType, string discriminatorValue) diff --git a/src/Squidex.Web/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs new file mode 100644 index 000000000..5001c8838 --- /dev/null +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -0,0 +1,56 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Http; +using Squidex.Infrastructure.Security; + +namespace Squidex.Web +{ + public static class PermissionExtensions + { + public static PermissionSet Permissions(this HttpContext httpContext) + { + return httpContext.Context().Permissions; + } + + public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet permissions = null) + { + return httpContext.Permissions().Includes(permission) || permissions?.Includes(permission) == true; + } + + public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*", PermissionSet permissions = null) + { + return httpContext.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions); + } + + public static bool HasPermission(this ApiController controller, Permission permission, PermissionSet permissions = null) + { + return controller.HttpContext.HasPermission(permission, permissions); + } + + public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*", PermissionSet permissions = null) + { + if (app == "*") + { + if (controller.RouteData.Values.TryGetValue("app", out var value) && value is string s) + { + app = s; + } + } + + if (schema == "*") + { + if (controller.RouteData.Values.TryGetValue("name", out var value) && value is string s) + { + schema = s; + } + } + + return controller.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions); + } + } +} diff --git a/src/Squidex.Web/Pipeline/ApiCostsFilter.cs b/src/Squidex.Web/Pipeline/ApiCostsFilter.cs index 0859a26c3..9f1241dad 100644 --- a/src/Squidex.Web/Pipeline/ApiCostsFilter.cs +++ b/src/Squidex.Web/Pipeline/ApiCostsFilter.cs @@ -47,15 +47,15 @@ namespace Squidex.Web.Pipeline { context.HttpContext.Features.Set(FilterDefinition); - var appFeature = context.HttpContext.Features.Get(); + var app = context.HttpContext.Context().App; - if (appFeature?.App != null && FilterDefinition.Weight > 0) + if (app != null && FilterDefinition.Weight > 0) { - var appId = appFeature.App.Id.ToString(); + var appId = app.Id.ToString(); using (Profiler.Trace("CheckUsage")) { - var plan = appPlansProvider.GetPlanForApp(appFeature.App); + var plan = appPlansProvider.GetPlanForApp(app); var usage = await usageTracker.GetMonthlyCallsAsync(appId, DateTime.Today); diff --git a/src/Squidex.Web/Pipeline/AppResolver.cs b/src/Squidex.Web/Pipeline/AppResolver.cs index cc0ae853b..25331c6f7 100644 --- a/src/Squidex.Web/Pipeline/AppResolver.cs +++ b/src/Squidex.Web/Pipeline/AppResolver.cs @@ -23,16 +23,6 @@ namespace Squidex.Web.Pipeline { private readonly IAppProvider appProvider; - public class AppFeature : IAppFeature - { - public IAppEntity App { get; } - - public AppFeature(IAppEntity app) - { - App = app; - } - } - public AppResolver(IAppProvider appProvider) { this.appProvider = appProvider; @@ -65,7 +55,10 @@ namespace Squidex.Web.Pipeline { var identity = user.Identities.First(); - identity.AddClaim(new Claim(ClaimTypes.Role, role)); + if (!string.IsNullOrWhiteSpace(role)) + { + identity.AddClaim(new Claim(ClaimTypes.Role, role)); + } foreach (var permission in permissions) { @@ -73,15 +66,15 @@ namespace Squidex.Web.Pipeline } } - var set = user.Permissions(); + var permissionSet = user.Permissions(); - if (!set.Includes(Permissions.ForApp(Permissions.App, appName))&& !AllowAnonymous(context)) + context.HttpContext.Context().App = app; + + if (!permissionSet.Includes(Permissions.ForApp(Permissions.App, appName)) && !AllowAnonymous(context)) { context.Result = new NotFoundResult(); return; } - - context.HttpContext.Features.Set(new AppFeature(app)); } await next(); diff --git a/src/Squidex.Web/Pipeline/DeferredActionFilter.cs b/src/Squidex.Web/Pipeline/DeferredActionFilter.cs new file mode 100644 index 000000000..e57a62981 --- /dev/null +++ b/src/Squidex.Web/Pipeline/DeferredActionFilter.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Squidex.Web.Pipeline +{ + public sealed class DeferredActionFilter : IAsyncActionFilter + { + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var resultContext = await next(); + + if (resultContext.Result is ObjectResult objectResult && objectResult.Value is Deferred deferred) + { + objectResult.Value = await deferred.Value; + } + } + } +} diff --git a/src/Squidex.Web/ETagFilter.cs b/src/Squidex.Web/Pipeline/ETagFilter.cs similarity index 98% rename from src/Squidex.Web/ETagFilter.cs rename to src/Squidex.Web/Pipeline/ETagFilter.cs index b76772ad3..4dd680374 100644 --- a/src/Squidex.Web/ETagFilter.cs +++ b/src/Squidex.Web/Pipeline/ETagFilter.cs @@ -12,7 +12,7 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; -namespace Squidex.Web +namespace Squidex.Web.Pipeline { public sealed class ETagFilter : IAsyncActionFilter { diff --git a/src/Squidex.Web/ETagOptions.cs b/src/Squidex.Web/Pipeline/ETagOptions.cs similarity index 93% rename from src/Squidex.Web/ETagOptions.cs rename to src/Squidex.Web/Pipeline/ETagOptions.cs index 8e832dbca..d6715b233 100644 --- a/src/Squidex.Web/ETagOptions.cs +++ b/src/Squidex.Web/Pipeline/ETagOptions.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.Web +namespace Squidex.Web.Pipeline { public sealed class ETagOptions { diff --git a/src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs b/src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs index c2e3c7006..bb754447f 100644 --- a/src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs +++ b/src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs @@ -47,9 +47,9 @@ namespace Squidex.Web.Pipeline } } - private static void LogFilters(HttpContext context, IObjectWriter c) + private static void LogFilters(HttpContext httpContext, IObjectWriter c) { - var app = context.Features.Get()?.App; + var app = httpContext.Context().App; if (app != null) { @@ -57,21 +57,21 @@ namespace Squidex.Web.Pipeline c.WriteProperty("appName", app.Name); } - var userId = context.User.OpenIdSubject(); + var userId = httpContext.User.OpenIdSubject(); if (!string.IsNullOrWhiteSpace(userId)) { c.WriteProperty(nameof(userId), userId); } - var clientId = context.User.OpenIdClientId(); + var clientId = httpContext.User.OpenIdClientId(); if (!string.IsNullOrWhiteSpace(clientId)) { c.WriteProperty(nameof(clientId), clientId); } - var costs = context.Features.Get()?.Weight ?? 0; + var costs = httpContext.Features.Get()?.Weight ?? 0; c.WriteProperty(nameof(costs), costs); } diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs new file mode 100644 index 000000000..9719dfd1d --- /dev/null +++ b/src/Squidex.Web/Resource.cs @@ -0,0 +1,61 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; +using Squidex.Infrastructure; + +namespace Squidex.Web +{ + public abstract class Resource + { + [JsonProperty("_links")] + [Required] + [Display(Description = "The links.")] + public Dictionary Links { get; } = new Dictionary(); + + public void AddSelfLink(string href) + { + AddGetLink("self", href); + } + + public void AddGetLink(string rel, string href, string metadata = null) + { + AddLink(rel, "GET", href, metadata); + } + + public void AddPatchLink(string rel, string href, string metadata = null) + { + AddLink(rel, "PATCH", href, metadata); + } + + public void AddPostLink(string rel, string href, string metadata = null) + { + AddLink(rel, "POST", href, metadata); + } + + public void AddPutLink(string rel, string href, string metadata = null) + { + AddLink(rel, "PUT", href, metadata); + } + + public void AddDeleteLink(string rel, string href, string metadata = null) + { + AddLink(rel, "DELETE", href, metadata); + } + + public void AddLink(string rel, string method, string href, string metadata = null) + { + Guard.NotNullOrEmpty(rel, nameof(rel)); + Guard.NotNullOrEmpty(href, nameof(href)); + Guard.NotNullOrEmpty(method, nameof(method)); + + Links[rel] = new ResourceLink { Href = href, Method = method, Metadata = metadata }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs b/src/Squidex.Web/ResourceLink.cs similarity index 57% rename from src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs rename to src/Squidex.Web/ResourceLink.cs index 090d81023..d1caffc8d 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs +++ b/src/Squidex.Web/ResourceLink.cs @@ -7,20 +7,19 @@ using System.ComponentModel.DataAnnotations; -namespace Squidex.Areas.Api.Controllers.Users.Models +namespace Squidex.Web { - public sealed class UserCreatedDto + public class ResourceLink { - /// - /// The id of the user. - /// [Required] - public string Id { get; set; } + [Display(Description = "The link url.")] + public string Href { get; set; } - /// - /// Additional permissions for the user. - /// [Required] - public string[] Permissions { get; set; } + [Display(Description = "The link method.")] + public string Method { get; set; } + + [Display(Description = "Additional data about the link.")] + public string Metadata { get; set; } } } diff --git a/src/Squidex.Web/Squidex.Web.csproj b/src/Squidex.Web/Squidex.Web.csproj index bbe645f13..2c9404cb7 100644 --- a/src/Squidex.Web/Squidex.Web.csproj +++ b/src/Squidex.Web/Squidex.Web.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/Squidex.Web/UrlHelperExtensions.cs b/src/Squidex.Web/UrlHelperExtensions.cs new file mode 100644 index 000000000..8d59cba5f --- /dev/null +++ b/src/Squidex.Web/UrlHelperExtensions.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Microsoft.AspNetCore.Mvc; + +#pragma warning disable RECS0108 // Warns about static fields in generic types + +namespace Squidex.Web +{ + public static class UrlHelperExtensions + { + private static class NameOf + { + public static readonly string Controller; + + static NameOf() + { + const string suffix = "Controller"; + + var name = typeof(T).Name; + + if (name.EndsWith(suffix, StringComparison.Ordinal)) + { + name = name.Substring(0, name.Length - suffix.Length); + } + + Controller = name; + } + } + + public static string Url(this IUrlHelper urlHelper, Func action, object values = null) where T : Controller + { + return urlHelper.Action(action(null), NameOf.Controller, values); + } + + public static string Url(this Controller controller, Func action, object values = null) where T : Controller + { + return controller.Url.Url(action, values); + } + } +} diff --git a/src/Squidex/.vscode/settings.json b/src/Squidex/.vscode/settings.json deleted file mode 100644 index 26a63fc30..000000000 --- a/src/Squidex/.vscode/settings.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - // When opening a file, `editor.tabSize` and `editor.insertSpaces` will be detected based on the file contents. - "editor.detectIndentation": false, - - // Typescript version from local package to be consistent - "typescript.tsdk": "node_modules/typescript/lib", - - // Configure glob patterns for excluding files and folders. - "files.exclude": { - "**/node_modules": true, - "**/Assets": true, - "**/artifacts": true, - "**/build": true, - "**/logs": true, - "**/out": true, - "**/obj": true, - "**/bin": true, - "**/*.lock.json": true, - "**/*.bat": true, - "**/*.sln": true, - "**/*.sln.DotSettings": true, - "**/*.user": true, - "**/*.xproj": true, - "**/*.gitattributes": true, - "appsetttings.Development.json": true, - "appsetttings.Production.json": true, - ".awcache": true, - ".vs:": true, - ".vscode:": true - }, - - "coverage-gutters.coverageFileNames": [ - "_test-output/coverage/lcov.info" - ], - "coverage-gutters.showLineCoverage": true -} \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Config/Swagger/ErrorDtoProcessor.cs b/src/Squidex/Areas/Api/Config/Swagger/ErrorDtoProcessor.cs new file mode 100644 index 000000000..f04099cb5 --- /dev/null +++ b/src/Squidex/Areas/Api/Config/Swagger/ErrorDtoProcessor.cs @@ -0,0 +1,70 @@ +// ========================================================================== +// 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 NJsonSchema; +using NSwag; +using NSwag.SwaggerGeneration.Processors; +using NSwag.SwaggerGeneration.Processors.Contexts; +using Squidex.ClientLibrary.Management; +using Squidex.Pipeline.Swagger; + +namespace Squidex.Areas.Api.Config.Swagger +{ + public sealed class ErrorDtoProcessor : IDocumentProcessor + { + public async Task ProcessAsync(DocumentProcessorContext context) + { + var errorSchema = await GetErrorSchemaAsync(context); + + foreach (var operation in context.Document.Paths.Values.SelectMany(x => x.Values)) + { + AddErrorResponses(operation, errorSchema); + + CleanupResponses(operation); + } + } + + private static void AddErrorResponses(SwaggerOperation operation, JsonSchema4 errorSchema) + { + if (!operation.Responses.ContainsKey("500")) + { + operation.AddResponse("500", "Operation failed", errorSchema); + } + + foreach (var (code, response) in operation.Responses) + { + if (code != "404" && code.StartsWith("4", StringComparison.OrdinalIgnoreCase) && response.Schema == null) + { + response.Schema = errorSchema; + } + } + } + + private static void CleanupResponses(SwaggerOperation operation) + { + foreach (var (code, response) in operation.Responses.ToList()) + { + if (string.IsNullOrWhiteSpace(response.Description) || + response.Description?.Contains("=>") == true || + response.Description?.Contains("=>") == true) + { + operation.Responses.Remove(code); + } + } + } + + private Task GetErrorSchemaAsync(DocumentProcessorContext context) + { + var errorType = typeof(ErrorDto); + + return context.SchemaGenerator.GenerateWithReferenceAsync(errorType, Enumerable.Empty(), context.SchemaResolver); + } + } +} diff --git a/src/Squidex/Areas/Api/Config/Swagger/FixProcessor.cs b/src/Squidex/Areas/Api/Config/Swagger/FixProcessor.cs index 83106901f..0131711f9 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/FixProcessor.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/FixProcessor.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure.Tasks; namespace Squidex.Areas.Api.Config.Swagger { - public class FixProcessor : IOperationProcessor + public sealed class FixProcessor : IOperationProcessor { private static readonly JsonSchema4 StringSchema = new JsonSchema4 { Type = JsonObjectType.String }; diff --git a/src/Squidex/Areas/Api/Config/Swagger/SecurityProcessor.cs b/src/Squidex/Areas/Api/Config/Swagger/SecurityProcessor.cs index a09e9c9f0..8dfa61f6e 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/SecurityProcessor.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/SecurityProcessor.cs @@ -15,7 +15,7 @@ using Squidex.Web; namespace Squidex.Areas.Api.Config.Swagger { - public class SecurityProcessor : SecurityDefinitionAppender + public sealed class SecurityProcessor : SecurityDefinitionAppender { public SecurityProcessor(IOptions urlOptions) : base(Constants.SecurityDefinition, Enumerable.Empty(), CreateOAuthSchema(urlOptions.Value)) @@ -24,26 +24,33 @@ namespace Squidex.Areas.Api.Config.Swagger private static SwaggerSecurityScheme CreateOAuthSchema(UrlsOptions urlOptions) { - var securityScheme = new SwaggerSecurityScheme(); + var security = new SwaggerSecurityScheme + { + Type = SwaggerSecuritySchemeType.OAuth2 + }; var tokenUrl = urlOptions.BuildUrl($"{Constants.IdentityServerPrefix}/connect/token", false); - securityScheme.TokenUrl = tokenUrl; + security.TokenUrl = tokenUrl; - var securityDocs = NSwagHelper.LoadDocs("security"); - var securityText = securityDocs.Replace("", tokenUrl); + SetupDescription(security, tokenUrl); - securityScheme.Description = securityText; - - securityScheme.Type = SwaggerSecuritySchemeType.OAuth2; - securityScheme.Flow = SwaggerOAuth2Flow.Application; + security.Flow = SwaggerOAuth2Flow.Application; - securityScheme.Scopes = new Dictionary + security.Scopes = new Dictionary { [Constants.ApiScope] = "Read and write access to the API" }; - return securityScheme; + return security; + } + + private static void SetupDescription(SwaggerSecurityScheme securityScheme, string tokenUrl) + { + var securityDocs = NSwagHelper.LoadDocs("security"); + var securityText = securityDocs.Replace("", tokenUrl); + + securityScheme.Description = securityText; } } } diff --git a/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs b/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs index ddccd616c..050a7619c 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs @@ -14,6 +14,7 @@ using NSwag.SwaggerGeneration; using NSwag.SwaggerGeneration.Processors; using Squidex.Areas.Api.Controllers.Contents.Generator; using Squidex.Areas.Api.Controllers.Rules.Models; +using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; namespace Squidex.Areas.Api.Config.Swagger @@ -22,6 +23,9 @@ namespace Squidex.Areas.Api.Config.Swagger { public static void AddMySwaggerSettings(this IServiceCollection services) { + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -73,7 +77,8 @@ namespace Squidex.Areas.Api.Config.Swagger }), new PrimitiveTypeMapper(typeof(Language), s => s.Type = JsonObjectType.String), - new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String) + new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String), + new PrimitiveTypeMapper(typeof(Status), s => s.Type = JsonObjectType.String), }; } } diff --git a/src/Squidex/Areas/Api/Config/Swagger/XmlResponseTypesProcessor.cs b/src/Squidex/Areas/Api/Config/Swagger/XmlResponseTypesProcessor.cs index 6d969c204..fedc54045 100644 --- a/src/Squidex/Areas/Api/Config/Swagger/XmlResponseTypesProcessor.cs +++ b/src/Squidex/Areas/Api/Config/Swagger/XmlResponseTypesProcessor.cs @@ -5,16 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Linq; +using System; using System.Text.RegularExpressions; using System.Threading.Tasks; using NJsonSchema.Infrastructure; using NSwag; using NSwag.SwaggerGeneration.Processors; using NSwag.SwaggerGeneration.Processors.Contexts; -using Squidex.Pipeline.Swagger; - -#pragma warning disable RECS0033 // Convert 'if' to '||' expression namespace Squidex.Areas.Api.Config.Swagger { @@ -26,46 +23,33 @@ namespace Squidex.Areas.Api.Config.Swagger { var operation = context.OperationDescription.Operation; - var returnsDescription = await context.MethodInfo.GetXmlDocumentationTagAsync("returns") ?? string.Empty; + var returnsDescription = await context.MethodInfo.GetXmlDocumentationTagAsync("returns"); - foreach (Match match in ResponseRegex.Matches(returnsDescription)) + if (!string.IsNullOrWhiteSpace(returnsDescription)) { - var statusCode = match.Groups["Code"].Value; - - if (!operation.Responses.TryGetValue(statusCode, out var response)) + foreach (Match match in ResponseRegex.Matches(returnsDescription)) { - response = new SwaggerResponse(); + var statusCode = match.Groups["Code"].Value; - operation.Responses[statusCode] = response; - } + if (!operation.Responses.TryGetValue(statusCode, out var response)) + { + response = new SwaggerResponse(); - response.Description = match.Groups["Description"].Value; - } + operation.Responses[statusCode] = response; + } - await AddInternalErrorResponseAsync(context, operation); + var description = match.Groups["Description"].Value; - CleanupResponses(operation); + if (description.Contains("=>")) + { + throw new InvalidOperationException("Description not formatted correcly."); + } - return true; - } - - private static async Task AddInternalErrorResponseAsync(OperationProcessorContext context, SwaggerOperation operation) - { - if (!operation.Responses.ContainsKey("500")) - { - operation.AddResponse("500", "Operation failed", await context.SchemaGenerator.GetErrorDtoSchemaAsync(context.SchemaResolver)); - } - } - - private static void CleanupResponses(SwaggerOperation operation) - { - foreach (var (code, response) in operation.Responses.ToList()) - { - if (string.IsNullOrWhiteSpace(response.Description) || response.Description?.Contains("=>") == true) - { - operation.Responses.Remove(code); + response.Description = description; } } + + return true; } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs index a17673252..92e749028 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs @@ -5,11 +5,11 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Commands; using Squidex.Shared; @@ -41,14 +41,17 @@ namespace Squidex.Areas.Api.Controllers.Apps /// [HttpGet] [Route("apps/{app}/clients/")] - [ProducesResponseType(typeof(ClientDto[]), 200)] + [ProducesResponseType(typeof(ClientsDto), 200)] [ApiPermission(Permissions.AppClientsRead)] [ApiCosts(0)] public IActionResult GetClients(string app) { - var response = App.Clients.Select(ClientDto.FromKvp).ToArray(); + var response = Deferred.Response(() => + { + return ClientsDto.FromApp(App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } @@ -60,6 +63,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// Client object that needs to be added to the app. /// /// 201 => Client generated. + /// 400 => Client request not valid. /// 404 => App not found. /// /// @@ -68,16 +72,14 @@ namespace Squidex.Areas.Api.Controllers.Apps /// [HttpPost] [Route("apps/{app}/clients/")] - [ProducesResponseType(typeof(ClientDto), 201)] + [ProducesResponseType(typeof(ClientsDto), 200)] [ApiPermission(Permissions.AppClientsCreate)] [ApiCosts(1)] public async Task PostClient(string app, [FromBody] CreateClientDto request) { var command = request.ToCommand(); - await CommandBus.PublishAsync(command); - - var response = ClientDto.FromCommand(command); + var response = await InvokeCommandAsync(command); return CreatedAtAction(nameof(GetClients), new { app }, response); } @@ -86,10 +88,10 @@ namespace Squidex.Areas.Api.Controllers.Apps /// Updates an app client. /// /// The name of the app. - /// The id of the client that must be updated. + /// The id of the client that must be updated. /// Client object that needs to be updated. /// - /// 204 => Client updated. + /// 200 => Client updated. /// 400 => Client request not valid. /// 404 => Client or app not found. /// @@ -97,37 +99,53 @@ namespace Squidex.Areas.Api.Controllers.Apps /// Only the display name can be changed, create a new client if necessary. /// [HttpPut] - [Route("apps/{app}/clients/{clientId}/")] + [Route("apps/{app}/clients/{id}/")] + [ProducesResponseType(typeof(ClientsDto), 200)] [ApiPermission(Permissions.AppClientsUpdate)] [ApiCosts(1)] - public async Task PutClient(string app, string clientId, [FromBody] UpdateClientDto request) + public async Task PutClient(string app, string id, [FromBody] UpdateClientDto request) { - await CommandBus.PublishAsync(request.ToCommand(clientId)); + var command = request.ToCommand(id); + + var response = await InvokeCommandAsync(command); - return NoContent(); + return Ok(response); } /// /// Revoke an app client. /// /// The name of the app. - /// The id of the client that must be deleted. + /// The id of the client that must be deleted. /// - /// 204 => Client revoked. + /// 200 => Client revoked. /// 404 => Client or app not found. /// /// /// The application that uses this client credentials cannot access the API after it has been revoked. /// [HttpDelete] - [Route("apps/{app}/clients/{clientId}/")] + [Route("apps/{app}/clients/{id}/")] + [ProducesResponseType(typeof(ClientsDto), 200)] [ApiPermission(Permissions.AppClientsDelete)] [ApiCosts(1)] - public async Task DeleteClient(string app, string clientId) + public async Task DeleteClient(string app, string id) { - await CommandBus.PublishAsync(new RevokeClient { Id = clientId }); + var command = new RevokeClient { Id = id }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = ClientsDto.FromApp(result, this); - return NoContent(); + return response; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index 98187cf77..8d1534b74 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Invitation; using Squidex.Domain.Apps.Entities.Apps.Services; @@ -47,9 +48,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetContributors(string app) { - var response = ContributorsDto.FromApp(App, appPlansProvider); + var response = Deferred.Response(() => + { + return ContributorsDto.FromApp(App, appPlansProvider, this, false); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } @@ -60,33 +64,22 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The name of the app. /// Contributor object that needs to be added to the app. /// - /// 200 => User assigned to app. + /// 201 => User assigned to app. /// 400 => User is already assigned to the app or not found. /// 404 => App not found. /// [HttpPost] [Route("apps/{app}/contributors/")] - [ProducesResponseType(typeof(ContributorAssignedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(ContributorsDto), 200)] [ApiPermission(Permissions.AppContributorsAssign)] [ApiCosts(1)] public async Task PostContributor(string app, [FromBody] AssignContributorDto request) { var command = request.ToCommand(); - var context = await CommandBus.PublishAsync(command); - var response = (ContributorAssignedDto)null; + var response = await InvokeCommandAsync(command); - if (context.PlainResult is EntityCreatedResult idOrValue) - { - response = ContributorAssignedDto.FromId(idOrValue.IdOrValue, false); - } - else if (context.PlainResult is InvitedResult invited) - { - response = ContributorAssignedDto.FromId(invited.Id.IdOrValue, true); - } - - return Ok(response); + return CreatedAtAction(nameof(GetContributors), new { app }, response); } /// @@ -95,20 +88,36 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The name of the app. /// The id of the contributor. /// - /// 204 => User removed from app. + /// 200 => User removed from app. /// 400 => User is not assigned to the app. /// 404 => Contributor or app not found. /// [HttpDelete] [Route("apps/{app}/contributors/{id}/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(ContributorsDto), 200)] [ApiPermission(Permissions.AppContributorsRevoke)] [ApiCosts(1)] public async Task DeleteContributor(string app, string id) { - await CommandBus.PublishAsync(new RemoveContributor { ContributorId = id }); + var command = new RemoveContributor { ContributorId = id }; + + var response = await InvokeCommandAsync(command); - return NoContent(); + return Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + if (context.PlainResult is InvitedResult invited) + { + return ContributorsDto.FromApp(invited.App, appPlansProvider, this, true); + } + else + { + return ContributorsDto.FromApp(context.Result(), appPlansProvider, this, false); + } } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs index 17ce8c320..43498aa82 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -39,14 +40,17 @@ namespace Squidex.Areas.Api.Controllers.Apps /// [HttpGet] [Route("apps/{app}/languages/")] - [ProducesResponseType(typeof(AppLanguageDto[]), 200)] + [ProducesResponseType(typeof(AppLanguagesDto), 200)] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public IActionResult GetLanguages(string app) { - var response = AppLanguageDto.FromApp(App); + var response = Deferred.Response(() => + { + return AppLanguagesDto.FromApp(App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } @@ -63,17 +67,14 @@ namespace Squidex.Areas.Api.Controllers.Apps /// [HttpPost] [Route("apps/{app}/languages/")] - [ProducesResponseType(typeof(AppLanguageDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(AppLanguagesDto), 201)] [ApiPermission(Permissions.AppLanguagesCreate)] [ApiCosts(1)] public async Task PostLanguage(string app, [FromBody] AddLanguageDto request) { var command = request.ToCommand(); - await CommandBus.PublishAsync(command); - - var response = AppLanguageDto.FromCommand(command); + var response = await InvokeCommandAsync(command); return CreatedAtAction(nameof(GetLanguages), new { app }, response); } @@ -85,19 +86,22 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The language to update. /// The language object. /// - /// 204 => Language updated. + /// 200 => Language updated. /// 400 => Language request not valid. /// 404 => Language or app not found. /// [HttpPut] [Route("apps/{app}/languages/{language}/")] + [ProducesResponseType(typeof(AppLanguagesDto), 200)] [ApiPermission(Permissions.AppLanguagesUpdate)] [ApiCosts(1)] - public async Task Update(string app, string language, [FromBody] UpdateLanguageDto request) + public async Task PutLanguage(string app, string language, [FromBody] UpdateLanguageDto request) { - await CommandBus.PublishAsync(request.ToCommand(ParseLanguage(language))); + var command = request.ToCommand(ParseLanguage(language)); + + var response = await InvokeCommandAsync(command); - return NoContent(); + return Ok(response); } /// @@ -106,18 +110,31 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The name of the app. /// The language to delete from the app. /// - /// 204 => Language deleted. + /// 200 => Language deleted. /// 404 => Language or app not found. /// [HttpDelete] [Route("apps/{app}/languages/{language}/")] + [ProducesResponseType(typeof(AppLanguagesDto), 200)] [ApiPermission(Permissions.AppLanguagesDelete)] [ApiCosts(1)] public async Task DeleteLanguage(string app, string language) { - await CommandBus.PublishAsync(new RemoveLanguage { Language = ParseLanguage(language) }); + var command = new RemoveLanguage { Language = ParseLanguage(language) }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = AppLanguagesDto.FromApp(result, this); - return NoContent(); + return response; } private static Language ParseLanguage(string language) diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs index fd4fac1c5..74f9fc136 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs @@ -6,11 +6,11 @@ // ========================================================================== using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Commands; using Squidex.Shared; @@ -42,14 +42,17 @@ namespace Squidex.Areas.Api.Controllers.Apps /// [HttpGet] [Route("apps/{app}/patterns/")] - [ProducesResponseType(typeof(AppPatternDto[]), 200)] - [ApiPermission(Permissions.AppPatternsRead)] + [ProducesResponseType(typeof(PatternsDto), 200)] + [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public IActionResult GetPatterns(string app) { - var response = App.Patterns.Select(AppPatternDto.FromKvp).OrderBy(x => x.Name).ToArray(); + var response = Deferred.Response(() => + { + return PatternsDto.FromApp(App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } @@ -66,16 +69,14 @@ namespace Squidex.Areas.Api.Controllers.Apps /// [HttpPost] [Route("apps/{app}/patterns/")] - [ProducesResponseType(typeof(AppPatternDto), 201)] + [ProducesResponseType(typeof(PatternsDto), 200)] [ApiPermission(Permissions.AppPatternsCreate)] [ApiCosts(1)] public async Task PostPattern(string app, [FromBody] UpdatePatternDto request) { var command = request.ToAddCommand(); - await CommandBus.PublishAsync(command); - - var response = AppPatternDto.FromCommand(command); + var response = await InvokeCommandAsync(command); return CreatedAtAction(nameof(GetPatterns), new { app }, response); } @@ -87,20 +88,22 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The id of the pattern to be updated. /// Pattern to be updated for the app. /// - /// 204 => Pattern updated. + /// 200 => Pattern updated. /// 400 => Pattern request not valid. /// 404 => Pattern or app not found. /// [HttpPut] [Route("apps/{app}/patterns/{id}/")] - [ProducesResponseType(typeof(AppPatternDto), 201)] + [ProducesResponseType(typeof(PatternsDto), 200)] [ApiPermission(Permissions.AppPatternsUpdate)] [ApiCosts(1)] public async Task UpdatePattern(string app, Guid id, [FromBody] UpdatePatternDto request) { - await CommandBus.PublishAsync(request.ToUpdateCommand(id)); + var command = request.ToUpdateCommand(id); + + var response = await InvokeCommandAsync(command); - return NoContent(); + return Ok(response); } /// @@ -109,7 +112,7 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The name of the app. /// The id of the pattern to be deleted. /// - /// 204 => Pattern removed. + /// 200 => Pattern removed. /// 404 => Pattern or app not found. /// /// @@ -117,13 +120,26 @@ namespace Squidex.Areas.Api.Controllers.Apps /// [HttpDelete] [Route("apps/{app}/patterns/{id}/")] + [ProducesResponseType(typeof(PatternsDto), 200)] [ApiPermission(Permissions.AppPatternsDelete)] [ApiCosts(1)] public async Task DeletePattern(string app, Guid id) { - await CommandBus.PublishAsync(new DeletePattern { PatternId = id }); + var command = new DeletePattern { PatternId = id }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = PatternsDto.FromApp(result, this); - return NoContent(); + return response; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs index e146c90b3..ac427a567 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs @@ -47,9 +47,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetRoles(string app) { - var response = RolesDto.FromApp(App); + var response = Deferred.Response(() => + { + return RolesDto.FromApp(App, this); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } @@ -67,11 +70,14 @@ namespace Squidex.Areas.Api.Controllers.Apps [ProducesResponseType(typeof(string[]), 200)] [ApiPermission(Permissions.AppRolesRead)] [ApiCosts(0)] - public async Task GetPermissions(string app) + public IActionResult GetPermissions(string app) { - var response = await permissionsProvider.GetPermissionsAsync(App); + var response = Deferred.AsyncResponse(() => + { + return permissionsProvider.GetPermissionsAsync(App); + }); - Response.Headers[HeaderNames.ETag] = string.Join(";", response).Sha256Base64(); + Response.Headers[HeaderNames.ETag] = string.Concat(response).Sha256Base64(); return Ok(response); } @@ -82,64 +88,81 @@ namespace Squidex.Areas.Api.Controllers.Apps /// The name of the app. /// Role object that needs to be added to the app. /// - /// 200 => User assigned to app. + /// 201 => User assigned to app. /// 400 => Role name already in use. /// 404 => App not found. /// [HttpPost] [Route("apps/{app}/roles/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(RolesDto), 200)] [ApiPermission(Permissions.AppRolesCreate)] [ApiCosts(1)] public async Task PostRole(string app, [FromBody] AddRoleDto request) { - await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); - return NoContent(); + return CreatedAtAction(nameof(GetRoles), new { app }, response); } /// /// Update an existing app role. /// /// The name of the app. - /// The name of the role to be updated. + /// The name of the role to be updated. /// Role to be updated for the app. /// - /// 204 => Role updated. + /// 200 => Role updated. /// 400 => Role request not valid. /// 404 => Role or app not found. /// [HttpPut] - [Route("apps/{app}/roles/{role}/")] + [Route("apps/{app}/roles/{name}/")] + [ProducesResponseType(typeof(RolesDto), 200)] [ApiPermission(Permissions.AppRolesUpdate)] [ApiCosts(1)] - public async Task UpdateRole(string app, string role, [FromBody] UpdateRoleDto request) + public async Task UpdateRole(string app, string name, [FromBody] UpdateRoleDto request) { - await CommandBus.PublishAsync(request.ToCommand(role)); + var command = request.ToCommand(name); + + var response = await InvokeCommandAsync(command); - return NoContent(); + return Ok(response); } /// /// Remove role from app. /// /// The name of the app. - /// The name of the role. + /// The name of the role. /// - /// 204 => Role deleted. + /// 200 => Role deleted. /// 400 => Role is in use by contributor or client or default role. /// 404 => Role or app not found. /// [HttpDelete] - [Route("apps/{app}/roles/{role}/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [Route("apps/{app}/roles/{name}/")] + [ProducesResponseType(typeof(RolesDto), 200)] [ApiPermission(Permissions.AppRolesDelete)] [ApiCosts(1)] - public async Task DeleteRole(string app, string role) + public async Task DeleteRole(string app, string name) { - await CommandBus.PublishAsync(new DeleteRole { Name = role }); + var command = new DeleteRole { Name = name }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = RolesDto.FromApp(result, this); - return NoContent(); + return response; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs new file mode 100644 index 000000000..9f798add0 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs @@ -0,0 +1,143 @@ +// ========================================================================== +// 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.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Infrastructure.Commands; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps +{ + /// + /// Manages and configures apps. + /// + [ApiExplorerSettings(GroupName = nameof(Apps))] + public sealed class AppWorkflowsController : ApiController + { + private readonly IWorkflowsValidator workflowsValidator; + + public AppWorkflowsController(ICommandBus commandBus, IWorkflowsValidator workflowsValidator) + : base(commandBus) + { + this.workflowsValidator = workflowsValidator; + } + + /// + /// Get app workflow. + /// + /// The name of the app. + /// + /// 200 => App workflows returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/workflows/")] + [ProducesResponseType(typeof(WorkflowsDto), 200)] + [ApiPermission(Permissions.AppWorkflowsRead)] + [ApiCosts(0)] + public IActionResult GetWorkflows(string app) + { + var response = Deferred.AsyncResponse(() => + { + return WorkflowsDto.FromAppAsync(workflowsValidator, App, this); + }); + + Response.Headers[HeaderNames.ETag] = App.ToEtag(); + + return Ok(response); + } + + /// + /// Create a workflow. + /// + /// The name of the app. + /// The new workflow. + /// + /// 200 => Workflow updated. + /// 400 => Workflow request is not valid. + /// 404 => Workflow or app not found. + /// + [HttpPost] + [Route("apps/{app}/workflows/")] + [ProducesResponseType(typeof(WorkflowsDto), 200)] + [ApiPermission(Permissions.AppWorkflowsUpdate)] + [ApiCosts(1)] + public async Task PostWorkflow(string app, [FromBody] AddWorkflowDto request) + { + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + /// + /// Update a workflow. + /// + /// The name of the app. + /// The new workflow. + /// The id of the workflow to update. + /// + /// 200 => Workflow updated. + /// 400 => Workflow request is not valid. + /// 404 => Workflow or app not found. + /// + [HttpPut] + [Route("apps/{app}/workflows/{id}")] + [ProducesResponseType(typeof(WorkflowsDto), 200)] + [ApiPermission(Permissions.AppWorkflowsUpdate)] + [ApiCosts(1)] + public async Task PutWorkflow(string app, Guid id, [FromBody] UpdateWorkflowDto request) + { + var command = request.ToCommand(id); + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + /// + /// Delete a workflow. + /// + /// The name of the app. + /// The id of the workflow to update. + /// + /// 200 => Workflow deleted. + /// 404 => Workflow or app not found. + /// + [HttpDelete] + [Route("apps/{app}/workflows/{id}")] + [ProducesResponseType(typeof(WorkflowsDto), 200)] + [ApiPermission(Permissions.AppWorkflowsUpdate)] + [ApiCosts(1)] + public async Task DeleteWorkflow(string app, Guid id) + { + var command = new DeleteWorkflow { WorkflowId = id }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this); + + return response; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 4219f42dd..8a1b950df 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -5,12 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure; @@ -58,13 +59,16 @@ namespace Squidex.Areas.Api.Controllers.Apps public async Task GetApps() { var userOrClientId = HttpContext.User.UserOrClientId(); - var userPermissions = HttpContext.User.Permissions(); + var userPermissions = HttpContext.Permissions(); - var entities = await appProvider.GetUserApps(userOrClientId, userPermissions); + var apps = await appProvider.GetUserApps(userOrClientId, userPermissions); - var response = entities.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider)); + var response = Deferred.Response(() => + { + return apps.Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray(); + }); - Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); + Response.Headers[HeaderNames.ETag] = apps.ToEtag(); return Ok(response); } @@ -84,17 +88,18 @@ namespace Squidex.Areas.Api.Controllers.Apps /// [HttpPost] [Route("apps/")] - [ProducesResponseType(typeof(AppCreatedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 400)] - [ProducesResponseType(typeof(ErrorDto), 409)] + [ProducesResponseType(typeof(AppDto), 201)] [ApiPermission] [ApiCosts(1)] public async Task PostApp([FromBody] CreateAppDto request) { var context = await CommandBus.PublishAsync(request.ToCommand()); - var result = context.Result>(); - var response = AppCreatedDto.FromResult(request.Name, result, appPlansProvider); + var userOrClientId = HttpContext.User.UserOrClientId(); + var userPermissions = HttpContext.Permissions(); + + var result = context.Result(); + var response = AppDto.FromApp(result, userOrClientId, userPermissions, appPlansProvider, this); return CreatedAtAction(nameof(GetApps), response); } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs similarity index 51% rename from src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs rename to src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs index e398c88be..823794c6b 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AddWorkflowDto.cs @@ -1,26 +1,27 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Commands; -namespace Squidex.Areas.Api.Controllers.Users.Models +namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class PublicUserDto + public sealed class AddWorkflowDto { /// - /// The id of the user. + /// The name of the workflow. /// [Required] - public string Id { get; set; } + public string Name { get; set; } - /// - /// The display name (usually first name and last name) of the user. - /// - [Required] - public string DisplayName { get; set; } + public ICommand ToCommand() + { + return new AddWorkflow { Name = Name }; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs deleted file mode 100644 index 38e6adae1..000000000 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppCreatedDto.cs +++ /dev/null @@ -1,59 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using Squidex.Domain.Apps.Core.Apps; -using Squidex.Domain.Apps.Entities.Apps.Services; -using Squidex.Infrastructure.Commands; - -namespace Squidex.Areas.Api.Controllers.Apps.Models -{ - public sealed class AppCreatedDto - { - /// - /// Id of the created entity. - /// - [Required] - public string Id { get; set; } - - /// - /// The permission level of the user. - /// - public string[] Permissions { get; set; } - - /// - /// The new version of the entity. - /// - public long Version { get; set; } - - /// - /// Gets the current plan name. - /// - public string PlanName { get; set; } - - /// - /// Gets the next plan name. - /// - public string PlanUpgrade { get; set; } - - public static AppCreatedDto FromResult(string name, EntityCreatedResult result, IAppPlansProvider apps) - { - var response = new AppCreatedDto - { - Id = result.IdOrValue.ToString(), - Permissions = Role.CreateOwner(name).Permissions.ToIds().ToArray(), - PlanName = apps.GetPlan(null)?.Name, - PlanUpgrade = apps.GetPlanUpgrade(null)?.Name, - Version = result.Version - }; - - return response; - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 7fe96d7bc..1eee4cdd5 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -9,17 +9,23 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Areas.Api.Controllers.Assets; +using Squidex.Areas.Api.Controllers.Backups; +using Squidex.Areas.Api.Controllers.Ping; +using Squidex.Areas.Api.Controllers.Plans; +using Squidex.Areas.Api.Controllers.Rules; +using Squidex.Areas.Api.Controllers.Schemas; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Services; -using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; +using AllPermissions = Squidex.Shared.Permissions; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class AppDto : IGenerateETag + public sealed class AppDto : Resource { /// /// The name of the app. @@ -51,7 +57,17 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// /// The permission level of the user. /// - public string[] Permissions { get; set; } + public IEnumerable Permissions { get; set; } + + /// + /// Indicates if the user can access the api. + /// + public bool CanAccessApi { get; set; } + + /// + /// Indicates if the user can access at least one content. + /// + public bool CanAccessContent { get; set; } /// /// Gets the current plan name. @@ -63,7 +79,27 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public string PlanUpgrade { get; set; } - public static AppDto FromApp(IAppEntity app, string userId, PermissionSet userPermissions, IAppPlansProvider plans) + public static AppDto FromApp(IAppEntity app, string userId, PermissionSet userPermissions, IAppPlansProvider plans, ApiController controller) + { + var permissions = GetPermissions(app, userId, userPermissions); + + var result = SimpleMapper.Map(app, new AppDto()); + + result.Permissions = permissions.ToIds(); + result.PlanName = plans.GetPlanForApp(app)?.Name; + + result.CanAccessApi = controller.HasPermission(AllPermissions.AppApi, app.Name, "*", permissions); + result.CanAccessContent = controller.HasPermission(AllPermissions.AppContentsRead, app.Name, "*", permissions); + + if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) + { + result.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + } + + return result.CreateLinks(controller, permissions); + } + + private static PermissionSet GetPermissions(IAppEntity app, string userId, PermissionSet userPermissions) { var permissions = new List(); @@ -77,13 +113,86 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models permissions.AddRange(userPermissions.ToAppPermissions(app.Name)); } - var response = SimpleMapper.Map(app, new AppDto()); + return new PermissionSet(permissions); + } + + private AppDto CreateLinks(ApiController controller, PermissionSet permissions) + { + var values = new { app = Name }; + + AddGetLink("ping", controller.Url(x => nameof(x.GetAppPing), values)); - response.Permissions = permissions.ToArray(x => x.Id); - response.PlanName = plans.GetPlanForApp(app)?.Name; - response.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + if (controller.HasPermission(AllPermissions.AppDelete, Name, permissions: permissions)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteApp), values)); + } + + if (controller.HasPermission(AllPermissions.AppAssetsRead, Name, permissions: permissions)) + { + AddGetLink("assets", controller.Url(x => nameof(x.GetAssets), values)); + } + + if (controller.HasPermission(AllPermissions.AppBackupsRead, Name, permissions: permissions)) + { + AddGetLink("backups", controller.Url(x => nameof(x.GetBackups), values)); + } + + if (controller.HasPermission(AllPermissions.AppClientsRead, Name, permissions: permissions)) + { + AddGetLink("clients", controller.Url(x => nameof(x.GetClients), values)); + } + + if (controller.HasPermission(AllPermissions.AppContributorsRead, Name, permissions: permissions)) + { + AddGetLink("contributors", controller.Url(x => nameof(x.GetContributors), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, Name, permissions: permissions)) + { + AddGetLink("languages", controller.Url(x => nameof(x.GetLanguages), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, Name, permissions: permissions)) + { + AddGetLink("patterns", controller.Url(x => nameof(x.GetPatterns), values)); + } + + if (controller.HasPermission(AllPermissions.AppPlansRead, Name, permissions: permissions)) + { + AddGetLink("plans", controller.Url(x => nameof(x.GetPlans), values)); + } + + if (controller.HasPermission(AllPermissions.AppRolesRead, Name, permissions: permissions)) + { + AddGetLink("roles", controller.Url(x => nameof(x.GetRoles), values)); + } + + if (controller.HasPermission(AllPermissions.AppRulesRead, Name, permissions: permissions)) + { + AddGetLink("rules", controller.Url(x => nameof(x.GetRules), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, Name, permissions: permissions)) + { + AddGetLink("schemas", controller.Url(x => nameof(x.GetSchemas), values)); + } + + if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions)) + { + AddGetLink("workflows", controller.Url(x => nameof(x.GetWorkflows), values)); + } + + if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions)) + { + AddPostLink("schemas/create", controller.Url(x => nameof(x.PostSchema), values)); + } + + if (controller.HasPermission(AllPermissions.AppAssetsCreate, Name, permissions: permissions)) + { + AddPostLink("assets/create", controller.Url(x => nameof(x.PostSchema), values)); + } - return response; + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs index 62501e4c2..85f6ef7ce 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs @@ -5,18 +5,18 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.ComponentModel.DataAnnotations; using System.Linq; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps; -using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class AppLanguageDto + public sealed class AppLanguageDto : Resource { /// /// The iso code of the language. @@ -46,25 +46,37 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public bool IsOptional { get; set; } - public static AppLanguageDto FromCommand(AddLanguage command) + public static AppLanguageDto FromLanguage(LanguageConfig language, IAppEntity app, ApiController controller) { - return SimpleMapper.Map(command.Language, new AppLanguageDto { Fallback = Array.Empty() }); - } + var result = SimpleMapper.Map(language.Language, + new AppLanguageDto + { + IsMaster = language == app.LanguagesConfig.Master, + IsOptional = language.IsOptional, + Fallback = language.LanguageFallbacks.ToArray() + }); - public static AppLanguageDto[] FromApp(IAppEntity app) - { - return app.LanguagesConfig.OfType().Select(x => FromLanguage(x, app)).OrderByDescending(x => x.IsMaster).ThenBy(x => x.Iso2Code).ToArray(); + return result.CreateLinks(controller, app); } - private static AppLanguageDto FromLanguage(LanguageConfig x, IAppEntity app) + private AppLanguageDto CreateLinks(ApiController controller, IAppEntity app) { - return SimpleMapper.Map(x.Language, - new AppLanguageDto + var values = new { app = app.Name, language = Iso2Code }; + + if (!IsMaster) + { + if (controller.HasPermission(Permissions.AppLanguagesUpdate, app.Name)) { - IsMaster = x == app.LanguagesConfig.Master, - IsOptional = x.IsOptional, - Fallback = x.LanguageFallbacks.ToArray() - }); + AddPutLink("update", controller.Url(x => nameof(x.PutLanguage), values)); + } + + if (controller.HasPermission(Permissions.AppLanguagesDelete, app.Name) && app.LanguagesConfig.Count > 1) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteLanguage), values)); + } + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs new file mode 100644 index 000000000..77580f5e9 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class AppLanguagesDto : Resource + { + /// + /// The languages. + /// + [Required] + public AppLanguageDto[] Items { get; set; } + + public static AppLanguagesDto FromApp(IAppEntity app, ApiController controller) + { + var result = new AppLanguagesDto + { + Items = app.LanguagesConfig.OfType() + .Select(x => AppLanguageDto.FromLanguage(x, app, controller)) + .OrderByDescending(x => x.IsMaster) + .ThenBy(x => x.Iso2Code) + .ToArray() + }; + + return result.CreateLinks(controller, app.Name); + } + + private AppLanguagesDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetLanguages), values)); + + if (controller.HasPermission(Permissions.AppLanguagesCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostLanguage), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs index 75d6b8da3..a7a344251 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs @@ -5,16 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Squidex.Domain.Apps.Core.Apps; -using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Reflection; -using Roles = Squidex.Domain.Apps.Core.Apps.Role; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class ClientDto + public sealed class ClientDto : Resource { /// /// The client id. @@ -39,14 +38,28 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public string Role { get; set; } - public static ClientDto FromKvp(KeyValuePair kvp) + public static ClientDto FromClient(string id, AppClient client, ApiController controller, string app) { - return SimpleMapper.Map(kvp.Value, new ClientDto { Id = kvp.Key }); + var result = SimpleMapper.Map(client, new ClientDto { Id = id }); + + return result.CreateLinks(controller, app); } - public static ClientDto FromCommand(AttachClient command) + private ClientDto CreateLinks(ApiController controller, string app) { - return SimpleMapper.Map(command, new ClientDto { Name = command.Id, Role = Roles.Editor }); + var values = new { app, id = Id }; + + if (controller.HasPermission(Permissions.AppClientsUpdate, app)) + { + AddPutLink("update", controller.Url(x => nameof(x.PutClient), values)); + } + + if (controller.HasPermission(Permissions.AppClientsDelete, app)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteClient), values)); + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs new file mode 100644 index 000000000..7116e99f2 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class ClientsDto : Resource + { + /// + /// The clients. + /// + [Required] + public ClientDto[] Items { get; set; } + + public static ClientsDto FromApp(IAppEntity app, ApiController controller) + { + var result = new ClientsDto + { + Items = app.Clients.Select(x => ClientDto.FromClient(x.Key, x.Value, controller, app.Name)).ToArray() + }; + + return result.CreateLinks(controller, app.Name); + } + + private ClientsDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetClients), values)); + + if (controller.HasPermission(Permissions.AppClientsCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostClient), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs index 42ea190e5..4929ed76b 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs @@ -6,10 +6,12 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class ContributorDto + public sealed class ContributorDto : Resource { /// /// The id of the user that contributes to the app. @@ -21,5 +23,30 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// The role of the contributor. /// public string Role { get; set; } + + public static ContributorDto FromIdAndRole(string id, string role, ApiController controller, string app) + { + var result = new ContributorDto { ContributorId = id, Role = role }; + + return result.CreateLinks(controller, app); + } + + private ContributorDto CreateLinks(ApiController controller, string app) + { + if (!controller.IsUser(ContributorId)) + { + if (controller.HasPermission(Permissions.AppContributorsAssign, app)) + { + AddPostLink("update", controller.Url(x => nameof(x.PostContributor), new { app })); + } + + if (controller.HasPermission(Permissions.AppContributorsRevoke, app)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteContributor), new { app, id = ContributorId })); + } + } + + return this; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs index e482b1a69..b9e264241 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs @@ -6,32 +6,67 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using System.Linq; +using Newtonsoft.Json; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class ContributorsDto + public sealed class ContributorsDto : Resource { /// /// The contributors. /// [Required] - public ContributorDto[] Contributors { get; set; } + public ContributorDto[] Items { get; set; } /// /// The maximum number of allowed contributors. /// public int MaxContributors { get; set; } - public static ContributorsDto FromApp(IAppEntity app, IAppPlansProvider plans) + /// + /// The metadata. + /// + [JsonProperty("_meta")] + public ContributorsMetadata Metadata { get; set; } + + public static ContributorsDto FromApp(IAppEntity app, IAppPlansProvider plans, ApiController controller, bool isInvited) + { + var result = new ContributorsDto + { + Items = app.Contributors.Select(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name)).ToArray(), + }; + + if (isInvited) + { + result.Metadata = new ContributorsMetadata + { + IsInvited = isInvited.ToString() + }; + } + + result.MaxContributors = plans.GetPlanForApp(app).MaxContributors; + + return result.CreateLinks(controller, app.Name); + } + + private ContributorsDto CreateLinks(ApiController controller, string app) { - var plan = plans.GetPlanForApp(app); + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetContributors), values)); - var contributors = app.Contributors.ToArray(x => new ContributorDto { ContributorId = x.Key, Role = x.Value }); + if (controller.HasPermission(Permissions.AppContributorsAssign, app) && (MaxContributors < 0 || Items.Length < MaxContributors)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostContributor), values)); + } - return new ContributorsDto { Contributors = contributors, MaxContributors = plan.MaxContributors }; + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsMetadata.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsMetadata.cs new file mode 100644 index 000000000..0d0950441 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsMetadata.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class ContributorsMetadata + { + /// + /// Indicates whether the user has been invited. + /// + public string IsInvited { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/PatternDto.cs similarity index 53% rename from src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs rename to src/Squidex/Areas/Api/Controllers/Apps/Models/PatternDto.cs index ad8efa50f..1377aee3e 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/PatternDto.cs @@ -6,20 +6,20 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Squidex.Domain.Apps.Core.Apps; -using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class AppPatternDto + public sealed class PatternDto : Resource { /// /// Unique id of the pattern. /// - public Guid PatternId { get; set; } + public Guid Id { get; set; } /// /// The name of the suggestion. @@ -38,14 +38,28 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public string Message { get; set; } - public static AppPatternDto FromKvp(KeyValuePair kvp) + public static PatternDto FromPattern(Guid id, AppPattern pattern, ApiController controller, string app) { - return SimpleMapper.Map(kvp.Value, new AppPatternDto { PatternId = kvp.Key }); + var result = SimpleMapper.Map(pattern, new PatternDto { Id = id }); + + return result.CreateLinks(controller, app); } - public static AppPatternDto FromCommand(AddPattern command) + private PatternDto CreateLinks(ApiController controller, string app) { - return SimpleMapper.Map(command, new AppPatternDto()); + var values = new { app, id = Id }; + + if (controller.HasPermission(Permissions.AppPatternsUpdate, app)) + { + AddPutLink("update", controller.Url(x => nameof(x.UpdatePattern), values)); + } + + if (controller.HasPermission(Permissions.AppPatternsDelete, app)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeletePattern), values)); + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/PatternsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/PatternsDto.cs new file mode 100644 index 000000000..ca9a2605a --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/PatternsDto.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class PatternsDto : Resource + { + /// + /// The patterns. + /// + [Required] + public PatternDto[] Items { get; set; } + + public static PatternsDto FromApp(IAppEntity app, ApiController controller) + { + var result = new PatternsDto + { + Items = app.Patterns.Select(x => PatternDto.FromPattern(x.Key, x.Value, controller, app.Name)).ToArray() + }; + + return result.CreateLinks(controller, app.Name); + } + + private PatternsDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetPatterns), values)); + + if (controller.HasPermission(Permissions.AppPatternsCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostPattern), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs index 2c833707b..f703c3cf5 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs @@ -5,16 +5,17 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Web; +using AllPermissions = Squidex.Shared.Permissions; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class RoleDto + public sealed class RoleDto : Resource { /// /// The role name. @@ -32,23 +33,51 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public int NumContributors { get; set; } + /// + /// Indicates if the role is an builtin default role. + /// + public bool IsDefaultRole { get; set; } + /// /// Associated list of permissions. /// [Required] public IEnumerable Permissions { get; set; } - public static RoleDto FromRole(Role role, IAppEntity app) + public static RoleDto FromRole(Role role, IAppEntity app, ApiController controller) { var permissions = role.Permissions.WithoutApp(app.Name); - return new RoleDto + var result = new RoleDto { Name = role.Name, - NumClients = app.Clients.Count(x => string.Equals(x.Value.Role, role.Name, StringComparison.OrdinalIgnoreCase)), - NumContributors = app.Contributors.Count(x => string.Equals(x.Value, role.Name, StringComparison.OrdinalIgnoreCase)), - Permissions = permissions.ToIds() + NumClients = app.Clients.Count(x => Role.IsRole(x.Value.Role, role.Name)), + NumContributors = app.Contributors.Count(x => Role.IsRole(x.Value, role.Name)), + Permissions = permissions.ToIds(), + IsDefaultRole = Role.IsDefaultRole(role.Name) }; + + return result.CreateLinks(controller, app.Name); + } + + private RoleDto CreateLinks(ApiController controller, string app) + { + var values = new { app, name = Name }; + + if (!IsDefaultRole) + { + if (controller.HasPermission(AllPermissions.AppRolesUpdate, app) && NumClients == 0 && NumContributors == 0) + { + AddPutLink("update", controller.Url(x => nameof(x.UpdateRole), values)); + } + + if (controller.HasPermission(AllPermissions.AppRolesDelete, app)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteRole), values)); + } + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs index 64390c625..b43c32041 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs @@ -8,22 +8,41 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class RolesDto + public sealed class RolesDto : Resource { /// - /// The app roles. + /// The roles. /// [Required] - public RoleDto[] Roles { get; set; } + public RoleDto[] Items { get; set; } - public static RolesDto FromApp(IAppEntity app) + public static RolesDto FromApp(IAppEntity app, ApiController controller) { - var roles = app.Roles.Values.Select(x => RoleDto.FromRole(x, app)).ToArray(); + var result = new RolesDto + { + Items = app.Roles.Values.Select(x => RoleDto.FromRole(x, app, controller)).OrderBy(x => x.Name).ToArray() + }; - return new RolesDto { Roles = roles }; + return result.CreateLinks(controller, app.Name); + } + + private RolesDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetRoles), values)); + + if (controller.HasPermission(Permissions.AppRolesCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostRole), values)); + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateLanguageDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateLanguageDto.cs index d5c11c15e..8c6d2e707 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateLanguageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateLanguageDto.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Linq; +using System.Collections.Generic; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; @@ -27,11 +27,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// /// Optional fallback languages. /// - public Language[] Fallback { get; set; } + public List Fallback { get; set; } public UpdateLanguage ToCommand(Language language) { - return SimpleMapper.Map(this, new UpdateLanguage { Language = language, Fallback = Fallback?.ToList() }); + return SimpleMapper.Map(this, new UpdateLanguage { Language = language }); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs new file mode 100644 index 000000000..08e1b4fa1 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateWorkflowDto.cs @@ -0,0 +1,58 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Apps.Commands; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class UpdateWorkflowDto + { + /// + /// The name of the workflow. + /// + public string Name { get; set; } + + /// + /// The workflow steps. + /// + [Required] + public Dictionary Steps { get; set; } + + /// + /// The schema ids. + /// + public List SchemaIds { get; set; } + + /// + /// The initial step. + /// + public Status Initial { get; set; } + + public UpdateWorkflow ToCommand(Guid id) + { + var workflow = new Workflow( + Initial, + Steps?.ToDictionary( + x => x.Key, + x => new WorkflowStep( + x.Value?.Transitions.ToDictionary( + y => y.Key, + y => new WorkflowTransition(y.Value.Expression, y.Value.Role)), + x.Value.Color, + x.Value.NoUpdate)), + SchemaIds, + Name); + + return new UpdateWorkflow { WorkflowId = id, Workflow = workflow }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs new file mode 100644 index 000000000..5e249085b --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs @@ -0,0 +1,80 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowDto : Resource + { + /// + /// The workflow id. + /// + public Guid Id { get; set; } + + /// + /// The name of the workflow. + /// + public string Name { get; set; } + + /// + /// The workflow steps. + /// + [Required] + public Dictionary Steps { get; set; } + + /// + /// The schema ids. + /// + public IReadOnlyList SchemaIds { get; set; } + + /// + /// The initial step. + /// + public Status Initial { get; set; } + + public static WorkflowDto FromWorkflow(Guid id, Workflow workflow, ApiController controller, string app) + { + var result = SimpleMapper.Map(workflow, new WorkflowDto { Id = id }); + + result.Steps = workflow.Steps.ToDictionary( + x => x.Key, + x => SimpleMapper.Map(x.Value, new WorkflowStepDto + { + Transitions = x.Value.Transitions.ToDictionary( + y => y.Key, + y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role }) + })); + + return result.CreateLinks(controller, app, id); + } + + private WorkflowDto CreateLinks(ApiController controller, string app, Guid id) + { + var values = new { app, id }; + + if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app)) + { + AddPutLink("update", controller.Url(x => nameof(x.PutWorkflow), values)); + } + + if (controller.HasPermission(Permissions.AppWorkflowsDelete, app)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteWorkflow), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs similarity index 56% rename from src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs rename to src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs index 871fcc6e9..9012d406d 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs @@ -5,26 +5,28 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class ContributorAssignedDto + public sealed class WorkflowStepDto { /// - /// The id of the user that has been assigned as contributor. + /// The transitions. /// [Required] - public string ContributorId { get; set; } + public Dictionary Transitions { get; set; } /// - /// Indicates if the user was created. + /// The optional color. /// - public bool IsCreated { get; set; } + public string Color { get; set; } - public static ContributorAssignedDto FromId(string id, bool isCreated) - { - return new ContributorAssignedDto { ContributorId = id, IsCreated = isCreated }; - } + /// + /// Indicates if updates should not be allowed. + /// + public bool NoUpdate { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs new file mode 100644 index 000000000..94235a60d --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowTransitionDto + { + /// + /// The optional expression. + /// + public string Expression { get; set; } + + /// + /// The optional restricted role. + /// + public string Role { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs new file mode 100644 index 000000000..5e3515eba --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class WorkflowsDto : Resource + { + /// + /// The workflow. + /// + [Required] + public WorkflowDto[] Items { get; set; } + + /// + /// The errros that should be fixed. + /// + [Required] + public string[] Errors { get; set; } + + public static async Task FromAppAsync(IWorkflowsValidator workflowsValidator, IAppEntity app, ApiController controller) + { + var result = new WorkflowsDto + { + Items = app.Workflows.Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value, controller, app.Name)).ToArray(), + }; + + var errors = await workflowsValidator.ValidateAsync(app.Id, app.Workflows); + + result.Errors = errors.ToArray(); + + return result.CreateLinks(controller, app.Name); + } + + private WorkflowsDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetWorkflows), values)); + + if (controller.HasPermission(Permissions.AppWorkflowsCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostWorkflow), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index 8d947d64c..48c0dc0e0 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -74,18 +74,18 @@ namespace Squidex.Areas.Api.Controllers.Assets [FromQuery] int? quality = null, [FromQuery] string mode = null) { - IAssetEntity entity; + IAssetEntity asset; if (Guid.TryParse(idOrSlug, out var guid)) { - entity = await assetRepository.FindAssetAsync(guid); + asset = await assetRepository.FindAssetAsync(guid); } else { - entity = await assetRepository.FindAssetBySlugAsync(App.Id, idOrSlug); + asset = await assetRepository.FindAssetBySlugAsync(App.Id, idOrSlug); } - return DeliverAsset(entity, version, width, height, quality, mode, dl); + return DeliverAsset(asset, version, width, height, quality, mode, dl); } /// @@ -115,25 +115,25 @@ namespace Squidex.Areas.Api.Controllers.Assets [FromQuery] int? quality = null, [FromQuery] string mode = null) { - var entity = await assetRepository.FindAssetAsync(id); + var asset = await assetRepository.FindAssetAsync(id); - return DeliverAsset(entity, version, width, height, quality, mode, dl); + return DeliverAsset(asset, version, width, height, quality, mode, dl); } - private IActionResult DeliverAsset(IAssetEntity entity, long version, int? width, int? height, int? quality, string mode, int download = 1) + private IActionResult DeliverAsset(IAssetEntity asset, long version, int? width, int? height, int? quality, string mode, int download = 1) { - if (entity == null || entity.FileVersion < version || width == 0 || height == 0 || quality == 0) + if (asset == null || asset.FileVersion < version || width == 0 || height == 0 || quality == 0) { return NotFound(); } - Response.Headers[HeaderNames.ETag] = entity.FileVersion.ToString(); + Response.Headers[HeaderNames.ETag] = asset.FileVersion.ToString(); var handler = new Func(async bodyStream => { - var assetId = entity.Id.ToString(); + var assetId = asset.Id.ToString(); - if (entity.IsImage && (width.HasValue || height.HasValue || quality.HasValue)) + if (asset.IsImage && (width.HasValue || height.HasValue || quality.HasValue)) { var assetSuffix = $"{width}_{height}_{mode}"; @@ -144,7 +144,7 @@ namespace Squidex.Areas.Api.Controllers.Assets try { - await assetStore.DownloadAsync(assetId, entity.FileVersion, assetSuffix, bodyStream); + await assetStore.DownloadAsync(assetId, asset.FileVersion, assetSuffix, bodyStream); } catch (AssetNotFoundException) { @@ -156,7 +156,7 @@ namespace Squidex.Areas.Api.Controllers.Assets { using (Profiler.Trace("ResizeDownload")) { - await assetStore.DownloadAsync(assetId, entity.FileVersion, null, sourceStream); + await assetStore.DownloadAsync(assetId, asset.FileVersion, null, sourceStream); sourceStream.Position = 0; } @@ -168,7 +168,7 @@ namespace Squidex.Areas.Api.Controllers.Assets using (Profiler.Trace("ResizeUpload")) { - await assetStore.UploadAsync(assetId, entity.FileVersion, assetSuffix, destinationStream); + await assetStore.UploadAsync(assetId, asset.FileVersion, assetSuffix, destinationStream); destinationStream.Position = 0; } @@ -180,17 +180,17 @@ namespace Squidex.Areas.Api.Controllers.Assets } else { - await assetStore.DownloadAsync(assetId, entity.FileVersion, null, bodyStream); + await assetStore.DownloadAsync(assetId, asset.FileVersion, null, bodyStream); } }); if (download == 1) { - return new FileCallbackResult(entity.MimeType, entity.FileName, true, handler); + return new FileCallbackResult(asset.MimeType, asset.FileName, true, handler); } else { - return new FileCallbackResult(entity.MimeType, null, true, handler); + return new FileCallbackResult(asset.MimeType, null, true, handler); } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 041adcdde..0456767f0 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -77,9 +77,11 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task GetTags(string app) { - var response = await tagService.GetTagsAsync(AppId, TagGroups.Assets); + var tags = await tagService.GetTagsAsync(AppId, TagGroups.Assets); - return Ok(response); + Response.Headers[HeaderNames.ETag] = tags.Version.ToString(); + + return Ok(tags); } /// @@ -101,18 +103,19 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task GetAssets(string app, [FromQuery] string ids = null) { - var context = Context(); - - var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids)); + var assets = await assetQuery.QueryAsync(Context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids)); - var response = AssetsDto.FromAssets(assets); + var response = Deferred.Response(() => + { + return AssetsDto.FromAssets(assets, this, app); + }); - if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) + if (controllerOptions.Value.EnableSurrogateKeys && assets.Count <= controllerOptions.Value.MaxItemsForSurrogateKeys) { - Response.Headers["Surrogate-Key"] = response.Items.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = assets.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = response.Items.ToManyEtag(response.Total); + Response.Headers[HeaderNames.ETag] = assets.ToEtag(); return Ok(response); } @@ -133,23 +136,24 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(1)] public async Task GetAsset(string app, Guid id) { - var context = Context(); - - var entity = await assetQuery.FindAssetAsync(context, id); + var asset = await assetQuery.FindAssetAsync(id); - if (entity == null) + if (asset == null) { return NotFound(); } - var response = AssetDto.FromAsset(entity); + var response = Deferred.Response(() => + { + return AssetDto.FromAsset(asset, this, app); + }); if (controllerOptions.Value.EnableSurrogateKeys) { - Response.Headers["Surrogate-Key"] = entity.Id.ToString(); + Response.Headers["Surrogate-Key"] = asset.ToSurrogateKey(); } - Response.Headers[HeaderNames.ETag] = entity.Version.ToString(); + Response.Headers[HeaderNames.ETag] = asset.ToEtag(); return Ok(response); } @@ -169,8 +173,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// [HttpPost] [Route("apps/{app}/assets/")] - [ProducesResponseType(typeof(AssetCreatedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(AssetDto), 200)] [AssetRequestSizeLimit] [ApiPermission(Permissions.AppAssetsCreate)] [ApiCosts(1)] @@ -179,12 +182,10 @@ namespace Squidex.Areas.Api.Controllers.Assets var assetFile = await CheckAssetFileAsync(file); var command = new CreateAsset { File = assetFile }; - var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = AssetCreatedDto.FromCommand(command, result); + var response = await InvokeCommandAsync(app, command); - return StatusCode(201, response); + return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response); } /// @@ -194,7 +195,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// The id of the asset. /// The file to upload. /// - /// 201 => Asset updated. + /// 200 => Asset updated. /// 404 => Asset or app not found. /// 400 => Asset exceeds the maximum size. /// @@ -203,8 +204,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// [HttpPut] [Route("apps/{app}/assets/{id}/content/")] - [ProducesResponseType(typeof(AssetReplacedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(AssetDto), 200)] [ApiPermission(Permissions.AppAssetsUpdate)] [ApiCosts(1)] public async Task PutAssetContent(string app, Guid id, [SwaggerIgnore] List file) @@ -212,12 +212,10 @@ namespace Squidex.Areas.Api.Controllers.Assets var assetFile = await CheckAssetFileAsync(file); var command = new UpdateAsset { File = assetFile, AssetId = id }; - var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = AssetReplacedDto.FromCommand(command, result); + var response = await InvokeCommandAsync(app, command); - return StatusCode(201, response); + return Ok(response); } /// @@ -227,21 +225,23 @@ namespace Squidex.Areas.Api.Controllers.Assets /// The id of the asset. /// The asset object that needs to updated. /// - /// 204 => Asset updated. + /// 200 => Asset updated. /// 400 => Asset name not valid. /// 404 => Asset or app not found. /// [HttpPut] [Route("apps/{app}/assets/{id}/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(AssetDto), 200)] [AssetRequestSizeLimit] [ApiPermission(Permissions.AppAssetsUpdate)] [ApiCosts(1)] public async Task PutAsset(string app, Guid id, [FromBody] AnnotateAssetDto request) { - await CommandBus.PublishAsync(request.ToCommand(id)); + var command = request.ToCommand(id); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -250,7 +250,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// The name of the app. /// The id of the asset to delete. /// - /// 204 => Asset has been deleted. + /// 204 => Asset deleted. /// 404 => Asset or app not found. /// [HttpDelete] @@ -264,6 +264,20 @@ namespace Squidex.Areas.Api.Controllers.Assets return NoContent(); } + private async Task InvokeCommandAsync(string app, ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + if (context.PlainResult is AssetCreatedResult created) + { + return AssetDto.FromAsset(created.Asset, this, app, created.IsDuplicate); + } + else + { + return AssetDto.FromAsset(context.Result(), this, app); + } + } + private async Task CheckAssetFileAsync(IReadOnlyList file) { if (file.Count != 1) @@ -297,10 +311,5 @@ namespace Squidex.Areas.Api.Controllers.Assets return assetFile; } - - private QueryContext Context() - { - return QueryContext.Create(App, User); - } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs deleted file mode 100644 index da76057c0..000000000 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs +++ /dev/null @@ -1,108 +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.ComponentModel.DataAnnotations; -using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Assets.Commands; -using Squidex.Infrastructure; - -namespace Squidex.Areas.Api.Controllers.Assets.Models -{ - public sealed class AssetCreatedDto - { - /// - /// The id of the asset. - /// - public Guid Id { get; set; } - - /// - /// The file type. - /// - [Required] - public string FileType { get; set; } - - /// - /// The file name. - /// - [Required] - public string FileName { get; set; } - - /// - /// The slug. - /// - [Required] - public string Slug { get; set; } - - /// - /// The mime type. - /// - [Required] - public string MimeType { get; set; } - - /// - /// The default tags. - /// - [Required] - public HashSet Tags { get; set; } - - /// - /// The size of the file in bytes. - /// - public long FileSize { get; set; } - - /// - /// The version of the file. - /// - public long FileVersion { get; set; } - - /// - /// Determines of the created file is an image. - /// - public bool IsImage { get; set; } - - /// - /// The width of the image in pixels if the asset is an image. - /// - public int? PixelWidth { get; set; } - - /// - /// The height of the image in pixels if the asset is an image. - /// - public int? PixelHeight { get; set; } - - /// - /// Indicates if the asset has been already uploaded. - /// - public bool IsDuplicate { get; set; } - - /// - /// The version of the asset. - /// - public long Version { get; set; } - - public static AssetCreatedDto FromCommand(CreateAsset command, AssetCreatedResult result) - { - return new AssetCreatedDto - { - Id = result.IdOrValue, - FileName = command.File.FileName, - FileSize = command.File.FileSize, - FileType = command.File.FileName.FileType(), - FileVersion = result.FileVersion, - MimeType = command.File.MimeType, - IsImage = command.ImageInfo != null, - IsDuplicate = result.IsDuplicate, - PixelWidth = command.ImageInfo?.PixelWidth, - PixelHeight = command.ImageInfo?.PixelHeight, - Tags = result.Tags, - Version = result.Version - }; - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index 1053302be..4ba2cb68f 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs @@ -8,15 +8,17 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; using NodaTime; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Assets.Models { - public sealed class AssetDto : IGenerateETag + public sealed class AssetDto : Resource { /// /// The id of the asset. @@ -110,9 +112,51 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// public long Version { get; set; } - public static AssetDto FromAsset(IAssetEntity asset) + /// + /// The metadata. + /// + [JsonProperty("_meta")] + public AssetMetadata Metadata { get; set; } + + public static AssetDto FromAsset(IEnrichedAssetEntity asset, ApiController controller, string app, bool isDuplicate = false) { - return SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() }); + var response = SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() }); + + response.Tags = asset.TagNames; + + if (isDuplicate) + { + response.Metadata = new AssetMetadata { IsDuplicate = "true" }; + } + + return CreateLinks(response, controller, app); + } + + private static AssetDto CreateLinks(AssetDto response, ApiController controller, string app) + { + var values = new { app, id = response.Id }; + + response.AddSelfLink(controller.Url(x => nameof(x.GetAsset), values)); + + if (controller.HasPermission(Permissions.AppAssetsUpdate)) + { + response.AddPutLink("update", controller.Url(x => nameof(x.PutAsset), values)); + response.AddPutLink("upload", controller.Url(x => nameof(x.PutAssetContent), values)); + } + + if (controller.HasPermission(Permissions.AppAssetsDelete)) + { + response.AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteAsset), values)); + } + + response.AddGetLink("content", controller.Url(x => nameof(x.GetAssetContent), new { id = response.Id, version = response.FileVersion })); + + if (!string.IsNullOrWhiteSpace(response.Slug)) + { + response.AddGetLink("content/slug", controller.Url(x => nameof(x.GetAssetContentBySlug), new { app, idOrSlug = response.Slug, version = response.Version })); + } + + return response; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetMetadata.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetMetadata.cs new file mode 100644 index 000000000..71f8e9065 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetMetadata.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.Api.Controllers.Assets.Models +{ + public sealed class AssetMetadata + { + /// + /// Indicates whether the asset is a duplicate. + /// + public string IsDuplicate { get; set; } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs deleted file mode 100644 index e65faf494..000000000 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs +++ /dev/null @@ -1,74 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; -using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Assets.Commands; - -namespace Squidex.Areas.Api.Controllers.Assets.Models -{ - public sealed class AssetReplacedDto - { - /// - /// The mime type. - /// - [Required] - public string MimeType { get; set; } - - /// - /// The file hash. - /// - [Required] - public string FileHash { get; set; } - - /// - /// The size of the file in bytes. - /// - public long FileSize { get; set; } - - /// - /// The version of the file. - /// - public long FileVersion { get; set; } - - /// - /// Determines of the created file is an image. - /// - public bool IsImage { get; set; } - - /// - /// The width of the image in pixels if the asset is an image. - /// - public int? PixelWidth { get; set; } - - /// - /// The height of the image in pixels if the asset is an image. - /// - public int? PixelHeight { get; set; } - - /// - /// The version of the asset. - /// - public long Version { get; set; } - - public static AssetReplacedDto FromCommand(UpdateAsset command, AssetSavedResult result) - { - var response = new AssetReplacedDto - { - FileSize = command.File.FileSize, - FileVersion = result.FileVersion, - MimeType = command.File.MimeType, - IsImage = command.ImageInfo != null, - PixelWidth = command.ImageInfo?.PixelWidth, - PixelHeight = command.ImageInfo?.PixelHeight, - Version = result.Version - }; - - return response; - } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs index 6e81fa113..fbaa6dd46 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs @@ -9,25 +9,49 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Assets.Models { - public sealed class AssetsDto + public sealed class AssetsDto : Resource { + /// + /// The total number of assets. + /// + public long Total { get; set; } + /// /// The assets. /// [Required] public AssetDto[] Items { get; set; } - /// - /// The total number of assets. - /// - public long Total { get; set; } + public static AssetsDto FromAssets(IResultList assets, ApiController controller, string app) + { + var response = new AssetsDto + { + Total = assets.Total, + Items = assets.Select(x => AssetDto.FromAsset(x, controller, app)).ToArray() + }; + + return CreateLinks(response, controller, app); + } - public static AssetsDto FromAssets(IResultList assets) + private static AssetsDto CreateLinks(AssetsDto response, ApiController controller, string app) { - return new AssetsDto { Total = assets.Total, Items = assets.Select(AssetDto.FromAsset).ToArray() }; + var values = new { app }; + + response.AddSelfLink(controller.Url(x => nameof(x.GetAssets), values)); + + if (controller.HasPermission(Permissions.AppAssetsCreate)) + { + response.AddPostLink("create", controller.Url(x => nameof(x.PostAsset), values)); + } + + response.AddGetLink("tags", controller.Url(x => nameof(x.GetTags), values)); + + return response; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs index 0198fa186..e692bcfba 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc; using Orleans; using Squidex.Areas.Api.Controllers.Backups.Models; using Squidex.Domain.Apps.Entities.Backup; -using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Tasks; using Squidex.Shared; @@ -44,16 +43,16 @@ namespace Squidex.Areas.Api.Controllers.Backups /// [HttpGet] [Route("apps/{app}/backups/")] - [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(BackupJobsDto), 200)] [ApiPermission(Permissions.AppBackupsRead)] [ApiCosts(0)] - public async Task GetJobs(string app) + public async Task GetBackups(string app) { var backupGrain = grainFactory.GetGrain(AppId); var jobs = await backupGrain.GetStateAsync(); - var response = jobs.Value.ToArray(BackupJobDto.FromBackup); + var response = BackupJobsDto.FromBackups(jobs.Value, this, app); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs index 475f4b059..5e0163380 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -9,10 +9,12 @@ using System; using NodaTime; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Backups.Models { - public sealed class BackupJobDto + public sealed class BackupJobDto : Resource { /// /// The id of the backup job. @@ -44,9 +46,25 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models /// public JobStatus Status { get; set; } - public static BackupJobDto FromBackup(IBackupJob backup) + public static BackupJobDto FromBackup(IBackupJob backup, ApiController controller, string app) { - return SimpleMapper.Map(backup, new BackupJobDto()); + var result = SimpleMapper.Map(backup, new BackupJobDto()); + + return result.CreateLinks(controller, app); + } + + private BackupJobDto CreateLinks(ApiController controller, string app) + { + var values = new { app, id = Id }; + + if (controller.HasPermission(Permissions.AppBackupsDelete, app)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteBackup), values)); + } + + AddGetLink("download", controller.Url(x => nameof(x.GetBackupContent), values)); + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs new file mode 100644 index 000000000..d533a5030 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class BackupJobsDto : Resource + { + /// + /// The backups. + /// + [Required] + public BackupJobDto[] Items { get; set; } + + public static BackupJobsDto FromBackups(IEnumerable backups, ApiController controller, string app) + { + var result = new BackupJobsDto + { + Items = backups.Select(x => BackupJobDto.FromBackup(x, controller, app)).ToArray() + }; + + return result.CreateLinks(controller, app); + } + + private BackupJobsDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetBackups), values)); + + if (controller.HasPermission(Permissions.AppBackupsCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostBackup), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequestDto.cs similarity index 95% rename from src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs rename to src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequestDto.cs index a6b103a05..f51bc342b 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequest.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/RestoreRequestDto.cs @@ -10,7 +10,7 @@ using System.ComponentModel.DataAnnotations; namespace Squidex.Areas.Api.Controllers.Backups.Models { - public sealed class RestoreRequest + public sealed class RestoreRequestDto { /// /// The name of the app. diff --git a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs index 6ad1cf1c0..8a5a5c4d1 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -41,7 +41,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [HttpGet] [Route("apps/restore/")] [ProducesResponseType(typeof(RestoreJobDto), 200)] - [ApiPermission(Permissions.AdminRestoreRead)] + [ApiPermission(Permissions.AdminRestore)] public async Task GetJob() { var restoreGrain = grainFactory.GetGrain(SingleGrain.Id); @@ -67,8 +67,8 @@ namespace Squidex.Areas.Api.Controllers.Backups /// [HttpPost] [Route("apps/restore/")] - [ApiPermission(Permissions.AdminRestoreCreate)] - public async Task PostRestore([FromBody] RestoreRequest request) + [ApiPermission(Permissions.AdminRestore)] + public async Task PostRestore([FromBody] RestoreRequestDto request) { var restoreGrain = grainFactory.GetGrain(SingleGrain.Id); diff --git a/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs index 5603ec207..735bd640a 100644 --- a/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs @@ -55,9 +55,13 @@ namespace Squidex.Areas.Api.Controllers.Comments public async Task GetComments(string app, Guid commentsId, [FromQuery] long version = EtagVersion.Any) { var result = await grainFactory.GetGrain(commentsId).GetCommentsAsync(version); - var response = CommentsDto.FromResult(result); - Response.Headers[HeaderNames.ETag] = response.Version.ToString(); + var response = Deferred.Response(() => + { + return CommentsDto.FromResult(result); + }); + + Response.Headers[HeaderNames.ETag] = result.Version.ToString(); return Ok(response); } @@ -76,7 +80,6 @@ namespace Squidex.Areas.Api.Controllers.Comments [HttpPost] [Route("apps/{app}/comments/{commentsId}")] [ProducesResponseType(typeof(EntityCreatedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 400)] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public async Task PostComment(string app, Guid commentsId, [FromBody] UpsertCommentDto request) @@ -104,7 +107,6 @@ namespace Squidex.Areas.Api.Controllers.Comments /// [HttpPut] [Route("apps/{app}/comments/{commentsId}/{commentId}")] - [ProducesResponseType(typeof(ErrorDto), 400)] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public async Task PutComment(string app, Guid commentsId, Guid commentId, [FromBody] UpsertCommentDto request) @@ -126,7 +128,6 @@ namespace Squidex.Areas.Api.Controllers.Comments /// [HttpDelete] [Route("apps/{app}/comments/{commentsId}/{commentId}")] - [ProducesResponseType(typeof(ErrorDto), 400)] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public async Task DeleteComment(string app, Guid commentsId, Guid commentId) diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index fc1e9e63f..42bf4b1ce 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -6,13 +6,11 @@ // ========================================================================== using System; -using System.Linq; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; -using NodaTime; -using NodaTime.Text; using Squidex.Areas.Api.Controllers.Contents.Models; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities; @@ -21,7 +19,6 @@ using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Infrastructure.Commands; using Squidex.Shared; -using Squidex.Shared.Identity; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Contents @@ -30,15 +27,18 @@ namespace Squidex.Areas.Api.Controllers.Contents { private readonly IOptions controllerOptions; private readonly IContentQueryService contentQuery; + private readonly IContentWorkflow contentWorkflow; private readonly IGraphQLService graphQl; public ContentsController(ICommandBus commandBus, IContentQueryService contentQuery, + IContentWorkflow contentWorkflow, IGraphQLService graphQl, IOptions controllerOptions) : base(commandBus) { this.contentQuery = contentQuery; + this.contentWorkflow = contentWorkflow; this.controllerOptions = controllerOptions; this.graphQl = graphQl; @@ -63,7 +63,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(2)] public async Task PostGraphQL(string app, [FromBody] GraphQLQuery query) { - var result = await graphQl.QueryAsync(Context(), query); + var result = await graphQl.QueryAsync(Context, query); if (result.HasError) { @@ -94,7 +94,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(2)] public async Task PostGraphQLBatch(string app, [FromBody] GraphQLQuery[] batch) { - var result = await graphQl.QueryAsync(Context(), batch); + var result = await graphQl.QueryAsync(Context, batch); if (result.HasError) { @@ -111,7 +111,6 @@ namespace Squidex.Areas.Api.Controllers.Contents /// /// The name of the app. /// The optional ids of the content to fetch. - /// The requested status, only for frontend client. /// /// 200 => Contents retrieved. /// 404 => App not found. @@ -121,26 +120,24 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpGet] [Route("content/{app}/")] + [ProducesResponseType(typeof(ContentsDto), 200)] [ApiPermission] [ApiCosts(1)] - public async Task GetAllContents(string app, [FromQuery] string ids, [FromQuery] string status = null) + public async Task GetAllContents(string app, [FromQuery] string ids) { - var context = Context().WithFrontendStatus(status); + var contents = await contentQuery.QueryAsync(Context, Q.Empty.WithIds(ids).Ids); - var result = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids); - - var response = new ContentsDto + var response = Deferred.AsyncResponse(() => { - Total = result.Count, - Items = result.Take(200).Select(x => ContentDto.FromContent(x, context)).ToArray() - }; + return ContentsDto.FromContentsAsync(contents, Context, this, null, contentWorkflow); + }); - if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) + if (ShouldProvideSurrogateKeys(contents)) { - Response.Headers["Surrogate-Key"] = response.Items.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = response.Items.ToManyEtag(); + Response.Headers[HeaderNames.ETag] = contents.ToEtag(App); return Ok(response); } @@ -151,7 +148,6 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the app. /// The name of the schema. /// The optional ids of the content to fetch. - /// The requested status, only for frontend client. /// /// 200 => Contents retrieved. /// 404 => Schema or app not found. @@ -161,26 +157,26 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpGet] [Route("content/{app}/{name}/")] + [ProducesResponseType(typeof(ContentsDto), 200)] [ApiPermission] [ApiCosts(1)] - public async Task GetContents(string app, string name, [FromQuery] string ids = null, [FromQuery] string status = null) + public async Task GetContents(string app, string name, [FromQuery] string ids = null) { - var context = Context().WithFrontendStatus(status); - - var result = await contentQuery.QueryAsync(context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); + var contents = await contentQuery.QueryAsync(Context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); - var response = new ContentsDto + var response = Deferred.AsyncResponse(async () => { - Total = result.Total, - Items = result.Take(200).Select(x => ContentDto.FromContent(x, context)).ToArray() - }; + var schema = await contentQuery.GetSchemaOrThrowAsync(Context, name); + + return await ContentsDto.FromContentsAsync(contents, Context, this, schema, contentWorkflow); + }); - if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) + if (ShouldProvideSurrogateKeys(contents)) { - Response.Headers["Surrogate-Key"] = response.Items.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = response.Items.ToManyEtag(response.Total); + Response.Headers[HeaderNames.ETag] = contents.ToEtag(App); return Ok(response); } @@ -200,21 +196,21 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpGet] [Route("content/{app}/{name}/{id}/")] + [ProducesResponseType(typeof(ContentsDto), 200)] [ApiPermission] [ApiCosts(1)] public async Task GetContent(string app, string name, Guid id) { - var context = Context(); - var content = await contentQuery.FindContentAsync(context, name, id); + var content = await contentQuery.FindContentAsync(Context, name, id); - var response = ContentDto.FromContent(content, context); + var response = ContentDto.FromContent(Context, content, this); if (controllerOptions.Value.EnableSurrogateKeys) { - Response.Headers["Surrogate-Key"] = content.Id.ToString(); + Response.Headers["Surrogate-Key"] = content.ToSurrogateKey(); } - Response.Headers[HeaderNames.ETag] = content.Version.ToString(); + Response.Headers[HeaderNames.ETag] = content.ToEtag(App); return Ok(response); } @@ -240,17 +236,16 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(1)] public async Task GetContentVersion(string app, string name, Guid id, int version) { - var context = Context(); - var content = await contentQuery.FindContentAsync(context, name, id, version); + var content = await contentQuery.FindContentAsync(Context, name, id, version); - var response = ContentDto.FromContent(content, context); + var response = ContentDto.FromContent(Context, content, this); if (controllerOptions.Value.EnableSurrogateKeys) { - Response.Headers["Surrogate-Key"] = content.Id.ToString(); + Response.Headers["Surrogate-Key"] = content.ToSurrogateKey(); } - Response.Headers[HeaderNames.ETag] = content.Version.ToString(); + Response.Headers[HeaderNames.ETag] = content.ToEtag(App); return Ok(response.Data); } @@ -272,25 +267,16 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpPost] [Route("content/{app}/{name}/")] + [ProducesResponseType(typeof(ContentsDto), 200)] [ApiPermission(Permissions.AppContentsCreate)] [ApiCosts(1)] public async Task PostContent(string app, string name, [FromBody] NamedContentData request, [FromQuery] bool publish = false) { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); - - var publishPermission = Permissions.ForApp(Permissions.AppContentsPublish, app, name); - - if (publish && !User.Permissions().Includes(publishPermission)) - { - return new StatusCodeResult(123); - } + await contentQuery.GetSchemaOrThrowAsync(Context, name); var command = new CreateContent { ContentId = Guid.NewGuid(), Data = request.ToCleaned(), Publish = publish }; - var context = await CommandBus.PublishAsync(command); - - var result = context.Result>(); - var response = ContentDto.FromCommand(command, result); + var response = await InvokeCommandAsync(app, name, command); return CreatedAtAction(nameof(GetContent), new { id = command.ContentId }, response); } @@ -313,17 +299,16 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpPut] [Route("content/{app}/{name}/{id}/")] + [ProducesResponseType(typeof(ContentsDto), 200)] [ApiPermission(Permissions.AppContentsUpdate)] [ApiCosts(1)] public async Task PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false) { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); + await contentQuery.GetSchemaOrThrowAsync(Context, name); var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft }; - var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = result.Data; + var response = await InvokeCommandAsync(app, name, command); return Ok(response); } @@ -346,17 +331,16 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpPatch] [Route("content/{app}/{name}/{id}/")] + [ProducesResponseType(typeof(ContentsDto), 200)] [ApiPermission(Permissions.AppContentsUpdate)] [ApiCosts(1)] public async Task PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false) { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); + await contentQuery.GetSchemaOrThrowAsync(Context, name); var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft }; - var context = await CommandBus.PublishAsync(command); - var result = context.Result(); - var response = result.Data; + var response = await InvokeCommandAsync(app, name, command); return Ok(response); } @@ -367,118 +351,29 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the app. /// The name of the schema. /// The id of the content item to publish. - /// The date and time when the content should be published. - /// - /// 204 => Content published. - /// 404 => Content, schema or app not found. - /// 400 => Content was already published. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs. - /// - [HttpPut] - [Route("content/{app}/{name}/{id}/publish/")] - [ApiPermission(Permissions.AppContentsPublish)] - [ApiCosts(1)] - public async Task PublishContent(string app, string name, Guid id, string dueTime = null) - { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); - - var command = CreateCommand(id, Status.Published, dueTime); - - await CommandBus.PublishAsync(command); - - return NoContent(); - } - - /// - /// Unpublish a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to unpublish. - /// The date and time when the content should be unpublished. + /// The status request. /// - /// 204 => Content unpublished. + /// 200 => Content published. /// 404 => Content, schema or app not found. - /// 400 => Content was not published. + /// 400 => Request is not valid. /// /// /// You can read the generated documentation for your app at /api/content/{appName}/docs. /// [HttpPut] - [Route("content/{app}/{name}/{id}/unpublish/")] - [ApiPermission(Permissions.AppContentsUnpublish)] - [ApiCosts(1)] - public async Task UnpublishContent(string app, string name, Guid id, string dueTime = null) - { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); - - var command = CreateCommand(id, Status.Draft, dueTime); - - await CommandBus.PublishAsync(command); - - return NoContent(); - } - - /// - /// Archive a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to archive. - /// The date and time when the content should be archived. - /// - /// 204 => Content archived. - /// 404 => Content, schema or app not found. - /// 400 => Content was already archived. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs. - /// - [HttpPut] - [Route("content/{app}/{name}/{id}/archive/")] - [ApiPermission(Permissions.AppContentsArchive)] - [ApiCosts(1)] - public async Task ArchiveContent(string app, string name, Guid id, string dueTime = null) - { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); - - var command = CreateCommand(id, Status.Archived, dueTime); - - await CommandBus.PublishAsync(command); - - return NoContent(); - } - - /// - /// Restore a content item. - /// - /// The name of the app. - /// The name of the schema. - /// The id of the content item to restore. - /// The date and time when the content should be restored. - /// - /// 204 => Content restored. - /// 404 => Content, schema or app not found. - /// 400 => Content was not archived. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs. - /// - [HttpPut] - [Route("content/{app}/{name}/{id}/restore/")] - [ApiPermission(Permissions.AppContentsRestore)] + [Route("content/{app}/{name}/{id}/status/")] + [ProducesResponseType(typeof(ContentsDto), 200)] + [ApiPermission] [ApiCosts(1)] - public async Task RestoreContent(string app, string name, Guid id, string dueTime = null) + public async Task PutContentStatus(string app, string name, Guid id, ChangeStatusDto request) { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); + await contentQuery.GetSchemaOrThrowAsync(Context, name); - var command = CreateCommand(id, Status.Draft, dueTime); + var command = request.ToCommand(id); - await CommandBus.PublishAsync(command); + var response = await InvokeCommandAsync(app, name, command); - return NoContent(); + return Ok(response); } /// @@ -488,7 +383,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the schema. /// The id of the content item to discard changes. /// - /// 204 => Content restored. + /// 200 => Content restored. /// 404 => Content, schema or app not found. /// 400 => Content was not archived. /// @@ -497,17 +392,18 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpPut] [Route("content/{app}/{name}/{id}/discard/")] - [ApiPermission(Permissions.AppContentsDiscard)] + [ProducesResponseType(typeof(ContentsDto), 200)] + [ApiPermission(Permissions.AppContentsDraftDiscard)] [ApiCosts(1)] - public async Task DiscardChanges(string app, string name, Guid id) + public async Task DiscardDraft(string app, string name, Guid id) { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); + await contentQuery.GetSchemaOrThrowAsync(Context, name); var command = new DiscardChanges { ContentId = id }; - await CommandBus.PublishAsync(command); + var response = await InvokeCommandAsync(app, name, command); - return NoContent(); + return Ok(response); } /// @@ -517,7 +413,7 @@ namespace Squidex.Areas.Api.Controllers.Contents /// The name of the schema. /// The id of the content item to delete. /// - /// 204 => Content has been deleted. + /// 204 => Content deleted. /// 404 => Content, schema or app not found. /// /// @@ -529,7 +425,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(1)] public async Task DeleteContent(string app, string name, Guid id) { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); + await contentQuery.GetSchemaOrThrowAsync(Context, name); var command = new DeleteContent { ContentId = id }; @@ -538,30 +434,19 @@ namespace Squidex.Areas.Api.Controllers.Contents return NoContent(); } - private static ChangeContentStatus CreateCommand(Guid id, Status status, string dueTime) + private async Task InvokeCommandAsync(string app, string schema, ICommand command) { - Instant? dt = null; - - if (!string.IsNullOrWhiteSpace(dueTime)) - { - var parseResult = InstantPattern.General.Parse(dueTime); + var context = await CommandBus.PublishAsync(command); - if (parseResult.Success) - { - dt = parseResult.Value; - } - } + var result = context.Result(); + var response = ContentDto.FromContent(Context, result, this); - return new ChangeContentStatus { Status = status, ContentId = id, DueTime = dt }; + return response; } - private QueryContext Context() + private bool ShouldProvideSurrogateKeys(IReadOnlyList response) { - return QueryContext.Create(App, User) - .WithAssetUrlsToResolve(Request.Headers["X-Resolve-Urls"]) - .WithFlatten(Request.Headers.ContainsKey("X-Flatten")) - .WithLanguages(Request.Headers["X-Languages"]) - .WithUnpublished(Request.Headers.ContainsKey("X-Unpublished")); + return controllerOptions.Value.EnableSurrogateKeys && response.Count <= controllerOptions.Value.MaxItemsForSurrogateKeys; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs index b9703b584..56209c00c 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemaSwaggerGenerator.cs @@ -32,6 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator private readonly string schemaName; private readonly string schemaType; private readonly string appPath; + private readonly JsonSchema4 statusSchema; private readonly string appName; static SchemaSwaggerGenerator() @@ -46,6 +47,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator string appPath, Schema schema, SchemaResolver schemaResolver, + JsonSchema4 statusSchema, PartitionResolver partitionResolver) { this.document = document; @@ -53,6 +55,8 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator this.appName = appName; this.appPath = appPath; + this.statusSchema = statusSchema; + schemaPath = schema.Name; schemaName = schema.DisplayName(); schemaType = schema.TypeName(); @@ -72,15 +76,13 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator var schemaOperations = new List { - GenerateSchemaQueryOperation(), - GenerateSchemaCreateOperation(), + GenerateSchemaGetsOperation(), GenerateSchemaGetOperation(), + GenerateSchemaCreateOperation(), GenerateSchemaUpdateOperation(), - GenerateSchemaPatchOperation(), - GenerateSchemaPublishOperation(), - GenerateSchemaUnpublishOperation(), - GenerateSchemaArchiveOperation(), - GenerateSchemaRestoreOperation(), + GenerateSchemaUpdatePatchOperation(), + GenerateSchemaStatusOperation(), + GenerateSchemaDiscardOperation(), GenerateSchemaDeleteOperation() }; @@ -90,11 +92,12 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator } } - private SwaggerPathItem GenerateSchemaQueryOperation() + private SwaggerPathItem GenerateSchemaGetsOperation() { return AddOperation(SwaggerOperationMethod.Get, null, $"{appPath}/{schemaPath}", operation => { operation.OperationId = $"Query{schemaType}Contents"; + operation.Summary = $"Queries {schemaName} contents."; operation.Description = SchemaQueryDescription; @@ -103,7 +106,8 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator 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."); - operation.AddQueryParameter("orderby", JsonObjectType.String, "Optional OData order definition."); + operation.AddQueryParameter("$orderby", JsonObjectType.String, "Optional OData order definition."); + operation.AddQueryParameter("$orderby", JsonObjectType.String, "Optional OData order definition."); operation.AddResponse("200", $"{schemaName} content retrieved.", CreateContentsSchema(schemaName, contentSchema)); @@ -116,6 +120,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator return AddOperation(SwaggerOperationMethod.Get, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { operation.OperationId = $"Get{schemaType}Content"; + operation.Summary = $"Get a {schemaName} content."; operation.AddResponse("200", $"{schemaName} content found.", contentSchema); @@ -129,12 +134,14 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator return AddOperation(SwaggerOperationMethod.Post, null, $"{appPath}/{schemaPath}", operation => { operation.OperationId = $"Create{schemaType}Content"; + operation.Summary = $"Create a {schemaName} content."; operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); operation.AddQueryParameter("publish", JsonObjectType.Boolean, "Set to true to autopublish content."); - operation.AddResponse("201", $"{schemaName} content created.", contentSchema); + operation.AddResponse("200", $"{schemaName} content created.", contentSchema); + operation.AddResponse("400", "Content data valid."); AddSecurity(operation, Permissions.AppContentsCreate); }); @@ -145,80 +152,64 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { operation.OperationId = $"Update{schemaType}Content"; + operation.Summary = $"Update a {schemaName} content."; operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); - operation.AddResponse("200", $"{schemaName} content updated.", dataSchema); + operation.AddResponse("200", $"{schemaName} content updated.", contentSchema); + operation.AddResponse("400", "Content data valid."); AddSecurity(operation, Permissions.AppContentsUpdate); }); } - private SwaggerPathItem GenerateSchemaPatchOperation() + private SwaggerPathItem GenerateSchemaUpdatePatchOperation() { return AddOperation(SwaggerOperationMethod.Patch, schemaName, $"{appPath}/{schemaPath}/{{id}}", operation => { operation.OperationId = $"Path{schemaType}Content"; + operation.Summary = $"Patch a {schemaName} content."; operation.AddBodyParameter("data", dataSchema, SchemaBodyDescription); - operation.AddResponse("200", $"{schemaName} content patched.", dataSchema); + operation.AddResponse("200", $"{schemaName} content patched.", contentSchema); + operation.AddResponse("400", "Status change not valid."); AddSecurity(operation, Permissions.AppContentsUpdate); }); } - private SwaggerPathItem GenerateSchemaPublishOperation() + private SwaggerPathItem GenerateSchemaStatusOperation() { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/publish", operation => + return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/status", operation => { - operation.OperationId = $"Publish{schemaType}Content"; - operation.Summary = $"Publish a {schemaName} content."; - - operation.AddResponse("204", $"{schemaName} content published."); + operation.OperationId = $"Change{schemaType}ContentStatus"; - AddSecurity(operation, Permissions.AppContentsPublish); - }); - } + operation.Summary = $"Change status of {schemaName} content."; - private SwaggerPathItem GenerateSchemaUnpublishOperation() - { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/unpublish", operation => - { - operation.OperationId = $"Unpublish{schemaType}Content"; - operation.Summary = $"Unpublish a {schemaName} content."; + operation.AddBodyParameter("request", statusSchema, "The request to change content status."); - operation.AddResponse("204", $"{schemaName} content unpublished."); + operation.AddResponse("204", $"{schemaName} content status changed.", contentSchema); + operation.AddResponse("400", "Content data valid."); - AddSecurity(operation, Permissions.AppContentsUnpublish); + AddSecurity(operation, Permissions.AppContentsUpdate); }); } - private SwaggerPathItem GenerateSchemaArchiveOperation() + private SwaggerPathItem GenerateSchemaDiscardOperation() { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/archive", operation => + return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/discard", operation => { - operation.OperationId = $"Archive{schemaType}Content"; - operation.Summary = $"Archive a {schemaName} content."; + operation.OperationId = $"Discard{schemaType}Content"; - operation.AddResponse("204", $"{schemaName} content restored."); + operation.Summary = $"Discard changes of {schemaName} content."; - AddSecurity(operation, Permissions.AppContentsRead); - }); - } + operation.AddResponse("400", "No pending draft."); + operation.AddResponse("200", $"{schemaName} content status changed.", contentSchema); - private SwaggerPathItem GenerateSchemaRestoreOperation() - { - return AddOperation(SwaggerOperationMethod.Put, schemaName, $"{appPath}/{schemaPath}/{{id}}/restore", operation => - { - operation.OperationId = $"Restore{schemaType}Content"; - operation.Summary = $"Restore a {schemaName} content."; - - operation.AddResponse("204", $"{schemaName} content restored."); - - AddSecurity(operation, Permissions.AppContentsRestore); + AddSecurity(operation, Permissions.AppContentsDraftDiscard); }); } @@ -227,6 +218,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator return AddOperation(SwaggerOperationMethod.Delete, schemaName, $"{appPath}/{schemaPath}/{{id}}/", operation => { operation.OperationId = $"Delete{schemaType}Content"; + operation.Summary = $"Delete a {schemaName} content."; operation.AddResponse("204", $"{schemaName} content deleted."); diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs index e2e7a8d2c..7ea592429 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasSwaggerGenerator.cs @@ -18,6 +18,7 @@ using NSwag.SwaggerGeneration; using NSwag.SwaggerGeneration.Processors; using NSwag.SwaggerGeneration.Processors.Contexts; using Squidex.Areas.Api.Config.Swagger; +using Squidex.Areas.Api.Controllers.Contents.Models; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; @@ -32,6 +33,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator private readonly SwaggerDocumentSettings settings = new SwaggerDocumentSettings(); private SwaggerJsonSchemaGenerator schemaGenerator; private SwaggerDocument document; + private JsonSchema4 statusSchema; private JsonSchemaResolver schemaResolver; public SchemasSwaggerGenerator(IOptions urlOptions, IEnumerable documentProcessors) @@ -53,9 +55,9 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator schemaGenerator = new SwaggerJsonSchemaGenerator(settings); schemaResolver = new SwaggerSchemaResolver(document, settings); - GenerateSchemasOperations(schemas, app); + statusSchema = await GenerateStatusSchemaAsync(); - await GenerateDefaultErrorsAsync(); + GenerateSchemasOperations(schemas, app); var context = new DocumentProcessorContext(document, @@ -73,25 +75,23 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator return document; } - private void GenerateSchemasOperations(IEnumerable schemas, IAppEntity app) + private Task GenerateStatusSchemaAsync() { - var appBasePath = $"/content/{app.Name}"; + var errorType = typeof(ChangeStatusDto); - foreach (var schema in schemas.Select(x => x.SchemaDef).Where(x => x.IsPublished)) - { - new SchemaSwaggerGenerator(document, app.Name, appBasePath, schema, AppendSchema, app.PartitionResolver()).GenerateSchemaOperations(); - } + return schemaGenerator.GenerateWithReferenceAsync(errorType, Enumerable.Empty(), schemaResolver); } - private async Task GenerateDefaultErrorsAsync() + private void GenerateSchemasOperations(IEnumerable schemas, IAppEntity app) { - const string errorDescription = "Operation failed with internal server error."; - - var errorDtoSchema = await schemaGenerator.GetErrorDtoSchemaAsync(schemaResolver); + var appBasePath = $"/content/{app.Name}"; - foreach (var operation in document.Paths.Values.SelectMany(x => x.Values)) + foreach (var schema in schemas.Select(x => x.SchemaDef).Where(x => x.IsPublished)) { - operation.Responses.Add("500", new SwaggerResponse { Description = errorDescription, Schema = errorDtoSchema }); + var partition = app.PartitionResolver(); + + new SchemaSwaggerGenerator(document, app.Name, appBasePath, schema, AppendSchema, statusSchema, partition) + .GenerateSchemaOperations(); } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ChangeStatusDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ChangeStatusDto.cs new file mode 100644 index 000000000..7a89789c9 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ChangeStatusDto.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.ComponentModel.DataAnnotations; +using NodaTime; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities.Contents.Commands; + +namespace Squidex.Areas.Api.Controllers.Contents.Models +{ + public sealed class ChangeStatusDto + { + /// + /// The new status. + /// + [Required] + public Status Status { get; set; } + + /// + /// The due time. + /// + public Instant? DueTime { get; set; } + + public ChangeContentStatus ToCommand(Guid id) + { + return new ChangeContentStatus { ContentId = id, Status = Status, DueTime = DueTime }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs index 5e896298b..ce728612f 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -12,15 +12,14 @@ using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Contents.Models { - public sealed class ContentDto : IGenerateETag + public sealed class ContentDto : Resource { /// /// The if of the content item. @@ -71,39 +70,25 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models public Instant LastModified { get; set; } /// - /// The the status of the content. + /// The status of the content. /// public Status Status { get; set; } + /// + /// The color of the status. + /// + public string StatusColor { get; set; } + /// /// The version of the content. /// public long Version { get; set; } - public static ContentDto FromCommand(CreateContent command, EntityCreatedResult result) - { - var now = SystemClock.Instance.GetCurrentInstant(); - - var response = new ContentDto - { - 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; - } - - public static ContentDto FromContent(IContentEntity content, QueryContext context) + public static ContentDto FromContent(Context context, IEnrichedContentEntity content, ApiController controller) { var response = SimpleMapper.Map(content, new ContentDto()); - if (context.Flatten) + if (context.IsFlatten()) { response.Data = content.Data?.ToFlatten(); response.DataDraft = content.DataDraft?.ToFlatten(); @@ -119,7 +104,64 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models response.ScheduleJob = SimpleMapper.Map(content.ScheduleJob, new ScheduleJobDto()); } - return response; + return response.CreateLinksAsync(content, controller, content.AppId.Name, content.SchemaId.Name); + } + + private ContentDto CreateLinksAsync(IEnrichedContentEntity content, ApiController controller, string app, string schema) + { + var values = new { app, name = schema, id = Id }; + + AddSelfLink(controller.Url(x => nameof(x.GetContent), values)); + + if (Version > 0) + { + var versioned = new { app, name = schema, id = Id, version = Version - 1 }; + + AddGetLink("prev", controller.Url(x => nameof(x.GetContentVersion), versioned)); + } + + if (IsPending) + { + if (controller.HasPermission(Permissions.AppContentsDraftDiscard, app, schema)) + { + AddPutLink("draft/discard", controller.Url(x => nameof(x.DiscardDraft), values)); + } + + if (controller.HasPermission(Permissions.AppContentsDraftPublish, app, schema)) + { + AddPutLink("draft/publish", controller.Url(x => nameof(x.PutContentStatus), values)); + } + } + + if (controller.HasPermission(Permissions.AppContentsUpdate, app, schema)) + { + if (content.CanUpdate) + { + AddPutLink("update", controller.Url(x => nameof(x.PutContent), values)); + } + + if (Status == Status.Published) + { + AddPutLink("draft/propose", controller.Url((ContentsController x) => nameof(x.PutContent), values) + "?asDraft=true"); + } + + AddPatchLink("patch", controller.Url(x => nameof(x.PatchContent), values)); + + if (content.Nexts != null) + { + foreach (var next in content.Nexts) + { + AddPutLink($"status/{next.Status}", controller.Url(x => nameof(x.PutContentStatus), values), next.Color); + } + } + } + + if (controller.HasPermission(Permissions.AppContentsDelete, app, schema)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteContent), values)); + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs index 12c19cd9c..f665c4d40 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs @@ -5,9 +5,20 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Shared; +using Squidex.Web; + namespace Squidex.Areas.Api.Controllers.Contents.Models { - public sealed class ContentsDto + public sealed class ContentsDto : Resource { /// /// The total number of content items. @@ -17,6 +28,53 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// /// The content items. /// + [Required] public ContentDto[] Items { get; set; } + + /// + /// The possible statuses. + /// + [Required] + public StatusInfoDto[] Statuses { get; set; } + + public static async Task FromContentsAsync(IResultList contents, + Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow) + { + var result = new ContentsDto + { + Total = contents.Total, + Items = contents.Select(x => ContentDto.FromContent(context, x, controller)).ToArray() + }; + + await result.AssignStatusesAsync(contentWorkflow, schema); + + return result.CreateLinks(controller, schema.AppId.Name, schema.SchemaDef.Name); + } + + private async Task AssignStatusesAsync(IContentWorkflow contentWorkflow, ISchemaEntity schema) + { + var allStatuses = await contentWorkflow.GetAllAsync(schema); + + Statuses = allStatuses.Select(StatusInfoDto.FromStatusInfo).ToArray(); + } + + private ContentsDto CreateLinks(ApiController controller, string app, string schema) + { + if (schema != null) + { + var values = new { app, name = schema }; + + AddSelfLink(controller.Url(x => nameof(x.GetContents), values)); + + if (controller.HasPermission(Permissions.AppContentsCreate, app, schema)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostContent), values)); + + AddPostLink("create/publish", controller.Url(x => nameof(x.PostContent), values) + "?publish=true"); + } + } + + return this; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs new file mode 100644 index 000000000..510ba8a7a --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/StatusInfoDto.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Areas.Api.Controllers.Contents.Models +{ + public sealed class StatusInfoDto + { + /// + /// The name of the status. + /// + [Required] + public Status Status { get; set; } + + /// + /// The color of the status. + /// + [Required] + public string Color { get; set; } + + public static StatusInfoDto FromStatusInfo(StatusInfo statusInfo) + { + return new StatusInfoDto { Status = statusInfo.Status, Color = statusInfo.Color }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs index 1369eb9c7..47977f2c5 100644 --- a/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Orleans; @@ -30,44 +29,54 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers [HttpGet] [Route("event-consumers/")] + [ProducesResponseType(typeof(EventConsumersDto), 200)] [ApiPermission(Permissions.AdminEventsRead)] public async Task GetEventConsumers() { - var entities = await GetGrain().GetConsumersAsync(); + var eventConsumers = await GetGrain().GetConsumersAsync(); - var response = entities.Value.OrderBy(x => x.Name).Select(EventConsumerDto.FromEventConsumerInfo).ToArray(); + var response = EventConsumersDto.FromResults(eventConsumers.Value, this); return Ok(response); } [HttpPut] [Route("event-consumers/{name}/start/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Start(string name) + public async Task StartEventConsumer(string name) { - await GetGrain().StartAsync(name); + var eventConsumer = await GetGrain().StartAsync(name); - return NoContent(); + var response = EventConsumerDto.FromEventConsumerInfo(eventConsumer.Value, this); + + return Ok(response); } [HttpPut] [Route("event-consumers/{name}/stop/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Stop(string name) + public async Task StopEventConsumer(string name) { - await GetGrain().StopAsync(name); + var eventConsumer = await GetGrain().StopAsync(name); + + var response = EventConsumerDto.FromEventConsumerInfo(eventConsumer.Value, this); - return NoContent(); + return Ok(response); } [HttpPut] [Route("event-consumers/{name}/reset/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Reset(string name) + public async Task ResetEventConsumer(string name) { - await GetGrain().ResetAsync(name); + var eventConsumer = await GetGrain().ResetAsync(name); + + var response = EventConsumerDto.FromEventConsumerInfo(eventConsumer.Value, this); - return NoContent(); + return Ok(response); } private IEventConsumerManagerGrain GetGrain() diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs index 3ef6535c3..e40d6d196 100644 --- a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs @@ -7,10 +7,12 @@ using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.EventConsumers.Models { - public sealed class EventConsumerDto + public sealed class EventConsumerDto : Resource { public bool IsStopped { get; set; } @@ -22,9 +24,35 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers.Models public string Position { get; set; } - public static EventConsumerDto FromEventConsumerInfo(EventConsumerInfo eventConsumerInfo) + public static EventConsumerDto FromEventConsumerInfo(EventConsumerInfo eventConsumerInfo, ApiController controller) { - return SimpleMapper.Map(eventConsumerInfo, new EventConsumerDto()); + var result = SimpleMapper.Map(eventConsumerInfo, new EventConsumerDto()); + + return result.CreateLinks(controller); + } + + private EventConsumerDto CreateLinks(ApiController controller) + { + if (controller.HasPermission(Permissions.AdminEventsManage)) + { + var values = new { name = Name }; + + if (!IsResetting) + { + AddPutLink("reset", controller.Url(x => nameof(x.ResetEventConsumer), values)); + } + + if (IsStopped) + { + AddPutLink("start", controller.Url(x => nameof(x.StartEventConsumer), values)); + } + else + { + AddPutLink("stop", controller.Url(x => nameof(x.StopEventConsumer), values)); + } + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs new file mode 100644 index 000000000..65f11e289 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.EventConsumers.Models +{ + public sealed class EventConsumersDto : Resource + { + /// + /// The event consumers. + /// + public EventConsumerDto[] Items { get; set; } + + public static EventConsumersDto FromResults(IEnumerable items, ApiController controller) + { + var result = new EventConsumersDto + { + Items = items.Select(x => EventConsumerDto.FromEventConsumerInfo(x, controller)).ToArray() + }; + + return result.CreateLinks(controller); + } + + private EventConsumersDto CreateLinks(ApiController controller) + { + AddSelfLink(controller.Url(c => nameof(c.GetEventConsumers))); + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs b/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs index f4cff725a..4a208143c 100644 --- a/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs +++ b/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Squidex.Areas.Api.Controllers.History.Models; @@ -46,9 +47,9 @@ namespace Squidex.Areas.Api.Controllers.History [ApiCosts(0.1)] public async Task GetHistory(string app, string channel) { - var entities = await historyService.QueryByChannelAsync(AppId, channel, 100); + var events = await historyService.QueryByChannelAsync(AppId, channel, 100); - var response = entities.ToArray(HistoryEventDto.FromHistoryEvent); + var response = events.Select(HistoryEventDto.FromHistoryEvent).Where(x => x.Message != null).ToArray(); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs index e87b8c68c..62726a9bb 100644 --- a/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs @@ -40,7 +40,10 @@ namespace Squidex.Areas.Api.Controllers.Languages [ApiPermission] public IActionResult GetLanguages() { - var response = Language.AllLanguages.Select(LanguageDto.FromLanguage).ToArray(); + var response = Deferred.Response(() => + { + return Language.AllLanguages.Select(LanguageDto.FromLanguage).ToArray(); + }); Response.Headers[HeaderNames.ETag] = "1"; diff --git a/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs b/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs index 310edd238..9b1641946 100644 --- a/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs +++ b/src/Squidex/Areas/Api/Controllers/News/Service/FeaturesService.cs @@ -43,9 +43,9 @@ namespace Squidex.Areas.Api.Controllers.News.Service if (client != null && version < FeatureVersion) { - var entities = await client.GetAsync(filter: $"data/version/iv gt {version}", context: Flatten); + var features = await client.GetAsync(filter: $"data/version/iv gt {version}", context: Flatten); - result.Features.AddRange(entities.Items.Select(x => x.Data)); + result.Features.AddRange(features.Items.Select(x => x.Data)); } return result; diff --git a/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs b/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs index 22a0254ad..388026dc4 100644 --- a/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs +++ b/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs @@ -18,9 +18,26 @@ namespace Squidex.Areas.Api.Controllers.Ping [ApiExplorerSettings(GroupName = nameof(Ping))] public sealed class PingController : ApiController { - public PingController(ICommandBus commandBus) + private readonly ExposedValues exposedValues; + + public PingController(ICommandBus commandBus, ExposedValues exposedValues) : base(commandBus) { + this.exposedValues = exposedValues; + } + + /// + /// Get general info status of the API. + /// + /// + /// 200 => Infos returned. + /// + [HttpGet] + [ProducesResponseType(typeof(ExposedValues), 200)] + [Route("info/")] + public IActionResult Info() + { + return Ok(exposedValues); } /// diff --git a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index 9490a4f48..99de9745b 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -51,9 +51,12 @@ namespace Squidex.Areas.Api.Controllers.Plans { var hasPortal = appPlansBillingManager.HasPortal; - var response = AppPlansDto.FromApp(App, appPlansProvider, hasPortal); + var response = Deferred.Response(() => + { + return AppPlansDto.FromApp(App, appPlansProvider, hasPortal); + }); - Response.Headers[HeaderNames.ETag] = App.Version.ToString(); + Response.Headers[HeaderNames.ETag] = App.ToEtag(); return Ok(response); } @@ -71,10 +74,9 @@ namespace Squidex.Areas.Api.Controllers.Plans [HttpPut] [Route("apps/{app}/plan/")] [ProducesResponseType(typeof(PlanChangedDto), 200)] - [ProducesResponseType(typeof(ErrorDto), 400)] [ApiPermission(Permissions.AppPlansChange)] [ApiCosts(0)] - public async Task ChangePlanAsync(string app, [FromBody] ChangePlanDto request) + public async Task PutPlan(string app, [FromBody] ChangePlanDto request) { var context = await CommandBus.PublishAsync(request.ToCommand()); diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs index 8f1da7b9e..b108b7be4 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionConverter.cs @@ -8,11 +8,11 @@ using System; using System.Collections.Generic; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Web; +using Squidex.Web.Json; namespace Squidex.Areas.Api.Controllers.Rules.Models { - public sealed class RuleActionConverter : MyJsonInheritanceConverter + public sealed class RuleActionConverter : TypedJsonInheritanceConverter { public static IReadOnlyDictionary Mapping { get; set; } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs index b0337ed42..7bd538707 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleActionProcessor.cs @@ -55,7 +55,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models if (oldName != null) { context.Document.Definitions.Remove(oldName); - context.Document.Definitions.Add(action.Key, derivedSchema); + context.Document.Definitions.Add($"{action.Key}RuleActionDto", derivedSchema); } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs index 4ee256530..e625f2f35 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs @@ -14,11 +14,12 @@ using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Rules.Models { - public sealed class RuleDto : IGenerateETag + public sealed class RuleDto : Resource { /// /// The id of the rule. @@ -70,19 +71,48 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models [JsonConverter(typeof(RuleActionConverter))] public RuleAction Action { get; set; } - public static RuleDto FromRule(IRuleEntity rule) + public static RuleDto FromRule(IRuleEntity rule, ApiController controller, string app) { - var response = new RuleDto(); + var result = new RuleDto(); - SimpleMapper.Map(rule, response); - SimpleMapper.Map(rule.RuleDef, response); + SimpleMapper.Map(rule, result); + SimpleMapper.Map(rule.RuleDef, result); if (rule.RuleDef.Trigger != null) { - response.Trigger = RuleTriggerDtoFactory.Create(rule.RuleDef.Trigger); + result.Trigger = RuleTriggerDtoFactory.Create(rule.RuleDef.Trigger); } - return response; + return result.CreateLinks(controller, app); + } + + private RuleDto CreateLinks(ApiController controller, string app) + { + var values = new { app, id = Id }; + + if (controller.HasPermission(Permissions.AppRulesDisable, app)) + { + if (IsEnabled) + { + AddPutLink("disable", controller.Url(x => nameof(x.DisableRule), values)); + } + else + { + AddPutLink("enable", controller.Url(x => nameof(x.EnableRule), values)); + } + } + + if (controller.HasPermission(Permissions.AppRulesUpdate)) + { + AddPutLink("update", controller.Url(x => nameof(x.PutRule), values)); + } + + if (controller.HasPermission(Permissions.AppRulesDelete)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteRule), values)); + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs index f577f405a..a513495ab 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventDto.cs @@ -11,10 +11,11 @@ using NodaTime; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Infrastructure.Reflection; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Rules.Models { - public sealed class RuleEventDto + public sealed class RuleEventDto : Resource { /// /// The id of the event. @@ -63,14 +64,28 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// public RuleJobResult JobResult { get; set; } - public static RuleEventDto FromRuleEvent(IRuleEventEntity ruleEvent) + public static RuleEventDto FromRuleEvent(IRuleEventEntity ruleEvent, ApiController controller, string app) { - var response = new RuleEventDto(); + var result = new RuleEventDto(); - SimpleMapper.Map(ruleEvent, response); - SimpleMapper.Map(ruleEvent.Job, response); + SimpleMapper.Map(ruleEvent, result); + SimpleMapper.Map(ruleEvent.Job, result); - return response; + return result.CreateLinks(controller, app); + } + + private RuleEventDto CreateLinks(ApiController controller, string app) + { + var values = new { app, id = Id }; + + AddPutLink("update", controller.Url(x => nameof(x.PutEvent), values)); + + if (NextAttempt.HasValue) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteEvent), values)); + } + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs index ef320b78d..d0f382f98 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs @@ -9,10 +9,11 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Rules.Models { - public sealed class RuleEventsDto + public sealed class RuleEventsDto : Resource { /// /// The rule events. @@ -25,9 +26,22 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models /// public long Total { get; set; } - public static RuleEventsDto FromRuleEvents(IReadOnlyList items, long total) + public static RuleEventsDto FromRuleEvents(IReadOnlyList items, long total, ApiController controller, string app) { - return new RuleEventsDto { Total = total, Items = items.Select(RuleEventDto.FromRuleEvent).ToArray() }; + var result = new RuleEventsDto + { + Total = total, + Items = items.Select(x => RuleEventDto.FromRuleEvent(x, controller, app)).ToArray() + }; + + return result.CreateLinks(controller, app); + } + + private RuleEventsDto CreateLinks(ApiController controller, string app) + { + AddSelfLink(controller.Url(x => nameof(x.GetEvents), new { app })); + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs index 9ac6cd699..4392bdfba 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleTriggerDto.cs @@ -10,11 +10,11 @@ using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Web; +using Squidex.Web.Json; namespace Squidex.Areas.Api.Controllers.Rules.Models { - [JsonConverter(typeof(MyJsonInheritanceConverter), "triggerType")] + [JsonConverter(typeof(TypedJsonInheritanceConverter), "triggerType")] [KnownType(nameof(Subtypes))] public abstract class RuleTriggerDto { diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs new file mode 100644 index 000000000..7379e019a --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs @@ -0,0 +1,54 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Rules; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Rules.Models +{ + public sealed class RulesDto : Resource + { + /// + /// The rules. + /// + [Required] + public RuleDto[] Items { get; set; } + + public static RulesDto FromRules(IEnumerable items, ApiController controller, string app) + { + var result = new RulesDto + { + Items = items.Select(x => RuleDto.FromRule(x, controller, app)).ToArray() + }; + + return result.CreateLinks(controller, app); + } + + private RulesDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetRules), values)); + + if (controller.HasPermission(Permissions.AppRulesCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostRule), values)); + } + + if (controller.HasPermission(Permissions.AppRulesEvents, app)) + { + AddGetLink("events", controller.Url(x => nameof(x.GetEvents), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index b657ef1cf..bd5ed4fbd 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -15,6 +15,7 @@ using NodaTime; using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Infrastructure; @@ -57,9 +58,12 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiCosts(0)] public IActionResult GetActions() { - var etag = string.Join(";", ruleRegistry.Actions.Select(x => x.Key)).Sha256Base64(); + var etag = string.Concat(ruleRegistry.Actions.Select(x => x.Key)).Sha256Base64(); - var response = ruleRegistry.Actions.ToDictionary(x => x.Key, x => RuleElementDto.FromDefinition(x.Value)); + var response = Deferred.Response(() => + { + return ruleRegistry.Actions.ToDictionary(x => x.Key, x => RuleElementDto.FromDefinition(x.Value)); + }); Response.Headers[HeaderNames.ETag] = etag; @@ -76,16 +80,19 @@ namespace Squidex.Areas.Api.Controllers.Rules /// [HttpGet] [Route("apps/{app}/rules/")] - [ProducesResponseType(typeof(RuleDto[]), 200)] + [ProducesResponseType(typeof(RulesDto), 200)] [ApiPermission(Permissions.AppRulesRead)] [ApiCosts(1)] public async Task GetRules(string app) { - var entities = await appProvider.GetRulesAsync(AppId); + var rules = await appProvider.GetRulesAsync(AppId); - var response = entities.Select(RuleDto.FromRule).ToArray(); + var response = Deferred.Response(() => + { + return RulesDto.FromRules(rules, this, app); + }); - Response.Headers[HeaderNames.ETag] = response.ToManyEtag(0); + Response.Headers[HeaderNames.ETag] = rules.ToEtag(); return Ok(response); } @@ -102,16 +109,14 @@ namespace Squidex.Areas.Api.Controllers.Rules /// [HttpPost] [Route("apps/{app}/rules/")] - [ProducesResponseType(typeof(EntityCreatedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(RuleDto), 201)] [ApiPermission(Permissions.AppRulesCreate)] [ApiCosts(1)] public async Task PostRule(string app, [FromBody] CreateRuleDto request) { - var context = await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); - var result = context.Result>(); - var response = EntityCreatedDto.FromResult(result); + var response = await InvokeCommandAsync(app, command); return CreatedAtAction(nameof(GetRules), new { app }, response); } @@ -123,7 +128,7 @@ namespace Squidex.Areas.Api.Controllers.Rules /// The id of the rule to update. /// The rule object that needs to be added to the app. /// - /// 204 => Rule updated. + /// 200 => Rule updated. /// 400 => Rule is not valid. /// 404 => Rule or app not found. /// @@ -132,14 +137,16 @@ namespace Squidex.Areas.Api.Controllers.Rules /// [HttpPut] [Route("apps/{app}/rules/{id}/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(RuleDto), 400)] [ApiPermission(Permissions.AppRulesUpdate)] [ApiCosts(1)] public async Task PutRule(string app, Guid id, [FromBody] UpdateRuleDto request) { - await CommandBus.PublishAsync(request.ToCommand(id)); + var command = request.ToCommand(id); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -148,19 +155,22 @@ namespace Squidex.Areas.Api.Controllers.Rules /// The name of the app. /// The id of the rule to enable. /// - /// 204 => Rule enabled. + /// 200 => Rule enabled. /// 400 => Rule already enabled. /// 404 => Rule or app not found. /// [HttpPut] [Route("apps/{app}/rules/{id}/enable/")] + [ProducesResponseType(typeof(RuleDto), 200)] [ApiPermission(Permissions.AppRulesDisable)] [ApiCosts(1)] public async Task EnableRule(string app, Guid id) { - await CommandBus.PublishAsync(new EnableRule { RuleId = id }); + var command = new EnableRule { RuleId = id }; - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -169,19 +179,22 @@ namespace Squidex.Areas.Api.Controllers.Rules /// The name of the app. /// The id of the rule to disable. /// - /// 204 => Rule disabled. + /// 200 => Rule disabled. /// 400 => Rule already disabled. /// 404 => Rule or app not found. /// [HttpPut] [Route("apps/{app}/rules/{id}/disable/")] + [ProducesResponseType(typeof(RuleDto), 200)] [ApiPermission(Permissions.AppRulesDisable)] [ApiCosts(1)] public async Task DisableRule(string app, Guid id) { - await CommandBus.PublishAsync(new DisableRule { RuleId = id }); + var command = new DisableRule { RuleId = id }; - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -190,7 +203,7 @@ namespace Squidex.Areas.Api.Controllers.Rules /// The name of the app. /// The id of the rule to delete. /// - /// 204 => Rule has been deleted. + /// 204 => Rule deleted. /// 404 => Rule or app not found. /// [HttpDelete] @@ -226,7 +239,7 @@ namespace Squidex.Areas.Api.Controllers.Rules await Task.WhenAll(taskForItems, taskForCount); - var response = RuleEventsDto.FromRuleEvents(taskForItems.Result, taskForCount.Result); + var response = RuleEventsDto.FromRuleEvents(taskForItems.Result, taskForCount.Result, this, app); return Ok(response); } @@ -237,7 +250,7 @@ namespace Squidex.Areas.Api.Controllers.Rules /// The name of the app. /// The event to enqueue. /// - /// 200 => Rule enqueued. + /// 204 => Rule enqueued. /// 404 => App or rule event not found. /// [HttpPut] @@ -246,9 +259,9 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiCosts(0)] public async Task PutEvent(string app, Guid id) { - var entity = await ruleEventsRepository.FindAsync(id); + var ruleEvent = await ruleEventsRepository.FindAsync(id); - if (entity == null) + if (ruleEvent == null) { return NotFound(); } @@ -264,7 +277,7 @@ namespace Squidex.Areas.Api.Controllers.Rules /// The name of the app. /// The event to enqueue. /// - /// 200 => Rule deqeued. + /// 204 => Rule deqeued. /// 404 => App or rule event not found. /// [HttpDelete] @@ -273,9 +286,9 @@ namespace Squidex.Areas.Api.Controllers.Rules [ApiCosts(0)] public async Task DeleteEvent(string app, Guid id) { - var entity = await ruleEventsRepository.FindAsync(id); + var ruleEvent = await ruleEventsRepository.FindAsync(id); - if (entity == null) + if (ruleEvent == null) { return NotFound(); } @@ -284,5 +297,15 @@ namespace Squidex.Areas.Api.Controllers.Rules return NoContent(); } + + private async Task InvokeCommandAsync(string app, ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = RuleDto.FromRule(result, this, app); + + return response; + } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs index d9eab980f..bc583bb03 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldDto.cs @@ -7,10 +7,12 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using Squidex.Areas.Api.Controllers.Schemas.Models.Fields; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class FieldDto + public sealed class FieldDto : Resource { /// /// The id of the field. @@ -55,5 +57,56 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// The nested fields. /// public List Nested { get; set; } + + public void CreateLinks(ApiController controller, string app, string schema, bool allowUpdate) + { + allowUpdate = allowUpdate && !IsLocked; + + if (allowUpdate) + { + var values = new { app, name = schema, id = FieldId }; + + AddPutLink("update", controller.Url(x => nameof(x.PutField), values)); + + if (IsHidden) + { + AddPutLink("show", controller.Url(x => nameof(x.ShowField), values)); + } + else + { + AddPutLink("hide", controller.Url(x => nameof(x.HideField), values)); + } + + if (IsDisabled) + { + AddPutLink("enable", controller.Url(x => nameof(x.EnableField), values)); + } + else + { + AddPutLink("disable", controller.Url(x => nameof(x.DisableField), values)); + } + + if (Properties is ArrayFieldPropertiesDto) + { + var parentValues = new { app, name = schema, parentId = FieldId }; + + AddPostLink("fields/add", controller.Url(x => nameof(x.PostNestedField), parentValues)); + + AddPutLink("fields/order", controller.Url(x => nameof(x.PutNestedFieldOrdering), parentValues)); + } + + AddPutLink("lock", controller.Url(x => nameof(x.LockField), values)); + + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteField), values)); + } + + if (Nested != null) + { + foreach (var nested in Nested) + { + nested.CreateLinks(controller, app, schema, FieldId, allowUpdate); + } + } + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs index 02376143b..b09c7d002 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/FieldPropertiesDto.cs @@ -11,11 +11,11 @@ using System.Linq; using System.Runtime.Serialization; using Newtonsoft.Json; using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Web; +using Squidex.Web.Json; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - [JsonConverter(typeof(MyJsonInheritanceConverter), "fieldType")] + [JsonConverter(typeof(TypedJsonInheritanceConverter), "fieldType")] [KnownType(nameof(Subtypes))] public abstract class FieldPropertiesDto { diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs index 334c50376..580ea0220 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/NestedFieldDto.cs @@ -6,10 +6,11 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class NestedFieldDto + public sealed class NestedFieldDto : Resource { /// /// The id of the field. @@ -43,5 +44,39 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// [Required] public FieldPropertiesDto Properties { get; set; } + + public void CreateLinks(ApiController controller, string app, string schema, long parentId, bool allowUpdate) + { + allowUpdate = allowUpdate && !IsLocked; + + if (allowUpdate) + { + var values = new { app, name = schema, parentId, id = FieldId }; + + AddPutLink("update", controller.Url(x => nameof(x.PutNestedField), values)); + + if (IsHidden) + { + AddPutLink("show", controller.Url(x => nameof(x.ShowNestedField), values)); + } + else + { + AddPutLink("hide", controller.Url(x => nameof(x.HideNestedField), values)); + } + + if (IsDisabled) + { + AddPutLink("enable", controller.Url(x => nameof(x.EnableNestedField), values)); + } + else + { + AddPutLink("show", controller.Url(x => nameof(x.DisableNestedField), values)); + } + + AddPutLink("lock", controller.Url(x => nameof(x.LockNestedField), values)); + + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteNestedField), values)); + } + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs index a6ed2b46b..716dc5ad0 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs @@ -5,49 +5,21 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using NodaTime; using Squidex.Areas.Api.Controllers.Schemas.Models.Converters; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class SchemaDetailsDto + public sealed class SchemaDetailsDto : SchemaDto { private static readonly Dictionary EmptyPreviewUrls = new Dictionary(); - /// - /// The id of the schema. - /// - public Guid Id { get; set; } - - /// - /// The name of the schema. Unique within the app. - /// - [Required] - [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] - public string Name { get; set; } - - /// - /// The name of the category. - /// - public string Category { get; set; } - - /// - /// Indicates if the schema is a singleton. - /// - public bool IsSingleton { get; set; } - - /// - /// Indicates if the schema is published. - /// - public bool IsPublished { get; set; } - /// /// The scripts. /// @@ -64,54 +36,21 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [Required] public List Fields { get; set; } - /// - /// The schema properties. - /// - [Required] - public SchemaPropertiesDto Properties { get; set; } = new SchemaPropertiesDto(); - - /// - /// The user that has created the schema. - /// - [Required] - public RefToken CreatedBy { get; set; } - - /// - /// The user that has updated the schema. - /// - [Required] - public RefToken LastModifiedBy { get; set; } - - /// - /// The date and time when the schema has been created. - /// - public Instant Created { get; set; } - - /// - /// The date and time when the schema has been modified last. - /// - public Instant LastModified { get; set; } - - /// - /// The version of the schema. - /// - public long Version { get; set; } - - public static SchemaDetailsDto FromSchema(ISchemaEntity schema) + public static SchemaDetailsDto FromSchemaWithDetails(ISchemaEntity schema, ApiController controller, string app) { - var response = new SchemaDetailsDto(); + var result = new SchemaDetailsDto(); - SimpleMapper.Map(schema, response); - SimpleMapper.Map(schema.SchemaDef, response); - SimpleMapper.Map(schema.SchemaDef.Scripts, response.Scripts); - SimpleMapper.Map(schema.SchemaDef.Properties, response.Properties); + SimpleMapper.Map(schema, result); + SimpleMapper.Map(schema.SchemaDef, result); + SimpleMapper.Map(schema.SchemaDef.Scripts, result.Scripts); + SimpleMapper.Map(schema.SchemaDef.Properties, result.Properties); if (schema.SchemaDef.PreviewUrls.Count > 0) { - response.PreviewUrls = new Dictionary(schema.SchemaDef.PreviewUrls); + result.PreviewUrls = new Dictionary(schema.SchemaDef.PreviewUrls); } - response.Fields = new List(); + result.Fields = new List(); foreach (var field in schema.SchemaDef.Fields) { @@ -144,10 +83,27 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models } } - response.Fields.Add(fieldDto); + result.Fields.Add(fieldDto); } - return response; + result.CreateLinks(controller, app); + + return result; + } + + protected override void CreateLinks(ApiController controller, string app) + { + base.CreateLinks(controller, app); + + var allowUpdate = controller.HasPermission(Permissions.AppSchemasUpdate, app, Name); + + if (Fields != null) + { + foreach (var nested in Fields) + { + nested.CreateLinks(controller, app, Name, allowUpdate); + } + } } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index f66d64d23..4b349216c 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -8,14 +8,16 @@ using System; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Areas.Api.Controllers.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class SchemaDto : IGenerateETag + public class SchemaDto : Resource { /// /// The id of the schema. @@ -38,7 +40,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// The schema properties. /// [Required] - public SchemaPropertiesDto Properties { get; set; } + public SchemaPropertiesDto Properties { get; set; } = new SchemaPropertiesDto(); /// /// Indicates if the schema is a singleton. @@ -77,15 +79,65 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// public long Version { get; set; } - public static SchemaDto FromSchema(ISchemaEntity schema) + public static SchemaDto FromSchema(ISchemaEntity schema, ApiController controller, string app) { - var response = new SchemaDto { Properties = new SchemaPropertiesDto() }; + var result = new SchemaDto(); - SimpleMapper.Map(schema, response); - SimpleMapper.Map(schema.SchemaDef, response); - SimpleMapper.Map(schema.SchemaDef.Properties, response.Properties); + SimpleMapper.Map(schema, result); + SimpleMapper.Map(schema.SchemaDef, result); + SimpleMapper.Map(schema.SchemaDef.Properties, result.Properties); - return response; + result.CreateLinks(controller, app); + + return result; + } + + protected virtual void CreateLinks(ApiController controller, string app) + { + var values = new { app, name = Name }; + + var allowUpdate = controller.HasPermission(Permissions.AppSchemasUpdate, app, Name); + + AddSelfLink(controller.Url(x => nameof(x.GetSchema), values)); + + if (controller.HasPermission(Permissions.AppContentsRead, app, Name)) + { + AddGetLink("contents", controller.Url(x => nameof(x.GetContents), values)); + } + + if (controller.HasPermission(Permissions.AppSchemasPublish, app, Name)) + { + if (IsPublished) + { + AddPutLink("unpublish", controller.Url(x => nameof(x.UnpublishSchema), values)); + } + else + { + AddPutLink("publish", controller.Url(x => nameof(x.PublishSchema), values)); + } + } + + if (allowUpdate) + { + AddPutLink("fields/order", controller.Url(x => nameof(x.PutSchemaFieldOrdering), values)); + + AddPutLink("update", controller.Url(x => nameof(x.PutSchema), values)); + AddPutLink("update/category", controller.Url(x => nameof(x.PutCategory), values)); + AddPutLink("update/sync", controller.Url(x => nameof(x.PutSchemaSync), values)); + AddPutLink("update/urls", controller.Url(x => nameof(x.PutPreviewUrls), values)); + + AddPostLink("fields/add", controller.Url(x => nameof(x.PostField), values)); + } + + if (controller.HasPermission(Permissions.AppSchemasScripts, app, Name)) + { + AddPutLink("update/scripts", controller.Url(x => nameof(x.PutScripts), values)); + } + + if (controller.HasPermission(Permissions.AppSchemasDelete, app, Name)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteSchema), values)); + } } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs new file mode 100644 index 000000000..ebdaa95ab --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs @@ -0,0 +1,48 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models +{ + public sealed class SchemasDto : Resource + { + /// + /// The schemas. + /// + public SchemaDto[] Items { get; set; } + + public static SchemasDto FromSchemas(IList schemas, ApiController controller, string app) + { + var result = new SchemasDto + { + Items = schemas.Select(x => SchemaDto.FromSchema(x, controller, app)).ToArray() + }; + + return result.CreateLinks(controller, app); + } + + private SchemasDto CreateLinks(ApiController controller, string app) + { + var values = new { app }; + + AddSelfLink(controller.Url(x => nameof(x.GetSchemas), values)); + + if (controller.HasPermission(Permissions.AppSchemasCreate, app)) + { + AddPostLink("create", controller.Url(x => nameof(x.PostSchema), values)); + } + + return this; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs index 74a36f916..6c50117dc 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Squidex.Areas.Api.Controllers.Schemas.Models; +using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure.Commands; using Squidex.Shared; @@ -40,17 +41,14 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPost] [Route("apps/{app}/schemas/{name}/fields/")] - [ProducesResponseType(typeof(EntityCreatedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 409)] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PostField(string app, string name, [FromBody] AddFieldDto request) { - var context = await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); - var result = context.Result>(); - var response = EntityCreatedDto.FromResult(result); + var response = await InvokeCommandAsync(app, command); return StatusCode(201, response); } @@ -70,17 +68,14 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPost] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/")] - [ProducesResponseType(typeof(EntityCreatedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 409)] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PostNestedField(string app, string name, long parentId, [FromBody] AddFieldDto request) { - var context = await CommandBus.PublishAsync(request.ToCommand(parentId)); + var command = request.ToCommand(parentId); - var result = context.Result>(); - var response = EntityCreatedDto.FromResult(result); + var response = await InvokeCommandAsync(app, command); return StatusCode(201, response); } @@ -92,20 +87,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The request that contains the field ids. /// - /// 204 => Schema fields reorderd. + /// 200 => Schema fields reordered. /// 400 => Schema field ids do not cover the fields of the schema. /// 404 => Schema or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/ordering/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PutSchemaFieldOrdering(string app, string name, [FromBody] ReorderFieldsDto request) { - await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -116,20 +113,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The parent field id. /// The request that contains the field ids. /// - /// 204 => Schema fields reorderd. + /// 200 => Schema fields reordered. /// 400 => Schema field ids do not cover the fields of the schema. /// 404 => Schema, field or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/ordering/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PutNestedFieldOrdering(string app, string name, long parentId, [FromBody] ReorderFieldsDto request) { - await CommandBus.PublishAsync(request.ToCommand(parentId)); + var command = request.ToCommand(parentId); + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -140,20 +139,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The id of the field to update. /// The field object that needs to be added to the schema. /// - /// 204 => Schema field updated. + /// 200 => Schema field updated. /// 400 => Schema field properties not valid or field is locked. /// 404 => Schema, field or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PutField(string app, string name, long id, [FromBody] UpdateFieldDto request) { - await CommandBus.PublishAsync(request.ToCommand(id)); + var command = request.ToCommand(id); + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -165,20 +166,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The id of the field to update. /// The field object that needs to be added to the schema. /// - /// 204 => Schema field updated. + /// 200 => Schema field updated. /// 400 => Schema field properties not valid or field is locked. /// 404 => Schema, field or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PutNestedField(string app, string name, long parentId, long id, [FromBody] UpdateFieldDto request) { - await CommandBus.PublishAsync(request.ToCommand(id, parentId)); + var command = request.ToCommand(id, parentId); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -188,7 +191,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The id of the field to lock. /// - /// 204 => Schema field shown. + /// 200 => Schema field shown. /// 400 => Schema field already locked. /// 404 => Schema, field or app not found. /// @@ -197,14 +200,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/lock/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task LockField(string app, string name, long id) { - await CommandBus.PublishAsync(new LockField { FieldId = id }); + var command = new LockField { FieldId = id }; + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -215,7 +220,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The parent field id. /// The id of the field to lock. /// - /// 204 => Schema field hidden. + /// 200 => Schema field hidden. /// 400 => Schema field already hidden. /// 404 => Field, schema, or app not found. /// @@ -224,14 +229,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/lock/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task LockNestedField(string app, string name, long parentId, long id) { - await CommandBus.PublishAsync(new LockField { ParentFieldId = parentId, FieldId = id }); + var command = new LockField { ParentFieldId = parentId, FieldId = id }; + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -241,7 +248,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The id of the field to hide. /// - /// 204 => Schema field hidden. + /// 200 => Schema field hidden. /// 400 => Schema field already hidden. /// 404 => Schema, field or app not found. /// @@ -250,14 +257,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/hide/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task HideField(string app, string name, long id) { - await CommandBus.PublishAsync(new HideField { FieldId = id }); + var command = new HideField { FieldId = id }; - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -268,7 +277,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The parent field id. /// The id of the field to hide. /// - /// 204 => Schema field hidden. + /// 200 => Schema field hidden. /// 400 => Schema field already hidden. /// 404 => Field, schema, or app not found. /// @@ -277,14 +286,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/hide/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task HideNestedField(string app, string name, long parentId, long id) { - await CommandBus.PublishAsync(new HideField { ParentFieldId = parentId, FieldId = id }); + var command = new HideField { ParentFieldId = parentId, FieldId = id }; + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -294,7 +305,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The id of the field to show. /// - /// 204 => Schema field shown. + /// 200 => Schema field shown. /// 400 => Schema field already visible. /// 404 => Schema, field or app not found. /// @@ -303,14 +314,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/show/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task ShowField(string app, string name, long id) { - await CommandBus.PublishAsync(new ShowField { FieldId = id }); + var command = new ShowField { FieldId = id }; + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -321,7 +334,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The parent field id. /// The id of the field to show. /// - /// 204 => Schema field shown. + /// 200 => Schema field shown. /// 400 => Schema field already visible. /// 404 => Schema, field or app not found. /// @@ -330,14 +343,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/show/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task ShowNestedField(string app, string name, long parentId, long id) { - await CommandBus.PublishAsync(new ShowField { ParentFieldId = parentId, FieldId = id }); + var command = new ShowField { ParentFieldId = parentId, FieldId = id }; - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -347,7 +362,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The id of the field to enable. /// - /// 204 => Schema field enabled. + /// 200 => Schema field enabled. /// 400 => Schema field already enabled. /// 404 => Schema, field or app not found. /// @@ -356,14 +371,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/enable/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task EnableField(string app, string name, long id) { - await CommandBus.PublishAsync(new EnableField { FieldId = id }); + var command = new EnableField { FieldId = id }; + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -374,7 +391,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The parent field id. /// The id of the field to enable. /// - /// 204 => Schema field enabled. + /// 200 => Schema field enabled. /// 400 => Schema field already enabled. /// 404 => Schema, field or app not found. /// @@ -383,14 +400,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/enable/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task EnableNestedField(string app, string name, long parentId, long id) { - await CommandBus.PublishAsync(new EnableField { ParentFieldId = parentId, FieldId = id }); + var command = new EnableField { ParentFieldId = parentId, FieldId = id }; - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -400,7 +419,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The id of the field to disable. /// - /// 204 => Schema field disabled. + /// 200 => Schema field disabled. /// 400 => Schema field already disabled. /// 404 => Schema, field or app not found. /// @@ -409,14 +428,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{id:long}/disable/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task DisableField(string app, string name, long id) { - await CommandBus.PublishAsync(new DisableField { FieldId = id }); + var command = new DisableField { FieldId = id }; + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -427,7 +448,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The parent field id. /// The id of the field to disable. /// - /// 204 => Schema field disabled. + /// 200 => Schema field disabled. /// 400 => Schema field already disabled. /// 404 => Schema, field or app not found. /// @@ -436,14 +457,16 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPut] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/disable/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task DisableNestedField(string app, string name, long parentId, long id) { - await CommandBus.PublishAsync(new DisableField { ParentFieldId = parentId, FieldId = id }); + var command = new DisableField { ParentFieldId = parentId, FieldId = id }; + + var response = await InvokeCommandAsync(app, command); - return NoContent(); + return Ok(response); } /// @@ -453,19 +476,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The id of the field to disable. /// - /// 204 => Schema field deleted. + /// 200 => Schema field deleted. /// 400 => Field is locked. /// 404 => Schema, field or app not found. /// [HttpDelete] [Route("apps/{app}/schemas/{name}/fields/{id:long}/")] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task DeleteField(string app, string name, long id) { - await CommandBus.PublishAsync(new DeleteField { FieldId = id }); + var command = new DeleteField { FieldId = id }; - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -476,19 +502,32 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The parent field id. /// The id of the field to disable. /// - /// 204 => Schema field deleted. + /// 200 => Schema field deleted. /// 400 => Field is locked. /// 404 => Schema, field or app not found. /// [HttpDelete] [Route("apps/{app}/schemas/{name}/fields/{parentId:long}/nested/{id:long}/")] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task DeleteNestedField(string app, string name, long parentId, long id) { - await CommandBus.PublishAsync(new DeleteField { ParentFieldId = parentId, FieldId = id }); + var command = new DeleteField { ParentFieldId = parentId, FieldId = id }; + + var response = await InvokeCommandAsync(app, command); + + return Ok(response); + } + + private async Task InvokeCommandAsync(string app, ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = SchemaDetailsDto.FromSchemaWithDetails(result, this, app); - return NoContent(); + return response; } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index 1fb64ec83..036999da6 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -13,7 +13,6 @@ using Squidex.Areas.Api.Controllers.Schemas.Models; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; -using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Shared; using Squidex.Web; @@ -44,16 +43,19 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpGet] [Route("apps/{app}/schemas/")] - [ProducesResponseType(typeof(SchemaDto[]), 200)] + [ProducesResponseType(typeof(SchemasDto), 200)] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public async Task GetSchemas(string app) { var schemas = await appProvider.GetSchemasAsync(AppId); - var response = schemas.ToArray(SchemaDto.FromSchema); + var response = Deferred.Response(() => + { + return SchemasDto.FromSchemas(schemas, this, app); + }); - Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); + Response.Headers[HeaderNames.ETag] = schemas.ToEtag(); return Ok(response); } @@ -74,25 +76,28 @@ namespace Squidex.Areas.Api.Controllers.Schemas [ApiCosts(0)] public async Task GetSchema(string app, string name) { - ISchemaEntity entity; + ISchemaEntity schema; if (Guid.TryParse(name, out var id)) { - entity = await appProvider.GetSchemaAsync(AppId, id); + schema = await appProvider.GetSchemaAsync(AppId, id); } else { - entity = await appProvider.GetSchemaAsync(AppId, name); + schema = await appProvider.GetSchemaAsync(AppId, name); } - if (entity == null || entity.IsDeleted) + if (schema == null || schema.IsDeleted) { return NotFound(); } - var response = SchemaDetailsDto.FromSchema(entity); + var response = Deferred.Response(() => + { + return SchemaDetailsDto.FromSchemaWithDetails(schema, this, app); + }); - Response.Headers[HeaderNames.ETag] = entity.Version.ToString(); + Response.Headers[HeaderNames.ETag] = schema.ToEtag(); return Ok(response); } @@ -109,18 +114,14 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// [HttpPost] [Route("apps/{app}/schemas/")] - [ProducesResponseType(typeof(EntityCreatedDto), 201)] - [ProducesResponseType(typeof(ErrorDto), 400)] - [ProducesResponseType(typeof(ErrorDto), 409)] + [ProducesResponseType(typeof(SchemaDetailsDto), 201)] [ApiPermission(Permissions.AppSchemasCreate)] [ApiCosts(1)] public async Task PostSchema(string app, [FromBody] CreateSchemaDto request) { var command = request.ToCommand(); - var context = await CommandBus.PublishAsync(command); - var result = context.Result>(); - var response = new EntityCreatedDto { Id = command.SchemaId.ToString(), Version = result.Version }; + var response = await InvokeCommandAsync(app, command); return CreatedAtAction(nameof(GetSchema), new { name = request.Name }, response); } @@ -132,19 +133,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The schema object that needs to updated. /// - /// 204 => Schema updated. + /// 200 => Schema updated. /// 400 => Schema properties are not valid. /// 404 => Schema or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/")] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PutSchema(string app, string name, [FromBody] UpdateSchemaDto request) { - await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -154,19 +158,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The schema object that needs to updated. /// - /// 204 => Schema updated. + /// 200 => Schema updated. /// 400 => Schema properties are not valid. /// 404 => Schema or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/sync")] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PutSchemaSync(string app, string name, [FromBody] SynchronizeSchemaDto request) { - await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -176,18 +183,21 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The schema object that needs to updated. /// - /// 204 => Schema updated. + /// 200 => Schema updated. /// 404 => Schema or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/category")] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PutCategory(string app, string name, [FromBody] ChangeCategoryDto request) { - await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -197,40 +207,46 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the schema. /// The preview urls for the schema. /// - /// 204 => Schema updated. + /// 200 => Schema updated. /// 404 => Schema or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/preview-urls")] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasUpdate)] [ApiCosts(1)] public async Task PutPreviewUrls(string app, string name, [FromBody] ConfigurePreviewUrlsDto request) { - await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// - /// Update the scripts of a schema. + /// Update the scripts. /// /// The name of the app. /// The name of the schema. /// The schema scripts object that needs to updated. /// - /// 204 => Schema updated. + /// 200 => Schema updated. /// 400 => Schema properties are not valid. /// 404 => Schema or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/scripts/")] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasScripts)] [ApiCosts(1)] - public async Task PutSchemaScripts(string app, string name, [FromBody] SchemaScriptsDto request) + public async Task PutScripts(string app, string name, [FromBody] SchemaScriptsDto request) { - await CommandBus.PublishAsync(request.ToCommand()); + var command = request.ToCommand(); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -239,20 +255,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the app. /// The name of the schema to publish. /// - /// 204 => Schema has been published. + /// 200 => Schema has been published. /// 400 => Schema is already published. /// 404 => Schema or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/publish/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasPublish)] [ApiCosts(1)] public async Task PublishSchema(string app, string name) { - await CommandBus.PublishAsync(new PublishSchema()); + var command = new PublishSchema(); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -261,20 +279,22 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the app. /// The name of the schema to unpublish. /// - /// 204 => Schema has been unpublished. + /// 200 => Schema has been unpublished. /// 400 => Schema is not published. /// 404 => Schema or app not found. /// [HttpPut] [Route("apps/{app}/schemas/{name}/unpublish/")] - [ProducesResponseType(typeof(ErrorDto), 400)] + [ProducesResponseType(typeof(SchemaDetailsDto), 200)] [ApiPermission(Permissions.AppSchemasPublish)] [ApiCosts(1)] public async Task UnpublishSchema(string app, string name) { - await CommandBus.PublishAsync(new UnpublishSchema()); + var command = new UnpublishSchema(); - return NoContent(); + var response = await InvokeCommandAsync(app, command); + + return Ok(response); } /// @@ -283,7 +303,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas /// The name of the app. /// The name of the schema to delete. /// - /// 204 => Schema has been deleted. + /// 204 => Schema deleted. /// 404 => Schema or app not found. /// [HttpDelete] @@ -296,5 +316,15 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NoContent(); } + + private async Task InvokeCommandAsync(string app, ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = SchemaDetailsDto.FromSchemaWithDetails(result, this, app); + + return response; + } } } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs b/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs index d7a7f4eaa..5a6271099 100644 --- a/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs @@ -127,9 +127,9 @@ namespace Squidex.Areas.Api.Controllers.Statistics return BadRequest(); } - var entities = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date); + var usages = await usageTracker.QueryAsync(AppId.ToString(), fromDate.Date, toDate.Date); - var response = entities.ToDictionary(x => x.Key, x => x.Value.Select(CallsUsageDto.FromUsage).ToArray()); + var response = usages.ToDictionary(x => x.Key, x => x.Value.Select(CallsUsageDto.FromUsage).ToArray()); return Ok(response); } @@ -181,9 +181,9 @@ namespace Squidex.Areas.Api.Controllers.Statistics return BadRequest(); } - var entities = await assetStatsRepository.QueryAsync(AppId, fromDate.Date, toDate.Date); + var usages = await assetStatsRepository.QueryAsync(AppId, fromDate.Date, toDate.Date); - var models = entities.Select(StorageUsageDto.FromStats).ToArray(); + var models = usages.Select(StorageUsageDto.FromStats).ToArray(); return Ok(models); } diff --git a/src/Squidex/Areas/Api/Controllers/UI/Models/UISettingsDto.cs b/src/Squidex/Areas/Api/Controllers/UI/Models/UISettingsDto.cs index 675b1265d..7e1424d73 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/Models/UISettingsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/Models/UISettingsDto.cs @@ -5,24 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.ComponentModel.DataAnnotations; - namespace Squidex.Areas.Api.Controllers.UI.Models { public sealed class UISettingsDto { - /// - /// The type of the map control. - /// - [Required] - public string MapType { get; set; } - - /// - /// The key for the map control. - /// - [Required] - public string MapKey { get; set; } - /// /// True when the user can create apps. /// diff --git a/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs b/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs index 206d380ef..a7ed32357 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs @@ -13,8 +13,18 @@ namespace Squidex.Areas.Api.Controllers.UI { public Dictionary RegexSuggestions { get; set; } + public Dictionary More { get; set; } = new Dictionary(); + public MapOptions Map { get; set; } + public bool ShowInfo { get; set; } + + public bool HideNews { get; set; } + + public bool HideOnboarding { get; set; } + + public bool RedirectToLogin { get; set; } + public bool OnlyAdminsCanCreateApps { get; set; } public sealed class MapOptions diff --git a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs index e66c8feb8..dd12c5be5 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -16,7 +16,6 @@ using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Security; using Squidex.Shared; -using Squidex.Shared.Identity; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.UI @@ -27,9 +26,7 @@ namespace Squidex.Areas.Api.Controllers.UI private readonly MyUIOptions uiOptions; private readonly IGrainFactory grainFactory; - public UIController(ICommandBus commandBus, - IOptions uiOptions, - IGrainFactory grainFactory) + public UIController(ICommandBus commandBus, IOptions uiOptions, IGrainFactory grainFactory) : base(commandBus) { this.uiOptions = uiOptions.Value; @@ -51,14 +48,9 @@ namespace Squidex.Areas.Api.Controllers.UI { var result = new UISettingsDto { - MapType = uiOptions.Map?.Type ?? "OSM", - MapKey = uiOptions.Map?.GoogleMaps?.Key + CanCreateApps = !uiOptions.OnlyAdminsCanCreateApps || Context.Permissions.Includes(CreateAppPermission) }; - var canCreateApps = !uiOptions.OnlyAdminsCanCreateApps || User.Permissions().Includes(CreateAppPermission); - - result.CanCreateApps = canCreateApps; - return Ok(result); } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs index b59f83b53..9dbd2feac 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs @@ -40,7 +40,13 @@ namespace Squidex.Areas.Api.Controllers.Users.Models public UserValues ToValues() { - return new UserValues { Email = Email, DisplayName = DisplayName, Password = Password, Permissions = new PermissionSet(Permissions) }; + return new UserValues + { + Email = Email, + DisplayName = DisplayName, + Password = Password, + Permissions = new PermissionSet(Permissions) + }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs new file mode 100644 index 000000000..0b5fa32b2 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Areas.Api.Controllers.Backups; +using Squidex.Areas.Api.Controllers.EventConsumers; +using Squidex.Areas.Api.Controllers.Languages; +using Squidex.Areas.Api.Controllers.Ping; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Users.Models +{ + public sealed class ResourcesDto : Resource + { + public static ResourcesDto FromController(ApiController controller) + { + var result = new ResourcesDto(); + + result.AddGetLink("ping", controller.Url(x => nameof(x.GetPing))); + + if (controller.HasPermission(Permissions.AdminEventsRead)) + { + result.AddGetLink("admin/events", controller.Url(x => nameof(x.GetEventConsumers))); + } + + if (controller.HasPermission(Permissions.AdminRestore)) + { + result.AddGetLink("admin/restore", controller.Url(x => nameof(x.GetJob))); + } + + if (controller.HasPermission(Permissions.AdminUsersRead)) + { + result.AddGetLink("admin/users", controller.Url(x => nameof(x.GetUsers))); + } + + result.AddGetLink("languages", controller.Url(x => nameof(x.GetLanguages))); + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs index 77b41f567..d6391da83 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs @@ -39,7 +39,13 @@ namespace Squidex.Areas.Api.Controllers.Users.Models public UserValues ToValues() { - return new UserValues { Email = Email, DisplayName = DisplayName, Password = Password, Permissions = new PermissionSet(Permissions) }; + return new UserValues + { + Email = Email, + DisplayName = DisplayName, + Password = Password, + Permissions = new PermissionSet(Permissions) + }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs index 8a2a5a2d4..81260dd3f 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs @@ -9,10 +9,12 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Squidex.Infrastructure.Reflection; using Squidex.Shared.Users; +using Squidex.Web; +using AllPermissions = Squidex.Shared.Permissions; namespace Squidex.Areas.Api.Controllers.Users.Models { - public sealed class UserDto + public sealed class UserDto : Resource { /// /// The id of the user. @@ -44,11 +46,50 @@ namespace Squidex.Areas.Api.Controllers.Users.Models [Required] public IEnumerable Permissions { get; set; } - public static UserDto FromUser(IUser user) + public static UserDto FromUser(IUser user, ApiController controller) { - var permissions = user.Permissions().ToIds(); + var userPermssions = user.Permissions().ToIds(); + var userName = user.DisplayName(); - return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName(), Permissions = permissions }); + var result = SimpleMapper.Map(user, new UserDto { DisplayName = userName, Permissions = userPermssions }); + + return result.CreateLinks(controller); + } + + private UserDto CreateLinks(ApiController controller) + { + var values = new { id = Id }; + + if (controller is UserManagementController) + { + AddSelfLink(controller.Url(c => nameof(c.GetUser), values)); + } + else + { + AddSelfLink(controller.Url(c => nameof(c.GetUser), values)); + } + + if (!controller.IsUser(Id)) + { + if (controller.HasPermission(AllPermissions.AdminUsersLock) && !IsLocked) + { + AddPutLink("lock", controller.Url(c => nameof(c.LockUser), values)); + } + + if (controller.HasPermission(AllPermissions.AdminUsersUnlock) && IsLocked) + { + AddPutLink("unlock", controller.Url(c => nameof(c.UnlockUser), values)); + } + } + + if (controller.HasPermission(AllPermissions.AdminUsersUpdate)) + { + AddPutLink("update", controller.Url(c => nameof(c.PutUser), values)); + } + + AddGetLink("picture", controller.Url(c => nameof(c.GetUserPicture), values)); + + return this; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs index 2d83ff47d..c8a2937b1 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs @@ -5,9 +5,16 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Users; +using Squidex.Shared; +using Squidex.Web; + namespace Squidex.Areas.Api.Controllers.Users.Models { - public sealed class UsersDto + public sealed class UsersDto : Resource { /// /// The total number of users. @@ -17,6 +24,30 @@ namespace Squidex.Areas.Api.Controllers.Users.Models /// /// The users. /// + [Required] public UserDto[] Items { get; set; } + + public static UsersDto FromResults(IEnumerable items, long total, ApiController controller) + { + var result = new UsersDto + { + Total = total, + Items = items.Select(x => UserDto.FromUser(x, controller)).ToArray() + }; + + return result.CreateLinks(controller); + } + + private UsersDto CreateLinks(ApiController controller) + { + AddSelfLink(controller.Url(c => nameof(c.GetUsers))); + + if (controller.HasPermission(Permissions.AdminUsersCreate)) + { + AddPostLink("create", controller.Url(c => nameof(c.PostUser))); + } + + return this; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs index 7d547be14..4225171db 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -14,7 +12,6 @@ using Squidex.Areas.Api.Controllers.Users.Models; using Squidex.Domain.Users; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; @@ -35,6 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Users [HttpGet] [Route("user-management/")] + [ProducesResponseType(typeof(UsersDto), 200)] [ApiPermission(Permissions.AdminUsersRead)] public async Task GetUsers([FromQuery] string query = null, [FromQuery] int skip = 0, [FromQuery] int take = 10) { @@ -43,89 +41,89 @@ namespace Squidex.Areas.Api.Controllers.Users await Task.WhenAll(taskForItems, taskForCount); - var response = new UsersDto - { - Total = taskForCount.Result, - Items = taskForItems.Result.Select(UserDto.FromUser).ToArray() - }; + var response = UsersDto.FromResults(taskForItems.Result, taskForCount.Result, this); return Ok(response); } [HttpGet] [Route("user-management/{id}/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersRead)] public async Task GetUser(string id) { - var entity = await userManager.FindByIdWithClaimsAsync(id); + var user = await userManager.FindByIdWithClaimsAsync(id); - if (entity == null) + if (user == null) { return NotFound(); } - var response = UserDto.FromUser(entity); + var response = UserDto.FromUser(user, this); return Ok(response); } [HttpPost] [Route("user-management/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersCreate)] public async Task PostUser([FromBody] CreateUserDto request) { var user = await userManager.CreateAsync(userFactory, request.ToValues()); - var response = new UserCreatedDto { Id = user.Id }; + var response = UserDto.FromUser(user, this); return Ok(response); } [HttpPut] [Route("user-management/{id}/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersUpdate)] public async Task PutUser(string id, [FromBody] UpdateUserDto request) { - await userManager.UpdateAsync(id, request.ToValues()); + var user = await userManager.UpdateAsync(id, request.ToValues()); + + var response = UserDto.FromUser(user, this); - return NoContent(); + return Ok(response); } [HttpPut] [Route("user-management/{id}/lock/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersLock)] public async Task LockUser(string id) { - if (IsSelf(id)) + if (this.IsUser(id)) { throw new ValidationException("Locking user failed.", new ValidationError("You cannot lock yourself.")); } - await userManager.LockAsync(id); + var user = await userManager.LockAsync(id); - return NoContent(); + var response = UserDto.FromUser(user, this); + + return Ok(response); } [HttpPut] [Route("user-management/{id}/unlock/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersUnlock)] public async Task UnlockUser(string id) { - if (IsSelf(id)) + if (this.IsUser(id)) { throw new ValidationException("Unlocking user failed.", new ValidationError("You cannot unlock yourself.")); } - await userManager.UnlockAsync(id); + var user = await userManager.UnlockAsync(id); - return NoContent(); - } - - private bool IsSelf(string id) - { - var subject = User.OpenIdSubject(); + var response = UserDto.FromUser(user, this); - return string.Equals(subject, id, StringComparison.OrdinalIgnoreCase); + return Ok(response); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index 36c556adb..597dc64de 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -56,6 +56,23 @@ namespace Squidex.Areas.Api.Controllers.Users this.log = log; } + /// + /// Get the user resources. + /// + /// + /// 200 => User resources returned. + /// + [HttpGet] + [Route("/")] + [ProducesResponseType(typeof(ResourcesDto), 200)] + [ApiPermission] + public IActionResult GetUserResources() + { + var response = ResourcesDto.FromController(this); + + return Ok(response); + } + /// /// Get users by query. /// @@ -68,17 +85,17 @@ namespace Squidex.Areas.Api.Controllers.Users /// [HttpGet] [Route("users/")] - [ProducesResponseType(typeof(PublicUserDto[]), 200)] + [ProducesResponseType(typeof(UserDto[]), 200)] [ApiPermission] public async Task GetUsers(string query) { try { - var entities = await userResolver.QueryByEmailAsync(query); + var users = await userResolver.QueryByEmailAsync(query); - var models = entities.Where(x => !x.IsHidden()).Select(UserDto.FromUser).ToArray(); + var response = users.Where(x => !x.IsHidden()).Select(x => UserDto.FromUser(x, this)).ToArray(); - return Ok(models); + return Ok(response); } catch (Exception ex) { @@ -100,7 +117,7 @@ namespace Squidex.Areas.Api.Controllers.Users /// [HttpGet] [Route("users/{id}/")] - [ProducesResponseType(typeof(PublicUserDto), 200)] + [ProducesResponseType(typeof(UserDto), 200)] [ApiPermission] public async Task GetUser(string id) { @@ -110,7 +127,7 @@ namespace Squidex.Areas.Api.Controllers.Users if (entity != null) { - var response = UserDto.FromUser(entity); + var response = UserDto.FromUser(entity, this); return Ok(response); } diff --git a/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs b/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs index 13db98ab4..cf283c1ba 100644 --- a/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs +++ b/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs @@ -7,6 +7,11 @@ using System; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Squidex.Areas.Api.Controllers.UI; +using Squidex.Infrastructure.Json; +using Squidex.Web; namespace Squidex.Areas.Frontend.Middlewares { @@ -26,5 +31,34 @@ namespace Squidex.Areas.Frontend.Middlewares { return context.Response.ContentType?.ToLower().Contains("text/html") == true; } + + public static string AdjustHtml(this string html, HttpContext httpContext) + { + var result = html; + + if (httpContext.Request.PathBase.HasValue) + { + result = result.Replace("", $""); + } + + var uiOptions = httpContext.RequestServices.GetService>()?.Value; + + if (uiOptions != null) + { + var values = httpContext.RequestServices.GetService(); + + if (values != null) + { + uiOptions.More["info"] = values.ToString(); + } + + var jsonSerializer = httpContext.RequestServices.GetRequiredService(); + var jsonOptions = jsonSerializer.Serialize(uiOptions, false); + + result = result.Replace("", $""); + } + + return result; + } } } diff --git a/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs b/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs index 9925e0513..cf4a2dde8 100644 --- a/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs +++ b/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs @@ -25,7 +25,7 @@ namespace Squidex.Areas.Frontend.Middlewares { var basePath = context.Request.PathBase; - if (context.IsHtmlPath() && basePath.HasValue) + if (context.IsHtmlPath() && context.Response.StatusCode != 304) { var responseBuffer = new MemoryStream(); var responseBody = context.Response.Body; @@ -36,24 +36,19 @@ namespace Squidex.Areas.Frontend.Middlewares context.Response.Body = responseBody; - var response = Encoding.UTF8.GetString(responseBuffer.ToArray()); + var html = Encoding.UTF8.GetString(responseBuffer.ToArray()); - response = AdjustBase(response, basePath); + html = html.AdjustHtml(context); - context.Response.ContentLength = Encoding.UTF8.GetByteCount(response); + context.Response.ContentLength = Encoding.UTF8.GetByteCount(html); context.Response.Body = responseBody; - await context.Response.WriteAsync(response); + await context.Response.WriteAsync(html); } else { await next(context); } } - - private static string AdjustBase(string response, string baseUrl) - { - return response.Replace("", $""); - } } } diff --git a/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs b/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs index d71fb128d..8ed930af6 100644 --- a/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs +++ b/src/Squidex/Areas/Frontend/Middlewares/WebpackMiddleware.cs @@ -37,7 +37,7 @@ namespace Squidex.Areas.Frontend.Middlewares { var html = await result.Content.ReadAsStringAsync(); - html = AdjustBase(html, context.Request.PathBase); + html = html.AdjustHtml(context); await context.Response.WriteHtmlAsync(html); } @@ -58,7 +58,7 @@ namespace Squidex.Areas.Frontend.Middlewares var html = Encoding.UTF8.GetString(responseBuffer.ToArray()); - html = AdjustBase(html, context.Request.PathBase); + html = html.AdjustHtml(context); context.Response.ContentLength = Encoding.UTF8.GetByteCount(html); context.Response.Body = responseBody; @@ -71,17 +71,5 @@ namespace Squidex.Areas.Frontend.Middlewares await next(context); } } - - private static string AdjustBase(string html, PathString baseUrl) - { - if (baseUrl.HasValue) - { - return html.Replace("", $""); - } - else - { - return html; - } - } } } diff --git a/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs b/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs index 2df7efe1c..e07e6d059 100644 --- a/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs +++ b/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs @@ -176,7 +176,7 @@ namespace Squidex.Areas.IdentityServer.Config }, Claims = new List { - new Claim(SquidexClaimTypes.Permissions, Permissions.Admin) + new Claim(SquidexClaimTypes.Permissions, Permissions.All) } }; } diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 99092c745..b9a9813c3 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -32,7 +32,6 @@ using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Contents; -using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Edm; using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Domain.Apps.Entities.Contents.Text; @@ -97,9 +96,15 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -115,6 +120,12 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .AsOptional(); + + services.AddSingletonAs() + .AsOptional(); + services.AddSingletonAs() .AsSelf(); @@ -219,6 +230,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -228,9 +242,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs>() .As(); - services.AddSingletonAs>() - .As(); - services.AddSingletonAs>() .As(); diff --git a/src/Squidex/Config/Domain/InfrastructureServices.cs b/src/Squidex/Config/Domain/InfrastructureServices.cs index 915dd035a..e47181641 100644 --- a/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -16,6 +16,7 @@ using Squidex.Areas.Api.Controllers.News.Service; using Squidex.Domain.Apps.Entities.Apps.Diagnostics; using Squidex.Domain.Apps.Entities.Rules.UsageTracking; using Squidex.Domain.Users; +using Squidex.Infrastructure; using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.EventSourcing.Grains; @@ -57,6 +58,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs>() .AsSelf(); + services.AddSingletonAs() + .AsSelf(); + services.AddSingletonAs() .As(); diff --git a/src/Squidex/Config/Domain/SerializationInitializer.cs b/src/Squidex/Config/Domain/SerializationInitializer.cs index 0254b318b..9d1bb868e 100644 --- a/src/Squidex/Config/Domain/SerializationInitializer.cs +++ b/src/Squidex/Config/Domain/SerializationInitializer.cs @@ -29,6 +29,7 @@ namespace Squidex.Config.Domain { this.jsonNetSerializer = jsonNetSerializer; this.jsonSerializer = jsonSerializer; + this.ruleRegistry = ruleRegistry; } diff --git a/src/Squidex/Config/Domain/SerializationServices.cs b/src/Squidex/Config/Domain/SerializationServices.cs index 0b1f7cfce..9217ff2fb 100644 --- a/src/Squidex/Config/Domain/SerializationServices.cs +++ b/src/Squidex/Config/Domain/SerializationServices.cs @@ -11,6 +11,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Apps.Json; +using Squidex.Domain.Apps.Core.Contents.Json; using Squidex.Domain.Apps.Core.Rules.Json; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas.Json; @@ -44,7 +45,9 @@ namespace Squidex.Config.Domain new RolesConverter(), new RuleConverter(), new SchemaConverter(), - new StringEnumConverter()); + new StatusConverter(), + new StringEnumConverter(), + new WorkflowConverter()); settings.NullValueHandling = NullValueHandling.Ignore; diff --git a/src/Squidex/Config/Web/WebServices.cs b/src/Squidex/Config/Web/WebServices.cs index ec55d161a..7cbcc446d 100644 --- a/src/Squidex/Config/Web/WebServices.cs +++ b/src/Squidex/Config/Web/WebServices.cs @@ -8,7 +8,9 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Squidex.Config.Domain; +using Squidex.Domain.Apps.Entities; using Squidex.Pipeline.Plugins; using Squidex.Pipeline.Robots; using Squidex.Web; @@ -20,6 +22,9 @@ namespace Squidex.Config.Web { public static void AddMyMvcWithPlugins(this IServiceCollection services, IConfiguration config) { + services.AddSingletonAs(c => new ExposedValues(c.GetRequiredService>().Value, config, typeof(WebServices).Assembly)) + .AsSelf(); + services.AddSingletonAs() .AsSelf(); @@ -41,12 +46,16 @@ namespace Squidex.Config.Web services.AddSingletonAs() .AsSelf(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() - .As(); + .AsOptional(); services.AddMvc(options => { options.Filters.Add(); + options.Filters.Add(); options.Filters.Add(); options.Filters.Add(); }) diff --git a/src/Squidex/Pipeline/Swagger/NSwagHelper.cs b/src/Squidex/Pipeline/Swagger/NSwagHelper.cs index 385a2b48f..edeb39d1f 100644 --- a/src/Squidex/Pipeline/Swagger/NSwagHelper.cs +++ b/src/Squidex/Pipeline/Swagger/NSwagHelper.cs @@ -8,11 +8,8 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using NJsonSchema; -using NJsonSchema.Generation; using NSwag; using Squidex.Web; @@ -69,13 +66,6 @@ namespace Squidex.Pipeline.Swagger return document; } - public static async Task GetErrorDtoSchemaAsync(this JsonSchemaGenerator schemaGenerator, JsonSchemaResolver resolver) - { - var errorType = typeof(ErrorDto); - - return await schemaGenerator.GenerateWithReferenceAsync(errorType, Enumerable.Empty(), resolver); - } - public static void AddQueryParameter(this SwaggerOperation operation, string name, JsonObjectType type, string description = null) { var parameter = new SwaggerParameter { Type = type, Name = name, Kind = SwaggerParameterKind.Query }; diff --git a/src/Squidex/WebStartup.cs b/src/Squidex/WebStartup.cs index 6bc48e67d..8b2072990 100644 --- a/src/Squidex/WebStartup.cs +++ b/src/Squidex/WebStartup.cs @@ -29,12 +29,14 @@ using Squidex.Config.Startup; using Squidex.Config.Web; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.Translations; using Squidex.Pipeline.Plugins; using Squidex.Pipeline.Robots; using Squidex.Web; +using Squidex.Web.Pipeline; namespace Squidex { @@ -80,6 +82,8 @@ namespace Squidex config.GetSection("assets")); services.Configure( config.GetSection("translations:deepL")); + services.Configure( + config.GetSection("languages")); services.Configure( config.GetSection("mode")); services.Configure( @@ -94,6 +98,8 @@ namespace Squidex config.GetSection("usage")); services.Configure( config.GetSection("rebuild")); + services.Configure( + config.GetSection("exposedConfiguration")); services.Configure( config.GetSection("contentsController")); diff --git a/src/Squidex/app-config/helpers.js b/src/Squidex/app-config/helpers.js deleted file mode 100644 index acae3e4bb..000000000 --- a/src/Squidex/app-config/helpers.js +++ /dev/null @@ -1,25 +0,0 @@ -var path = require('path'); - -var appRoot = path.resolve(__dirname, '..'); - -exports.root = function () { - var newArgs = Array.prototype.slice.call(arguments, 0); - - return path.join.apply(path, [appRoot].concat(newArgs)); -}; - -exports.removeLoaders = function (config, extensions) { - var rules = config.module.rules; - - for (var i = 0; i < rules.length; i += 1) { - var rule = rules[i]; - - for (var j = 0; j < extensions.length; j += 1) { - if (rule.test.source.indexOf(extensions[j]) >= 0) { - rules.splice(i, 1); - i--; - break; - } - } - } -} \ No newline at end of file diff --git a/src/Squidex/app-config/karma-test-shim.js b/src/Squidex/app-config/karma-test-shim.js index 41a41e554..773d09e87 100644 --- a/src/Squidex/app-config/karma-test-shim.js +++ b/src/Squidex/app-config/karma-test-shim.js @@ -1,7 +1,6 @@ Error.stackTraceLimit = Infinity; -require('core-js/es6'); -require('core-js/es7/reflect'); +require('core-js/proposals/reflect-metadata'); require('zone.js/dist/zone'); require('zone.js/dist/long-stack-trace-zone'); diff --git a/src/Squidex/app-config/karma.conf.js b/src/Squidex/app-config/karma.conf.js index 8b64456f4..f4776e18a 100644 --- a/src/Squidex/app-config/karma.conf.js +++ b/src/Squidex/app-config/karma.conf.js @@ -1,4 +1,4 @@ -var webpackConfig = require('./webpack.test'); +const webpackConfig = require('./webpack.config'); module.exports = function (config) { var _config = { @@ -10,7 +10,7 @@ module.exports = function (config) { frameworks: ['jasmine'], /** - * Load additional test shim to setup angular2 for testing. + * Load additional test shim to setup angular for testing. */ files: [ { pattern: './app-config/karma-test-shim.js', watched: false } @@ -23,7 +23,7 @@ module.exports = function (config) { /** * Load the files with webpack and use test configuration for it. */ - webpack: webpackConfig, + webpack: webpackConfig({ target: 'tests', jit: true }), webpackMiddleware: { stats: 'errors-only' diff --git a/src/Squidex/app-config/karma.coverage.conf.js b/src/Squidex/app-config/karma.coverage.conf.js index 441b998b9..8a9b849bf 100644 --- a/src/Squidex/app-config/karma.coverage.conf.js +++ b/src/Squidex/app-config/karma.coverage.conf.js @@ -1,4 +1,4 @@ -var webpackConfig = require('./webpack.test.coverage'); +const webpackConfig = require('./webpack.config'); module.exports = function (config) { var _config = { @@ -10,7 +10,7 @@ module.exports = function (config) { frameworks: ['jasmine'], /** - * Load additional test shim to setup angular2 for testing. + * Load additional test shim to setup angular for testing. */ files: [ { pattern: './app-config/karma-test-shim.js', watched: false } @@ -23,7 +23,7 @@ module.exports = function (config) { /** * Load the files with webpack and use test configuration for it. */ - webpack: webpackConfig, + webpack: webpackConfig({ target: 'tests', coverage: true, jit: true }), webpackMiddleware: { stats: 'errors-only' diff --git a/src/Squidex/app-config/webpack.config.js b/src/Squidex/app-config/webpack.config.js index 5ff10d725..8c46363b7 100644 --- a/src/Squidex/app-config/webpack.config.js +++ b/src/Squidex/app-config/webpack.config.js @@ -1,6 +1,13 @@ const webpack = require('webpack'), - path = require('path'), - helpers = require('./helpers'); + path = require('path'); + +const appRoot = path.resolve(__dirname, '..'); + +function root() { + var newArgs = Array.prototype.slice.call(arguments, 0); + + return path.join.apply(path, [appRoot].concat(newArgs)); +}; const plugins = { // https://github.com/webpack-contrib/mini-css-extract-plugin @@ -8,147 +15,345 @@ const plugins = { // https://github.com/dividab/tsconfig-paths-webpack-plugin TsconfigPathsPlugin: require('tsconfig-paths-webpack-plugin'), // https://github.com/aackerman/circular-dependency-plugin - CircularDependencyPlugin: require('circular-dependency-plugin') + CircularDependencyPlugin: require('circular-dependency-plugin'), + // https://github.com/jantimon/html-webpack-plugin + HtmlWebpackPlugin: require('html-webpack-plugin'), + // https://github.com/mishoo/UglifyJS2/tree/harmony + UglifyJsPlugin: require('uglifyjs-webpack-plugin'), + // https://www.npmjs.com/package/@ngtools/webpack + NgToolsWebpack: require('@ngtools/webpack'), + // https://github.com/NMFR/optimize-css-assets-webpack-plugin + OptimizeCSSAssetsPlugin: require("optimize-css-assets-webpack-plugin"), + // https://github.com/jrparish/tslint-webpack-plugin + TsLintPlugin: require('tslint-webpack-plugin') }; -const isDevServer = path.basename(require.main.filename) === 'webpack-dev-server.js'; +module.exports = function (env) { + const isDevServer = path.basename(require.main.filename) === 'webpack-dev-server.js'; + const isProduction = env && env.production; + const isTests = env && env.target === 'tests'; + const isCoverage = env && env.coverage; + const isAot = isProduction; + + const config = { + mode: isProduction ? 'production' : 'development', -module.exports = { - /** - * Options affecting the resolving of modules. - * - * See: https://webpack.js.org/configuration/resolve/ - */ - resolve: { /** - * An array of extensions that should be used to resolve modules. + * Source map for Karma from the help of karma-sourcemap-loader & karma-webpack. * - * See: https://webpack.js.org/configuration/resolve/#resolve-extensions + * See: https://webpack.js.org/configuration/devtool/ */ - extensions: ['.js', '.mjs', '.ts', '.css', '.scss'], - modules: [ - helpers.root('app'), - helpers.root('app', 'theme'), - helpers.root('node_modules') - ], + devtool: isProduction ? false : 'inline-source-map', - plugins: [ - new plugins.TsconfigPathsPlugin() - ] - }, - - /** - * Options affecting the normal modules. - * - * See: https://webpack.js.org/configuration/module/ - */ - module: { /** - * An array of Rules which are matched to requests when modules are created. + * Options affecting the resolving of modules. * - * See: https://webpack.js.org/configuration/module/#module-rules + * See: https://webpack.js.org/configuration/resolve/ */ - rules: [{ - test: /\.mjs$/, - type: "javascript/auto", - include: [/node_modules/] - }, { - test: /[\/\\]@angular[\/\\]core[\/\\].+\.js$/, - parser: { system: true }, - include: [/node_modules/] - }, { - test: /\.ts$/, - use: [{ - loader: 'awesome-typescript-loader' + resolve: { + /** + * An array of extensions that should be used to resolve modules. + * + * See: https://webpack.js.org/configuration/resolve/#resolve-extensions + */ + extensions: ['.ts', '.js', '.mjs', '.css', '.scss'], + modules: [ + root('app'), + root('app', 'theme'), + root('node_modules') + ], + + plugins: [ + new plugins.TsconfigPathsPlugin() + ] + }, + + /** + * Options affecting the normal modules. + * + * See: https://webpack.js.org/configuration/module/ + */ + module: { + /** + * An array of Rules which are matched to requests when modules are created. + * + * See: https://webpack.js.org/configuration/module/#module-rules + */ + rules: [{ + test: /\.mjs$/, + type: "javascript/auto", + include: [/node_modules/] }, { - loader: 'angular-router-loader' + test: /[\/\\]@angular[\/\\]core[\/\\].+\.js$/, + parser: { system: true }, + include: [/node_modules/] }, { - loader: 'angular2-template-loader' - }], - exclude: [/node_modules/] - }, { + test: /\.js\.flow$/, + use: [{ + loader: 'ignore-loader' + }], + include: [/node_modules/] + }, { + test: /\.(woff|woff2|ttf|eot)(\?.*$|$)/, + use: [{ + loader: 'file-loader?name=[name].[hash].[ext]', + options: { + outputPath: 'assets', + /* + * Use custom public path as ./ is not supported by fonts. + */ + publicPath: isDevServer ? undefined : 'assets' + } + }] + }, { + test: /\.(png|jpe?g|gif|svg|ico)(\?.*$|$)/, + use: [{ + loader: 'file-loader?name=[name].[hash].[ext]', + options: { + outputPath: 'assets' + } + }] + }, { + test: /\.css$/, + use: [ + plugins.MiniCssExtractPlugin.loader, + { + loader: 'css-loader' + }] + }, { + test: /\.scss$/, + use: [{ + loader: 'raw-loader' + }, { + loader: 'sass-loader', options: { includePaths: [root('app', 'theme')] } + }], + exclude: root('app', 'theme') + }] + }, + + plugins: [ + new webpack.ContextReplacementPlugin(/\@angular(\\|\/)core(\\|\/)fesm5/, root('./app'), {}), + new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/), + + /** + * Puts each bundle into a file and appends the hash of the file to the path. + * + * See: https://github.com/webpack-contrib/mini-css-extract-plugin + */ + new plugins.MiniCssExtractPlugin('[name].css'), + + new webpack.LoaderOptionsPlugin({ + options: { + htmlLoader: { + /** + * Define the root for images, so that we can use absolute urls. + * + * See: https://github.com/webpack/html-loader#Advanced_Options + */ + root: root('app', 'images') + }, + context: '/' + } + }), + + /** + * Detect circular dependencies in app. + * + * See: https://github.com/aackerman/circular-dependency-plugin + */ + new plugins.CircularDependencyPlugin({ + exclude: /([\\\/]node_modules[\\\/])|(ngfactory\.js$)/, + // Add errors to webpack instead of warnings + failOnError: true + }), + ], + + devServer: { + headers: { + 'Access-Control-Allow-Origin': '*' + }, + historyApiFallback: true + } + }; + + if (!isTests) { + /** + * The entry point for the bundle. Our Angular app. + * + * See: https://webpack.js.org/configuration/entry-context/ + */ + config.entry = { + 'shims': './app/shims.ts', + 'app': './app/app.ts' + }; + + if (isProduction) { + config.output = { + /** + * The output directory as absolute path (required). + * + * See: https://webpack.js.org/configuration/output/#output-path + */ + path: root('wwwroot/build/'), + + publicPath: './build/', + + /** + * Specifies the name of each output file on disk. + * + * See: https://webpack.js.org/configuration/output/#output-filename + */ + filename: '[name].js', + + /** + * The filename of non-entry chunks as relative path inside the output.path directory. + * + * See: https://webpack.js.org/configuration/output/#output-chunkfilename + */ + chunkFilename: '[id].[hash].chunk.js' + }; + } else { + config.output = { + filename: '[name].js', + + /** + * Set the public path, because we are running the website from another port (5000). + */ + publicPath: 'http://localhost:3000/' + }; + } + + config.plugins.push( + new plugins.HtmlWebpackPlugin({ + hash: true, + chunks: ['shims', 'app'], + chunksSortMode: 'manual', + template: 'wwwroot/index.html' + }) + ); + + config.plugins.push( + new plugins.HtmlWebpackPlugin({ + template: 'wwwroot/_theme.html', hash: true, chunksSortMode: 'none', filename: 'theme.html' + }) + ); + + config.plugins.push( + new plugins.TsLintPlugin({ + files: ['./app/**/*.ts'], + /** + * Path to a configuration file. + */ + config: root('tslint.json'), + /** + * Wait for linting and fail the build when linting error occur. + */ + waitForLinting: isProduction + }) + ); + } + + if (!isCoverage) { + config.plugins.push( + new plugins.NgToolsWebpack.AngularCompilerPlugin({ + directTemplateLoading: true, + entryModule: 'app/app.module#AppModule', + sourceMap: !isProduction, + skipCodeGeneration: !isAot, + tsConfigPath: './tsconfig.json' + }) + ); + } + + if (isProduction) { + config.optimization = { + minimizer: [ + new plugins.UglifyJsPlugin({ + uglifyOptions: { + compress: false, + ecma: 6, + mangle: true, + output: { + comments: false + } + }, + extractComments: true + }), + + new plugins.OptimizeCSSAssetsPlugin({}) + ] + }; + + config.performance = { + hints: false + }; + } + + if (isCoverage) { + // Do not instrument tests. + config.module.rules.push({ test: /\.ts$/, use: [{ - loader: 'awesome-typescript-loader' + loader: 'ts-loader' }], - include: [/node_modules/] - }, { - test: /\.js\.flow$/, + include: [/\.(e2e|spec)\.ts$/], + }); + + // Use instrument loader for all normal files. + config.module.rules.push({ + test: /\.ts$/, use: [{ - loader: 'ignore-loader' + loader: 'istanbul-instrumenter-loader' + }, { + loader: 'ts-loader' }], - include: [/node_modules/] - }, { - test: /\.html$/, + exclude: [/\.(e2e|spec)\.ts$/] + }); + } else { + config.module.rules.push({ + test: /(?:\.ngfactory\.js|\.ngstyle\.js|\.ts)$/, use: [{ - loader: 'raw-loader' + loader: plugins.NgToolsWebpack.NgToolsLoader }] - }, { - test: /\.(woff|woff2|ttf|eot)(\?.*$|$)/, - use: [{ - loader: 'file-loader?name=[name].[hash].[ext]', - options: { - outputPath: 'assets', - /* - * Use custom public path as ./ is not supported by fonts. - */ - publicPath: isDevServer ? undefined : 'assets' - } - }] - }, { - test: /\.(png|jpe?g|gif|svg|ico)(\?.*$|$)/, - use: [{ - loader: 'file-loader?name=[name].[hash].[ext]', - options: { - outputPath: 'assets' - } - }] - }, { - test: /\.css$/, + }) + } + + if (isProduction) { + config.module.rules.push({ + test: /\.scss$/, + /* + * Extract the content from a bundle to a file. + * + * See: https://github.com/webpack-contrib/extract-text-webpack-plugin + */ use: [ plugins.MiniCssExtractPlugin.loader, - { - loader: 'css-loader' - }] - }, { + { + loader: 'css-loader' + }, { + loader: 'sass-loader' + }], + /* + * Do not include component styles. + */ + include: root('app', 'theme'), + }); + } else { + config.module.rules.push({ test: /\.scss$/, use: [{ - loader: 'raw-loader' + loader: 'style-loader' }, { - loader: 'sass-loader', options: { includePaths: [helpers.root('app', 'theme')] } + loader: 'css-loader' + }, { + loader: 'sass-loader?sourceMap' }], - exclude: helpers.root('app', 'theme') - }] - }, + /* + * Do not include component styles. + */ + include: root('app', 'theme') + }); + } - plugins: [ - /** - * Puts each bundle into a file and appends the hash of the file to the path. - * - * See: https://github.com/webpack-contrib/mini-css-extract-plugin - */ - new plugins.MiniCssExtractPlugin('[name].css'), - - new webpack.LoaderOptionsPlugin({ - options: { - htmlLoader: { - /** - * Define the root for images, so that we can use absolute urls. - * - * See: https://github.com/webpack/html-loader#Advanced_Options - */ - root: helpers.root('app', 'images') - }, - context: '/' - } - }), - - new plugins.CircularDependencyPlugin({ - exclude: /([\\\/]node_modules[\\\/])|(ngfactory\.js$)/, - // Add errors to webpack instead of warnings - failOnError: true - }), - - new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/) - ] + return config; }; \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.run.base.js b/src/Squidex/app-config/webpack.run.base.js deleted file mode 100644 index 06012fa60..000000000 --- a/src/Squidex/app-config/webpack.run.base.js +++ /dev/null @@ -1,35 +0,0 @@ -const webpack = require('webpack'), - webpackMerge = require('webpack-merge'), - path = require('path'), - helpers = require('./helpers'), - commonConfig = require('./webpack.config.js'); - -const plugins = { - // https://github.com/jantimon/html-webpack-plugin - HtmlWebpackPlugin: require('html-webpack-plugin') -}; - -module.exports = webpackMerge(commonConfig, { - /** - * The entry point for the bundle. Our Angular app. - * - * See: https://webpack.js.org/configuration/entry-context/ - */ - entry: { - 'shims': './app/shims.ts', - 'app': './app/app.ts' - }, - - plugins: [ - new plugins.HtmlWebpackPlugin({ - hash: true, - chunks: ['shims', 'app'], - chunksSortMode: 'manual', - template: 'wwwroot/index.html' - }), - - new plugins.HtmlWebpackPlugin({ - template: 'wwwroot/_theme.html', hash: true, chunksSortMode: 'none', filename: 'theme.html' - }) - ] -}); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.run.dev.js b/src/Squidex/app-config/webpack.run.dev.js deleted file mode 100644 index 5a34d49d9..000000000 --- a/src/Squidex/app-config/webpack.run.dev.js +++ /dev/null @@ -1,68 +0,0 @@ -const webpack = require('webpack'), - webpackMerge = require('webpack-merge'), - path = require('path'), - helpers = require('./helpers'), - runConfig = require('./webpack.run.base.js'); - -const plugins = { - // https://github.com/jrparish/tslint-webpack-plugin - TsLintPlugin: require('tslint-webpack-plugin') -}; - -module.exports = webpackMerge(runConfig, { - mode: 'development', - - devtool: 'source-map', - - output: { - filename: '[name].js', - - /** - * Set the public path, because we are running the website from another port (5000). - */ - publicPath: 'http://localhost:3000/' - }, - - /* - * Options affecting the normal modules. - * - * See: https://webpack.js.org/configuration/module/ - */ - module: { - /** - * An array of Rules which are matched to requests when modules are created. - * - * See: https://webpack.js.org/configuration/module/#module-rules - */ - rules: [{ - test: /\.scss$/, - use: [{ - loader: 'style-loader' - }, { - loader: 'css-loader' - }, { - loader: 'sass-loader?sourceMap', options: { includePaths: [helpers.root('app', 'theme')] } - }], - include: helpers.root('app', 'theme') - }] - }, - - plugins: [ - new webpack.ContextReplacementPlugin(/\@angular(\\|\/)core(\\|\/)fesm5/, helpers.root('./src'), {}), - - new plugins.TsLintPlugin({ - files: ['./app/**/*.ts'], - /** - * Path to a configuration file. - */ - config: helpers.root('tslint.json') - }) - ], - - devServer: { - headers: { - 'Access-Control-Allow-Origin': '*' - }, - historyApiFallback: true - } -}); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.run.prod.js b/src/Squidex/app-config/webpack.run.prod.js deleted file mode 100644 index 07fbca634..000000000 --- a/src/Squidex/app-config/webpack.run.prod.js +++ /dev/null @@ -1,137 +0,0 @@ -const webpack = require('webpack'), - webpackMerge = require('webpack-merge'), - path = require('path'), - helpers = require('./helpers'), - runConfig = require('./webpack.run.base.js'); - -const plugins = { - // https://github.com/mishoo/UglifyJS2/tree/harmony - UglifyJsPlugin: require('uglifyjs-webpack-plugin'), - // https://www.npmjs.com/package/@ngtools/webpack - NgToolsWebpack: require('@ngtools/webpack'), - // https://github.com/webpack-contrib/mini-css-extract-plugin - MiniCssExtractPlugin: require('mini-css-extract-plugin'), - // https://github.com/NMFR/optimize-css-assets-webpack-plugin - OptimizeCSSAssetsPlugin: require("optimize-css-assets-webpack-plugin"), - // https://github.com/jrparish/tslint-webpack-plugin - TsLintPlugin: require('tslint-webpack-plugin') -}; - -helpers.removeLoaders(runConfig, ['scss', 'ts']); - -module.exports = webpackMerge(runConfig, { - mode: 'production', - - output: { - /** - * The output directory as absolute path (required). - * - * See: https://webpack.js.org/configuration/output/#output-path - */ - path: helpers.root('wwwroot/build/'), - - publicPath: './build/', - - /** - * Specifies the name of each output file on disk. - * - * See: https://webpack.js.org/configuration/output/#output-filename - */ - filename: '[name].js', - - /** - * The filename of non-entry chunks as relative path inside the output.path directory. - * - * See: https://webpack.js.org/configuration/output/#output-chunkfilename - */ - chunkFilename: '[id].[hash].chunk.js' - }, - - /* - * Options affecting the normal modules. - * - * See: https://webpack.js.org/configuration/module/ - */ - module: { - /** - * An array of Rules which are matched to requests when modules are created. - * - * See: https://webpack.js.org/configuration/module/#module-rules - */ - rules: [{ - test: /\.scss$/, - /* - * Extract the content from a bundle to a file. - * - * See: https://github.com/webpack-contrib/extract-text-webpack-plugin - */ - use: [ - plugins.MiniCssExtractPlugin.loader, - { - loader: 'css-loader' - }, { - loader: 'sass-loader' - }], - /* - * Do not include component styles. - */ - include: helpers.root('app', 'theme'), - }, { - test: /\.scss$/, - use: [{ - loader: 'raw-loader' - }, { - loader: 'sass-loader', options: { includePaths: [helpers.root('app', 'theme')] } - }], - exclude: helpers.root('app', 'theme'), - }, { - test: /(?:\.ngfactory\.js|\.ngstyle\.js|\.ts)$/, - use: [{ - loader: '@ngtools/webpack' - }] - }] - }, - - plugins: [ - new plugins.NgToolsWebpack.AngularCompilerPlugin({ - entryModule: 'app/app.module#AppModule', - sourceMap: false, - skipSourceGeneration: false, - tsConfigPath: './tsconfig.json' - }), - - new plugins.TsLintPlugin({ - files: ['./app/**/*.ts'], - /** - * Path to a configuration file. - */ - config: helpers.root('tslint.json'), - /** - * Wait for linting and fail the build when linting error occur. - */ - waitForLinting: true - }) - ], - - optimization: { - minimizer: [ - new plugins.UglifyJsPlugin({ - uglifyOptions: { - compress: false, - ecma: 6, - mangle: true, - output: { - comments: false - } - }, - extractComments: true - }), - - new plugins.OptimizeCSSAssetsPlugin({}) - ] - }, - - performance: { - hints: false - } -}); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.test.coverage.js b/src/Squidex/app-config/webpack.test.coverage.js deleted file mode 100644 index 1989b80d4..000000000 --- a/src/Squidex/app-config/webpack.test.coverage.js +++ /dev/null @@ -1,37 +0,0 @@ -const webpack = require('webpack'), - webpackMerge = require('webpack-merge'), - path = require('path'), - helpers = require('./helpers'), - testConfig = require('./webpack.test.js'); - -helpers.removeLoaders(testConfig, ['ts']); - -module.exports = webpackMerge(testConfig, { - module: { - /** - * An array of Rules which are matched to requests when modules are created. - * - * See: https://webpack.js.org/configuration/module/#module-rules - */ - rules: [{ - test: /\.ts$/, - use: [{ - loader: 'ts-loader' - }], - include: [/\.(e2e|spec)\.ts$/], - - }, { - test: /\.ts$/, - use: [{ - loader: 'istanbul-instrumenter-loader' - }, { - loader: 'ts-loader' - }, { - loader: 'angular-router-loader' - }, { - loader: 'angular2-template-loader' - }], - exclude: [/\.(e2e|spec)\.ts$/] - }] - } -}); \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.test.js b/src/Squidex/app-config/webpack.test.js deleted file mode 100644 index eacdb4361..000000000 --- a/src/Squidex/app-config/webpack.test.js +++ /dev/null @@ -1,16 +0,0 @@ -const webpack = require('webpack'), - webpackMerge = require('webpack-merge'), - path = require('path'), - helpers = require('./helpers'), - commonConfig = require('./webpack.config.js'); - -module.exports = webpackMerge(commonConfig, { - mode: 'development', - - /** - * Source map for Karma from the help of karma-sourcemap-loader & karma-webpack. - * - * See: https://webpack.js.org/configuration/devtool/ - */ - devtool: 'inline-source-map' -}); \ No newline at end of file diff --git a/src/Squidex/app/app.module.ts b/src/Squidex/app/app.module.ts index 98b2a6c97..f4c512db3 100644 --- a/src/Squidex/app/app.module.ts +++ b/src/Squidex/app/app.module.ts @@ -23,7 +23,8 @@ import { DecimalSeparatorConfig, SqxFrameworkModule, SqxSharedModule, - TitlesConfig + TitlesConfig, + UIOptions } from './shared'; import { SqxShellModule } from './shell'; @@ -49,6 +50,10 @@ export function configApiUrl() { } } +export function configUIOptions() { + return new UIOptions(window['options']); +} + export function configTitles() { return new TitlesConfig({}, undefined, 'Squidex Headless CMS'); } @@ -88,7 +93,8 @@ export function configCurrency() { { provide: ApiUrlConfig, useFactory: configApiUrl }, { provide: CurrencyConfig, useFactory: configCurrency }, { provide: DecimalSeparatorConfig, useFactory: configDecimalSeparator }, - { provide: TitlesConfig, useFactory: configTitles } + { provide: TitlesConfig, useFactory: configTitles }, + { provide: UIOptions, useFactory: configUIOptions } ], entryComponents: [AppComponent] }) diff --git a/src/Squidex/app/app.routes.ts b/src/Squidex/app/app.routes.ts index d7598968f..8331b68a4 100644 --- a/src/Squidex/app/app.routes.ts +++ b/src/Squidex/app/app.routes.ts @@ -38,12 +38,12 @@ export const routes: Routes = [ children: [ { path: '', - loadChildren: './features/apps/module#SqxFeatureAppsModule', + loadChildren: () => import('./features/apps/module').then(m => m.SqxFeatureAppsModule), canActivate: [UnsetAppGuard] }, { path: 'administration', - loadChildren: './features/administration/module#SqxFeatureAdministrationModule', + loadChildren: () => import('./features/administration/module').then(m => m.SqxFeatureAdministrationModule), canActivate: [UnsetAppGuard] }, { @@ -53,31 +53,31 @@ export const routes: Routes = [ children: [ { path: '', - loadChildren: './features/dashboard/module#SqxFeatureDashboardModule' + loadChildren: () => import('./features/dashboard/module').then(m => m.SqxFeatureDashboardModule) }, { path: 'content', - loadChildren: './features/content/module#SqxFeatureContentModule' + loadChildren: () => import('./features/content/module').then(m => m.SqxFeatureContentModule) }, { path: 'schemas', - loadChildren: './features/schemas/module#SqxFeatureSchemasModule' + loadChildren: () => import('./features/schemas/module').then(m => m.SqxFeatureSchemasModule) }, { path: 'assets', - loadChildren: './features/assets/module#SqxFeatureAssetsModule' + loadChildren: () => import('./features/assets/module').then(m => m.SqxFeatureAssetsModule) }, { path: 'rules', - loadChildren: './features/rules/module#SqxFeatureRulesModule' + loadChildren: () => import('./features/rules/module').then(m => m.SqxFeatureRulesModule) }, { path: 'settings', - loadChildren: './features/settings/module#SqxFeatureSettingsModule' + loadChildren: () => import('./features/settings/module').then(m => m.SqxFeatureSettingsModule) }, { path: 'api', - loadChildren: './features/api/module#SqxFeatureApiModule' + loadChildren: () => import('./features/api/module').then(m => m.SqxFeatureApiModule) } ] } diff --git a/src/Squidex/app/declarations.d.ts b/src/Squidex/app/declarations.d.ts index d40cdbd10..43716dcf2 100644 --- a/src/Squidex/app/declarations.d.ts +++ b/src/Squidex/app/declarations.d.ts @@ -12,6 +12,8 @@ declare module 'progressbar.js'; declare module 'sortablejs' { export class Ref { public destroy(): any; + + public option(property: string, value: any): any; } export function create(element: any, options: any): Ref; diff --git a/src/Squidex/app/features/administration/administration-area.component.html b/src/Squidex/app/features/administration/administration-area.component.html index c3b623cdb..658062f67 100644 --- a/src/Squidex/app/features/administration/administration-area.component.html +++ b/src/Squidex/app/features/administration/administration-area.component.html @@ -2,17 +2,17 @@ -
+
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 cb5d2a3d6..d2c2c1583 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 @@ -24,7 +24,9 @@ import { templateUrl: './user-page.component.html' }) export class UserPageComponent extends ResourceOwner implements OnInit { - public user?: { user: UserDto, isCurrentUser: boolean }; + public isEditable = true; + + public user?: UserDto; public userForm = new UserForm(this.formBuilder); constructor( @@ -43,17 +45,24 @@ export class UserPageComponent extends ResourceOwner implements OnInit { this.user = selectedUser!; if (selectedUser) { - this.userForm.load(selectedUser.user); + this.isEditable = this.user.canUpdate; + + this.userForm.load(selectedUser); + this.userForm.setEnabled(this.isEditable); } })); } public save() { + if (!this.isEditable) { + return; + } + const value = this.userForm.submit(); if (value) { if (this.user) { - this.usersState.update(this.user.user, value) + this.usersState.update(this.user, value) .subscribe(() => { this.userForm.submitCompleted(); }, error => { 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 223f584bf..3fe383d68 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 @@ -12,15 +12,18 @@ -
- + + + + + @@ -48,32 +51,24 @@
- - + + diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.ts b/src/Squidex/app/features/administration/pages/users/users-page.component.ts index 065b20858..f029712b4 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.ts @@ -51,8 +51,8 @@ export class UsersPageComponent implements OnInit { this.usersState.unlock(user); } - public trackByUser(index: number, userInfo: { user: UserDto }) { - return userInfo.user.id; + public trackByUser(index: number, user: UserDto) { + return user.id; } } diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts index 5eacb6d62..6815d1093 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts @@ -8,9 +8,13 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; -import { ApiUrlConfig } from '@app/framework'; +import { ApiUrlConfig, Resource, ResourceLinks } from '@app/framework'; -import { EventConsumerDto, EventConsumersService } from './event-consumers.service'; +import { + EventConsumerDto, + EventConsumersDto, + EventConsumersService +} from './event-consumers.service'; describe('EventConsumersService', () => { beforeEach(() => { @@ -32,7 +36,7 @@ describe('EventConsumersService', () => { it('should make get request to get event consumers', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - let eventConsumers: EventConsumerDto[]; + let eventConsumers: EventConsumersDto; eventConsumersService.getEventConsumers().subscribe(result => { eventConsumers = result; @@ -43,66 +47,118 @@ describe('EventConsumersService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush([ - { - name: 'event-consumer1', - position: '13', - isStopped: true, - isResetting: true, - error: 'an error 1' - }, - { - name: 'event-consumer2', - position: '29', - isStopped: true, - isResetting: true, - error: 'an error 2' - } - ]); + req.flush({ + items: [ + eventConsumerResponse(12), + eventConsumerResponse(13) + ] + }); expect(eventConsumers!).toEqual( - [ - new EventConsumerDto('event-consumer1', true, true, 'an error 1', '13'), - new EventConsumerDto('event-consumer2', true, true, 'an error 2', '29') - ]); + new EventConsumersDto([ + createEventConsumer(12), + createEventConsumer(13) + ])); })); it('should make put request to start event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putStart('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + start: { method: 'PUT', href: 'api/event-consumers/event-consumer123/start' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/start'); + eventConsumersService.putStart(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/start'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(123)); + + expect(eventConsumer!).toEqual(createEventConsumer(123)); })); it('should make put request to stop event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putStop('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + stop: { method: 'PUT', href: 'api/event-consumers/event-consumer123/stop' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/stop'); + eventConsumersService.putStop(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/stop'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(12)); + + expect(eventConsumer!).toEqual(createEventConsumer(12)); })); it('should make put request to reset event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putReset('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + reset: { method: 'PUT', href: 'api/event-consumers/event-consumer123/reset' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/reset'); + eventConsumersService.putReset(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/reset'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(12)); + + expect(eventConsumer!).toEqual(createEventConsumer(12)); })); -}); \ No newline at end of file + + function eventConsumerResponse(id: number) { + return { + name: `event-consumer${id}`, + position: `position-${id}`, + isStopped: true, + isResetting: true, + error: `failure-${id}`, + _links: { + reset: { method: 'PUT', href: `/event-consumers/${id}/reset` } + } + }; + } +}); + +export function createEventConsumer(id: number, suffix = '') { + const links: ResourceLinks = { + reset: { method: 'PUT', href: `/event-consumers/${id}/reset` } + }; + + return new EventConsumerDto(links, + `event-consumer${id}`, + true, + true, + `failure-${id}${suffix}`, + `position-${id}${suffix}`); +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.ts b/src/Squidex/app/features/administration/services/event-consumers.service.ts index 05b2cd0fd..2aa592765 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.ts @@ -12,19 +12,41 @@ import { map } from 'rxjs/operators'; import { ApiUrlConfig, - Model, - pretifyError + hasAnyLink, + pretifyError, + Resource, + ResourceLinks } from '@app/shared'; -export class EventConsumerDto extends Model { +export class EventConsumersDto { + public readonly _links: ResourceLinks; + constructor( + public readonly items: EventConsumerDto[], links?: ResourceLinks + ) { + this._links = links || {}; + } +} + +export class EventConsumerDto { + public readonly _links: ResourceLinks; + + public readonly canStop: boolean; + public readonly canStart: boolean; + public readonly canRestart: boolean; + + constructor(links: ResourceLinks, public readonly name: string, public readonly isStopped?: boolean, public readonly isResetting?: boolean, public readonly error?: string, public readonly position?: string ) { - super(); + this._links = links; + + this.canStop = hasAnyLink(links, 'stop'); + this.canStart = hasAnyLink(links, 'start'); + this.canRestart = hasAnyLink(links, 'canReset'); } } @@ -36,42 +58,61 @@ export class EventConsumersService { ) { } - public getEventConsumers(): Observable { + public getEventConsumers(): Observable { const url = this.apiUrl.buildUrl('/api/event-consumers'); - return this.http.get(url).pipe( - map(body => { - const eventConsumers = body.map(item => - new EventConsumerDto( - item.name, - item.isStopped, - item.isResetting, - item.error, - item.position)); - - return eventConsumers; - }), - pretifyError('Failed to load event consumers. Please reload.')); + return this.http.get<{ items: any[] } & Resource>(url).pipe( + map(({ items, _links }) => { + const eventConsumers = items.map(item => parseEventConsumer(item)); + + return new EventConsumersDto(eventConsumers, _links); + }), + pretifyError('Failed to load event consumers. Please reload.')); } - public putStart(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/start`); + public putStart(eventConsumer: Resource): Observable { + const link = eventConsumer._links['start']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( - pretifyError('Failed to start event consumer. Please reload.')); + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), + pretifyError('Failed to start event consumer. Please reload.')); } - public putStop(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/stop`); + public putStop(eventConsumer: Resource): Observable { + const link = eventConsumer._links['stop']; - return this.http.put(url, {}).pipe( - pretifyError('Failed to stop event consumer. Please reload.')); + const url = this.apiUrl.buildUrl(link.href); + + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), + pretifyError('Failed to stop event consumer. Please reload.')); } - public putReset(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/reset`); + public putReset(eventConsumer: Resource): Observable { + const link = eventConsumer._links['reset']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( - pretifyError('Failed to reset event consumer. Please reload.')); + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), + pretifyError('Failed to reset event consumer. Please reload.')); } -} \ No newline at end of file +} + +function parseEventConsumer(response: any): EventConsumerDto { + return new EventConsumerDto( + response._links, + response.name, + response.isStopped, + response.isResetting, + response.error, + response.position); +} diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts index ee74d079f..028660711 100644 --- a/src/Squidex/app/features/administration/services/users.service.spec.ts +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -8,7 +8,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; -import { ApiUrlConfig } from '@app/framework'; +import { ApiUrlConfig, Resource, ResourceLinks } from '@app/framework'; import { UserDto, @@ -50,27 +50,15 @@ describe('UsersService', () => { req.flush({ total: 100, items: [ - { - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }, - { - id: '456', - email: 'mail2@domain.com', - displayName: 'User2', - permissions: ['Permission2'], - isLocked: true - } + userResponse(12), + userResponse(13) ] }); expect(users!).toEqual( new UsersDto(100, [ - new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true), - new UserDto('456', 'mail2@domain.com', 'User2', ['Permission2'], true) + createUser(12), + createUser(13) ])); })); @@ -91,27 +79,15 @@ describe('UsersService', () => { req.flush({ total: 100, items: [ - { - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }, - { - id: '456', - email: 'mail2@domain.com', - displayName: 'User2', - permissions: ['Permission2'], - isLocked: true - } + userResponse(12), + userResponse(13) ] }); expect(users!).toEqual( new UsersDto(100, [ - new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true), - new UserDto('456', 'mail2@domain.com', 'User2', ['Permission2'], true) + createUser(12), + createUser(13) ])); })); @@ -129,15 +105,9 @@ describe('UsersService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({ - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }); + req.flush(userResponse(12)); - expect(user!).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true)); + expect(user!).toEqual(createUser(12)); })); it('should make post request to create user', @@ -156,9 +126,9 @@ describe('UsersService', () => { expect(req.request.method).toEqual('POST'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({ id: '123', pictureUrl: 'path/to/image1' }); + req.flush(userResponse(12)); - expect(user!).toEqual(new UserDto('123', dto.email, dto.displayName, dto.permissions, false)); + expect(user!).toEqual(createUser(12)); })); it('should make put request to update user', @@ -166,39 +136,107 @@ describe('UsersService', () => { const dto = { email: 'mail@squidex.io', displayName: 'Squidex User', permissions: ['Permission1'], password: 'password' }; - userManagementService.putUser('123', dto).subscribe(); + const resource: Resource = { + _links: { + update: { method: 'PUT', href: 'api/user-management/123' } + } + }; + + let user: UserDto; + + userManagementService.putUser(resource, dto).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); it('should make put request to lock user', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - userManagementService.lockUser('123').subscribe(); + const resource: Resource = { + _links: { + lock: { method: 'PUT', href: 'api/user-management/123/lock' } + } + }; + + let user: UserDto; + + userManagementService.lockUser(resource).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123/lock'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); it('should make put request to unlock user', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - userManagementService.unlockUser('123').subscribe(); + const resource: Resource = { + _links: { + unlock: { method: 'PUT', href: 'api/user-management/123/unlock' } + } + }; + + let user: UserDto; + + userManagementService.unlockUser(resource).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123/unlock'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); -}); \ No newline at end of file + + function userResponse(id: number) { + return { + id: `${id}`, + email: `user${id}@domain.com`, + displayName: `user${id}`, + permissions: [ + `Permission${id}` + ], + isLocked: true, + _links: { + update: { + method: 'PUT', href: `/users/${id}` + } + } + }; + } +}); + +export function createUser(id: number, suffix = '') { + const links: ResourceLinks = { + update: { method: 'PUT', href: `/users/${id}` } + }; + + return new UserDto(links, + `${id}`, + `user${id}${suffix}@domain.com`, + `user${id}${suffix}`, + [ + `Permission${id}${suffix}` + ], + true); +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index a657eee38..3d6cc1a05 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -12,26 +12,38 @@ import { map } from 'rxjs/operators'; import { ApiUrlConfig, - Model, + hasAnyLink, pretifyError, + Resource, + ResourceLinks, ResultSet } from '@app/shared'; -export class UsersDto extends ResultSet {} +export class UsersDto extends ResultSet { + public get canCreate() { + return hasAnyLink(this._links, 'create'); + } +} -export class UserDto extends Model { - constructor( +export class UserDto { + public readonly _links: ResourceLinks; + + public readonly canLock: boolean; + public readonly canUnlock: boolean; + public readonly canUpdate: boolean; + + constructor(links: ResourceLinks, public readonly id: string, public readonly email: string, public readonly displayName: string, public readonly permissions: string[] = [], public readonly isLocked?: boolean ) { - super(); - } + this._links = links; - public with(value: Partial): UserDto { - return this.clone(value); + this.canLock = hasAnyLink(links, 'lock'); + this.canUnlock = hasAnyLink(links, 'unlock'); + this.canUpdate = hasAnyLink(links, 'update'); } } @@ -60,73 +72,78 @@ export class UsersService { public getUsers(take: number, skip: number, query?: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`); - return this.http.get<{ total: number, items: any[] }>(url).pipe( - map(body => { - const users = body.items.map(item => - new UserDto( - item.id, - item.email, - item.displayName, - item.permissions, - item.isLocked)); - - return new UsersDto(body.total, users); - }), - pretifyError('Failed to load users. Please reload.')); + return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe( + map(({ total, items, _links }) => { + const users = items.map(item => parseUser(item)); + + return new UsersDto(total, users, _links); + }), + pretifyError('Failed to load users. Please reload.')); } public getUser(id: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management/${id}`); - return this.http.get(url).pipe( - map(body => { - const user = new UserDto( - body.id, - body.email, - body.displayName, - body.permissions, - body.isLocked); - - return user; - }), - pretifyError('Failed to load user. Please reload.')); + return this.http.get(url).pipe( + map(body => { + return parseUser(body); + }), + pretifyError('Failed to load user. Please reload.')); } public postUser(dto: CreateUserDto): Observable { const url = this.apiUrl.buildUrl('api/user-management'); - return this.http.post(url, dto).pipe( - map(body => { - const user = new UserDto( - body.id, - dto.email, - dto.displayName, - dto.permissions, - false); - - return user; - }), - pretifyError('Failed to create user. Please reload.')); + return this.http.post(url, dto).pipe( + map(body => { + return parseUser(body); + }), + pretifyError('Failed to create user. Please reload.')); } - public putUser(id: string, dto: UpdateUserDto): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}`); + public putUser(user: Resource, dto: UpdateUserDto): Observable { + const link = user._links['update']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, dto).pipe( - pretifyError('Failed to update user. Please reload.')); + return this.http.request(link.method, url, { body: dto }).pipe( + map(body => { + return parseUser(body); + }), + pretifyError('Failed to update user. Please reload.')); } - public lockUser(id: string): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`); + public lockUser(user: Resource): Observable { + const link = user._links['lock']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( - pretifyError('Failed to load users. Please retry.')); + return this.http.request(link.method, url).pipe( + map(body => { + return parseUser(body); + }), + pretifyError('Failed to load users. Please retry.')); } - public unlockUser(id: string): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`); + public unlockUser(user: Resource): Observable { + const link = user._links['unlock']; - return this.http.put(url, {}).pipe( - pretifyError('Failed to load users. Please retry.')); + const url = this.apiUrl.buildUrl(link.href); + + return this.http.request(link.method, url).pipe( + map(body => { + return parseUser(body); + }), + pretifyError('Failed to load users. Please retry.')); } +} + +function parseUser(response: any) { + return new UserDto( + response._links, + response.id, + response.email, + response.displayName, + response.permissions, + response.isLocked); } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts index 4a69ffbeb..2a4c32c14 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts @@ -11,14 +11,14 @@ import { IMock, It, Mock, Times } from 'typemoq'; import { DialogService } from '@app/framework'; -import { EventConsumerDto, EventConsumersService } from '@app/features/administration/internal'; +import { EventConsumersDto, EventConsumersService } from '@app/features/administration/internal'; import { EventConsumersState } from './event-consumers.state'; +import { createEventConsumer } from './../services/event-consumers.service.spec'; + describe('EventConsumersState', () => { - const oldConsumers = [ - new EventConsumerDto('name1', false, false, 'error', '1'), - new EventConsumerDto('name2', true, true, 'error', '2') - ]; + const eventConsumer1 = createEventConsumer(1); + const eventConsumer2 = createEventConsumer(2); let dialogs: IMock; let eventConsumersService: IMock; @@ -38,11 +38,11 @@ describe('EventConsumersState', () => { describe('Loading', () => { it('should load event consumers', () => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load().subscribe(); - expect(eventConsumersState.snapshot.eventConsumers.values).toEqual(oldConsumers); + expect(eventConsumersState.snapshot.eventConsumers.values).toEqual([eventConsumer1, eventConsumer2]); expect(eventConsumersState.snapshot.isLoaded).toBeTruthy(); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); @@ -50,7 +50,7 @@ describe('EventConsumersState', () => { it('should show notification on load when reload is true', () => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load(true).subscribe(); @@ -74,42 +74,48 @@ describe('EventConsumersState', () => { describe('Updates', () => { beforeEach(() => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load().subscribe(); }); - it('should unmark as stopped when started', () => { - eventConsumersService.setup(x => x.putStart(oldConsumers[1].name)) - .returns(() => of({})).verifiable(); + it('should update event consumer when started', () => { + const updated = createEventConsumer(2, '_new'); + + eventConsumersService.setup(x => x.putStart(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - eventConsumersState.start(oldConsumers[1]).subscribe(); + eventConsumersState.start(eventConsumer2).subscribe(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(1); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); - expect(es_1.isStopped).toBeFalsy(); + expect(newConsumer2).toEqual(updated); }); - it('should mark as stopped when stopped', () => { - eventConsumersService.setup(x => x.putStop(oldConsumers[0].name)) - .returns(() => of({})).verifiable(); + it('should update event consumer when stopped', () => { + const updated = createEventConsumer(2, '_new'); - eventConsumersState.stop(oldConsumers[0]).subscribe(); + eventConsumersService.setup(x => x.putStop(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(0); + eventConsumersState.stop(eventConsumer2).subscribe(); - expect(es_1.isStopped).toBeTruthy(); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); + + expect(newConsumer2).toEqual(updated); }); - it('should mark as resetting when reset', () => { - eventConsumersService.setup(x => x.putReset(oldConsumers[0].name)) - .returns(() => of({})).verifiable(); + it('should update event consumer when reset', () => { + const updated = createEventConsumer(2, '_new'); + + eventConsumersService.setup(x => x.putReset(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - eventConsumersState.reset(oldConsumers[0]).subscribe(); + eventConsumersState.reset(eventConsumer2).subscribe(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(0); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); - expect(es_1.isResetting).toBeTruthy(); + expect(newConsumer2).toEqual(updated); }); }); }); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.ts b/src/Squidex/app/features/administration/state/event-consumers.state.ts index f5c5a0d1d..671adbbdc 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, map, tap } from 'rxjs/operators'; +import { tap } from 'rxjs/operators'; import { DialogService, @@ -31,12 +31,10 @@ type EventConsumersList = ImmutableArray; @Injectable() export class EventConsumersState extends State { public eventConsumers = - this.changes.pipe(map(x => x.eventConsumers), - distinctUntilChanged()); + this.project(x => x.eventConsumers); public isLoaded = - this.changes.pipe(map(x => !!x.isLoaded), - distinctUntilChanged()); + this.project(x => !!x.isLoaded); constructor( private readonly dialogs: DialogService, @@ -51,12 +49,12 @@ export class EventConsumersState extends State { } return this.eventConsumersService.getEventConsumers().pipe( - tap(payload => { + tap(({ items }) => { if (isReload && !silent) { this.dialogs.notifyInfo('Event Consumers reloaded.'); } - const eventConsumers = ImmutableArray.of(payload); + const eventConsumers = ImmutableArray.of(items); this.next(s => { return { ...s, eventConsumers, isLoaded: true }; @@ -66,8 +64,7 @@ export class EventConsumersState extends State { } public start(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putStart(eventConsumer.name).pipe( - map(() => setStopped(eventConsumer, false)), + return this.eventConsumersService.putStart(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -75,8 +72,7 @@ export class EventConsumersState extends State { } public stop(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putStop(eventConsumer.name).pipe( - map(() => setStopped(eventConsumer, true)), + return this.eventConsumersService.putStop(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -84,8 +80,7 @@ export class EventConsumersState extends State { } public reset(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putReset(eventConsumer.name).pipe( - map(() => reset(eventConsumer)), + return this.eventConsumersService.putReset(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -99,10 +94,4 @@ export class EventConsumersState extends State { return { ...s, eventConsumers }; }); } -} - -const setStopped = (eventConsumer: EventConsumerDto, isStopped: boolean) => - eventConsumer.with({ isStopped }); - -const reset = (eventConsumer: EventConsumerDto) => - eventConsumer.with({ isResetting: true }); \ No newline at end of file +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts index c2a912ecd..90fd5321d 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -8,7 +8,7 @@ import { of, throwError } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; -import { AuthService, DialogService } from '@app/shared'; +import { DialogService } from '@app/shared'; import { UserDto, @@ -16,31 +16,27 @@ import { UsersService } from '@app/features/administration/internal'; -import { SnapshotUser, UsersState } from './users.state'; +import { UsersState } from './users.state'; + +import { createUser } from './../services/users.service.spec'; describe('UsersState', () => { - const oldUsers = [ - new UserDto('id1', 'mail1@mail.de', 'name1', ['Permission1'], false), - new UserDto('id2', 'mail2@mail.de', 'name2', ['Permission2'], true) - ]; + const user1 = createUser(1); + const user2 = createUser(2); + + const oldUsers = new UsersDto(200, [user1, user2]); - const newUser = new UserDto('id3', 'mail3@mail.de', 'name3', ['Permission3'], false); + const newUser = createUser(3); - let authService: IMock; let dialogs: IMock; let usersService: IMock; let usersState: UsersState; beforeEach(() => { - authService = Mock.ofType(); - - authService.setup(x => x.user) - .returns(() => { id: 'id2' }); - dialogs = Mock.ofType(); usersService = Mock.ofType(); - usersState = new UsersState(authService.object, dialogs.object, usersService.object); + usersState = new UsersState(dialogs.object, usersService.object); }); afterEach(() => { @@ -50,14 +46,11 @@ describe('UsersState', () => { describe('Loading', () => { it('should load users', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(oldUsers)).verifiable(); usersState.load().subscribe(); - expect(usersState.snapshot.users.values).toEqual([ - { isCurrentUser: false, user: oldUsers[0] }, - { isCurrentUser: true, user: oldUsers[1] } - ]); + expect(usersState.snapshot.users.values).toEqual([user1, user2]); expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); expect(usersState.snapshot.isLoaded).toBeTruthy(); @@ -66,7 +59,7 @@ describe('UsersState', () => { it('should show notification on load when reload is true', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(oldUsers)).verifiable(); usersState.load(true).subscribe(); @@ -77,26 +70,26 @@ describe('UsersState', () => { it('should replace selected user when reloading', () => { const newUsers = [ - new UserDto('id1', 'mail1@mail.de_new', 'name1_new', ['Permission1_New'], false), - new UserDto('id2', 'mail2@mail.de_new', 'name2_new', ['Permission2_New'], true) + createUser(1, '_new'), + createUser(2, '_new') ]; usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(Times.exactly(2)); + .returns(() => of(oldUsers)).verifiable(Times.exactly(2)); usersService.setup(x => x.getUsers(10, 0, undefined)) .returns(() => of(new UsersDto(200, newUsers))); usersState.load().subscribe(); - usersState.select('id1').subscribe(); + usersState.select(user1.id).subscribe(); usersState.load().subscribe(); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUsers[0] }); + expect(usersState.snapshot.selectedUser).toEqual(newUsers[0]); }); it('should load next page and prev page when paging', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(Times.exactly(2)); + .returns(() => of(oldUsers)).verifiable(Times.exactly(2)); usersService.setup(x => x.getUsers(10, 10, undefined)) .returns(() => of(new UsersDto(200, []))).verifiable(); @@ -121,38 +114,38 @@ describe('UsersState', () => { describe('Updates', () => { beforeEach(() => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(oldUsers)).verifiable(); usersState.load().subscribe(); }); it('should return user on select and not load when already loaded', () => { - let selectedUser: SnapshotUser; + let selectedUser: UserDto; - usersState.select('id1').subscribe(x => { + usersState.select(user1.id).subscribe(x => { selectedUser = x!; }); - expect(selectedUser!.user).toEqual(oldUsers[0]); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: oldUsers[0] }); + expect(selectedUser!).toEqual(user1); + expect(usersState.snapshot.selectedUser).toEqual(user1); }); it('should return user on select and load when not loaded', () => { usersService.setup(x => x.getUser('id3')) .returns(() => of(newUser)); - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select('id3').subscribe(x => { selectedUser = x!; }); - expect(selectedUser!.user).toEqual(newUser); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUser }); + expect(selectedUser!).toEqual(newUser); + expect(usersState.snapshot.selectedUser).toEqual(newUser); }); it('should return null on select when unselecting user', () => { - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select(null).subscribe(x => { selectedUser = x!; @@ -166,7 +159,7 @@ describe('UsersState', () => { usersService.setup(x => x.getUser('unknown')) .returns(() => throwError({})).verifiable(); - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select('unknown').subscribe(x => { selectedUser = x!; @@ -176,47 +169,50 @@ describe('UsersState', () => { expect(usersState.snapshot.selectedUser).toBeNull(); }); - it('should mark as locked when locked', () => { - usersService.setup(x => x.lockUser('id1')) - .returns(() => of({})).verifiable(); + it('should update user and selected user when locked', () => { + const updated = createUser(2, '_new'); - usersState.select('id1').subscribe(); - usersState.lock(oldUsers[0]).subscribe(); + usersService.setup(x => x.lockUser(user2)) + .returns(() => of(updated)).verifiable(); - const user_1 = usersState.snapshot.users.at(0); + usersState.select(user2.id).subscribe(); + usersState.lock(user2).subscribe(); - expect(user_1.user.isLocked).toBeTruthy(); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + const user2New = usersState.snapshot.users.at(1); + + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); - it('should unmark as locked when unlocked', () => { - usersService.setup(x => x.unlockUser('id2')) - .returns(() => of({})).verifiable(); + it('should update user and selected user when unlocked', () => { + const updated = createUser(2, '_new'); + + usersService.setup(x => x.unlockUser(user2)) + .returns(() => of(updated)).verifiable(); - usersState.select('id2').subscribe(); - usersState.unlock(oldUsers[1]).subscribe(); + usersState.select(user2.id).subscribe(); + usersState.unlock(user2).subscribe(); - const user_1 = usersState.snapshot.users.at(1); + const user2New = usersState.snapshot.users.at(1); - expect(user_1.user.isLocked).toBeFalsy(); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + expect(user2New).toEqual(updated); + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); - it('should update user properties when updated', () => { + it('should update user and selected user when updated', () => { const request = { email: 'new@mail.com', displayName: 'New', permissions: ['Permission1'] }; - usersService.setup(x => x.putUser('id1', request)) - .returns(() => of({})).verifiable(); + const updated = createUser(2, '_new'); + + usersService.setup(x => x.putUser(user2, request)) + .returns(() => of(updated)).verifiable(); - usersState.select('id1').subscribe(); - usersState.update(oldUsers[0], request).subscribe(); + usersState.select(user2.id).subscribe(); + usersState.update(user2, request).subscribe(); - const user_1 = usersState.snapshot.users.at(0); + const user2New = usersState.snapshot.users.at(1); - expect(user_1.user.email).toEqual(request.email); - expect(user_1.user.displayName).toEqual(request.displayName); - expect(user_1.user.permissions).toEqual(request.permissions); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + expect(user2New).toEqual(updated); + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); it('should add user to snapshot when created', () => { @@ -227,11 +223,7 @@ describe('UsersState', () => { usersState.create(request).subscribe(); - expect(usersState.snapshot.users.values).toEqual([ - { isCurrentUser: false, user: newUser }, - { isCurrentUser: false, user: oldUsers[0] }, - { isCurrentUser: true, user: oldUsers[1] } - ]); + expect(usersState.snapshot.users.values).toEqual([newUser, user1, user2]); expect(usersState.snapshot.usersPager.numberOfItems).toBe(201); }); }); diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts index 7de907b24..773f441a7 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -7,12 +7,11 @@ import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators'; +import { catchError, tap } from 'rxjs/operators'; import '@app/framework/utils/rxjs-extensions'; import { - AuthService, DialogService, ImmutableArray, Pager, @@ -27,14 +26,6 @@ import { UsersService } from './../services/users.service'; -export interface SnapshotUser { - // The user. - user: UserDto; - - // Indicates if the user is the current user. - isCurrentUser: boolean; -} - interface Snapshot { // The current users. users: UsersList; @@ -49,39 +40,40 @@ interface Snapshot { isLoaded?: boolean; // The selected user. - selectedUser?: SnapshotUser | null; + selectedUser?: UserDto | null; + + // Indicates if the user can create a user. + canCreate?: boolean; } -export type UsersList = ImmutableArray; +export type UsersList = ImmutableArray; export type UsersResult = { total: number, users: UsersList }; @Injectable() export class UsersState extends State { public users = - this.changes.pipe(map(x => x.users), - distinctUntilChanged()); + this.project(x => x.users); public usersPager = - this.changes.pipe(map(x => x.usersPager), - distinctUntilChanged()); + this.project(x => x.usersPager); public selectedUser = - this.changes.pipe(map(x => x.selectedUser), - distinctUntilChanged()); + this.project(x => x.selectedUser); public isLoaded = - this.changes.pipe(map(x => !!x.isLoaded), - distinctUntilChanged()); + this.project(x => !!x.isLoaded); + + public canCreate = + this.project(x => !!x.canCreate); constructor( - private readonly authState: AuthService, private readonly dialogs: DialogService, private readonly usersService: UsersService ) { super({ users: ImmutableArray.empty(), usersPager: new Pager(0) }); } - public select(id: string | null): Observable { + public select(id: string | null): Observable { return this.loadUser(id).pipe( tap(selectedUser => { this.next(s => ({ ...s, selectedUser })); @@ -94,13 +86,13 @@ export class UsersState extends State { return of(null); } - const found = this.snapshot.users.find(x => x.user.id === id); + const found = this.snapshot.users.find(x => x.id === id); if (found) { return of(found); } - return this.usersService.getUser(id).pipe(map(x => this.createUser(x)), catchError(() => of(null))); + return this.usersService.getUser(id).pipe(catchError(() => of(null))); } public load(isReload = false): Observable { @@ -118,22 +110,28 @@ export class UsersState extends State { this.snapshot.usersPager.pageSize, this.snapshot.usersPager.skip, this.snapshot.usersQuery).pipe( - tap(({ total, items }) => { + tap(({ total, items, canCreate }) => { if (isReload) { this.dialogs.notifyInfo('Users reloaded.'); } this.next(s => { const usersPager = s.usersPager.setCount(total); - const users = ImmutableArray.of(items.map(x => this.createUser(x))); + const users = ImmutableArray.of(items); let selectedUser = s.selectedUser; if (selectedUser) { - selectedUser = users.find(x => x.user.id === selectedUser!.user.id) || selectedUser; + selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser; } - return { ...s, users, usersPager, selectedUser, isLoaded: true }; + return { ...s, + canCreate, + isLoaded: true, + selectedUser, + users, + usersPager + }; }); }), shareSubscribed(this.dialogs)); @@ -143,7 +141,7 @@ export class UsersState extends State { return this.usersService.postUser(request).pipe( tap(created => { this.next(s => { - const users = s.users.pushFront(this.createUser(created)); + const users = s.users.pushFront(created); const usersPager = s.usersPager.incrementCount(); return { ...s, users, usersPager }; @@ -153,8 +151,7 @@ export class UsersState extends State { } public update(user: UserDto, request: UpdateUserDto): Observable { - return this.usersService.putUser(user.id, request).pipe( - map(() => update(user, request)), + return this.usersService.putUser(user, request).pipe( tap(updated => { this.replaceUser(updated); }), @@ -162,8 +159,7 @@ export class UsersState extends State { } public lock(user: UserDto): Observable { - return this.usersService.lockUser(user.id).pipe( - map(() => setLocked(user, true)), + return this.usersService.lockUser(user).pipe( tap(updated => { this.replaceUser(updated); }), @@ -171,8 +167,7 @@ export class UsersState extends State { } public unlock(user: UserDto): Observable { - return this.usersService.unlockUser(user.id).pipe( - map(() => setLocked(user, false)), + return this.usersService.unlockUser(user).pipe( tap(updated => { this.replaceUser(updated); }), @@ -199,30 +194,15 @@ export class UsersState extends State { private replaceUser(user: UserDto) { return this.next(s => { - const users = s.users.map(u => u.user.id === user.id ? this.createUser(user) : u); + const users = s.users.map(u => u.id === user.id ? user : u); const selectedUser = s.selectedUser && - s.selectedUser.user.id !== user.id ? + s.selectedUser.id !== user.id ? s.selectedUser : - users.find(x => x.user.id === user.id); + users.find(x => x.id === user.id); return { ...s, users, selectedUser }; }); } - - private get userId() { - return this.authState.user!.id; - } - - private createUser(user: UserDto): SnapshotUser { - return { user, isCurrentUser: user.id === this.userId }; - } -} - - -const update = (user: UserDto, request: UpdateUserDto) => - user.with(request); - -const setLocked = (user: UserDto, isLocked: boolean) => - user.with({ isLocked }); \ No newline at end of file +} \ No newline at end of file diff --git a/src/Squidex/app/features/api/module.ts b/src/Squidex/app/features/api/module.ts index f4701b425..471c3efd6 100644 --- a/src/Squidex/app/features/api/module.ts +++ b/src/Squidex/app/features/api/module.ts @@ -45,4 +45,4 @@ const routes: Routes = [ GraphQLPageComponent ] }) -export class SqxFeatureApiModule { } \ No newline at end of file +export class SqxFeatureApiModule {} \ No newline at end of file diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss index a70e7cbb6..415dd89e2 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss @@ -27,4 +27,4 @@ .CodeMirror-linenumbers { min-width: 29px; } -} \ No newline at end of file +} diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts index 3ae06545c..3d32114d0 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.ts @@ -22,7 +22,7 @@ import { AppsState, GraphQlService } from '@app/shared'; templateUrl: './graphql-page.component.html' }) export class GraphQLPageComponent implements AfterViewInit { - @ViewChild('graphiQLContainer') + @ViewChild('graphiQLContainer', { static: false }) public graphiQLContainer: ElementRef; constructor( diff --git a/src/Squidex/app/features/apps/module.ts b/src/Squidex/app/features/apps/module.ts index 16387df1f..cf43f604e 100644 --- a/src/Squidex/app/features/apps/module.ts +++ b/src/Squidex/app/features/apps/module.ts @@ -35,4 +35,4 @@ const routes: Routes = [ OnboardingDialogComponent ] }) -export class SqxFeatureAppsModule { } \ No newline at end of file +export class SqxFeatureAppsModule {} \ No newline at end of file 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 38eb2d9d3..2a02152a8 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.html +++ b/src/Squidex/app/features/apps/pages/apps-page.component.html @@ -100,6 +100,8 @@ +
{{info}}
+ diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.scss b/src/Squidex/app/features/apps/pages/apps-page.component.scss index e428d32c9..ca1de18bf 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.scss +++ b/src/Squidex/app/features/apps/pages/apps-page.component.scss @@ -66,7 +66,7 @@ } &:hover { - @include box-shadow(0, 3px, 16px, .2px); + @include box-shadow(0, 3px, 16px, .2); } &:focus { @@ -89,4 +89,11 @@ .deeplinks { display: none; } +} + +.info { + color: $color-border-dark; + padding: 2rem; + padding-left: $size-sidebar-width + .25rem; + font-size: .8rem; } \ No newline at end of file diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.ts b/src/Squidex/app/features/apps/pages/apps-page.component.ts index 845d48924..1c73760fd 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.ts +++ b/src/Squidex/app/features/apps/pages/apps-page.component.ts @@ -17,6 +17,7 @@ import { LocalStoreService, NewsService, OnboardingService, + UIOptions, UIState } from '@app/shared'; @@ -34,14 +35,20 @@ export class AppsPageComponent implements OnInit { public newsFeatures: FeatureDto[]; public newsDialog = new DialogModel(); + public info: string; + constructor( public readonly appsState: AppsState, public readonly authState: AuthService, public readonly uiState: UIState, private readonly localStore: LocalStoreService, private readonly newsService: NewsService, - private readonly onboardingService: OnboardingService + private readonly onboardingService: OnboardingService, + private readonly uiOptions: UIOptions ) { + if (uiOptions.get('showInfo')) { + this.info = uiOptions.get('more.info'); + } } public ngOnInit() { @@ -52,7 +59,7 @@ export class AppsPageComponent implements OnInit { if (shouldShowOnboarding && apps.length === 0) { this.onboardingService.disable('dialog'); this.onboardingDialog.show(); - } else { + } else if (!this.uiOptions.get('hideNews')) { const newsVersion = this.localStore.getInt('squidex.news.version'); this.newsService.getFeatures(newsVersion) diff --git a/src/Squidex/app/features/assets/module.ts b/src/Squidex/app/features/assets/module.ts index 755987200..bcfb24292 100644 --- a/src/Squidex/app/features/assets/module.ts +++ b/src/Squidex/app/features/assets/module.ts @@ -39,4 +39,4 @@ const routes: Routes = [ AssetsPageComponent ] }) -export class SqxFeatureAssetsModule { } \ No newline at end of file +export class SqxFeatureAssetsModule {} \ No newline at end of file diff --git a/src/Squidex/app/features/assets/pages/assets-filters-page.component.html b/src/Squidex/app/features/assets/pages/assets-filters-page.component.html index f6c48e822..7d8cb432f 100644 --- a/src/Squidex/app/features/assets/pages/assets-filters-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-filters-page.component.html @@ -28,16 +28,28 @@
-

Saved queries

+
\ No newline at end of file diff --git a/src/Squidex/app/features/assets/pages/assets-filters-page.component.scss b/src/Squidex/app/features/assets/pages/assets-filters-page.component.scss index fbb752506..bc0b6a3e7 100644 --- a/src/Squidex/app/features/assets/pages/assets-filters-page.component.scss +++ b/src/Squidex/app/features/assets/pages/assets-filters-page.component.scss @@ -1,2 +1,6 @@ @import '_vars'; -@import '_mixins'; \ No newline at end of file +@import '_mixins'; + +.text-muted { + text-decoration: none; +} \ No newline at end of file diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index d169e27fe..a16cf64f0 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-page.component.html @@ -25,7 +25,7 @@
- - + New Content - - Edit Content - - - Show Content - + + Content + - - @@ -39,6 +36,7 @@ [class.active]="dropdown.isOpen | async" #optionsButton> - - - + + diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.ts b/src/Squidex/app/features/content/pages/content/content-page.component.ts index 228595a75..8eee695c3 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.ts +++ b/src/Squidex/app/features/content/pages/content/content-page.component.ts @@ -59,12 +59,12 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD public language: AppLanguageDto; public languages: ImmutableArray; - @ViewChild('dueTimeSelector') + @ViewChild('dueTimeSelector', { static: false }) public dueTimeSelector: DueTimeSelectorComponent; constructor(apiUrl: ApiUrlConfig, authService: AuthService, public readonly appsState: AppsState, - private readonly contentsState: ContentsState, + public readonly contentsState: ContentsState, private readonly dialogs: DialogService, private readonly languagesState: LanguagesState, private readonly messageBus: MessageBus, @@ -124,7 +124,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD this.saveContent(true, false); } - public saveAsProposal() { + public saveAsDraft() { this.saveContent(false, true); } @@ -132,23 +132,27 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD this.saveContent(false, false); } - private saveContent(publish: boolean, asProposal: boolean) { - if (this.content && this.content.status === 'Archived') { - return; - } - + private saveContent(publish: boolean, asDraft: boolean) { const value = this.contentForm.submit(); if (value) { if (this.content) { - if (asProposal) { - this.contentsState.proposeUpdate(this.content, value) + if (asDraft) { + if (this.content && !this.content.canDraftPropose) { + return; + } + + this.contentsState.proposeDraft(this.content, value) .subscribe(() => { this.contentForm.submitCompleted({ noReset: true }); }, error => { this.contentForm.submitFailed(error); }); } else { + if (this.content && !this.content.canUpdateAny) { + return; + } + this.contentsState.update(this.content, value) .subscribe(() => { this.contentForm.submitCompleted({ noReset: true }); @@ -157,6 +161,10 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD }); } } else { + if ((publish && !this.contentsState.snapshot.canCreate) || (!publish && !this.contentsState.snapshot.canCreateAndPublish)) { + return; + } + this.contentsState.create(value, publish) .subscribe(() => { this.back(); @@ -174,27 +182,12 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD } private loadContent(data: any) { - this.contentForm.loadContent(data, this.content && this.content.status === 'Archived'); + this.contentForm.loadContent(data); + this.contentForm.setEnabled(!this.content || this.content.canUpdateAny); } public discardChanges() { - this.contentsState.discardChanges(this.content); - } - - public publish() { - this.changeContentItems('Publish', 'Published'); - } - - public unpublish() { - this.changeContentItems('Unpublish', 'Draft'); - } - - public archive() { - this.changeContentItems('Archive', 'Archived'); - } - - public restore() { - this.changeContentItems('Restore', 'Draft'); + this.contentsState.discardDraft(this.content); } public delete() { @@ -206,13 +199,13 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD public publishChanges() { this.dueTimeSelector.selectDueTime('Publish').pipe( - switchMap(d => this.contentsState.publishChanges(this.content, d)), onErrorResumeNext()) + switchMap(d => this.contentsState.publishDraft(this.content, d)), onErrorResumeNext()) .subscribe(); } - private changeContentItems(action: string, status: string) { - this.dueTimeSelector.selectDueTime(action).pipe( - switchMap(d => this.contentsState.changeStatus(this.content, action, status, d)), onErrorResumeNext()) + public changeStatus(status: string) { + this.dueTimeSelector.selectDueTime(status).pipe( + switchMap(d => this.contentsState.changeStatus(this.content, status, d)), onErrorResumeNext()) .subscribe(); } @@ -227,13 +220,12 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD if (compare) { if (this.contentFormCompare === null) { this.contentFormCompare = new EditContentForm(this.schema, this.languages); - this.contentFormCompare.form.disable(); } - const isArchive = this.content && this.content.status === 'Archived'; + this.contentFormCompare.loadContent(dto.payload); + this.contentFormCompare.setEnabled(false); - this.contentFormCompare.loadContent(dto.payload, true); - this.contentForm.loadContent(this.content.dataDraft, isArchive); + this.loadContent(this.content.dataDraft); } else { if (this.contentFormCompare) { this.contentFormCompare = null; diff --git a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html index 224f33408..600764d90 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html @@ -12,17 +12,39 @@
+ +
+ +
\ No newline at end of file diff --git a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss index fbb752506..684ee4478 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss +++ b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.scss @@ -1,2 +1,6 @@ @import '_vars'; -@import '_mixins'; \ No newline at end of file +@import '_mixins'; + +.text-muted { + pointer-events: none; +} diff --git a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts index 4779955c7..5a0ff774a 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts +++ b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts @@ -24,7 +24,7 @@ export class ContentsFiltersPageComponent extends ResourceOwner implements OnIni public schemaQueries: Queries; constructor( - private readonly contentsState: ContentsState, + public readonly contentsState: ContentsState, private readonly schemasState: SchemasState, private readonly uiState: UIState ) { diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-page.component.html index 5cf558066..eae57a6f3 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.html @@ -2,18 +2,12 @@ - - Archive - - - - Contents - + Contents
-
+
- @@ -80,25 +71,13 @@
- {{selectionCount}} items selected:   + {{selectionCount}} items selected   - - - - - - - -
@@ -37,12 +38,12 @@
-
- +
diff --git a/src/Squidex/app/features/schemas/module.ts b/src/Squidex/app/features/schemas/module.ts index b328efd96..ad7b2c59e 100644 --- a/src/Squidex/app/features/schemas/module.ts +++ b/src/Squidex/app/features/schemas/module.ts @@ -112,4 +112,4 @@ const routes: Routes = [ TagsValidationComponent ] }) -export class SqxFeatureSchemasModule { } \ No newline at end of file +export class SqxFeatureSchemasModule {} \ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.html b/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.html index 1b4866640..cf9681be6 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.html @@ -65,6 +65,7 @@
; @Input() diff --git a/src/Squidex/app/features/schemas/pages/schema/field.component.html b/src/Squidex/app/features/schemas/pages/schema/field.component.html index 2fee24efa..7eaee9463 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/field.component.html @@ -20,7 +20,7 @@
-
+
@@ -87,7 +91,10 @@ -
+
@@ -95,7 +102,7 @@
-
+
- +
@@ -49,10 +49,13 @@ import {
` }) -export class FieldFormComponent { +export class FieldFormComponent implements AfterViewInit { @Input() public showButtons: boolean; + @Input() + public isEditable: boolean; + @Input() public editForm: FormGroup; @@ -70,6 +73,14 @@ export class FieldFormComponent { public selectedTab = 0; + public ngAfterViewInit() { + if (!this.isEditable) { + this.editForm.disable(); + } else { + this.editForm.enable(); + } + } + public selectTab(tab: number) { this.selectedTab = tab; } diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.html b/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.html index 4fda1eae7..494aad805 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.html @@ -30,7 +30,7 @@ - + \ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.ts b/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.ts index 15148f23f..b25b815ae 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.ts @@ -28,6 +28,8 @@ export class SchemaEditFormComponent implements OnInit { public editForm = new EditSchemaForm(this.formBuilder); + public isEditable = false; + constructor( private readonly formBuilder: FormBuilder, private readonly schemasState: SchemasState @@ -35,7 +37,10 @@ export class SchemaEditFormComponent implements OnInit { } public ngOnInit() { + this.isEditable = this.schema.canUpdate; + this.editForm.load(this.schema.properties); + this.editForm.setEnabled(this.isEditable); } public emitComplete() { @@ -43,6 +48,10 @@ export class SchemaEditFormComponent implements OnInit { } public saveSchema() { + if (!this.isEditable) { + return; + } + const value = this.editForm.submit(); if (value) { diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html index 69ccaa9fc..1b1593848 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html @@ -11,10 +11,10 @@
- -
@@ -25,26 +25,31 @@
@@ -61,13 +66,14 @@
No field created yet. -
@@ -75,7 +81,7 @@
-
diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts index abc5b1f46..d9957a1f0 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts @@ -87,7 +87,7 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit { } public sortFields(fields: FieldDto[]) { - this.schemasState.sortFields(this.schema, fields).subscribe(); + this.schemasState.orderFields(this.schema, fields).subscribe(); } public trackByField(index: number, field: FieldDto) { diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.html b/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.html index cbf504ee0..1d528d269 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.html @@ -6,7 +6,7 @@ - Adding a preview url generates a link in the content editor, referring to your custom preview environment where the content item is used. + Adding a preview url generates a link in the content editor, referring to your custom preview or production environment where the content item is used. @@ -14,6 +14,10 @@
+
+ No preview urls configured. +
+
@@ -29,6 +33,7 @@
-
+
@@ -65,7 +70,7 @@ - + diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts b/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts index 72a59074e..795bc63af 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts @@ -31,6 +31,8 @@ export class SchemaPreviewUrlsFormComponent implements OnInit { public editForm = new ConfigurePreviewUrlsForm(this.formBuilder); + public isEditable = false; + constructor( private readonly formBuilder: FormBuilder, private readonly schemasState: SchemasState @@ -38,7 +40,10 @@ export class SchemaPreviewUrlsFormComponent implements OnInit { } public ngOnInit() { + this.isEditable = this.schema.canUpdateUrls; + this.editForm.load(this.schema.previewUrls); + this.editForm.setEnabled(this.isEditable); } public emitComplete() { @@ -50,6 +55,10 @@ export class SchemaPreviewUrlsFormComponent implements OnInit { } public add() { + if (!this.isEditable) { + return; + } + const value = this.addForm.submit(); if (value) { @@ -60,6 +69,10 @@ export class SchemaPreviewUrlsFormComponent implements OnInit { } public saveSchema() { + if (!this.isEditable) { + return; + } + const value = this.editForm.submit(); if (value) { diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.html b/src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.html index d148e5ae3..b886f8a7a 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.html @@ -24,7 +24,7 @@ - + diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.ts b/src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.ts index 25ea72bf5..9b5d7a2ec 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.ts +++ b/src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.ts @@ -30,6 +30,8 @@ export class SchemaScriptsFormComponent implements OnInit { public editForm = new EditScriptsForm(this.formBuilder); + public isEditable = false; + constructor( private readonly formBuilder: FormBuilder, private readonly schemasState: SchemasState @@ -37,7 +39,10 @@ export class SchemaScriptsFormComponent implements OnInit { } public ngOnInit() { + this.isEditable = this.schema.canUpdateScripts; + this.editForm.load(this.schema.scripts); + this.editForm.setEnabled(this.isEditable); } public emitComplete() { @@ -49,6 +54,10 @@ export class SchemaScriptsFormComponent implements OnInit { } public saveSchema() { + if (!this.isEditable) { + return; + } + const value = this.editForm.submit(); if (value) { diff --git a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html index a1b82746f..1c35ce05b 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html @@ -6,12 +6,17 @@ - - + + + + +
@@ -22,10 +27,9 @@ + (remove)="removeCategory(category.name)">
diff --git a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts index 7c8aa690f..10b07fade 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts +++ b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts @@ -16,6 +16,7 @@ import { DialogModel, MessageBus, ResourceOwner, + SchemaCategory, SchemaDto, SchemasState } from '@app/shared'; @@ -94,8 +95,8 @@ export class SchemasPageComponent extends ResourceOwner implements OnInit { this.addSchemaDialog.show(); } - public trackByCategory(index: number, category: string) { - return category; + public trackByCategory(index: number, category: SchemaCategory) { + return category.name; } } diff --git a/src/Squidex/app/features/settings/declarations.ts b/src/Squidex/app/features/settings/declarations.ts index fdb82e6b5..1a0553935 100644 --- a/src/Squidex/app/features/settings/declarations.ts +++ b/src/Squidex/app/features/settings/declarations.ts @@ -18,5 +18,9 @@ export * from './pages/patterns/patterns-page.component'; export * from './pages/plans/plans-page.component'; export * from './pages/roles/role.component'; export * from './pages/roles/roles-page.component'; +export * from './pages/workflows/workflow-step.component'; +export * from './pages/workflows/workflow-transition.component'; +export * from './pages/workflows/workflow.component'; +export * from './pages/workflows/workflows-page.component'; export * from './settings-area.component'; \ No newline at end of file diff --git a/src/Squidex/app/features/settings/module.ts b/src/Squidex/app/features/settings/module.ts index 121e03d8b..031f1a72e 100644 --- a/src/Squidex/app/features/settings/module.ts +++ b/src/Squidex/app/features/settings/module.ts @@ -16,7 +16,6 @@ import { } from '@app/shared'; import { - BackupDownloadUrlPipe, BackupDurationPipe, BackupsPageComponent, ClientComponent, @@ -30,7 +29,11 @@ import { PlansPageComponent, RoleComponent, RolesPageComponent, - SettingsAreaComponent + SettingsAreaComponent, + WorkflowComponent, + WorkflowsPageComponent, + WorkflowStepComponent, + WorkflowTransitionComponent } from './declarations'; const routes: Routes = [ @@ -59,74 +62,94 @@ const routes: Routes = [ ] }, { - path: 'plans', - component: PlansPageComponent, + path: 'clients', + component: ClientsPageComponent, children: [ { path: 'history', component: HistoryComponent, data: { - channel: 'settings.plan' + channel: 'settings.clients' + } + }, + { + path: 'help', + component: HelpComponent, + data: { + helpPage: '05-integrated/clients' } } ] }, { - path: 'patterns', - component: PatternsPageComponent, + path: 'contributors', + component: ContributorsPageComponent, children: [ { path: 'history', component: HistoryComponent, data: { - channel: 'settings.patterns' + channel: 'settings.contributors' } }, { path: 'help', component: HelpComponent, data: { - helpPage: '05-integrated/patterns' + helpPage: '05-integrated/contributors' } } ] }, { - path: 'clients', - component: ClientsPageComponent, + path: 'languages', + component: LanguagesPageComponent, children: [ { path: 'history', component: HistoryComponent, data: { - channel: 'settings.clients' + channel: 'settings.languages' } }, { path: 'help', component: HelpComponent, data: { - helpPage: '05-integrated/clients' + helpPage: '05-integrated/languages' } } ] }, { - path: 'contributors', - component: ContributorsPageComponent, + path: 'patterns', + component: PatternsPageComponent, children: [ { path: 'history', component: HistoryComponent, data: { - channel: 'settings.contributors' + channel: 'settings.patterns' } }, { path: 'help', component: HelpComponent, data: { - helpPage: '05-integrated/contributors' + helpPage: '05-integrated/patterns' + } + } + ] + }, + { + path: 'plans', + component: PlansPageComponent, + children: [ + { + path: 'history', + component: HistoryComponent, + data: { + channel: 'settings.plan' } } ] @@ -152,21 +175,14 @@ const routes: Routes = [ ] }, { - path: 'languages', - component: LanguagesPageComponent, + path: 'workflows', + component: WorkflowsPageComponent, children: [ - { - path: 'history', - component: HistoryComponent, - data: { - channel: 'settings.languages' - } - }, { path: 'help', component: HelpComponent, data: { - helpPage: '05-integrated/languages' + helpPage: '05-integrated/workflows' } } ] @@ -182,7 +198,6 @@ const routes: Routes = [ RouterModule.forChild(routes) ], declarations: [ - BackupDownloadUrlPipe, BackupDurationPipe, BackupsPageComponent, ClientComponent, @@ -196,7 +211,11 @@ const routes: Routes = [ PlansPageComponent, RoleComponent, RolesPageComponent, - SettingsAreaComponent + SettingsAreaComponent, + WorkflowComponent, + WorkflowsPageComponent, + WorkflowTransitionComponent, + WorkflowStepComponent ] }) -export class SqxFeatureSettingsModule { } \ No newline at end of file +export class SqxFeatureSettingsModule {} \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index dddcf8cf6..f55d7a783 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -12,7 +12,7 @@ -
@@ -52,7 +52,7 @@ Duration:
-
+
{{backup.started | sqxFromNow}}
@@ -72,13 +72,14 @@
Download: - + Ready
- - - - - - -

- {{client.name}} -

- - -
+
+ + +
- + - {{userInfo.user.displayName}} + {{user.displayName}} - {{userInfo.user.email}} + {{user.email}} - - - - - - - - + +
- + + @@ -24,6 +24,7 @@ @@ -55,19 +56,10 @@
- diff --git a/src/Squidex/app/features/content/shared/contents-selector.component.ts b/src/Squidex/app/features/content/shared/contents-selector.component.ts index 3a7d11597..656df7c16 100644 --- a/src/Squidex/app/features/content/shared/contents-selector.component.ts +++ b/src/Squidex/app/features/content/shared/contents-selector.component.ts @@ -8,9 +8,7 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { - CONTENT_STATUSES, ContentDto, - ContentQueryStatus, FilterState, LanguageDto, ManualContentsState, @@ -48,8 +46,6 @@ export class ContentsSelectorComponent implements OnInit { public filter = new FilterState(); - public statuses = CONTENT_STATUSES; - public selectedItems: { [id: string]: ContentDto; } = {}; public selectionCount = 0; @@ -74,10 +70,6 @@ export class ContentsSelectorComponent implements OnInit { this.contentsState.search(this.filter.apiFilter); } - public filterStatus(status: ContentQueryStatus) { - this.contentsState.filterStatus(status); - } - public goNext() { this.contentsState.goNext(); } diff --git a/src/Squidex/app/features/dashboard/module.ts b/src/Squidex/app/features/dashboard/module.ts index 0942cc3b6..d821eb1f6 100644 --- a/src/Squidex/app/features/dashboard/module.ts +++ b/src/Squidex/app/features/dashboard/module.ts @@ -33,4 +33,4 @@ const routes: Routes = [ DashboardPageComponent ] }) -export class SqxFeatureDashboardModule { } \ No newline at end of file +export class SqxFeatureDashboardModule {} \ No newline at end of file diff --git a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html index 78b0002c0..5a9c1cd00 100644 --- a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html +++ b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html @@ -12,7 +12,7 @@
- +
diff --git a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts index 031d5d1b7..59f67b101 100644 --- a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts +++ b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.ts @@ -6,10 +6,9 @@ */ import { Component, OnInit } from '@angular/core'; -import { filter, map, switchMap } from 'rxjs/operators'; +import { switchMap } from 'rxjs/operators'; import { - AppDto, AppsState, AuthService, DateTime, @@ -52,7 +51,7 @@ export class DashboardPageComponent extends ResourceOwner implements OnInit { public isPerformanceStacked = false; - public app = this.appsState.selectedApp.pipe(filter(x => !!x), map(x => x)); + public app = this.appsState.selectedValidApp; public chartOptions = { responsive: true, diff --git a/src/Squidex/app/features/rules/module.ts b/src/Squidex/app/features/rules/module.ts index 7ee3218b3..78b84b860 100644 --- a/src/Squidex/app/features/rules/module.ts +++ b/src/Squidex/app/features/rules/module.ts @@ -66,4 +66,4 @@ const routes: Routes = [ UsageTriggerComponent ] }) -export class SqxFeatureRulesModule { } \ No newline at end of file +export class SqxFeatureRulesModule {} \ No newline at end of file diff --git a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html index c7a0f98de..9c9c6f5cb 100644 --- a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html +++ b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html @@ -70,7 +70,7 @@ Next: {{event.nextAttempt | sqxFromNow}}
- diff --git a/src/Squidex/app/features/rules/pages/rules/rule-element.component.scss b/src/Squidex/app/features/rules/pages/rules/rule-element.component.scss index aab579f44..7c0f4f750 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-element.component.scss +++ b/src/Squidex/app/features/rules/pages/rules/rule-element.component.scss @@ -82,7 +82,9 @@ display: inline-block; } -/deep/ svg { - fill: $color-dark-foreground; - display: block; +::ng-deep { + svg { + fill: $color-dark-foreground; + display: block; + } } \ No newline at end of file diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html index 422b0a98f..9f0fde00f 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html @@ -106,12 +106,12 @@ - + - +
diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts index c9b1a77a2..6c47ce087 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { @@ -26,7 +26,7 @@ export const MODE_EDIT_ACTION = 'EditAction'; styleUrls: ['./rule-wizard.component.scss'], templateUrl: './rule-wizard.component.html' }) -export class RuleWizardComponent implements OnInit { +export class RuleWizardComponent implements AfterViewInit, OnInit { public actionForm = new Form(new FormGroup({})); public actionType: string; public action: any = {}; @@ -35,6 +35,8 @@ export class RuleWizardComponent implements OnInit { public triggerType: string; public trigger: any = {}; + public isEditable: boolean; + public step = 1; @Output() @@ -61,6 +63,8 @@ export class RuleWizardComponent implements OnInit { } public ngOnInit() { + this.isEditable = !this.rule || this.rule.canUpdate; + if (this.mode === MODE_EDIT_ACTION) { this.step = 4; @@ -74,6 +78,12 @@ export class RuleWizardComponent implements OnInit { } } + public ngAfterViewInit() { + this.actionForm.setEnabled(this.isEditable); + + this.triggerForm.setEnabled(this.isEditable); + } + public emitComplete() { this.complete.emit(); } @@ -132,6 +142,10 @@ export class RuleWizardComponent implements OnInit { } private updateTrigger() { + if (!this.isEditable) { + return; + } + this.rulesState.updateTrigger(this.rule, this.trigger) .subscribe(() => { this.emitComplete(); @@ -143,6 +157,10 @@ export class RuleWizardComponent implements OnInit { } private updateAction() { + if (!this.isEditable) { + return; + } + this.rulesState.updateAction(this.rule, this.action) .subscribe(() => { this.emitComplete(); diff --git a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html index d6102c1be..29ee47512 100644 --- a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html @@ -6,16 +6,19 @@ - - - + + + + + @@ -23,7 +26,7 @@
No rule created yet. -
@@ -48,10 +51,11 @@
- + -
- + @@ -47,7 +50,7 @@
- + - {{contributorInfo.contributor.contributorId | sqxUserName}} + {{contributor.contributorId | sqxUserName}} - -
-