diff --git a/.testrunsettings b/.testrunsettings new file mode 100644 index 000000000..0082141a9 --- /dev/null +++ b/.testrunsettings @@ -0,0 +1,6 @@ + + + + 4 + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7941618aa..9037de7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## v2.1.0 - 2019-06-22 + +## Features + +* **Assets**: Parameter to prevent download in Browser. +* **Assets**: FTP asset store. +* **GraphQL**: Logging for field resolvers +* **GraphQL**: Performance optimizations for asset fields and references with DataLoader. +* **MongoDB**: Performance optimizations. +* **MongoDB**: Support for AWS DocumentDB. +* **Schemas**: Separator field. +* **Schemas**: Setting to prevent duplicate references. +* **UI**: Improved styling of DateTime editor. +* **UI**: Custom Editors: Provide all values. +* **UI**: Custom Editors: Provide context with user information and auth token. +* **UI**: Filter by status. +* **UI**: Dropdown field for references. +* **Users**: Email notifications when contributors is added. + +## Bugfixes + +* **Contents**: Fix for scheduled publishing. +* **GraphQL**: Fix query parameters for assets. +* **GraphQL**: Fix for duplicate field names in GraphQL. +* **GraphQL**: Fix for invalid field names. +* **Plans**: Fix when plans reset and extra events. +* **UI**: Unify slugify in Frontend and Backend. + ## v2.0.5 - 2019-04-21 ## Features diff --git a/Dockerfile b/Dockerfile index 86485f677..e57b35d67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,20 @@ FROM squidex/dotnet:2.2-sdk-chromium-phantomjs-node as builder WORKDIR /src +# Copy Node project files. COPY src/Squidex/package*.json /tmp/ # Install Node packages RUN cd /tmp && npm install --loglevel=error +# Copy nuget project files. +COPY /**/**/*.csproj /tmp/ +# Copy nuget.config for package sources. +COPY NuGet.Config /tmp/ + +# Install nuget packages +RUN bash -c 'pushd /tmp; for p in *.csproj; do dotnet restore $p --verbosity quiet; true; done; popd' + COPY . . # Build Frontend @@ -19,8 +28,7 @@ RUN cp -a /tmp/node_modules src/Squidex/ \ && npm run build # Test Backend -RUN dotnet restore \ - && dotnet test --filter Category!=Dependencies tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj \ +RUN dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj --filter Category!=Dependencies \ && dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \ && dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \ && dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \ diff --git a/Dockerfile.build b/Dockerfile.build index 30a5d3091..96debc8cd 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -2,11 +2,20 @@ FROM squidex/dotnet:2.2-sdk-chromium-phantomjs-node as builder WORKDIR /src +# Copy Node project files. COPY src/Squidex/package*.json /tmp/ # Install Node packages RUN cd /tmp && npm install --loglevel=error +# Copy Dotnet project files. +COPY /**/**/*.csproj /tmp/ +# Copy nuget.config for package sources. +COPY NuGet.Config /tmp/ + +# Install Dotnet packages +RUN bash -c 'pushd /tmp; for p in *.csproj; do dotnet restore $p --verbosity quiet; true; done; popd' + COPY . . # Build Frontend @@ -16,8 +25,7 @@ RUN cp -a /tmp/node_modules src/Squidex/ \ && npm run build # Test Backend -RUN dotnet restore \ - && dotnet test --filter Category!=Dependencies tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj \ +RUN dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj --filter Category!=Dependencies \ && dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \ && dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \ && dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \ diff --git a/README.md b/README.md index 2484db7f7..bbf5dfe3b 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,10 @@ Current Version v2.0.4. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap ### Contributors -* [pushrbx](https://pushrbx.net/): Azure Store support. -* [cpmstars](https://www.cpmstars.com): Asset support for rich editor. * [civicplus](https://www.civicplus.com/) ([Avd6977](https://github.com/Avd6977), [dsbegnoce](https://github.com/dsbegnoche)): Google Maps support, custom regex patterns and a lot of small improvements. +* [cpmstars](https://www.cpmstars.com): Asset support for rich editor. +* [guohai](https://github.com/seamys): FTP asset store support, Email rule support, custom editors and bug fixes. +* [pushrbx](https://pushrbx.net/): Azure Store support. * [razims](https://github.com/razims): GridFS support. ## Contributing 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/Role.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs index 700fb4246..0e2770682 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs @@ -7,6 +7,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Security; +using System; using System.Collections.Generic; using System.Diagnostics.Contracts; using P = Squidex.Shared.Permissions; @@ -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, 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..a56722c55 --- /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 Newtonsoft.Json; +using Squidex.Infrastructure.Json.Newtonsoft; +using System; +using System.Collections.Generic; + +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/Status.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs index c20e0c4eb..d4c0374c8 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs @@ -5,12 +5,57 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Infrastructure; +using System; + namespace Squidex.Domain.Apps.Core.Contents { - public enum Status + 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/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.Core.Model/Schemas/FieldCollection.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs index d09841b55..f07114303 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs @@ -122,9 +122,14 @@ namespace Squidex.Domain.Apps.Core.Schemas { Guard.NotNull(field, nameof(field)); - if (ByName.ContainsKey(field.Name) || ById.ContainsKey(field.Id)) + if (ByName.ContainsKey(field.Name)) { - throw new ArgumentException($"A field with name '{field.Name}' and id {field.Id} already exists.", nameof(field)); + throw new ArgumentException($"A field with name '{field.Name}' already exists.", nameof(field)); + } + + if (ById.ContainsKey(field.Id)) + { + throw new ArgumentException($"A field with id {field.Id} already exists.", nameof(field)); } return Clone(clone => 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/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/ValidateContent/Validators/FieldValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs index eafa95d22..8f2f2689c 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs @@ -34,11 +34,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { var typedValue = value; - if (value == null) - { - typedValue = Undefined.Value; - } - else if (value is IJsonValue jsonValue) + if (value is IJsonValue jsonValue) { if (jsonValue.Type == JsonValueType.Null) { diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs index 6c2b26246..c86c85be3 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs @@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators public async Task ValidateAsync(object value, ValidationContext context, AddError addError) { - if (value == null) + if (value.IsNullOrUndefined()) { value = DefaultValue; } @@ -49,17 +49,22 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators { var name = field.Key; - if (!values.TryGetValue(name, out var fieldValue)) + var (isOptional, validator) = field.Value; + + var fieldValue = Undefined.Value; + + if (!values.TryGetValue(name, out var temp)) { if (isPartial) { continue; } - - fieldValue = default; + } + else + { + fieldValue = temp; } - var (isOptional, validator) = field.Value; var fieldContext = context.Nested(name).Optional(isOptional); tasks.Add(validator.ValidateAsync(fieldValue, fieldContext, addError)); diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 44d93d8d4..6444ba638 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -119,12 +119,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets { var find = Collection.Find(x => ids.Contains(x.Id)).SortByDescending(x => x.LastModified); - var assetItems = find.ToListAsync(); - var assetCount = find.CountDocumentsAsync(); + var assetItems = await find.ToListAsync(); - await Task.WhenAll(assetItems, assetCount); - - return ResultList.Create(assetCount.Result, assetItems.Result.OfType()); + return ResultList.Create(assetItems.Count, assetItems.OfType()); } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index 1bb4c6128..9424333c0 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -137,17 +137,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents { var find = Collection.Find(FilterFactory.IdsBySchema(schema.Id, ids, status)); - var contentItems = find.WithoutDraft(includeDraft).ToListAsync(); - var contentCount = find.CountDocumentsAsync(); - - await Task.WhenAll(contentItems, contentCount); + var contentItems = await find.WithoutDraft(includeDraft).ToListAsync(); - foreach (var entity in contentItems.Result) + foreach (var entity in contentItems) { entity.ParseData(schema.SchemaDef, serializer); } - return ResultList.Create(contentCount.Result, contentItems.Result); + return ResultList.Create(contentItems.Count, contentItems); } public async Task FindContentAsync(ISchemaEntity schema, Guid id, Status[] status, bool includeDraft) 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..aa24583e6 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")) @@ -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/Apps/AppExtensions.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs new file mode 100644 index 000000000..8f94a80cf --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.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; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public static class AppExtensions + { + public static NamedId NamedId(this IAppEntity app) + { + return new NamedId(app.Id, app.Name); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 3645192a0..eceb7bfa5 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,137 @@ 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 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: @@ -194,7 +222,7 @@ namespace Squidex.Domain.Apps.Entities.Apps } else { - var result = await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, c.PlanId); + var result = await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), c.PlanId); switch (result) { @@ -213,7 +241,7 @@ namespace Squidex.Domain.Apps.Entities.Apps case ArchiveApp archiveApp: return UpdateAsync(archiveApp, async c => { - await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, null); + await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), null); ArchiveApp(c); }); 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/Services/IAppPlanBillingManager.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs index 89c6342cd..933a11ddf 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs @@ -7,6 +7,7 @@ using System; using System.Threading.Tasks; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Services { @@ -14,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services { bool HasPortal { get; } - Task ChangePlanAsync(string userId, Guid appId, string appName, string planId); + Task ChangePlanAsync(string userId, NamedId appId, string planId); Task GetPortalLinkAsync(string userId); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs index 8e968ccc7..b8c1f46ef 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs @@ -7,6 +7,7 @@ using System; using System.Threading.Tasks; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations { @@ -17,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations get { return false; } } - public Task ChangePlanAsync(string userId, Guid appId, string appName, string planId) + public Task ChangePlanAsync(string userId, NamedId appId, string planId) { return Task.FromResult(new PlanResetResult()); } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs index 5c93059f2..e45e02645 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.Security.Cryptography; using System.Threading.Tasks; using Orleans; +using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Tags; using Squidex.Infrastructure; @@ -24,25 +25,28 @@ namespace Squidex.Domain.Apps.Entities.Assets private readonly IAssetQueryService assetQuery; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IEnumerable> tagGenerators; + private readonly ITagService tagService; public AssetCommandMiddleware( IGrainFactory grainFactory, IAssetQueryService assetQuery, IAssetStore assetStore, IAssetThumbnailGenerator assetThumbnailGenerator, - IEnumerable> tagGenerators) + IEnumerable> tagGenerators, + ITagService tagService) : base(grainFactory) { Guard.NotNull(assetStore, nameof(assetStore)); Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); Guard.NotNull(tagGenerators, nameof(tagGenerators)); + Guard.NotNull(tagService, nameof(tagService)); this.assetStore = assetStore; this.assetQuery = assetQuery; this.assetThumbnailGenerator = assetThumbnailGenerator; - this.tagGenerators = tagGenerators; + this.tagService = tagService; } public override async Task HandleAsync(CommandContext context, Func next) @@ -56,9 +60,8 @@ 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 { @@ -70,13 +73,9 @@ namespace Squidex.Domain.Apps.Entities.Assets { if (IsDuplicate(createAsset, existing)) { - result = new AssetCreatedResult( - existing.Id, - existing.Tags, - existing.Version, - existing.FileVersion, - existing.FileHash, - true); + var denormalizedTags = await tagService.DenormalizeTagsAsync(createAsset.AppId.Id, TagGroups.Assets, existing.Tags); + + result = new AssetCreatedResult(existing, true, new HashSet(denormalizedTags.Values)); } break; @@ -89,17 +88,11 @@ namespace Squidex.Domain.Apps.Entities.Assets tagGenerator.GenerateTags(createAsset, createAsset.Tags); } - var commandResult = (AssetSavedResult)await ExecuteCommandAsync(createAsset); + var asset = (IAssetEntity)await ExecuteCommandAsync(createAsset); - result = new AssetCreatedResult( - createAsset.AssetId, - createAsset.Tags, - commandResult.Version, - commandResult.FileVersion, - commandResult.FileHash, - false); + result = new AssetCreatedResult(asset, false, createAsset.Tags); - await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null); + await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), asset.FileVersion, null); } context.Complete(result); @@ -114,16 +107,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); + var result = (AssetResult)await ExecuteAndAdjustTagsAsync(updateAsset); context.Complete(result); - await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.FileVersion, null); + await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.Asset.FileVersion, null); } finally { @@ -133,29 +126,54 @@ namespace Squidex.Domain.Apps.Entities.Assets break; } + case AssetCommand command: + { + var result = await ExecuteAndAdjustTagsAsync(command); + + context.Complete(result); + + break; + } + default: await base.HandleAsync(context, next); + break; } } + private async Task ExecuteAndAdjustTagsAsync(AssetCommand command) + { + var result = await ExecuteCommandAsync(command); + + if (result is IAssetEntity asset) + { + var denormalizedTags = await tagService.DenormalizeTagsAsync(asset.AppId.Id, TagGroups.Assets, asset.Tags); + + return new AssetResult(asset, new HashSet(denormalizedTags.Values)); + } + + return result; + } + 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..9ccc00763 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs @@ -5,30 +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 : AssetResult { - public HashSet Tags { get; } - - public long FileVersion { get; } - - public string FileHash { get; } - public bool IsDuplicate { get; } - public AssetCreatedResult(Guid id, HashSet tags, long version, long fileVersion, string fileHash, bool isDuplicate) - : base(id, version) + public AssetCreatedResult(IAssetEntity asset, bool isDuplicate, HashSet tags) + : base(asset, tags) { - Tags = tags; - - FileVersion = fileVersion; - FileHash = fileHash; - IsDuplicate = isDuplicate; } } 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/AssetResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs new file mode 100644 index 000000000..b43713da5 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// 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 class AssetResult + { + public IAssetEntity Asset { get; } + + public HashSet Tags { get; } + + public AssetResult(IAssetEntity asset, HashSet tags) + { + Asset = asset; + + Tags = tags; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs deleted file mode 100644 index a43e109cc..000000000 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs +++ /dev/null @@ -1,25 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Infrastructure.Commands; - -namespace Squidex.Domain.Apps.Entities.Assets -{ - public class AssetSavedResult : EntitySavedResult - { - public long FileVersion { get; } - - public string FileHash { get; } - - public AssetSavedResult(long version, long fileVersion, string fileHash) - : base(version) - { - FileVersion = fileVersion; - FileHash = fileHash; - } - } -} 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/Edm/EdmAssetModel.cs b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs index 590953cf6..4e7b83892 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs @@ -18,20 +18,27 @@ namespace Squidex.Domain.Apps.Entities.Assets.Edm { var entityType = new EdmEntityType("Squidex", "Asset"); - entityType.AddStructuralProperty(nameof(IAssetEntity.Id).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty(nameof(IAssetEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty(nameof(IAssetEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int64); - entityType.AddStructuralProperty(nameof(IAssetEntity.FileName).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.FileSize).ToCamelCase(), EdmPrimitiveTypeKind.Int64); - entityType.AddStructuralProperty(nameof(IAssetEntity.FileVersion).ToCamelCase(), EdmPrimitiveTypeKind.Int64); - entityType.AddStructuralProperty(nameof(IAssetEntity.IsImage).ToCamelCase(), EdmPrimitiveTypeKind.Boolean); - entityType.AddStructuralProperty(nameof(IAssetEntity.MimeType).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.PixelHeight).ToCamelCase(), EdmPrimitiveTypeKind.Int32); - entityType.AddStructuralProperty(nameof(IAssetEntity.PixelWidth).ToCamelCase(), EdmPrimitiveTypeKind.Int32); - entityType.AddStructuralProperty(nameof(IAssetEntity.Tags).ToCamelCase(), EdmPrimitiveTypeKind.String); + void AddProperty(string name, EdmPrimitiveTypeKind type) + { + entityType.AddStructuralProperty(name.ToCamelCase(), type); + } + + AddProperty(nameof(IAssetEntity.Id), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Created), EdmPrimitiveTypeKind.DateTimeOffset); + AddProperty(nameof(IAssetEntity.CreatedBy), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.LastModified), EdmPrimitiveTypeKind.DateTimeOffset); + AddProperty(nameof(IAssetEntity.LastModifiedBy), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Version), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.FileName), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.FileHash), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.FileSize), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.FileVersion), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.IsImage), EdmPrimitiveTypeKind.Boolean); + AddProperty(nameof(IAssetEntity.MimeType), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.PixelHeight), EdmPrimitiveTypeKind.Int32); + AddProperty(nameof(IAssetEntity.PixelWidth), EdmPrimitiveTypeKind.Int32); + AddProperty(nameof(IAssetEntity.Slug), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Tags), EdmPrimitiveTypeKind.String); var container = new EdmEntityContainer("Squidex", "Container"); 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/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/ContentDataChangedResult.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs deleted file mode 100644 index 9f3eda547..000000000 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs +++ /dev/null @@ -1,23 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Domain.Apps.Core.Contents; -using Squidex.Infrastructure.Commands; - -namespace Squidex.Domain.Apps.Entities.Contents -{ - public sealed class ContentDataChangedResult : EntitySavedResult - { - public NamedContentData Data { get; } - - public ContentDataChangedResult(NamedContentData data, long version) - : base(version) - { - Data = data; - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs index 9c5a5f3f7..8b1b6aac1 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs @@ -8,9 +8,7 @@ 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 { @@ -41,24 +39,5 @@ namespace Squidex.Domain.Apps.Entities.Contents public Status Status { get; set; } public bool IsPending { get; set; } - - public static ContentEntity Create(CreateContent command, EntityCreatedResult result) - { - var now = SystemClock.Instance.GetCurrentInstant(); - - var response = new ContentEntity - { - Id = command.ContentId, - Data = result.IdOrValue, - Version = result.Version, - Created = now, - CreatedBy = command.Actor, - LastModified = now, - LastModifiedBy = command.Actor, - Status = command.Publish ? Status.Published : Status.Draft - }; - - return response; - } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 5adf76236..60af67527 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; } @@ -79,35 +83,37 @@ namespace Squidex.Domain.Apps.Entities.Contents await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data); } - Create(c); + var status = await contentWorkflow.GetInitialStatusAsync(ctx.Schema); - return EntityCreatedResult.Create(c.Data, Version); + Create(c, status); + + return Snapshot; }); case UpdateContent updateContent: - return UpdateReturnAsync(updateContent, c => + return UpdateReturnAsync(updateContent, async c => { - GuardContent.CanUpdate(c); + await GuardContent.CanUpdate(Snapshot, contentWorkflow, c); - return UpdateAsync(c, x => c.Data, false); + return await UpdateAsync(c, x => c.Data, false); }); case PatchContent patchContent: - return UpdateReturnAsync(patchContent, c => + return UpdateReturnAsync(patchContent, async c => { - GuardContent.CanPatch(c); + await GuardContent.CanPatch(Snapshot, contentWorkflow, c); - return UpdateAsync(c, c.Data.MergeInto, true); + return await UpdateAsync(c, c.Data.MergeInto, true); }); case ChangeContentStatus changeContentStatus: - return UpdateAsync(changeContentStatus, async c => + return UpdateReturnAsync(changeContentStatus, async c => { try { 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); if (c.DueTime.HasValue) { @@ -127,17 +133,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 +159,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,14 +185,6 @@ 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(); } @@ -220,16 +226,16 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - 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 +274,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..b67536690 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs @@ -33,10 +33,7 @@ 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 readonly IAppProvider appProvider; @@ -76,16 +73,11 @@ 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) { Guard.NotNull(context, nameof(context)); - var schema = await GetSchemaAsync(context, schemaIdOrName); + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); CheckPermission(context.User, schema); @@ -93,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { var isVersioned = version > EtagVersion.Empty; - var status = GetFindStatus(context); + var status = GetStatus(context); var content = isVersioned ? @@ -113,13 +105,13 @@ namespace Squidex.Domain.Apps.Entities.Contents { Guard.NotNull(context, nameof(context)); - var schema = await GetSchemaAsync(context, schemaIdOrName); + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); CheckPermission(context.User, schema); using (Profiler.TraceMethod()) { - var status = GetQueryStatus(context); + var status = GetStatus(context); IResultList contents; @@ -139,13 +131,13 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public async Task> QueryAsync(QueryContext context, IReadOnlyList ids) + public async Task> QueryAsync(QueryContext context, IReadOnlyList ids) { Guard.NotNull(context, nameof(context)); using (Profiler.TraceMethod()) { - var status = GetQueryStatus(context); + var status = GetStatus(context); List result; @@ -217,7 +209,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.ApiStatus == StatusForApi.All || context.IsFrontendClient)) { result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters); } @@ -298,7 +290,7 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public async Task GetSchemaAsync(QueryContext context, string schemaIdOrName) + public async Task GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName) { ISchemaEntity schema = null; @@ -340,15 +332,11 @@ namespace Squidex.Domain.Apps.Entities.Contents return permissions.Allows(permission); } - private static Status[] GetFindStatus(QueryContext context) + private static Status[] GetStatus(QueryContext context) { - if (context.IsFrontendClient) - { - return StatusAll; - } - else if (context.ApiStatus == StatusForApi.PublishedDraft) + if (context.IsFrontendClient || context.ApiStatus == StatusForApi.All) { - return StatusPublishedDraft; + return null; } else { @@ -356,32 +344,6 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - private static Status[] GetQueryStatus(QueryContext context) - { - 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; - } - } - } - private Task> QueryAsync(QueryContext context, IReadOnlyList ids, Status[] status) { return contentRepository.QueryAsync(context.App, status, new HashSet(ids), ShouldIncludeDraft(context)); @@ -409,7 +371,7 @@ namespace Squidex.Domain.Apps.Entities.Contents private static bool ShouldIncludeDraft(QueryContext context) { - return context.ApiStatus == StatusForApi.PublishedDraft || context.IsFrontendClient; + return context.ApiStatus == StatusForApi.All || context.IsFrontendClient; } } } 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..a3da96afd --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs @@ -0,0 +1,54 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using 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 Status[] All = { Status.Archived, Status.Draft, Status.Published }; + + private static readonly Dictionary Flow = new Dictionary + { + [Status.Draft] = new[] { Status.Archived, Status.Published }, + [Status.Archived] = new[] { Status.Draft }, + [Status.Published] = new[] { Status.Draft, Status.Archived } + }; + + public Task GetInitialStatusAsync(ISchemaEntity schema) + { + return Task.FromResult(Status.Draft); + } + + public Task CanMoveToAsync(IContentEntity content, Status next) + { + return Task.FromResult(Flow.TryGetValue(content.Status, out var state) && state.Contains(next)); + } + + public Task CanUpdateAsync(IContentEntity content) + { + return Task.FromResult(content.Status != Status.Archived); + } + + public Task GetNextsAsync(IContentEntity content) + { + return Task.FromResult(Flow.TryGetValue(content.Status, out var result) ? result : Array.Empty()); + } + + public Task GetAllAsync(ISchemaEntity schema) + { + return Task.FromResult(All); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs index 2c57f06da..1a01b229a 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs @@ -8,6 +8,7 @@ using System; using System.Linq; using System.Threading.Tasks; +using GraphQL; using Microsoft.Extensions.Caching.Memory; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Assets; @@ -18,28 +19,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL public sealed class CachingGraphQLService : CachingProviderBase, IGraphQLService { private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); - private readonly IContentQueryService contentQuery; - private readonly IGraphQLUrlGenerator urlGenerator; - private readonly IAssetQueryService assetQuery; - private readonly IAppProvider appProvider; - - public CachingGraphQLService( - IMemoryCache cache, - IAppProvider appProvider, - IAssetQueryService assetQuery, - IContentQueryService contentQuery, - IGraphQLUrlGenerator urlGenerator) + private readonly IDependencyResolver resolver; + + public CachingGraphQLService(IMemoryCache cache, IDependencyResolver resolver) : base(cache) { - Guard.NotNull(appProvider, nameof(appProvider)); - Guard.NotNull(assetQuery, nameof(assetQuery)); - Guard.NotNull(contentQuery, nameof(contentQuery)); - Guard.NotNull(urlGenerator, nameof(urlGenerator)); - - this.appProvider = appProvider; - this.assetQuery = assetQuery; - this.contentQuery = contentQuery; - this.urlGenerator = urlGenerator; + Guard.NotNull(resolver, nameof(resolver)); + + this.resolver = resolver; } public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries) @@ -49,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var model = await GetModelAsync(context.App); - var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); + var ctx = new GraphQLExecutionContext(context, resolver); var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q))); @@ -63,14 +50,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var model = await GetModelAsync(context.App); - var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); + var ctx = new GraphQLExecutionContext(context, resolver); var result = await QueryInternalAsync(model, ctx, query); return result; } - private static async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query) + private async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query) { if (string.IsNullOrWhiteSpace(query.Query)) { @@ -97,9 +84,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { entry.AbsoluteExpirationRelativeToNow = CacheDuration; - var allSchemas = await appProvider.GetSchemasAsync(app.Id); + var allSchemas = await resolver.Resolve().GetSchemasAsync(app.Id); - return new GraphQLModel(app, allSchemas, contentQuery.DefaultPageSizeGraphQl, assetQuery.DefaultPageSizeGraphQl, urlGenerator); + return new GraphQLModel(app, + allSchemas, + resolver.Resolve().DefaultPageSizeGraphQl, + resolver.Resolve().DefaultPageSizeGraphQl, + resolver.Resolve()); }); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index 54a2793ab..c5dbf6024 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -7,37 +7,114 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using GraphQL; +using GraphQL.DataLoader; using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types; using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Log; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { public sealed class GraphQLExecutionContext : QueryExecutionContext { + private static readonly List EmptyAssets = new List(); + private static readonly List EmptyContents = new List(); + private readonly IDataLoaderContextAccessor dataLoaderContextAccessor; + private readonly IDependencyResolver resolver; + public IGraphQLUrlGenerator UrlGenerator { get; } - public GraphQLExecutionContext(QueryContext context, - IAssetQueryService assetQuery, - IContentQueryService contentQuery, - IGraphQLUrlGenerator urlGenerator) - : base(context, assetQuery, contentQuery) + public ISemanticLog Log { get; } + + public GraphQLExecutionContext(QueryContext context, IDependencyResolver resolver) + : base(context, + resolver.Resolve(), + resolver.Resolve()) + { + UrlGenerator = resolver.Resolve(); + + dataLoaderContextAccessor = resolver.Resolve(); + + this.resolver = resolver; + } + + public void Setup(ExecutionOptions execution) + { + var loader = resolver.Resolve(); + + var logger = LoggingMiddleware.Create(resolver.Resolve()); + + execution.Listeners.Add(loader); + execution.FieldMiddleware.Use(logger); + + execution.UserContext = this; + } + + public override Task FindAssetAsync(Guid id) { - UrlGenerator = urlGenerator; + var dataLoader = GetAssetsLoader(); + + return dataLoader.LoadAsync(id); + } + + public override Task FindContentAsync(Guid schemaId, Guid id) + { + var dataLoader = GetContentsLoader(schemaId); + + return dataLoader.LoadAsync(id); } - public Task> GetReferencedAssetsAsync(IJsonValue value) + public async Task> GetReferencedAssetsAsync(IJsonValue value) { var ids = ParseIds(value); - return GetReferencedAssetsAsync(ids); + if (ids == null) + { + return EmptyAssets; + } + + var dataLoader = GetAssetsLoader(); + + return await dataLoader.LoadManyAsync(ids); } - public Task> GetReferencedContentsAsync(Guid schemaId, IJsonValue value) + public async Task> GetReferencedContentsAsync(Guid schemaId, IJsonValue value) { var ids = ParseIds(value); - return GetReferencedContentsAsync(schemaId, ids); + if (ids == null) + { + return EmptyContents; + } + + var dataLoader = GetContentsLoader(schemaId); + + return await dataLoader.LoadManyAsync(ids); + } + + private IDataLoader GetAssetsLoader() + { + return dataLoaderContextAccessor.Context.GetOrAddBatchLoader("Assets", + async batch => + { + var result = await GetReferencedAssetsAsync(new List(batch)); + + return result.ToDictionary(x => x.Id); + }); + } + + private IDataLoader GetContentsLoader(Guid schemaId) + { + return dataLoaderContextAccessor.Context.GetOrAddBatchLoader($"Schema_{schemaId}", + async batch => + { + var result = await GetReferencedContentsAsync(schemaId, new List(batch)); + + return result.ToDictionary(x => x.Id); + }); } private static ICollection ParseIds(IJsonValue value) @@ -58,7 +135,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } catch { - return new List(); + return null; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs index a07e8aca3..5f4ee3be3 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs @@ -135,9 +135,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return partitionResolver(key); } - public (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field) + public (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field, string fieldName) { - return field.Accept(new QueryGraphTypeVisitor(schema, GetContentType, this, assetListType)); + return field.Accept(new QueryGraphTypeVisitor(schema, GetContentType, this, assetListType, fieldName)); } public IGraphType GetAssetType() @@ -175,15 +175,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { Guard.NotNull(context, nameof(context)); - var inputs = query.Variables?.ToInputs(); - - var result = await new DocumentExecuter().ExecuteAsync(options => + var result = await new DocumentExecuter().ExecuteAsync(execution => { - options.OperationName = query.OperationName; - options.UserContext = context; - options.Schema = graphQLSchema; - options.Inputs = inputs; - options.Query = query.Query; + context.Setup(execution); + + execution.Schema = graphQLSchema; + execution.Inputs = query.Variables?.ToInputs(); + execution.Query = query.Query; }).ConfigureAwait(false); return (result.Data, result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray()); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs index f44c36b58..1ee303d6a 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs @@ -35,6 +35,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL IGraphType GetContentDataType(Guid schemaId); - (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field); + (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field, string fieldName); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/LoggingMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/LoggingMiddleware.cs new file mode 100644 index 000000000..9db32cab0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/LoggingMiddleware.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using GraphQL.Instrumentation; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Log; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public static class LoggingMiddleware + { + public static Func Create(ISemanticLog log) + { + Guard.NotNull(log, nameof(log)); + + return new Func(next => + { + return async context => + { + try + { + return await next(context); + } + catch (Exception ex) + { + log.LogWarning(ex, w => w + .WriteProperty("action", "reolveField") + .WriteProperty("status", "failed") + .WriteProperty("field", context.FieldName)); + + throw ex; + } + }; + }); + } + } +} 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..8f3207ebf 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs @@ -28,8 +28,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 +44,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/ContentDataGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs index c25cd48bf..eb6ef19f5 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs @@ -25,18 +25,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Name = $"{schemaType}DataDto"; - foreach (var field in schema.SchemaDef.Fields.ForApi()) + foreach (var (field, fieldName, typeName) in schema.SchemaDef.Fields.SafeFields()) { - var (resolvedType, valueResolver) = model.GetGraphType(schema, field); + var (resolvedType, valueResolver) = model.GetGraphType(schema, field, fieldName); if (valueResolver != null) { - var fieldType = field.TypeName(); - var fieldName = field.DisplayName(); + var displayName = field.DisplayName(); var fieldGraphType = new ObjectGraphType { - Name = $"{schemaType}Data{fieldType}Dto" + Name = $"{schemaType}Data{typeName}Dto" }; var partition = model.ResolvePartition(field.Partitioning); @@ -45,45 +44,51 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { var key = partitionItem.Key; - var partitionResolver = new FuncFieldResolver(c => - { - if (((ContentFieldData)c.Source).TryGetValue(key, out var value)) - { - return valueResolver(value, c); - } - else - { - return null; - } - }); - fieldGraphType.AddField(new FieldType { Name = key.EscapePartition(), - Resolver = partitionResolver, + Resolver = PartitionResolver(valueResolver, key), ResolvedType = resolvedType, Description = field.RawProperties.Hints }); } - fieldGraphType.Description = $"The structure of the {fieldName} field of the {schemaName} content type."; - - var fieldResolver = new FuncFieldResolver>(c => - { - return c.Source.GetOrDefault(field.Name); - }); + fieldGraphType.Description = $"The structure of the {displayName} field of the {schemaName} content type."; AddField(new FieldType { - Name = field.Name.ToCamelCase(), - Resolver = fieldResolver, + Name = fieldName, + Resolver = FieldResolver(field), ResolvedType = fieldGraphType, - Description = $"The {fieldName} field." + Description = $"The {displayName} field." }); } } Description = $"The structure of the {schemaName} content type."; } + + private static FuncFieldResolver PartitionResolver(ValueResolver valueResolver, string key) + { + return new FuncFieldResolver(c => + { + if (((ContentFieldData)c.Source).TryGetValue(key, out var value)) + { + return valueResolver(value, c); + } + else + { + return null; + } + }); + } + + private static FuncFieldResolver> FieldResolver(RootField field) + { + return new FuncFieldResolver>(c => + { + return c.Source.GetOrDefault(field.Name); + }); + } } } 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..a6d876742 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs @@ -73,8 +73,8 @@ 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." }); diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs new file mode 100644 index 000000000..5b78b2728 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// 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 GraphQL.DataLoader; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public static class Extensions + { + public static IEnumerable<(T Field, string Name, string Type)> SafeFields(this IEnumerable fields) where T : IField + { + var allFields = + fields.ForApi() + .Select(f => (Field: f, Name: f.Name.ToCamelCase(), Type: f.TypeName())).GroupBy(x => x.Name) + .Select(g => + { + return g.Select((f, i) => (f.Field, f.Name.SafeString(i), f.Type.SafeString(i))); + }) + .SelectMany(x => x); + + return allFields; + } + + private static string SafeString(this string value, int index) + { + if (index > 0) + { + return value + (index + 1); + } + + return value; + } + + public static async Task> LoadManyAsync(this IDataLoader dataLoader, ICollection keys) where T : class + { + var contents = await Task.WhenAll(keys.Select(x => dataLoader.LoadAsync(x))); + + return contents.Where(x => x != null).ToList(); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs index e7b954698..2964ed201 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs @@ -16,44 +16,49 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { public sealed class NestedGraphType : ObjectGraphType { - public NestedGraphType(IGraphModel model, ISchemaEntity schema, IArrayField field) + public NestedGraphType(IGraphModel model, ISchemaEntity schema, IArrayField field, string fieldName) { var schemaType = schema.TypeName(); var schemaName = schema.DisplayName(); - var fieldName = field.DisplayName(); + var fieldDisplayName = field.DisplayName(); Name = $"{schemaType}{fieldName}ChildDto"; - foreach (var nestedField in field.Fields.ForApi()) + foreach (var (nestedField, nestedName, _) in field.Fields.SafeFields()) { - var fieldInfo = model.GetGraphType(schema, nestedField); + var fieldInfo = model.GetGraphType(schema, nestedField, nestedName); if (fieldInfo.ResolveType != null) { - var resolver = new FuncFieldResolver(c => - { - if (((JsonObject)c.Source).TryGetValue(nestedField.Name, out var value)) - { - return fieldInfo.Resolver(value, c); - } - else - { - return fieldInfo; - } - }); + var resolver = ValueResolver(nestedField, fieldInfo); AddField(new FieldType { - Name = nestedField.Name.ToCamelCase(), + Name = nestedName, Resolver = resolver, ResolvedType = fieldInfo.ResolveType, - Description = $"The {fieldName}/{nestedField.DisplayName()} nested field." + Description = $"The {fieldDisplayName}/{nestedField.DisplayName()} nested field." }); } } - Description = $"The structure of the {schemaName}.{fieldName} nested schema."; + Description = $"The structure of the {schemaName}.{fieldDisplayName} nested schema."; + } + + private static FuncFieldResolver ValueResolver(NestedField nestedField, (IGraphType ResolveType, ValueResolver Resolver) fieldInfo) + { + return new FuncFieldResolver(c => + { + if (((JsonObject)c.Source).TryGetValue(nestedField.Name, out var value)) + { + return fieldInfo.Resolver(value, c); + } + else + { + return fieldInfo; + } + }); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs index 32a9a308f..195eee1a7 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs @@ -22,13 +22,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types private readonly Func schemaResolver; private readonly IGraphModel model; private readonly IGraphType assetListType; + private readonly string fieldName; - public QueryGraphTypeVisitor(ISchemaEntity schema, Func schemaResolver, IGraphModel model, IGraphType assetListType) + public QueryGraphTypeVisitor(ISchemaEntity schema, Func schemaResolver, IGraphModel model, IGraphType assetListType, string fieldName) { this.model = model; this.assetListType = assetListType; this.schema = schema; this.schemaResolver = schemaResolver; + this.fieldName = fieldName; } public (IGraphType ResolveType, ValueResolver Resolver) Visit(IArrayField field) @@ -93,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types private (IGraphType ResolveType, ValueResolver Resolver) ResolveNested(IArrayField field) { - var schemaFieldType = new ListGraphType(new NonNullGraphType(new NestedGraphType(model, schema, field))); + var schemaFieldType = new ListGraphType(new NonNullGraphType(new NestedGraphType(model, schema, field, this.fieldName))); return (schemaFieldType, NoopResolver); } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs b/src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs index 379bd601f..4395ffd11 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; @@ -30,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards } } - public static void CanUpdate(UpdateContent command) + public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command) { Guard.NotNull(command, nameof(command)); @@ -38,9 +39,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { ValidateData(command, e); }); + + await ValidateCanUpdate(content, contentWorkflow); } - public static void CanPatch(PatchContent command) + public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command) { Guard.NotNull(command, nameof(command)); @@ -48,6 +51,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards { ValidateData(command, e); }); + + await ValidateCanUpdate(content, contentWorkflow); } public static void CanDiscardChanges(bool isPending, DiscardChanges command) @@ -60,33 +65,29 @@ 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) { 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)) + if (!await contentWorkflow.CanMoveToAsync(content, command.Status)) { - e(Not.Valid("Status"), nameof(command.Status)); - } - else if (!StatusFlow.CanChange(status, command.Status)) - { - if (status == command.Status && status == Status.Published) + if (content.Status == command.Status && content.Status == Status.Published) { - if (!isPending) + if (!content.IsPending) { e("Content has no changes to publish.", nameof(command.Status)); } } else { - e($"Cannot change status from {status} to {command.Status}.", nameof(command.Status)); + e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status)); } } @@ -114,5 +115,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/IContentQueryService.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs index 75d89c115..769422de1 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(QueryContext context, IReadOnlyList ids); Task> QueryAsync(QueryContext context, string schemaIdOrName, Q query); Task FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any); - Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName); + Task GetSchemaOrThrowAsync(QueryContext 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..c812f8a4f --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.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 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 CanMoveToAsync(IContentEntity content, Status next); + + Task CanUpdateAsync(IContentEntity content); + + Task GetNextsAsync(IContentEntity content); + + Task GetAllAsync(ISchemaEntity schema); + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs index 666b7474d..8b186cbd4 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs @@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents this.context = context; } - public async Task FindAssetAsync(Guid id) + public virtual async Task FindAssetAsync(Guid id) { var asset = cachedAssets.GetOrDefault(id); @@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return asset; } - public async Task FindContentAsync(Guid schemaId, Guid id) + public virtual async Task FindContentAsync(Guid schemaId, Guid id) { var content = cachedContents.GetOrDefault(id); @@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return content; } - public async Task> QueryAssetsAsync(string query) + public virtual async Task> QueryAssetsAsync(string query) { var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(query)); @@ -80,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return assets; } - public async Task> QueryContentsAsync(string schemaIdOrName, string query) + public virtual async Task> QueryContentsAsync(string schemaIdOrName, string query) { var result = await contentQuery.QueryAsync(context, schemaIdOrName, Q.Empty.WithODataQuery(query)); @@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return result; } - public async Task> GetReferencedAssetsAsync(ICollection ids) + public virtual async Task> GetReferencedAssetsAsync(ICollection ids) { Guard.NotNull(ids, nameof(ids)); @@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Entities.Contents return ids.Select(cachedAssets.GetOrDefault).Where(x => x != null).ToList(); } - public async Task> GetReferencedContentsAsync(Guid schemaId, ICollection ids) + public virtual async Task> GetReferencedContentsAsync(Guid schemaId, 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 232e8b4a7..fe48b89e4 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs @@ -22,17 +22,17 @@ namespace Squidex.Domain.Apps.Entities.Contents public Instant DueTime { get; } - public ScheduleJob(Guid id, Status status, RefToken by, Instant due) + public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime) { Id = id; - ScheduledBy = by; + ScheduledBy = scheduledBy; Status = status; - DueTime = due; + DueTime = dueTime; } - public static ScheduleJob Build(Status status, RefToken by, Instant due) + public static ScheduleJob Build(Status status, RefToken scheduledBy, Instant dueTime) { - return new ScheduleJob(Guid.NewGuid(), status, by, due); + return new ScheduleJob(Guid.NewGuid(), status, scheduledBy, dueTime); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index 1f2169431..0f0234dc9 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs @@ -50,6 +50,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.State SimpleMapper.Map(@event, this); UpdateData(null, @event.Data, false); + + if (Status == default) + { + Status = Status.Draft; + } } protected void On(ContentChangesPublished @event) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs b/src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs index 4d17ea6a6..e58b4c098 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs @@ -10,6 +10,6 @@ namespace Squidex.Domain.Apps.Entities.Contents public enum StatusForApi { PublishedOnly, - PublishedDraft, + All, } } diff --git a/src/Squidex.Domain.Apps.Entities/EntityExtensions.cs b/src/Squidex.Domain.Apps.Entities/EntityExtensions.cs new file mode 100644 index 000000000..b5c78cd43 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/EntityExtensions.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.Entities.Apps; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public static class EntityExtensions + { + public static NamedId NamedId(this IAppEntity entity) + { + return new NamedId(entity.Id, entity.Name); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Q.cs b/src/Squidex.Domain.Apps.Entities/Q.cs index 8bd9b0f39..966bdd279 100644 --- a/src/Squidex.Domain.Apps.Entities/Q.cs +++ b/src/Squidex.Domain.Apps.Entities/Q.cs @@ -25,6 +25,11 @@ namespace Squidex.Domain.Apps.Entities return Clone(c => c.ODataQuery = odataQuery); } + public Q WithIds(params Guid[] ids) + { + return Clone(c => c.Ids = ids.ToList()); + } + public Q WithIds(IEnumerable ids) { return Clone(c => c.Ids = ids.ToList()); diff --git a/src/Squidex.Domain.Apps.Entities/QueryContext.cs b/src/Squidex.Domain.Apps.Entities/QueryContext.cs index c6c552be9..094e56e3d 100644 --- a/src/Squidex.Domain.Apps.Entities/QueryContext.cs +++ b/src/Squidex.Domain.Apps.Entities/QueryContext.cs @@ -27,8 +27,6 @@ namespace Squidex.Domain.Apps.Entities public StatusForApi ApiStatus { get; private set; } - public StatusForFrontend FrontendStatus { get; private set; } - public IReadOnlyCollection AssetUrlsToResolve { get; private set; } public IReadOnlyCollection Languages { get; private set; } @@ -49,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities public QueryContext WithUnpublished(bool unpublished) { - return WithApiStatus(unpublished ? StatusForApi.PublishedDraft : StatusForApi.PublishedOnly); + return WithApiStatus(unpublished ? StatusForApi.All : StatusForApi.PublishedOnly); } public QueryContext WithApiStatus(StatusForApi status) @@ -57,21 +55,6 @@ namespace Squidex.Domain.Apps.Entities 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) 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 ab4851731..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 => @@ -142,49 +142,73 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards fieldIndex++; fieldPrefix = $"Fields[{fieldIndex}]"; - if (!field.Partitioning.IsValidPartitioning()) - { - e(Not.Valid("Partitioning"), $"{fieldPrefix}.{nameof(field.Partitioning)}"); - } + ValidateRootField(field, fieldPrefix, e); + } - ValidateField(field, fieldPrefix, e); + if (command.Fields.Select(x => x?.Name).Distinct().Count() != command.Fields.Count) + { + e("Fields cannot have duplicate names.", nameof(command.Fields)); + } + } + } - if (field.Nested?.Count > 0) - { - if (field.Properties is ArrayFieldProperties) - { - var nestedIndex = 0; - var nestedPrefix = string.Empty; + private static void ValidateRootField(UpsertSchemaField field, string prefix, AddValidation e) + { + if (field == null) + { + e(Not.Defined("Field"), prefix); + } + else + { + if (!field.Partitioning.IsValidPartitioning()) + { + e(Not.Valid("Partitioning"), $"{prefix}.{nameof(field.Partitioning)}"); + } - foreach (var nestedField in field.Nested) - { - nestedIndex++; - nestedPrefix = $"{fieldPrefix}.Nested[{nestedIndex}]"; + ValidateField(field, prefix, e); - if (nestedField.Properties is ArrayFieldProperties) - { - e("Nested field cannot be array fields.", $"{nestedPrefix}.{nameof(nestedField.Properties)}"); - } + if (field.Nested?.Count > 0) + { + if (field.Properties is ArrayFieldProperties) + { + var nestedIndex = 0; + var nestedPrefix = string.Empty; - ValidateField(nestedField, nestedPrefix, e); - } - } - else if (field.Nested.Count > 0) + foreach (var nestedField in field.Nested) { - e("Only array fields can have nested fields.", $"{fieldPrefix}.{nameof(field.Partitioning)}"); - } + nestedIndex++; + nestedPrefix = $"{prefix}.Nested[{nestedIndex}]"; - if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count) - { - e("Fields cannot have duplicate names.", $"{fieldPrefix}.Nested"); + ValidateNestedField(nestedField, nestedPrefix, e); } } + else if (field.Nested.Count > 0) + { + e("Only array fields can have nested fields.", $"{prefix}.{nameof(field.Partitioning)}"); + } + + if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count) + { + e("Fields cannot have duplicate names.", $"{prefix}.Nested"); + } } + } + } - if (command.Fields.Select(x => x.Name).Distinct().Count() != command.Fields.Count) + private static void ValidateNestedField(UpsertSchemaNestedField nestedField, string prefix, AddValidation e) + { + if (nestedField == null) + { + e(Not.Defined("Field"), prefix); + } + else + { + if (nestedField.Properties is ArrayFieldProperties) { - e("Fields cannot have duplicate names.", nameof(command.Fields)); + e("Nested field cannot be array fields.", $"{prefix}.{nameof(nestedField.Properties)}"); } + + ValidateField(nestedField, prefix, e); } } 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 5905b66fd..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); @@ -321,7 +353,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas { if (id.HasValue && Snapshot.SchemaDef.FieldsById.TryGetValue(id.Value, out var field)) { - return NamedId.Of(field.Id, field.Name); + return field.NamedId(); } return null; @@ -333,13 +365,13 @@ namespace Squidex.Domain.Apps.Entities.Schemas { if (Snapshot.SchemaDef.FieldsById.TryGetValue(pc.ParentFieldId.Value, out var field)) { - pe.ParentFieldId = NamedId.Of(field.Id, field.Name); + pe.ParentFieldId = field.NamedId(); if (command is FieldCommand fc && @event is FieldEvent fe) { if (field is IArrayField arrayField && arrayField.FieldsById.TryGetValue(fc.FieldId, out var nestedField)) { - fe.FieldId = NamedId.Of(nestedField.Id, nestedField.Name); + fe.FieldId = nestedField.NamedId(); } } } @@ -357,7 +389,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas { if (@event.SchemaId == null) { - @event.SchemaId = NamedId.Of(Snapshot.Id, Snapshot.SchemaDef.Name); + @event.SchemaId = Snapshot.NamedId(); } if (@event.AppId == null) diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs index ed06a71ec..317b3b176 100644 --- a/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs @@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Contents [EventType(nameof(ContentCreated))] 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..e7b201ffe 100644 --- a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs @@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Events.Contents [EventType(nameof(ContentStatusChanged))] 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.MongoDb/Assets/MongoGridFsAssetStore.cs b/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs index 3a86d4fd9..cde15c0da 100644 --- a/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs @@ -46,6 +46,8 @@ namespace Squidex.Infrastructure.Assets public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default) { + Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName)); + try { var sourceName = GetFileName(sourceFileName, nameof(sourceFileName)); @@ -63,6 +65,8 @@ namespace Squidex.Infrastructure.Assets public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) { + Guard.NotNull(stream, nameof(stream)); + try { var name = GetFileName(fileName, nameof(fileName)); @@ -80,6 +84,8 @@ namespace Squidex.Infrastructure.Assets public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { + Guard.NotNull(stream, nameof(stream)); + try { var name = GetFileName(fileName, nameof(fileName)); diff --git a/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs b/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs new file mode 100644 index 000000000..8619063f6 --- /dev/null +++ b/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs @@ -0,0 +1,158 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FluentFTP; +using Squidex.Infrastructure.Log; + +namespace Squidex.Infrastructure.Assets +{ + public sealed class FTPAssetStore : IAssetStore, IInitializable + { + private readonly string path; + private readonly ISemanticLog log; + private readonly Func factory; + + public FTPAssetStore(Func factory, string path, ISemanticLog log) + { + Guard.NotNull(factory, nameof(factory)); + Guard.NotNullOrEmpty(path, nameof(path)); + Guard.NotNull(log, nameof(log)); + + this.factory = factory; + this.path = path; + + this.log = log; + } + + public string GeneratePublicUrl(string fileName) + { + return null; + } + + public async Task InitializeAsync(CancellationToken ct = default) + { + using (var client = factory()) + { + await client.ConnectAsync(ct); + + if (!await client.DirectoryExistsAsync(path, ct)) + { + await client.CreateDirectoryAsync(path, ct); + } + } + + log.LogInformation(w => w + .WriteProperty("action", "FTPAssetStoreConfigured") + .WriteProperty("path", path)); + } + + public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default) + { + Guard.NotNullOrEmpty(sourceFileName, nameof(sourceFileName)); + Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName)); + + using (var client = GetFtpClient()) + { + var tempPath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); + + using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.DeleteOnClose)) + { + await DownloadAsync(client, sourceFileName, stream, ct); + await UploadAsync(client, targetFileName, stream, false, ct); + } + } + } + + public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) + { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); + + using (var client = GetFtpClient()) + { + await DownloadAsync(client, fileName, stream, ct); + } + } + + public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) + { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); + + using (var client = GetFtpClient()) + { + await UploadAsync(client, fileName, stream, overwrite, ct); + } + } + + private static async Task DownloadAsync(IFtpClient client, string fileName, Stream stream, CancellationToken ct) + { + try + { + await client.DownloadAsync(stream, fileName, token: ct); + } + catch (FtpException ex) when (IsNotFound(ex)) + { + throw new AssetNotFoundException(fileName, ex); + } + } + + private static async Task UploadAsync(IFtpClient client, string fileName, Stream stream, bool overwrite, CancellationToken ct) + { + if (!overwrite && await client.FileExistsAsync(fileName, ct)) + { + throw new AssetAlreadyExistsException(fileName); + } + + await client.UploadAsync(stream, fileName, overwrite ? FtpExists.Overwrite : FtpExists.Skip, true, null, ct); + } + + public async Task DeleteAsync(string fileName) + { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + + using (var client = GetFtpClient()) + { + try + { + await client.DeleteFileAsync(fileName); + } + catch (FtpException ex) + { + if (!IsNotFound(ex)) + { + throw ex; + } + } + } + } + + private IFtpClient GetFtpClient() + { + var client = factory(); + + client.Connect(); + client.SetWorkingDirectory(path); + + return client; + } + + private static bool IsNotFound(Exception exception) + { + if (exception is FtpCommandException command) + { + return command.CompletionCode == "550"; + } + + return exception.InnerException != null && IsNotFound(exception.InnerException); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs index 3df128be3..c9a3e83f0 100644 --- a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs @@ -82,7 +82,7 @@ namespace Squidex.Infrastructure.Assets public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); var file = GetFile(fileName); @@ -101,7 +101,7 @@ namespace Squidex.Infrastructure.Assets public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); var file = GetFile(fileName); @@ -120,8 +120,6 @@ namespace Squidex.Infrastructure.Assets public Task DeleteAsync(string fileName) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); - var file = GetFile(fileName); file.Delete(); diff --git a/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs b/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs index 10fa7fe84..20d9fc363 100644 --- a/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs @@ -43,6 +43,7 @@ namespace Squidex.Infrastructure.Assets public virtual async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) { Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); if (!streams.TryGetValue(fileName, out var sourceStream)) { @@ -65,6 +66,7 @@ namespace Squidex.Infrastructure.Assets public virtual async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); var memoryStream = new MemoryStream(); diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index 76be0fd06..cfe546a24 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -13,6 +13,14 @@ namespace Squidex.Infrastructure { public static class CollectionExtensions { + 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(); 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/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/Squidex.Infrastructure.csproj b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 882061c58..5877358e2 100644 --- a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -8,6 +8,7 @@ True + diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 964925efb..7118a0604 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,7 +77,6 @@ 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"; @@ -108,7 +105,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 +113,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 AppContentsStatus = "squidex.apps.{app}.contents.{name}.status.{status}"; 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 AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete"; public const string AppApi = "squidex.apps.{app}.api"; diff --git a/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs b/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs index 06e050784..0b7d872e5 100644 --- a/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs +++ b/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs @@ -57,7 +57,7 @@ namespace Squidex.Web.CommandMiddlewares throw new InvalidOperationException("Cannot resolve app."); } - return NamedId.Of(appFeature.App.Id, appFeature.App.Name); + return appFeature.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 672a16b74..a64798783 100644 --- a/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs +++ b/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs @@ -69,7 +69,7 @@ namespace Squidex.Web.CommandMiddlewares if (appFeature?.App != null) { - appId = NamedId.Of(appFeature.App.Id, appFeature.App.Name); + appId = appFeature.App.NamedId(); } } 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/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs new file mode 100644 index 000000000..ab63f14f8 --- /dev/null +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -0,0 +1,76 @@ +// ========================================================================== +// 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; +using Squidex.Shared.Identity; + +namespace Squidex.Web +{ + public static class PermissionExtensions + { + private sealed class PermissionFeature + { + public PermissionSet Permissions { get; } + + public PermissionFeature(PermissionSet permissions) + { + Permissions = permissions; + } + } + + public static PermissionSet Permissions(this HttpContext httpContext) + { + var feature = httpContext.Features.Get(); + + if (feature == null) + { + feature = new PermissionFeature(httpContext.User.Permissions()); + + httpContext.Features.Set(feature); + } + + return feature.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/AppResolver.cs b/src/Squidex.Web/Pipeline/AppResolver.cs index cc0ae853b..97e4e8f1e 100644 --- a/src/Squidex.Web/Pipeline/AppResolver.cs +++ b/src/Squidex.Web/Pipeline/AppResolver.cs @@ -65,7 +65,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) { diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs new file mode 100644 index 000000000..d3eba847d --- /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 Newtonsoft.Json; +using Squidex.Infrastructure; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +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) + { + AddLink(rel, "GET", href); + } + + public void AddPatchLink(string rel, string href) + { + AddLink(rel, "PATCH", href); + } + + public void AddPostLink(string rel, string href) + { + AddLink(rel, "POST", href); + } + + public void AddPutLink(string rel, string href) + { + AddLink(rel, "PUT", href); + } + + public void AddDeleteLink(string rel, string href) + { + AddLink(rel, "DELETE", href); + } + + public void AddLink(string rel, string method, string href) + { + Guard.NotNullOrEmpty(rel, nameof(rel)); + Guard.NotNullOrEmpty(href, nameof(href)); + Guard.NotNullOrEmpty(method, nameof(method)); + + Links[rel] = new ResourceLink { Href = href, Method = method }; + } + } +} 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..964610e7d 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs +++ b/src/Squidex.Web/ResourceLink.cs @@ -7,20 +7,16 @@ 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; } } } diff --git a/src/Squidex.Web/UrlHelperExtensions.cs b/src/Squidex.Web/UrlHelperExtensions.cs new file mode 100644 index 000000000..27f00a1d9 --- /dev/null +++ b/src/Squidex.Web/UrlHelperExtensions.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using System; + +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)) + { + 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/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..b89697d26 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,12 +41,12 @@ 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 = ClientsDto.FromApp(App, this); Response.Headers[HeaderNames.ETag] = App.Version.ToString(); @@ -60,6 +60,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 +69,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 +85,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 +96,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..021afa123 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,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetContributors(string app) { - var response = ContributorsDto.FromApp(App, appPlansProvider); + var response = ContributorsDto.FromApp(App, appPlansProvider, this, false); Response.Headers[HeaderNames.ETag] = App.Version.ToString(); @@ -60,14 +61,13 @@ 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) @@ -75,18 +75,18 @@ namespace Squidex.Areas.Api.Controllers.Apps var command = request.ToCommand(); var context = await CommandBus.PublishAsync(command); - var response = (ContributorAssignedDto)null; + var response = (ContributorsDto)null; - if (context.PlainResult is EntityCreatedResult idOrValue) + if (context.PlainResult is IAppEntity newApp) { - response = ContributorAssignedDto.FromId(idOrValue.IdOrValue, false); + response = ContributorsDto.FromApp(newApp, appPlansProvider, this, false); } else if (context.PlainResult is InvitedResult invited) { - response = ContributorAssignedDto.FromId(invited.Id.IdOrValue, true); + response = ContributorsDto.FromApp(invited.App, appPlansProvider, this, true); } - return Ok(response); + return CreatedAtAction(nameof(GetContributors), new { app }, response); } /// @@ -95,20 +95,32 @@ 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 Ok(response); + } + + private async Task InvokeCommandAsync(ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = ContributorsDto.FromApp(result, appPlansProvider, this, false); - return NoContent(); + return response; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs index 17ce8c320..03064da7b 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,12 +40,12 @@ 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 = AppLanguagesDto.FromApp(App, this); Response.Headers[HeaderNames.ETag] = App.Version.ToString(); @@ -63,17 +64,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 +83,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 +107,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..022a20cab 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,12 +42,12 @@ 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 = PatternsDto.FromApp(App, this); Response.Headers[HeaderNames.ETag] = App.Version.ToString(); @@ -66,16 +66,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 +85,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 +109,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 +117,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..d51daaf2d 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs @@ -47,7 +47,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public IActionResult GetRoles(string app) { - var response = RolesDto.FromApp(App); + var response = RolesDto.FromApp(App, this); Response.Headers[HeaderNames.ETag] = App.Version.ToString(); @@ -82,64 +82,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/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 4219f42dd..1d2b9c26a 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -5,12 +5,12 @@ // 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; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure; @@ -58,11 +58,11 @@ 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 = apps.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)); Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); @@ -84,17 +84,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/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..6d706dc30 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, IGenerateETag { /// /// 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,81 @@ 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)); + + 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)); + } - response.Permissions = permissions.ToArray(x => x.Id); - response.PlanName = plans.GetPlanForApp(app)?.Name; - response.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + 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.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..9d8baf5e3 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs @@ -6,32 +6,68 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; +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 plan = plans.GetPlanForApp(app); + var contributors = app.Contributors.ToArray(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name)); + + var result = new ContributorsDto + { + Items = contributors, + }; + + 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 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 0a470ff6c..8c6d2e707 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateLanguageDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateLanguageDto.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; @@ -26,7 +27,7 @@ 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) { diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index 65dd776eb..48c0dc0e0 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -52,6 +52,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// The id or slug of the asset. /// Optional suffix that can be used to seo-optimize the link to the image Has not effect. /// The optional version of the asset. + /// Set it to 0 to prevent download. /// The target width of the asset, if it is an image. /// The target height of the asset, if it is an image. /// Optional image quality, it is is an jpeg image. @@ -67,23 +68,24 @@ namespace Squidex.Areas.Api.Controllers.Assets [AllowAnonymous] public async Task GetAssetContentBySlug(string app, string idOrSlug, string more, [FromQuery] long version = EtagVersion.Any, + [FromQuery] int dl = 1, [FromQuery] int? width = null, [FromQuery] int? height = null, [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); + return DeliverAsset(asset, version, width, height, quality, mode, dl); } /// @@ -92,6 +94,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// The id of the asset. /// Optional suffix that can be used to seo-optimize the link to the image Has not effect. /// The optional version of the asset. + /// Set it to 0 to prevent download. /// The target width of the asset, if it is an image. /// The target height of the asset, if it is an image. /// Optional image quality, it is is an jpeg image. @@ -106,30 +109,31 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(0.5)] public async Task GetAssetContent(Guid id, string more, [FromQuery] long version = EtagVersion.Any, + [FromQuery] int dl = 1, [FromQuery] int? width = null, [FromQuery] int? height = null, [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); + return DeliverAsset(asset, version, width, height, quality, mode, dl); } - private IActionResult DeliverAsset(IAssetEntity entity, long version, int? width, int? height, int? quality, string mode) + 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(); - return new FileCallbackResult(entity.MimeType, entity.FileName, true, async bodyStream => + 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}"; @@ -140,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) { @@ -152,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; } @@ -164,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; } @@ -176,9 +180,18 @@ 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(asset.MimeType, asset.FileName, true, handler); + } + else + { + return new FileCallbackResult(asset.MimeType, null, true, handler); + } } private static FileStream GetTempStream() diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 041adcdde..965056fbe 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -105,14 +105,14 @@ namespace Squidex.Areas.Api.Controllers.Assets var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids)); - var response = AssetsDto.FromAssets(assets); + var response = AssetsDto.FromAssets(assets, this, app); if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) { - Response.Headers["Surrogate-Key"] = response.Items.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = response.Items.ToManyEtag(response.Total); + Response.Headers[HeaderNames.ETag] = response.ToEtag(); return Ok(response); } @@ -135,21 +135,21 @@ namespace Squidex.Areas.Api.Controllers.Assets { var context = Context(); - var entity = await assetQuery.FindAssetAsync(context, id); + var asset = await assetQuery.FindAssetAsync(context, id); - if (entity == null) + if (asset == null) { return NotFound(); } - var response = AssetDto.FromAsset(entity); + var response = AssetDto.FromAsset(asset, this, app); if (controllerOptions.Value.EnableSurrogateKeys) { - Response.Headers["Surrogate-Key"] = entity.Id.ToString(); + Response.Headers["Surrogate-Key"] = asset.Id.ToString(); } - Response.Headers[HeaderNames.ETag] = entity.Version.ToString(); + Response.Headers[HeaderNames.ETag] = asset.Version.ToString(); return Ok(response); } @@ -169,8 +169,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 +178,13 @@ 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 = AssetDto.FromAsset(result.Asset, this, app, result.Tags, result.IsDuplicate); - return StatusCode(201, response); + return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response); } /// @@ -194,7 +194,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 +203,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 +211,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 +224,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 +249,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 +263,16 @@ namespace Squidex.Areas.Api.Controllers.Assets return NoContent(); } + private async Task InvokeCommandAsync(string app, ICommand command) + { + var context = await CommandBus.PublishAsync(command); + + var result = context.Result(); + var response = AssetDto.FromAsset(result.Asset, this, app, result.Tags); + + return response; + } + private async Task CheckAssetFileAsync(IReadOnlyList file) { if (file.Count != 1) 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..ec0e68899 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, IGenerateETag { /// /// The id of the asset. @@ -110,9 +112,54 @@ 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(IAssetEntity asset, ApiController controller, string app, HashSet tags = null, bool isDuplicate = false) { - return SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() }); + var response = SimpleMapper.Map(asset, new AssetDto { FileType = asset.FileName.FileType() }); + + if (tags != null) + { + response.Tags = tags; + } + + 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.Domain.Apps.Entities/Contents/StatusForFrontend.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetMetadata.cs similarity index 61% rename from src/Squidex.Domain.Apps.Entities/Contents/StatusForFrontend.cs rename to src/Squidex/Areas/Api/Controllers/Assets/Models/AssetMetadata.cs index b29257b0c..71f8e9065 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/StatusForFrontend.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetMetadata.cs @@ -5,12 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -namespace Squidex.Domain.Apps.Entities.Contents +namespace Squidex.Areas.Api.Controllers.Assets.Models { - public enum StatusForFrontend + public sealed class AssetMetadata { - PublishedDraft, - PublishedOnly, - Archived + /// + /// 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..f6ceca82d 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetsDto.cs @@ -9,25 +9,59 @@ 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 string ToEtag() + { + return Items.ToManyEtag(Total); + } - public static AssetsDto FromAssets(IResultList assets) + public string ToSurrogateKeys() { - return new AssetsDto { Total = assets.Total, Items = assets.Select(AssetDto.FromAsset).ToArray() }; + return Items.ToSurrogateKeys(); + } + + 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); + } + + private static AssetsDto CreateLinks(AssetsDto response, ApiController controller, string app) + { + 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..8f39a7140 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,23 @@ 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)); + } + + 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/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs index 6ad1cf1c0..4426330de 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,7 +67,7 @@ namespace Squidex.Areas.Api.Controllers.Backups /// [HttpPost] [Route("apps/restore/")] - [ApiPermission(Permissions.AdminRestoreCreate)] + [ApiPermission(Permissions.AdminRestore)] public async Task PostRestore([FromBody] RestoreRequest 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..6125003f5 100644 --- a/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs @@ -76,7 +76,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 +103,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 +124,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..833c67dbe 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -6,22 +6,19 @@ // ========================================================================== using System; -using System.Linq; 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; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.GraphQL; +using Squidex.Infrastructure; 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; @@ -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,23 @@ 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 result = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids); + var context = Context(); + var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids); + var contentsList = ResultList.Create(contents.Count, contents); - var response = new ContentsDto - { - Total = result.Count, - Items = result.Take(200).Select(x => ContentDto.FromContent(x, context)).ToArray() - }; + var response = await ContentsDto.FromContentsAsync(contentsList, context, this, null, contentWorkflow); if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) { - Response.Headers["Surrogate-Key"] = response.Items.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = response.Items.ToManyEtag(); + Response.Headers[HeaderNames.ETag] = response.ToEtag(); return Ok(response); } @@ -151,7 +147,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 +156,24 @@ 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 context = Context(); + var contents = await contentQuery.QueryAsync(context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); - var result = await contentQuery.QueryAsync(context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString())); + var schema = await contentQuery.GetSchemaOrThrowAsync(context, name); - var response = new ContentsDto - { - Total = result.Total, - Items = result.Take(200).Select(x => ContentDto.FromContent(x, context)).ToArray() - }; + var response = await ContentsDto.FromContentsAsync(contents, context, this, schema, contentWorkflow); - if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys) + if (ShouldProvideSurrogateKeys(response)) { - Response.Headers["Surrogate-Key"] = response.Items.ToSurrogateKeys(); + Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys(); } - Response.Headers[HeaderNames.ETag] = response.Items.ToManyEtag(response.Total); + Response.Headers[HeaderNames.ETag] = response.ToEtag(); return Ok(response); } @@ -200,6 +193,7 @@ 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) @@ -207,7 +201,7 @@ namespace Squidex.Areas.Api.Controllers.Contents var context = Context(); var content = await contentQuery.FindContentAsync(context, name, id); - var response = ContentDto.FromContent(content, context); + var response = await ContentDto.FromContentAsync(context, content, contentWorkflow, this); if (controllerOptions.Value.EnableSurrogateKeys) { @@ -243,7 +237,7 @@ namespace Squidex.Areas.Api.Controllers.Contents var context = Context(); var content = await contentQuery.FindContentAsync(context, name, id, version); - var response = ContentDto.FromContent(content, context); + var response = await ContentDto.FromContentAsync(context, content, contentWorkflow, this); if (controllerOptions.Value.EnableSurrogateKeys) { @@ -272,25 +266,21 @@ 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); + await contentQuery.GetSchemaOrThrowAsync(Context(), name); - var publishPermission = Permissions.ForApp(Permissions.AppContentsPublish, app, name); - - if (publish && !User.Permissions().Includes(publishPermission)) + if (publish && !this.HasPermission(Helper.StatusPermission(app, name, Status.Published))) { - return new StatusCodeResult(123); + return new ForbidResult(); } 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 +303,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 +335,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 +355,34 @@ 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)] + [Route("content/{app}/{name}/{id}/status/")] + [ProducesResponseType(typeof(ContentsDto), 200)] + [ApiPermission] [ApiCosts(1)] - public async Task ArchiveContent(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); - - var command = CreateCommand(id, Status.Archived, dueTime); - - await CommandBus.PublishAsync(command); + await contentQuery.GetSchemaOrThrowAsync(Context(), name); - 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)] - [ApiCosts(1)] - public async Task RestoreContent(string app, string name, Guid id, string dueTime = null) - { - await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); + if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published))) + { + return new ForbidResult(); + } - 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 +392,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 +401,18 @@ namespace Squidex.Areas.Api.Controllers.Contents /// [HttpPut] [Route("content/{app}/{name}/{id}/discard/")] + [ProducesResponseType(typeof(ContentsDto), 200)] [ApiPermission(Permissions.AppContentsDiscard)] [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 +422,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 +434,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,21 +443,14 @@ 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 = await ContentDto.FromContentAsync(null, result, contentWorkflow, this); - return new ChangeContentStatus { Status = status, ContentId = id, DueTime = dt }; + return response; } private QueryContext Context() @@ -563,5 +461,10 @@ namespace Squidex.Areas.Api.Controllers.Contents .WithLanguages(Request.Headers["X-Languages"]) .WithUnpublished(Request.Headers.ContainsKey("X-Unpublished")); } + + private bool ShouldProvideSurrogateKeys(ContentsDto response) + { + return controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= 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..4aad54547 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.OperationId = $"Change{schemaType}ContentStatus"; - operation.AddResponse("204", $"{schemaName} content published."); + operation.Summary = $"Change status of {schemaName} content."; - AddSecurity(operation, Permissions.AppContentsPublish); - }); - } + operation.AddBodyParameter("request", statusSchema, "The request to change content status."); - 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.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.AppContentsStatus); }); } - 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.AddResponse("204", $"{schemaName} content restored."); - - AddSecurity(operation, Permissions.AppContentsRead); - }); - } + operation.OperationId = $"Discard{schemaType}Content"; - 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.Summary = $"Discard changes of {schemaName} content."; - operation.AddResponse("204", $"{schemaName} content restored."); + operation.AddResponse("400", "No pending draft."); + operation.AddResponse("200", $"{schemaName} content status changed.", contentSchema); - AddSecurity(operation, Permissions.AppContentsRestore); + AddSecurity(operation, Permissions.AppContentsDiscard); }); } @@ -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/Helper.cs b/src/Squidex/Areas/Api/Controllers/Contents/Helper.cs new file mode 100644 index 000000000..8644c925a --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Contents/Helper.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure.Security; +using Squidex.Shared; + +namespace Squidex.Areas.Api.Controllers.Contents +{ + public static class Helper + { + public static Permission StatusPermission(string app, string schema, Status status) + { + var id = Permissions.AppContentsStatus.Replace("{status}", status.Name); + + return Permissions.ForApp(id, app, schema); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ChangeStatusDto.cs similarity index 52% rename from src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs rename to src/Squidex/Areas/Api/Controllers/Contents/Models/ChangeStatusDto.cs index 871fcc6e9..7a89789c9 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorAssignedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ChangeStatusDto.cs @@ -5,26 +5,30 @@ // 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.Apps.Models +namespace Squidex.Areas.Api.Controllers.Contents.Models { - public sealed class ContributorAssignedDto + public sealed class ChangeStatusDto { /// - /// The id of the user that has been assigned as contributor. + /// The new status. /// [Required] - public string ContributorId { get; set; } + public Status Status { get; set; } /// - /// Indicates if the user was created. + /// The due time. /// - public bool IsCreated { get; set; } + public Instant? DueTime { get; set; } - public static ContributorAssignedDto FromId(string id, bool isCreated) + public ChangeContentStatus ToCommand(Guid id) { - return new ContributorAssignedDto { ContributorId = id, IsCreated = isCreated }; + 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..2a6afaacf 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs @@ -7,20 +7,20 @@ using System; using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; using NodaTime; 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, IGenerateETag { /// /// The if of the content item. @@ -80,30 +80,15 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// 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 ValueTask FromContentAsync( + QueryContext context, + IContentEntity content, + IContentWorkflow contentWorkflow, + ApiController controller) { var response = SimpleMapper.Map(content, new ContentDto()); - if (context.Flatten) + if (context?.Flatten == true) { response.Data = content.Data?.ToFlatten(); response.DataDraft = content.DataDraft?.ToFlatten(); @@ -119,7 +104,70 @@ 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, contentWorkflow); + } + + private async ValueTask CreateLinksAsync(IContentEntity content, + ApiController controller, + string app, + string schema, + IContentWorkflow contentWorkflow) + { + 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.AppContentsDiscard, app, schema)) + { + AddPutLink("draft/discard", controller.Url(x => nameof(x.DiscardDraft), values)); + } + + if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published))) + { + AddPutLink("draft/publish", controller.Url(x => nameof(x.PutContentStatus), values)); + } + } + + if (controller.HasPermission(Permissions.AppContentsUpdate, app, schema)) + { + if (await contentWorkflow.CanUpdateAsync(content)) + { + 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 (controller.HasPermission(Permissions.AppContentsDelete, app, schema)) + { + AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteContent), values)); + } + + var nextStatuses = await contentWorkflow.GetNextsAsync(content); + + foreach (var next in nextStatuses) + { + if (controller.HasPermission(Helper.StatusPermission(app, schema, next))) + { + AddPutLink($"status/{next}", controller.Url(x => nameof(x.PutContentStatus), 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..afcecb7fe 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,78 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// /// The content items. /// + [Required] public ContentDto[] Items { get; set; } + + /// + /// The possible statuses. + /// + [Required] + public Status[] Statuses { get; set; } + + public string ToEtag() + { + return Items.ToManyEtag(Total); + } + + public string ToSurrogateKeys() + { + return Items.ToSurrogateKeys(); + } + + public static async Task FromContentsAsync(IResultList contents, QueryContext context, + ApiController controller, + ISchemaEntity schema, + IContentWorkflow contentWorkflow) + { + var result = new ContentsDto + { + Total = contents.Total, + Items = new ContentDto[contents.Count] + }; + + await Task.WhenAll( + result.AssignContentsAsync(contentWorkflow, contents, context, controller), + 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.ToArray(); + } + + private async Task AssignContentsAsync(IContentWorkflow contentWorkflow, IResultList contents, QueryContext context, ApiController controller) + { + for (var i = 0; i < Items.Length; i++) + { + Items[i] = await ContentDto.FromContentAsync(context, contents[i], contentWorkflow, controller); + } + } + + 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)); + + if (controller.HasPermission(Helper.StatusPermission(app, schema, Status.Published))) + { + AddPostLink("create/publish", controller.Url(x => nameof(x.PostContent), values) + "?publish=true"); + } + } + } + + return this; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs b/src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs index b0e7b34cb..ef7ebda7b 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/Models/ScheduleJobDto.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.ComponentModel.DataAnnotations; using NodaTime; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; @@ -25,13 +26,14 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models public Status Status { get; set; } /// - /// The user who schedule the content. + /// The target date and time when the content should be scheduled. /// - public RefToken ScheduledBy { get; set; } + public Instant DueTime { get; set; } /// - /// The target date and time when the content should be scheduled. + /// The user who schedule the content. /// - public Instant DueTime { get; set; } + [Required] + public RefToken ScheduledBy { get; set; } } } 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..cb2b1ecde 100644 --- a/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs +++ b/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs @@ -46,9 +46,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.ToArray(HistoryEventDto.FromHistoryEvent); return Ok(response); } 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/Plans/AppPlansController.cs b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index 9490a4f48..a14f220a3 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -71,10 +71,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/RuleDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleDto.cs index 4ee256530..39ec2ff60 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, IGenerateETag { /// /// 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/RulesDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs new file mode 100644 index 000000000..c13c163fb --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RulesDto.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// 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 string GenerateEtag() + { + return Items.ToManyEtag(0); + } + + 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..c6213c93b 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; @@ -76,16 +77,16 @@ 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 = RulesDto.FromRules(rules, this, app); - Response.Headers[HeaderNames.ETag] = response.ToManyEtag(0); + Response.Headers[HeaderNames.ETag] = response.GenerateEtag(); return Ok(response); } @@ -102,16 +103,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 +122,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 +131,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 +149,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 +173,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 +197,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 +233,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 +244,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 +253,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 +271,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 +280,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 +291,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/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..35fafd420 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, IGenerateETag { /// /// 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..596c80d07 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemasDto.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// 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 string ToEtag() + { + return Items.ToManyEtag(); + } + + 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..67e807a45 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,16 @@ 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 = SchemasDto.FromSchemas(schemas, this, app); - Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); + Response.Headers[HeaderNames.ETag] = response.ToEtag(); return Ok(response); } @@ -74,25 +73,25 @@ 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 = SchemaDetailsDto.FromSchemaWithDetails(schema, this, app); - Response.Headers[HeaderNames.ETag] = entity.Version.ToString(); + Response.Headers[HeaderNames.ETag] = schema.Version.ToString(); return Ok(response); } @@ -109,18 +108,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), 200)] [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 +127,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 +152,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 +177,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 +201,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 +249,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 +273,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 +297,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 +310,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/UIController.cs b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs index e66c8feb8..e07d9f23e 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -27,9 +27,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; 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/PublicUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs deleted file mode 100644 index e398c88be..000000000 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; - -namespace Squidex.Areas.Api.Controllers.Users.Models -{ - public sealed class PublicUserDto - { - /// - /// The id of the user. - /// - [Required] - public string Id { get; set; } - - /// - /// The display name (usually first name and last name) of the user. - /// - [Required] - public string DisplayName { get; set; } - } -} 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/IdentityServer/Views/Account/Login.cshtml b/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml index 54b6042d0..dd23d6a95 100644 --- a/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml +++ b/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml @@ -53,22 +53,22 @@ { if (Model.IsLogin) { - if (Model.IsFailed) - { -
Email or password not correct.
- } + if (Model.IsFailed) + { +
Email or password not correct.
+ } -
-
- -
+ +
+ +
-
- -
+
+ +
- -
+ + } else { @@ -95,7 +95,6 @@ else var redirectButtons = document.getElementsByClassName("redirect-button"); if (redirectButtons.length === 1) { - debugger; redirectButtons[0].click(); } diff --git a/src/Squidex/Config/Domain/AssetServices.cs b/src/Squidex/Config/Domain/AssetServices.cs index 63f813aa6..90b5ac434 100644 --- a/src/Squidex/Config/Domain/AssetServices.cs +++ b/src/Squidex/Config/Domain/AssetServices.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using FluentFTP; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; @@ -68,6 +70,24 @@ namespace Squidex.Config.Domain return new MongoGridFsAssetStore(gridFsbucket); }) .As(); + }, + ["Ftp"] = () => + { + var serverHost = config.GetRequiredValue("assetStore:ftp:serverHost"); + var serverPort = config.GetOptionalValue("assetStore:ftp:serverPort", 21); + + var username = config.GetRequiredValue("assetStore:ftp:username"); + var password = config.GetRequiredValue("assetStore:ftp:password"); + + var path = config.GetOptionalValue("assetStore:ftp:path", "/"); + + services.AddSingletonAs(c => + { + var factory = new Func(() => new FtpClient(serverHost, serverPort, username, password)); + + return new FTPAssetStore(factory, path, c.GetRequiredService()); + }) + .As(); } }); diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 1b378c173..d6965d669 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -6,6 +6,8 @@ // ========================================================================== using System; +using GraphQL; +using GraphQL.DataLoader; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -74,6 +76,18 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As().As(); + services.AddSingletonAs(x => new FuncDependencyResolver(t => x.GetRequiredService(t))) + .As(); + + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .AsSelf(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -101,6 +115,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .AsOptional(); + services.AddSingletonAs() .AsSelf(); diff --git a/src/Squidex/Config/Domain/SerializationServices.cs b/src/Squidex/Config/Domain/SerializationServices.cs index 0b1f7cfce..44aadc738 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,6 +45,7 @@ namespace Squidex.Config.Domain new RolesConverter(), new RuleConverter(), new SchemaConverter(), + new StatusConverter(), new StringEnumConverter()); settings.NullValueHandling = NullValueHandling.Ignore; 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/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 0c7e1efc2..c848d5d5d 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,9 +24,9 @@ import { templateUrl: './user-page.component.html' }) export class UserPageComponent extends ResourceOwner implements OnInit { - public canUpdate = false; + public isEditable = false; - public user?: { user: UserDto, isCurrentUser: boolean }; + public user?: UserDto; public userForm = new UserForm(this.formBuilder); constructor( @@ -45,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..18ebdc7ca 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; + 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( + 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']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( + 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( + 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..4992334f1 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,17 +72,11 @@ 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); + 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.')); } @@ -78,16 +84,9 @@ export class UsersService { public getUser(id: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management/${id}`); - return this.http.get(url).pipe( + return this.http.get(url).pipe( map(body => { - const user = new UserDto( - body.id, - body.email, - body.displayName, - body.permissions, - body.isLocked); - - return user; + return parseUser(body); }), pretifyError('Failed to load user. Please reload.')); } @@ -95,38 +94,56 @@ export class UsersService { public postUser(dto: CreateUserDto): Observable { const url = this.apiUrl.buildUrl('api/user-management'); - return this.http.post(url, dto).pipe( + return this.http.post(url, dto).pipe( map(body => { - const user = new UserDto( - body.id, - dto.email, - dto.displayName, - dto.permissions, - false); - - return user; + 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( + 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( + 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( + 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/pages/graphql/graphql-page.component.scss b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss index e47eee080..91ea94e93 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 @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -::ng-deep { +:host ::ng-deep { @import '~graphiql/graphiql'; .graphiql-container { diff --git a/src/Squidex/app/features/apps/pages/news-dialog.component.scss b/src/Squidex/app/features/apps/pages/news-dialog.component.scss index 927e34891..beb51611a 100644 --- a/src/Squidex/app/features/apps/pages/news-dialog.component.scss +++ b/src/Squidex/app/features/apps/pages/news-dialog.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -::ng-deep { +:host ::ng-deep { img { @include box-shadow(0, 4px, 20px, .2); width: 80%; diff --git a/src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss b/src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss index 01f6bf120..3ae3573cb 100644 --- a/src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss +++ b/src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss @@ -19,31 +19,33 @@ p { max-width: 489px; } -::ng-deep .modal { - &-content, - &-dialog { - min-height: $size-height; - max-height: $size-height; - min-width: $size-width; - max-width: $size-width; - } +:host ::ng-deep { + .modal { + &-content, + &-dialog { + min-height: $size-height; + max-height: $size-height; + min-width: $size-width; + max-width: $size-width; + } - &-content { - color: $color-dark-foreground; - background-color: $color-dark-onboarding; - background-image: url('./images/onboarding-background.png'); - position: relative; - } + &-content { + color: $color-dark-foreground; + background-color: $color-dark-onboarding; + background-image: url('./images/onboarding-background.png'); + position: relative; + } - &-body, - &-content { - overflow: hidden; - } + &-body, + &-content { + overflow: hidden; + } - &-close { - text-decoration: underline !important; - cursor: pointer; - color: $color-dark-foreground; + &-close { + text-decoration: underline !important; + cursor: pointer; + color: $color-dark-foreground; + } } } 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 + - - @@ -48,35 +45,25 @@ - - - + + 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 5704468b4..cf274c475 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 @@ -64,7 +64,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD 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.canUpdate) { + 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.canUpdate); } 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..ea615ec67 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..00bb31427 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; +} \ No newline at end of file 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/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 @@
-
+
@@ -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..334bd3430 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 @@
- -
@@ -31,20 +31,25 @@ Edit Preview Urls
- - - - Clone - + + + + + Clone + + - - - - Delete - + + + + + Delete + +
@@ -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..f73a4c78d 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 @@ -29,6 +29,7 @@
-
+
@@ -65,7 +66,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..b03111c4b 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,13 @@ export class SchemaPreviewUrlsFormComponent implements OnInit { } public ngOnInit() { + this.isEditable = this.schema.canUpdateUrls; + this.editForm.load(this.schema.previewUrls); + + if (!this.isEditable) { + return; + } } public emitComplete() { @@ -50,6 +58,10 @@ export class SchemaPreviewUrlsFormComponent implements OnInit { } public add() { + if (!this.isEditable) { + return; + } + const value = this.addForm.submit(); if (value) { @@ -60,6 +72,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/schema-form.component.scss b/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss index 5642dbe21..7786056a4 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss +++ b/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss @@ -8,7 +8,7 @@ $icon-size: 4.5rem; margin-left: -.5rem; } -::ng-deep { +:host ::ng-deep { .editor { height: 15rem !important; margin-bottom: .5rem; 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..5aa97c034 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 @@ - - + + + + +
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..94ade6d25 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}}
@@ -78,7 +78,8 @@
-
@@ -29,6 +29,7 @@
- + - {{userInfo.user.displayName}} + {{user.displayName}} - {{userInfo.user.email}} + {{user.email}} - - - - - - - - + +
- + + @@ -55,19 +55,10 @@
- diff --git a/src/Squidex/app/features/content/shared/contents-selector.component.scss b/src/Squidex/app/features/content/shared/contents-selector.component.scss index 6daf3f73c..e3fbb5c9a 100644 --- a/src/Squidex/app/features/content/shared/contents-selector.component.scss +++ b/src/Squidex/app/features/content/shared/contents-selector.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -::ng-deep { +:host ::ng-deep { .modal-body { background: $color-background; } 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/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.scss b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss index c8ff0d705..cc50f6886 100644 --- a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss +++ b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss @@ -19,10 +19,12 @@ } } -::ng-deep canvas { - height: 12rem !important; - margin-top: -1rem; - margin-bottom: 0; +:host ::ng-deep { + canvas { + height: 12rem !important; + margin-top: -1rem; + margin-bottom: 0; + } } .subtext { @@ -72,7 +74,7 @@ } &:hover { - @include box-shadow(0, 3px, 16px, .2); + @include box-shadow(0, 3px, 16px, .2px); } &:focus { 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/pages/rules/rule-wizard.component.html b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html index fbe0b104a..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 @@ -22,6 +22,10 @@
+ + The selection of the trigger type cannot be changed later. + +
@@ -68,6 +72,10 @@
+ + The selection of the action type cannot be changed later. + +
@@ -98,12 +106,12 @@ - + - +
diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss index aa4ab7f00..4d3d9e03e 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss @@ -2,7 +2,7 @@ @import '_mixins'; .rule-element { - margin: .25rem; + padding-right: .25rem; } .wizard-title { @@ -11,6 +11,6 @@ margin-bottom: 1rem; font-weight: 400; font-size: 1.05rem; - padding: 1rem; + padding: 1rem 1.75rem; } 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}} - -
-