Browse Source

Collaborations2 (#1036)

* Describe graphql.

* Initial tests.

* Fixes.

* A lot of progress.

* Set other tests.

* YDotnet

* Simpler completion.

* Use locking.

* Remove comment tests.
pull/1038/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
26ba91b86b
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 32
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs
  2. 30
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  3. 4
      backend/src/Migrations/OldEvents/CommentDeleted.cs
  4. 4
      backend/src/Migrations/OldEvents/CommentUpdated.cs
  5. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs
  6. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj
  7. 1
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs
  9. 266
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentCollaborationHandler.cs
  10. 3
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentTriggerHandler.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotificationOptions.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotifications.cs
  13. 23
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/ICollaborationService.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/IUserNotifications.cs
  15. 2
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/NoopUserNotifications.cs
  16. 15
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs
  17. 22
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs
  18. 12
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs
  19. 12
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs
  20. 48
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs
  21. 32
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs
  22. 95
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs
  23. 76
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs
  24. 167
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs
  25. 87
      backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/Guards/GuardComments.cs
  26. 16
      backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs
  27. 16
      backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs
  28. 61
      backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingService.cs
  29. 2
      backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs
  30. 5
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  31. 7
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs
  32. 17
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs
  33. 2
      backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj
  34. 23
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs
  35. 1
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Utils.cs
  36. 22
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs
  37. 4
      backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  38. 6
      backend/src/Squidex.Infrastructure/DomainId.cs
  39. 3
      backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs
  40. 10
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs
  41. 20
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
  42. 18
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  43. 4
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppImageController.cs
  44. 22
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs
  45. 26
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs
  46. 10
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs
  47. 20
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  48. 40
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  49. 8
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  50. 26
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs
  51. 74
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  52. 8
      backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs
  53. 14
      backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs
  54. 4
      backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs
  55. 150
      backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
  56. 56
      backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs
  57. 47
      backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentsDto.cs
  58. 46
      backend/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs
  59. 99
      backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs
  60. 96
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  61. 30
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs
  62. 8
      backend/src/Squidex/Areas/Api/Controllers/Diagnostics/DiagnosticsController.cs
  63. 14
      backend/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs
  64. 8
      backend/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs
  65. 2
      backend/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs
  66. 2
      backend/src/Squidex/Areas/Api/Controllers/News/NewsController.cs
  67. 6
      backend/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs
  68. 10
      backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  69. 8
      backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs
  70. 68
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  71. 118
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs
  72. 62
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  73. 4
      backend/src/Squidex/Areas/Api/Controllers/Search/SearchController.cs
  74. 28
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  75. 18
      backend/src/Squidex/Areas/Api/Controllers/Teams/TeamContributorsController.cs
  76. 18
      backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs
  77. 6
      backend/src/Squidex/Areas/Api/Controllers/Templates/TemplatesController.cs
  78. 4
      backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs
  79. 3
      backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs
  80. 34
      backend/src/Squidex/Areas/Api/Controllers/UI/UIController.cs
  81. 34
      backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs
  82. 14
      backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs
  83. 10
      backend/src/Squidex/Config/Domain/CollaborationServices.cs
  84. 4
      backend/src/Squidex/Config/Domain/CommandsServices.cs
  85. 22
      backend/src/Squidex/Config/Domain/CommentsServices.cs
  86. 16
      backend/src/Squidex/Config/Domain/FontendServices.cs
  87. 2
      backend/src/Squidex/Config/Domain/RuleServices.cs
  88. 7
      backend/src/Squidex/Config/Domain/StoreServices.cs
  89. 3
      backend/src/Squidex/Config/Web/WebServices.cs
  90. 8
      backend/src/Squidex/Squidex.csproj
  91. 3
      backend/src/Squidex/Startup.cs
  92. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageNotifierWorkerTest.cs
  93. 335
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Collaboration/CommentCollaborationHandlerTests.cs
  94. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Collaboration/CommentTriggerHandlerTests.cs
  95. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Collaboration/EmailUserNotificationsTests.cs
  96. 46
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs
  97. 175
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsCommandMiddlewareTests.cs
  98. 175
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsStreamTests.cs
  99. 191
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/Guards/GuardCommentsTests.cs
  100. 82
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/WatchingServiceTests.cs

32
backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs

@ -7,31 +7,31 @@
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Extensions.Actions.Comment;
public sealed class CommentActionHandler : RuleActionHandler<CommentAction, CreateComment>
public sealed class CommentActionHandler : RuleActionHandler<CommentAction, CommentCreated>
{
private const string Description = "Send a Comment";
private readonly ICommandBus commandBus;
private readonly ICollaborationService collaboration;
public CommentActionHandler(RuleEventFormatter formatter, ICommandBus commandBus)
public CommentActionHandler(RuleEventFormatter formatter, ICollaborationService collaboration)
: base(formatter)
{
this.commandBus = commandBus;
this.collaboration = collaboration;
}
protected override async Task<(string Description, CreateComment Data)> CreateJobAsync(EnrichedEvent @event, CommentAction action)
protected override async Task<(string Description, CommentCreated Data)> CreateJobAsync(EnrichedEvent @event, CommentAction action)
{
if (@event is not EnrichedContentEvent contentEvent)
{
return ("Ignore", new CreateComment());
return ("Ignore", new CommentCreated());
}
var ruleJob = new CreateComment
var ruleJob = new CommentCreated
{
AppId = contentEvent.AppId
};
@ -40,7 +40,7 @@ public sealed class CommentActionHandler : RuleActionHandler<CommentAction, Crea
if (string.IsNullOrWhiteSpace(text))
{
return ("NoText", new CreateComment());
return ("NoText", new CommentCreated());
}
ruleJob.Text = text;
@ -59,20 +59,16 @@ public sealed class CommentActionHandler : RuleActionHandler<CommentAction, Crea
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(CreateComment job,
protected override async Task<Result> ExecuteJobAsync(CommentCreated job,
CancellationToken ct = default)
{
var command = job;
if (command.CommentsId == default)
if (job.CommentsId == default)
{
return Result.Ignored();
}
command.FromRule = true;
await commandBus.PublishAsync(command, ct);
await collaboration.CommentAsync(job.AppId, job.CommentsId, job.Text, job.Actor, job.Url, true, ct);
return Result.Success($"Commented: {command.Text}");
return Result.Success($"Commented: {job.Text}");
}
}

30
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs

@ -7,32 +7,31 @@
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Extensions.Actions.Notification;
public sealed class NotificationActionHandler : RuleActionHandler<NotificationAction, CreateComment>
public sealed class NotificationActionHandler : RuleActionHandler<NotificationAction, CommentCreated>
{
private const string Description = "Send a Notification";
private readonly ICommandBus commandBus;
private readonly ICollaborationService collaboration;
private readonly IUserResolver userResolver;
public NotificationActionHandler(RuleEventFormatter formatter, ICommandBus commandBus, IUserResolver userResolver)
public NotificationActionHandler(RuleEventFormatter formatter, ICollaborationService collaboration, IUserResolver userResolver)
: base(formatter)
{
this.commandBus = commandBus;
this.collaboration = collaboration;
this.userResolver = userResolver;
}
protected override async Task<(string Description, CreateComment Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action)
protected override async Task<(string Description, CommentCreated Data)> CreateJobAsync(EnrichedEvent @event, NotificationAction action)
{
if (@event is not EnrichedUserEventBase userEvent)
{
return ("Ignore", new CreateComment());
return ("Ignore", new CommentCreated());
}
var user = await userResolver.FindByIdOrEmailAsync(action.User);
@ -49,9 +48,8 @@ public sealed class NotificationActionHandler : RuleActionHandler<NotificationAc
actor = RefToken.Client(action.Client);
}
var ruleJob = new CreateComment
var ruleJob = new CommentCreated
{
AppId = CommentsCommand.NoApp,
Actor = actor,
CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id),
@ -72,18 +70,16 @@ public sealed class NotificationActionHandler : RuleActionHandler<NotificationAc
return (Description, ruleJob);
}
protected override async Task<Result> ExecuteJobAsync(CreateComment job,
protected override async Task<Result> ExecuteJobAsync(CommentCreated job,
CancellationToken ct = default)
{
var command = job;
if (command.CommentsId == default)
if (job.CommentsId == default)
{
return Result.Ignored();
}
await commandBus.PublishAsync(command, ct);
await collaboration.NotifyAsync(job.CommentsId.ToString(), job.Text, job.Actor, job.Url, true, ct);
return Result.Success($"Notified: {command.Text}");
return Result.Success($"Notified: {job.Text}");
}
}

4
backend/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs → backend/src/Migrations/OldEvents/CommentDeleted.cs

@ -7,9 +7,9 @@
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments;
namespace Migrations.OldEvents;
[EventType(nameof(CommentDeleted))]
public sealed class CommentDeleted : CommentsEvent
public sealed class CommentDeleted : IEvent
{
}

4
backend/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs → backend/src/Migrations/OldEvents/CommentUpdated.cs

@ -7,10 +7,10 @@
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments;
namespace Migrations.OldEvents;
[EventType(nameof(CommentUpdated))]
public sealed class CommentUpdated : CommentsEvent
public sealed class CommentUpdated : IEvent
{
public string Text { get; set; }
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Comments;
public sealed record Comment(DomainId Id, Instant Time, RefToken User, string Text, Uri? Url = null)
public sealed record Comment(Instant Time, RefToken User, string Text, Uri? Url = null, bool SkipHandlers = false)
{
public RefToken User { get; } = Guard.NotNull(User);

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj

@ -23,7 +23,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

1
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupProcessor.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Entities.Backup.State;

2
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageNotifierWorker.cs

@ -6,7 +6,7 @@
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;

266
backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentCollaborationHandler.cs

@ -0,0 +1,266 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared.Users;
using YDotNet.Document.Cells;
using YDotNet.Document.Types.Events;
using YDotNet.Extensions;
using YDotNet.Server;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed partial class CommentCollaborationHandler : IDocumentCallback, ICollaborationService
{
private static readonly Regex MentionRegex = BuildMentionRegex();
private readonly IJsonSerializer jsonSerializer;
private readonly IEventStore eventStore;
private readonly IEventFormatter eventFormatter;
private readonly IUserResolver userResolver;
private readonly IClock clock;
private readonly ILogger<CommentCollaborationHandler> log;
private IDocumentManager? currentManager;
public Task LastTask { get; private set; }
public CommentCollaborationHandler(
IJsonSerializer jsonSerializer,
IEventStore eventStore,
IEventFormatter eventFormatter,
IUserResolver userResolver,
IClock clock,
ILogger<CommentCollaborationHandler> log)
{
this.jsonSerializer = jsonSerializer;
this.eventStore = eventStore;
this.eventFormatter = eventFormatter;
this.userResolver = userResolver;
this.clock = clock;
this.log = log;
}
public ValueTask OnInitializedAsync(IDocumentManager manager)
{
currentManager = manager;
return default;
}
public Task NotifyAsync(string userId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default)
{
return CommentAsync(UserDocument(userId), text, actor, url, skipHandlers, ct);
}
public Task CommentAsync(NamedId<DomainId> appId, DomainId resourceId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default)
{
return CommentAsync(ResourceDocument(appId, resourceId), text, actor, url, skipHandlers, ct);
}
private async Task CommentAsync(string documentName, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct)
{
if (currentManager == null)
{
return;
}
var notificationsContext = new DocumentContext(documentName, 0);
// Use the update method to ensure that only one thread has access to the doc.
await currentManager.UpdateDocAsync(notificationsContext, doc =>
{
var stream = doc.Array("stream");
using (var transaction = doc.WriteTransaction())
{
var commentValue = new Comment(clock.GetCurrentInstant(), actor, text, url, skipHandlers);
var commentJson = jsonSerializer.Serialize(commentValue);
stream.InsertRange(transaction, stream.Length, InputFactory.FromJson(commentJson));
}
}, ct);
}
public ValueTask OnDocumentLoadedAsync(DocumentLoadEvent @event)
{
if (!IsResourceDocument(@event.Context.DocumentName, out var appId, out var resourceId))
{
return default;
}
var stream = @event.Document.Array("stream");
stream.ObserveDeep(changes =>
{
var newComments =
changes
.Where(x => x.Tag == EventBranchTag.Array)
.Select(x => x.ArrayEvent)
.SelectMany(x => x.Delta).Where(x => x.Tag == EventChangeTag.Add)
.SelectMany(x => x.Values).Where(x => x.Tag == OutputTag.JsonObject)
.ToArray();
if (newComments.Length == 0)
{
// Just store the last task for tests.
LastTask = Task.CompletedTask;
return;
}
LastTask = Task.Run(async () =>
{
try
{
// Run in an extra task to prevent deadlocks with the outer transaction.
await HandleAsync(@event, appId, resourceId, newComments);
}
catch (Exception ex)
{
// We are in an extra task, so the exception would be probably swallowed.
log.LogError(ex, "Failed to handle yjs event.");
throw;
}
});
});
return default;
}
private async Task HandleAsync(DocumentLoadEvent @event, NamedId<DomainId> appId, DomainId resourceId, Output[] newComments)
{
var comments = new List<Comment>();
// Use the update method to ensure that only one thread has access to the doc.
await @event.Source.UpdateDocAsync(@event.Context, (doc) =>
{
using (var transaction = @event.Document.ReadTransaction())
{
foreach (var output in newComments)
{
// Just use the json string for debuggability.
var json = output.ToJson(transaction);
var comment = jsonSerializer.Deserialize<Comment>(json);
if (!comment.SkipHandlers)
{
comments.Add(comment);
}
}
}
});
var streamName = $"comments-{DomainId.Combine(appId, resourceId)}";
foreach (var comment in comments)
{
var commentEvent = await CreateEventAsync(comment, appId, resourceId);
var eventBody = Envelope.Create<IEvent>(commentEvent);
var eventData = eventFormatter.ToEventData(eventBody, Guid.NewGuid());
await eventStore.AppendAsync(Guid.NewGuid(), streamName, EtagVersion.Any, new List<EventData> { eventData });
foreach (var mentionedUser in commentEvent.Mentions.OrEmpty())
{
await NotifyAsync(mentionedUser, comment.Text, RefToken.User(mentionedUser), comment.Url, true);
}
}
}
private async Task<CommentCreated> CreateEventAsync(Comment comment, NamedId<DomainId> appId, DomainId commentsId)
{
var @event = new CommentCreated
{
Actor = comment.User,
CommentId = DomainId.NewGuid(),
CommentsId = commentsId,
AppId = appId,
};
SimpleMapper.Map(comment, @event);
await MentionUsersAsync(@event);
return @event;
}
private async Task MentionUsersAsync(CommentCreated comment)
{
if (string.IsNullOrWhiteSpace(comment.Text))
{
return;
}
var emails = MentionRegex.Matches(comment.Text).Select(x => x.Value[1..]).ToArray();
if (emails.Length == 0)
{
return;
}
var mentions = new List<string>();
foreach (var email in emails)
{
var user = await userResolver.FindByIdOrEmailAsync(email);
if (user != null)
{
mentions.Add(user.Id);
}
}
if (mentions.Count > 0)
{
comment.Mentions = mentions.ToArray();
}
}
public string UserDocument(string userId)
{
return $"users/{userId}";
}
public string ResourceDocument(NamedId<DomainId> appId, DomainId resourceId)
{
return $"apps/{appId}/{resourceId}";
}
private static bool IsResourceDocument(string name, out NamedId<DomainId> appId, out DomainId resourceId)
{
resourceId = default;
if (!name.StartsWith("apps", StringComparison.Ordinal))
{
appId = default!;
return false;
}
var parts = name.Split('/');
if (parts.Length < 3 || !NamedId<DomainId>.TryParse(parts[1], DomainId.TryParse, out appId!))
{
appId = default!;
return false;
}
resourceId = DomainId.Create(string.Join('/', parts.Skip(2)));
return true;
}
[GeneratedRegex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 100)]
private static partial Regex BuildMentionRegex();
}

3
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentTriggerHandler.cs

@ -17,7 +17,7 @@ using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed class CommentTriggerHandler : IRuleTriggerHandler
{
@ -29,7 +29,6 @@ public sealed class CommentTriggerHandler : IRuleTriggerHandler
public CommentTriggerHandler(IScriptEngine scriptEngine, IUserResolver userResolver)
{
this.scriptEngine = scriptEngine;
this.userResolver = userResolver;
}

2
backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotificationOptions.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotificationOptions.cs

@ -5,7 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed class EmailUserNotificationOptions
{

2
backend/src/Squidex.Domain.Apps.Entities/Notifications/EmailUserNotifications.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/EmailUserNotifications.cs

@ -16,7 +16,7 @@ using Squidex.Infrastructure.Email;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed class EmailUserNotifications : IUserNotifications
{

23
backend/src/Squidex.Domain.Apps.Entities/Collaboration/ICollaborationService.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public interface ICollaborationService
{
Task NotifyAsync(string userId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default);
Task CommentAsync(NamedId<DomainId> appId, DomainId resourceId, string text, RefToken actor, Uri? url, bool skipHandlers,
CancellationToken ct = default);
string UserDocument(string userId);
string ResourceDocument(NamedId<DomainId> appId, DomainId resourceId);
}

2
backend/src/Squidex.Domain.Apps.Entities/Notifications/IUserNotifications.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/IUserNotifications.cs

@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public interface IUserNotifications
{

2
backend/src/Squidex.Domain.Apps.Entities/Notifications/NoopUserNotifications.cs → backend/src/Squidex.Domain.Apps.Entities/Collaboration/NoopUserNotifications.cs

@ -9,7 +9,7 @@ using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public sealed class NoopUserNotifications : IUserNotifications
{

15
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentTextCommand.cs

@ -1,15 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
public abstract class CommentTextCommand : CommentCommand
{
public string Text { get; set; }
public string[]? Mentions { get; set; }
}

22
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs

@ -1,22 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
public sealed class CreateComment : CommentTextCommand
{
public bool IsMention { get; set; }
public Uri? Url { get; set; }
public CreateComment()
{
CommentId = DomainId.NewGuid();
}
}

12
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs

@ -1,12 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
public sealed class DeleteComment : CommentCommand
{
}

12
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs

@ -1,12 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
public sealed class UpdateComment : CommentTextCommand
{
}

48
backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs

@ -1,48 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Comments.Commands;
public abstract class CommentCommand : CommentsCommand
{
public DomainId CommentId { get; set; }
}
public abstract class CommentsCommand : CommentsCommandBase
{
public static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.Empty, "none");
public DomainId CommentsId { get; set; }
public override DomainId AggregateId
{
get
{
if (AppId.Id == default)
{
return CommentsId;
}
else
{
return DomainId.Combine(AppId, CommentsId);
}
}
}
}
// This command is needed as marker for middlewares.
public abstract class CommentsCommandBase : SquidexCommand, IAppCommand, IAggregateCommand
{
public NamedId<DomainId> AppId { get; set; }
public abstract DomainId AggregateId { get; }
}

32
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs

@ -1,32 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments.DomainObject;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments;
public sealed class CommentsLoader : ICommentsLoader
{
private readonly IDomainObjectFactory domainObjectFactory;
public CommentsLoader(IDomainObjectFactory domainObjectFactory)
{
this.domainObjectFactory = domainObjectFactory;
}
public async Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default)
{
var stream = domainObjectFactory.Create<CommentsStream>(id);
await stream.LoadAsync(ct);
return stream.GetComments(version);
}
}

95
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs

@ -1,95 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Comments;
public sealed class CommentsResult
{
public List<Comment> CreatedComments { get; set; } = new List<Comment>();
public List<Comment> UpdatedComments { get; set; } = new List<Comment>();
public List<DomainId> DeletedComments { get; set; } = new List<DomainId>();
public long Version { get; set; }
public static CommentsResult FromEvents(IEnumerable<Envelope<CommentsEvent>> events, long currentVersion, int lastVersion)
{
var result = new CommentsResult { Version = currentVersion };
foreach (var @event in events.Skip(lastVersion < 0 ? 0 : lastVersion + 1))
{
switch (@event.Payload)
{
case CommentDeleted deleted:
{
var id = deleted.CommentId;
if (result.CreatedComments.Exists(x => x.Id == id))
{
result.CreatedComments.RemoveAll(x => x.Id == id);
}
else if (result.UpdatedComments.Exists(x => x.Id == id))
{
result.UpdatedComments.RemoveAll(x => x.Id == id);
result.DeletedComments.Add(id);
}
else
{
result.DeletedComments.Add(id);
}
break;
}
case CommentCreated created:
{
var comment = new Comment(
created.CommentId,
@event.Headers.Timestamp(),
@event.Payload.Actor,
created.Text,
created.Url);
result.CreatedComments.Add(comment);
break;
}
case CommentUpdated updated:
{
var id = updated.CommentId;
var comment = new Comment(
id,
@event.Headers.Timestamp(),
@event.Payload.Actor,
updated.Text,
null);
if (result.CreatedComments.Exists(x => x.Id == id))
{
result.CreatedComments.RemoveAll(x => x.Id == id);
result.CreatedComments.Add(comment);
}
else
{
result.UpdatedComments.Add(comment);
}
break;
}
}
}
return result;
}
}

76
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsCommandMiddleware.cs

@ -1,76 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject;
public sealed class CommentsCommandMiddleware : AggregateCommandMiddleware<CommentsCommandBase, CommentsStream>
{
private static readonly Regex MentionRegex = new Regex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, TimeSpan.FromMilliseconds(100));
private readonly IUserResolver userResolver;
public CommentsCommandMiddleware(IDomainObjectFactory domainObjectFactory, IUserResolver userResolver)
: base(domainObjectFactory)
{
this.userResolver = userResolver;
}
public override async Task HandleAsync(CommandContext context, NextDelegate next,
CancellationToken ct)
{
if (context.Command is CommentsCommand commentsCommand)
{
if (commentsCommand is CreateComment createComment && !IsMention(createComment))
{
await MentionUsersAsync(createComment);
}
}
await base.HandleAsync(context, next, ct);
}
private static bool IsMention(CreateComment createComment)
{
return createComment.IsMention;
}
private async Task MentionUsersAsync(CommentTextCommand command)
{
if (string.IsNullOrWhiteSpace(command.Text))
{
return;
}
var emails = MentionRegex.Matches(command.Text).Select(x => x.Value[1..]).ToArray();
if (emails.Length == 0)
{
return;
}
var mentions = new List<string>();
foreach (var email in emails)
{
var user = await userResolver.FindByIdOrEmailAsync(email);
if (user != null)
{
mentions.Add(user.Id);
}
}
if (mentions.Count > 0)
{
command.Mentions = mentions.ToArray();
}
}
}

167
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/CommentsStream.cs

@ -1,167 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject;
public class CommentsStream : IAggregate
{
private readonly List<Envelope<CommentsEvent>> uncommittedEvents = new List<Envelope<CommentsEvent>>();
private readonly List<Envelope<CommentsEvent>> events = new List<Envelope<CommentsEvent>>();
private readonly DomainId key;
private readonly IEventFormatter eventFormatter;
private readonly IEventStore eventStore;
private readonly string streamName;
private long version = EtagVersion.Empty;
private long Version => version;
public CommentsStream(
DomainId key,
IEventFormatter eventFormatter,
IEventStore eventStore)
{
this.key = key;
this.eventFormatter = eventFormatter;
this.eventStore = eventStore;
streamName = $"comments-{key}";
}
public virtual async Task LoadAsync(
CancellationToken ct)
{
var storedEvents = await eventStore.QueryStreamReverseAsync(streamName, 100, ct);
foreach (var @event in storedEvents)
{
var parsedEvent = eventFormatter.Parse(@event);
version = @event.EventStreamNumber;
events.Add(parsedEvent.To<CommentsEvent>());
}
}
public virtual async Task<CommandResult> ExecuteAsync(IAggregateCommand command,
CancellationToken ct)
{
await LoadAsync(ct);
switch (command)
{
case CreateComment createComment:
return await Upsert(createComment, c =>
{
GuardComments.CanCreate(c);
Create(c);
}, ct);
case UpdateComment updateComment:
return await Upsert(updateComment, c =>
{
GuardComments.CanUpdate(c, key.ToString(), events);
Update(c);
}, ct);
case DeleteComment deleteComment:
return await Upsert(deleteComment, c =>
{
GuardComments.CanDelete(c, key.ToString(), events);
Delete(c);
}, ct);
default:
ThrowHelper.NotSupportedException();
return null!;
}
}
private async Task<CommandResult> Upsert<TCommand>(TCommand command, Action<TCommand> handler,
CancellationToken ct) where TCommand : CommentsCommand
{
Guard.NotNull(command);
Guard.NotNull(handler);
if (command.ExpectedVersion > EtagVersion.Any && command.ExpectedVersion != Version)
{
throw new DomainObjectVersionException(key.ToString(), Version, command.ExpectedVersion);
}
var previousVersion = version;
try
{
handler(command);
if (uncommittedEvents.Count > 0)
{
var commitId = Guid.NewGuid();
var eventData = uncommittedEvents.Select(x => eventFormatter.ToEventData(x, commitId)).ToList();
await eventStore.AppendAsync(commitId, streamName, previousVersion, eventData, ct);
}
events.AddRange(uncommittedEvents);
return CommandResult.Empty(key, Version, previousVersion);
}
catch
{
version = previousVersion;
throw;
}
finally
{
uncommittedEvents.Clear();
}
}
public void Create(CreateComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentCreated()));
}
public void Update(UpdateComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentUpdated()));
}
public void Delete(DeleteComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentDeleted()));
}
private void RaiseEvent(CommentsEvent @event)
{
uncommittedEvents.Add(Envelope.Create(@event));
version++;
}
public virtual List<Envelope<CommentsEvent>> GetUncommittedEvents()
{
return uncommittedEvents;
}
public virtual CommentsResult GetComments(long sinceVersion = EtagVersion.Any)
{
return CommentsResult.FromEvents(events, Version, (int)sinceVersion);
}
}

87
backend/src/Squidex.Domain.Apps.Entities/Comments/DomainObject/Guards/GuardComments.cs

@ -1,87 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards;
public static class GuardComments
{
public static void CanCreate(CreateComment command)
{
Guard.NotNull(command);
Validate.It(e =>
{
if (string.IsNullOrWhiteSpace(command.Text))
{
e(Not.Defined(nameof(command.Text)), nameof(command.Text));
}
});
}
public static void CanUpdate(UpdateComment command, string commentsId, List<Envelope<CommentsEvent>> events)
{
Guard.NotNull(command);
var comment = FindComment(events, command.CommentId);
if (!string.Equals(commentsId, command.Actor.Identifier, StringComparison.Ordinal) && !comment.Payload.Actor.Equals(command.Actor))
{
throw new DomainException(T.Get("comments.notUserComment"));
}
Validate.It(e =>
{
if (string.IsNullOrWhiteSpace(command.Text))
{
e(Not.Defined(nameof(command.Text)), nameof(command.Text));
}
});
}
public static void CanDelete(DeleteComment command, string commentsId, List<Envelope<CommentsEvent>> events)
{
Guard.NotNull(command);
var comment = FindComment(events, command.CommentId);
if (!string.Equals(commentsId, command.Actor.Identifier, StringComparison.Ordinal) && !comment.Payload.Actor.Equals(command.Actor))
{
throw new DomainException(T.Get("comments.notUserComment"));
}
}
private static Envelope<CommentCreated> FindComment(List<Envelope<CommentsEvent>> events, DomainId commentId)
{
Envelope<CommentCreated>? result = null;
foreach (var @event in events)
{
if (@event.Payload is CommentCreated created && created.CommentId == commentId)
{
result = @event.To<CommentCreated>();
}
else if (@event.Payload is CommentDeleted deleted && deleted.CommentId == commentId)
{
result = null;
}
}
if (result == null)
{
throw new DomainObjectNotFoundException(commentId.ToString());
}
return result;
}
}

16
backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs

@ -1,16 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Comments;
public interface ICommentsLoader
{
Task<CommentsResult> GetCommentsAsync(DomainId id, long version = EtagVersion.Any,
CancellationToken ct = default);
}

16
backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs

@ -1,16 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Comments;
public interface IWatchingService
{
Task<string[]> GetWatchingUsersAsync(DomainId appId, string? resource, string userId,
CancellationToken ct = default);
}

61
backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingService.cs

@ -1,61 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Comments;
public sealed class WatchingService : IWatchingService
{
private readonly IPersistenceFactory<State> persistenceFactory;
[CollectionName("Watches")]
public sealed class State
{
private static readonly Duration Timeout = Duration.FromMinutes(1);
public Dictionary<string, Instant> Users { get; set; } = new Dictionary<string, Instant>();
public (bool, string[]) Add(string watcherId, IClock clock)
{
var now = clock.GetCurrentInstant();
foreach (var (userId, lastSeen) in Users.ToList())
{
var timeSinceLastSeen = now - lastSeen;
if (timeSinceLastSeen > Timeout)
{
Users.Remove(userId);
}
}
Users[watcherId] = now;
return (true, Users.Keys.ToArray());
}
}
public IClock Clock { get; set; } = SystemClock.Instance;
public WatchingService(IPersistenceFactory<State> persistenceFactory)
{
this.persistenceFactory = persistenceFactory;
}
public async Task<string[]> GetWatchingUsersAsync(DomainId appId, string? resource, string userId,
CancellationToken ct = default)
{
var state = new SimpleState<State>(persistenceFactory, GetType(), $"{appId}_{resource}");
await state.LoadAsync(ct);
return await state.UpdateAsync(x => x.Add(userId, Clock), ct: ct);
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Invitation/InvitationEventConsumer.cs

@ -7,7 +7,7 @@
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Domain.Apps.Events.Teams;
using Squidex.Infrastructure;

5
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -30,6 +30,8 @@
<PackageReference Include="Notifo.SDK" Version="1.7.4" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.CLI.Core" Version="11.2.0" />
<PackageReference Include="Squidex.YDotNet.Extensions" Version="0.2.9" />
<PackageReference Include="Squidex.YDotNet.Server" Version="0.2.9" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="7.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
@ -52,4 +54,7 @@
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Folder Include="Notifications\" />
</ItemGroup>
</Project>

7
backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs

@ -5,13 +5,18 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments;
[EventType(nameof(CommentCreated))]
public sealed class CommentCreated : CommentsEvent
public sealed class CommentCreated : AppEvent
{
public DomainId CommentsId { get; set; }
public DomainId CommentId { get; set; }
public string Text { get; set; }
public string[]? Mentions { get; set; }

17
backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs

@ -1,17 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Events.Comments;
public abstract class CommentsEvent : AppEvent
{
public DomainId CommentsId { get; set; }
public DomainId CommentId { get; set; }
}

2
backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj

@ -24,7 +24,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Security.Principal.Windows" Version="5.0.0" />

23
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs

@ -89,29 +89,6 @@ public sealed class GetEventStore : IEventStore, IInitializable
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryStreamReverseAsync(string streamName, int count = int.MaxValue,
CancellationToken ct = default)
{
if (count <= 0)
{
return EmptyEvents;
}
using (Telemetry.Activities.StartActivity("GetEventStore/GetEventStore"))
{
var result = new List<StoredEvent>();
var stream = QueryReverseAsync(streamName, StreamPosition.End, count, ct);
await foreach (var storedEvent in stream.IgnoreNotFound(ct))
{
result.Add(storedEvent);
}
return result.ToList();
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryStreamAsync(string streamName, long afterStreamPosition = EtagVersion.Empty,
CancellationToken ct = default)
{

1
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/Utils.cs

@ -7,7 +7,6 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using EventStore.Client;
namespace Squidex.Infrastructure.EventSourcing;

22
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs

@ -34,28 +34,6 @@ public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IE
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryStreamReverseAsync(string streamName, int count = int.MaxValue,
CancellationToken ct = default)
{
if (count <= 0)
{
return EmptyEvents;
}
using (Telemetry.Activities.StartActivity("MongoEventStore/QueryLatestAsync"))
{
var filter = FilterExtensions.ByStream(StreamFilter.Name(streamName));
var commits =
await Collection.Find(filter).Sort(Sort.Descending(x => x.Timestamp)).Limit(count)
.ToListAsync(ct);
var result = commits.Select(x => x.Filtered()).Reverse().SelectMany(x => x).TakeLast(count).ToList();
return result;
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryStreamAsync(string streamName, long afterStreamPosition = EtagVersion.Empty,
CancellationToken ct = default)
{

4
backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -18,8 +18,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.22.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

6
backend/src/Squidex.Infrastructure/DomainId.cs

@ -102,4 +102,10 @@ public readonly struct DomainId : IEquatable<DomainId>, IComparable<DomainId>
{
return new DomainId($"{id1}{IdSeparator}{id2}");
}
public static bool TryParse(ReadOnlySpan<char> input, out DomainId result)
{
result = new DomainId(input.ToString());
return true;
}
}

3
backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs

@ -11,9 +11,6 @@ namespace Squidex.Infrastructure.EventSourcing;
public interface IEventStore
{
Task<IReadOnlyList<StoredEvent>> QueryStreamReverseAsync(string streamName, int take = int.MaxValue,
CancellationToken ct = default);
Task<IReadOnlyList<StoredEvent>> QueryStreamAsync(string streamName, long afterStreamPosition = EtagVersion.Empty,
CancellationToken ct = default);

10
backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs

@ -29,8 +29,8 @@ public sealed class AppAssetsController : ApiController
/// Get the app asset scripts.
/// </summary>
/// <param name="app">The name of the app to get the asset scripts for.</param>
/// <response code="200">Asset scripts returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Asset scripts returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/assets/scripts")]
[ProducesResponseType(typeof(AssetScriptsDto), StatusCodes.Status200OK)]
@ -51,9 +51,9 @@ public sealed class AppAssetsController : ApiController
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="request">The values to update.</param>
/// <response code="200">Asset scripts updated.</response>.
/// <response code="400">Asset request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Asset scripts updated.</response>
/// <response code="400">Asset request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("apps/{app}/assets/scripts")]
[ProducesResponseType(typeof(AssetScriptsDto), StatusCodes.Status200OK)]

20
backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs

@ -31,8 +31,8 @@ public sealed class AppClientsController : ApiController
/// Get app clients.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Clients returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Clients returned.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// Gets all configured clients for the app with the specified name.
/// </remarks>
@ -58,9 +58,9 @@ public sealed class AppClientsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">Client object that needs to be added to the app.</param>
/// <response code="201">Client created.</response>.
/// <response code="400">Client request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="201">Client created.</response>
/// <response code="400">Client request not valid.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// Create a new client for the app with the specified name.
/// The client secret is auto generated on the server and returned. The client does not expire, the access token is valid for 30 days.
@ -85,9 +85,9 @@ public sealed class AppClientsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the client that must be updated.</param>
/// <param name="request">Client object that needs to be updated.</param>
/// <response code="200">Client updated.</response>.
/// <response code="400">Client request not valid.</response>.
/// <response code="404">Client or app not found.</response>.
/// <response code="200">Client updated.</response>
/// <response code="400">Client request not valid.</response>
/// <response code="404">Client or app not found.</response>
/// <remarks>
/// Only the display name can be changed, create a new client if necessary.
/// </remarks>
@ -110,8 +110,8 @@ public sealed class AppClientsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the client that must be deleted.</param>
/// <response code="200">Client deleted.</response>.
/// <response code="404">Client or app not found.</response>.
/// <response code="200">Client deleted.</response>
/// <response code="404">Client or app not found.</response>
/// <remarks>
/// The application that uses this client credentials cannot access the API after it has been revoked.
/// </remarks>

18
backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -38,8 +38,8 @@ public sealed class AppContributorsController : ApiController
/// Get app contributors.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Contributors returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Contributors returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/contributors/")]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]
@ -57,9 +57,9 @@ public sealed class AppContributorsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">Contributor object that needs to be added to the app.</param>
/// <response code="201">Contributor assigned to app.</response>.
/// <response code="400">Contributor request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="201">Contributor assigned to app.</response>
/// <response code="400">Contributor request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/contributors/")]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status201Created)]
@ -78,8 +78,8 @@ public sealed class AppContributorsController : ApiController
/// Remove yourself.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Contributor removed.</response>.
/// <response code="404">Contributor or app not found.</response>.
/// <response code="200">Contributor removed.</response>
/// <response code="404">Contributor or app not found.</response>
[HttpDelete]
[Route("apps/{app}/contributors/me/")]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]
@ -99,8 +99,8 @@ public sealed class AppContributorsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the contributor.</param>
/// <response code="200">Contributor removed.</response>.
/// <response code="404">Contributor or app not found.</response>.
/// <response code="200">Contributor removed.</response>
/// <response code="404">Contributor or app not found.</response>
[HttpDelete]
[Route("apps/{app}/contributors/{id}/")]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]

4
backend/src/Squidex/Areas/Api/Controllers/Apps/AppImageController.cs

@ -41,8 +41,8 @@ public sealed class AppImageController : ApiController
/// Get the app image.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">App image found and content or (resized) image returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App image found and content or (resized) image returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]

22
backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs

@ -32,8 +32,8 @@ public sealed class AppLanguagesController : ApiController
/// Get app languages.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Languages returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Languages returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/languages/")]
[ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status200OK)]
@ -56,9 +56,9 @@ public sealed class AppLanguagesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The language to add to the app.</param>
/// <response code="201">Language created.</response>.
/// <response code="400">Language request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="201">Language created.</response>
/// <response code="400">Language request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/languages/")]
[ProducesResponseType(typeof(AppLanguagesDto), 201)]
@ -79,9 +79,9 @@ public sealed class AppLanguagesController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="language">The language to update.</param>
/// <param name="request">The language object.</param>
/// <response code="200">Language updated.</response>.
/// <response code="400">Language request not valid.</response>.
/// <response code="404">Language or app not found.</response>.
/// <response code="200">Language updated.</response>
/// <response code="400">Language request not valid.</response>
/// <response code="404">Language or app not found.</response>
[HttpPut]
[Route("apps/{app}/languages/{language}/")]
[ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status200OK)]
@ -101,9 +101,9 @@ public sealed class AppLanguagesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="language">The language to delete from the app.</param>
/// <response code="200">Language deleted.</response>.
/// <response code="400">Language is master language.</response>.
/// <response code="404">Language or app not found.</response>.
/// <response code="200">Language deleted.</response>
/// <response code="400">Language is master language.</response>
/// <response code="404">Language or app not found.</response>
[HttpDelete]
[Route("apps/{app}/languages/{language}/")]
[ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status200OK)]

26
backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs

@ -35,8 +35,8 @@ public sealed class AppRolesController : ApiController
/// Get app roles.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Roles returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Roles returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/roles/")]
[ProducesResponseType(typeof(RolesDto), StatusCodes.Status200OK)]
@ -58,8 +58,8 @@ public sealed class AppRolesController : ApiController
/// Get app permissions.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">App permissions returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App permissions returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/roles/permissions")]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
@ -82,9 +82,9 @@ public sealed class AppRolesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">Role object that needs to be added to the app.</param>
/// <response code="201">Role created.</response>.
/// <response code="400">Role request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="201">Role created.</response>
/// <response code="400">Role request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/roles/")]
[ProducesResponseType(typeof(RolesDto), 201)]
@ -105,9 +105,9 @@ public sealed class AppRolesController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="roleName">The name of the role to be updated.</param>
/// <param name="request">Role to be updated for the app.</param>
/// <response code="200">Role updated.</response>.
/// <response code="400">Role request not valid.</response>.
/// <response code="404">Role or app not found.</response>.
/// <response code="200">Role updated.</response>
/// <response code="400">Role request not valid.</response>
/// <response code="404">Role or app not found.</response>
[HttpPut]
[Route("apps/{app}/roles/{roleName}/")]
[ProducesResponseType(typeof(RolesDto), StatusCodes.Status200OK)]
@ -128,9 +128,9 @@ public sealed class AppRolesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="roleName">The name of the role.</param>
/// <response code="200">Role deleted.</response>.
/// <response code="400">Role is in use by contributor or client or a default role.</response>.
/// <response code="404">Role or app not found.</response>.
/// <response code="200">Role deleted.</response>
/// <response code="400">Role is in use by contributor or client or a default role.</response>
/// <response code="404">Role or app not found.</response>
[HttpDelete]
[Route("apps/{app}/roles/{roleName}/")]
[ProducesResponseType(typeof(RolesDto), StatusCodes.Status200OK)]

10
backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs

@ -29,8 +29,8 @@ public sealed class AppSettingsController : ApiController
/// Get the app settings.
/// </summary>
/// <param name="app">The name of the app to get the settings for.</param>
/// <response code="200">App settings returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App settings returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/settings")]
[ProducesResponseType(typeof(AppSettingsDto), StatusCodes.Status200OK)]
@ -51,9 +51,9 @@ public sealed class AppSettingsController : ApiController
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="request">The values to update.</param>
/// <response code="200">App updated.</response>.
/// <response code="400">App request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App updated.</response>
/// <response code="400">App request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("apps/{app}/settings")]
[ProducesResponseType(typeof(AppSettingsDto), StatusCodes.Status200OK)]

20
backend/src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs

@ -36,8 +36,8 @@ public sealed class AppWorkflowsController : ApiController
/// Get app workflow.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Workflows returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Workflows returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/workflows/")]
[ProducesResponseType(typeof(WorkflowsDto), StatusCodes.Status200OK)]
@ -60,9 +60,9 @@ public sealed class AppWorkflowsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The new workflow.</param>
/// <response code="200">Workflow created.</response>.
/// <response code="400">Workflow request not valid.</response>.
/// <response code="404">Workflow or app not found.</response>.
/// <response code="200">Workflow created.</response>
/// <response code="400">Workflow request not valid.</response>
/// <response code="404">Workflow or app not found.</response>
[HttpPost]
[Route("apps/{app}/workflows/")]
[ProducesResponseType(typeof(WorkflowsDto), StatusCodes.Status200OK)]
@ -83,9 +83,9 @@ public sealed class AppWorkflowsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the workflow to update.</param>
/// <param name="request">The new workflow.</param>
/// <response code="200">Workflow updated.</response>.
/// <response code="400">Workflow request not valid.</response>.
/// <response code="404">Workflow or app not found.</response>.
/// <response code="200">Workflow updated.</response>
/// <response code="400">Workflow request not valid.</response>
/// <response code="404">Workflow or app not found.</response>
[HttpPut]
[Route("apps/{app}/workflows/{id}")]
[ProducesResponseType(typeof(WorkflowsDto), StatusCodes.Status200OK)]
@ -105,8 +105,8 @@ public sealed class AppWorkflowsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the workflow to update.</param>
/// <response code="200">Workflow deleted.</response>.
/// <response code="404">Workflow or app not found.</response>.
/// <response code="200">Workflow deleted.</response>
/// <response code="404">Workflow or app not found.</response>
[HttpDelete]
[Route("apps/{app}/workflows/{id}")]
[ProducesResponseType(typeof(WorkflowsDto), StatusCodes.Status200OK)]

40
backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -37,7 +37,7 @@ public sealed class AppsController : ApiController
/// <summary>
/// Get your apps.
/// </summary>
/// <response code="200">Apps returned.</response>.
/// <response code="200">Apps returned.</response>
/// <remarks>
/// You can only retrieve the list of apps when you are authenticated as a user (OpenID implicit flow).
/// You will retrieve all apps, where you are assigned as a contributor.
@ -68,7 +68,7 @@ public sealed class AppsController : ApiController
/// Get team apps.
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <response code="200">Apps returned.</response>.
/// <response code="200">Apps returned.</response>
/// <remarks>
/// You can only retrieve the list of apps when you are authenticated as a user (OpenID implicit flow).
/// You will retrieve all apps, where you are assigned as a contributor.
@ -96,8 +96,8 @@ public sealed class AppsController : ApiController
/// Get an app by name.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Apps returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Apps returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}")]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
@ -121,9 +121,9 @@ public sealed class AppsController : ApiController
/// Create a new app.
/// </summary>
/// <param name="request">The app object that needs to be added to Squidex.</param>
/// <response code="201">App created.</response>.
/// <response code="400">App request not valid.</response>.
/// <response code="409">App name is already in use.</response>.
/// <response code="201">App created.</response>
/// <response code="400">App request not valid.</response>
/// <response code="409">App name is already in use.</response>
/// <remarks>
/// You can only create an app when you are authenticated as a user (OpenID implicit flow).
/// You will be assigned as owner of the new app automatically.
@ -145,9 +145,9 @@ public sealed class AppsController : ApiController
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="request">The values to update.</param>
/// <response code="200">App updated.</response>.
/// <response code="400">App request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App updated.</response>
/// <response code="400">App request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("apps/{app}/")]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
@ -165,9 +165,9 @@ public sealed class AppsController : ApiController
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="request">The team information.</param>
/// <response code="200">App transferred.</response>.
/// <response code="400">App request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App transferred.</response>
/// <response code="400">App request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("apps/{app}/team")]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
@ -185,9 +185,9 @@ public sealed class AppsController : ApiController
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="file">The file to upload.</param>
/// <response code="200">App image uploaded.</response>.
/// <response code="400">App request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App image uploaded.</response>
/// <response code="400">App request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
@ -204,8 +204,8 @@ public sealed class AppsController : ApiController
/// Remove the app image.
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <response code="200">App image removed.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App image removed.</response>
/// <response code="404">App not found.</response>
[HttpDelete]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
@ -222,8 +222,8 @@ public sealed class AppsController : ApiController
/// Delete the app.
/// </summary>
/// <param name="app">The name of the app to delete.</param>
/// <response code="204">App deleted.</response>.
/// <response code="404">App not found.</response>.
/// <response code="204">App deleted.</response>
/// <response code="404">App not found.</response>
[HttpDelete]
[Route("apps/{app}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]

8
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -52,8 +52,8 @@ public sealed class AssetContentController : ApiController
/// <param name="idOrSlug">The id or slug of the asset.</param>
/// <param name="request">The request parameters.</param>
/// <param name="more">Optional suffix that can be used to seo-optimize the link to the image Has not effect.</param>
/// <response code="200">Asset found and content or (resized) image returned.</response>.
/// <response code="404">Asset or app not found.</response>.
/// <response code="200">Asset found and content or (resized) image returned.</response>
/// <response code="404">Asset or app not found.</response>
[HttpGet]
[Route("assets/{app}/{idOrSlug}/{*more}")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
@ -79,8 +79,8 @@ public sealed class AssetContentController : ApiController
/// </summary>
/// <param name="id">The ID of the asset.</param>
/// <param name="request">The request parameters.</param>
/// <response code="200">Asset found and content or (resized) image returned.</response>.
/// <response code="404">Asset or app not found.</response>.
/// <response code="200">Asset found and content or (resized) image returned.</response>
/// <response code="404">Asset or app not found.</response>
[HttpGet]
[Route("assets/{id}/")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]

26
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetFoldersController.cs

@ -39,8 +39,8 @@ public sealed class AssetFoldersController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="parentId">The optional parent folder id.</param>
/// <param name="scope">The scope of the query.</param>
/// <response code="200">Asset folders returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Asset folders returned.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// Get all asset folders for the app.
/// </remarks>
@ -90,9 +90,9 @@ public sealed class AssetFoldersController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The asset folder object that needs to be added to the App.</param>
/// <response code="201">Asset folder created.</response>.
/// <response code="400">Asset folder request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="201">Asset folder created.</response>
/// <response code="400">Asset folder request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/assets/folders", Order = -1)]
[ProducesResponseType(typeof(AssetFolderDto), StatusCodes.Status201Created)]
@ -114,9 +114,9 @@ public sealed class AssetFoldersController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset folder.</param>
/// <param name="request">The asset folder object that needs to updated.</param>
/// <response code="200">Asset folder updated.</response>.
/// <response code="400">Asset folder request not valid.</response>.
/// <response code="404">Asset folder or app not found.</response>.
/// <response code="200">Asset folder updated.</response>
/// <response code="400">Asset folder request not valid.</response>
/// <response code="404">Asset folder or app not found.</response>
[HttpPut]
[Route("apps/{app}/assets/folders/{id}/", Order = -1)]
[ProducesResponseType(typeof(AssetFolderDto), StatusCodes.Status200OK)]
@ -138,9 +138,9 @@ public sealed class AssetFoldersController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset folder.</param>
/// <param name="request">The asset folder object that needs to updated.</param>
/// <response code="200">Asset folder moved.</response>.
/// <response code="400">Asset folder request not valid.</response>.
/// <response code="404">Asset folder or app not found.</response>.
/// <response code="200">Asset folder moved.</response>
/// <response code="400">Asset folder request not valid.</response>
/// <response code="404">Asset folder or app not found.</response>
[HttpPut]
[Route("apps/{app}/assets/folders/{id}/parent", Order = -1)]
[ProducesResponseType(typeof(AssetFolderDto), StatusCodes.Status200OK)]
@ -161,8 +161,8 @@ public sealed class AssetFoldersController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset folder to delete.</param>
/// <response code="204">Asset folder deleted.</response>.
/// <response code="404">Asset folder or app not found.</response>.
/// <response code="204">Asset folder deleted.</response>
/// <response code="404">Asset folder or app not found.</response>
[HttpDelete]
[Route("apps/{app}/assets/folders/{id}/", Order = -1)]
[ProducesResponseType(StatusCodes.Status204NoContent)]

74
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -58,8 +58,8 @@ public sealed class AssetsController : ApiController
/// Get assets tags.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Assets tags returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Assets tags returned.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// Get all tags for assets.
/// </remarks>
@ -83,8 +83,8 @@ public sealed class AssetsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="name">The tag to return.</param>
/// <param name="request">The required request object.</param>
/// <response code="200">Asset tag renamed and new tags returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Asset tag renamed and new tags returned.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("apps/{app}/assets/tags/{name}")]
[ProducesResponseType(typeof(Dictionary<string, int>), StatusCodes.Status200OK)]
@ -104,8 +104,8 @@ public sealed class AssetsController : ApiController
/// <param name="parentId">The optional parent folder id.</param>
/// <param name="ids">The optional asset ids.</param>
/// <param name="q">The optional json query.</param>
/// <response code="200">Assets returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Assets returned.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// Get all assets for the app.
/// </remarks>
@ -134,8 +134,8 @@ public sealed class AssetsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The required query object.</param>
/// <response code="200">Assets returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Assets returned.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// Get all assets for the app.
/// </remarks>
@ -163,8 +163,8 @@ public sealed class AssetsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset to retrieve.</param>
/// <response code="200">Asset found.</response>.
/// <response code="404">Asset or app not found.</response>.
/// <response code="200">Asset found.</response>
/// <response code="404">Asset or app not found.</response>
[HttpGet]
[Route("apps/{app}/assets/{id}/")]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
@ -192,10 +192,10 @@ public sealed class AssetsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The request parameters.</param>
/// <response code="201">Asset created.</response>.
/// <response code="400">Asset request not valid.</response>.
/// <response code="413">Asset exceeds the maximum upload size.</response>.
/// <response code="404">App not found.</response>.
/// <response code="201">Asset created.</response>
/// <response code="400">Asset request not valid.</response>
/// <response code="413">Asset exceeds the maximum upload size.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// You can only upload one file at a time. The mime type of the file is not calculated by Squidex and is required correctly.
/// </remarks>
@ -218,10 +218,10 @@ public sealed class AssetsController : ApiController
/// Upload a new asset using tus.io.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="201">Asset created.</response>.
/// <response code="400">Asset request not valid.</response>.
/// <response code="413">Asset exceeds the maximum upload size.</response>.
/// <response code="404">App not found.</response>.
/// <response code="201">Asset created.</response>
/// <response code="400">Asset request not valid.</response>
/// <response code="413">Asset exceeds the maximum upload size.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// Use the tus protocol to upload an asset.
/// </remarks>
@ -254,9 +254,9 @@ public sealed class AssetsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The bulk update request.</param>
/// <response code="200">Assets created, update or delete.</response>.
/// <response code="400">Assets request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Assets created, update or delete.</response>
/// <response code="400">Assets request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/assets/bulk")]
[ProducesResponseType(typeof(BulkResultDto[]), StatusCodes.Status200OK)]
@ -280,10 +280,10 @@ public sealed class AssetsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The optional custom asset id.</param>
/// <param name="request">The request parameters.</param>
/// <response code="200">Asset created or updated.</response>.
/// <response code="400">Asset request not valid.</response>.
/// <response code="413">Asset exceeds the maximum upload size.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Asset created or updated.</response>
/// <response code="400">Asset request not valid.</response>
/// <response code="413">Asset exceeds the maximum upload size.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// You can only upload one file at a time. The mime type of the file is not calculated by Squidex and is required correctly.
/// </remarks>
@ -308,10 +308,10 @@ public sealed class AssetsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset.</param>
/// <param name="file">The file to upload.</param>
/// <response code="200">Asset updated.</response>.
/// <response code="400">Asset request not valid.</response>.
/// <response code="413">Asset exceeds the maximum upload size.</response>.
/// <response code="404">Asset or app not found.</response>.
/// <response code="200">Asset updated.</response>
/// <response code="400">Asset request not valid.</response>
/// <response code="413">Asset exceeds the maximum upload size.</response>
/// <response code="404">Asset or app not found.</response>
/// <remarks>
/// Use multipart request to upload an asset.
/// </remarks>
@ -336,9 +336,9 @@ public sealed class AssetsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset.</param>
/// <param name="request">The asset object that needs to updated.</param>
/// <response code="200">Asset updated.</response>.
/// <response code="400">Asset request not valid.</response>.
/// <response code="404">Asset or app not found.</response>.
/// <response code="200">Asset updated.</response>
/// <response code="400">Asset request not valid.</response>
/// <response code="404">Asset or app not found.</response>
[HttpPut]
[Route("apps/{app}/assets/{id}/")]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
@ -360,9 +360,9 @@ public sealed class AssetsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset.</param>
/// <param name="request">The asset object that needs to updated.</param>
/// <response code="200">Asset moved.</response>.
/// <response code="400">Asset request not valid.</response>.
/// <response code="404">Asset or app not found.</response>.
/// <response code="200">Asset moved.</response>
/// <response code="400">Asset request not valid.</response>
/// <response code="404">Asset or app not found.</response>
[HttpPut]
[Route("apps/{app}/assets/{id}/parent")]
[ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)]
@ -384,8 +384,8 @@ public sealed class AssetsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset to delete.</param>
/// <param name="request">The request parameters.</param>
/// <response code="204">Asset deleted.</response>.
/// <response code="404">Asset or app not found.</response>.
/// <response code="204">Asset deleted.</response>
/// <response code="404">Asset or app not found.</response>
[HttpDelete]
[Route("apps/{app}/assets/{id}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]

8
backend/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs

@ -37,8 +37,8 @@ public class BackupContentController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the backup.</param>
/// <response code="200">Backup found and content returned.</response>.
/// <response code="404">Backup or app not found.</response>.
/// <response code="200">Backup found and content returned.</response>
/// <response code="404">Backup or app not found.</response>
[HttpGet]
[Route("apps/{app}/backups/{id}")]
[ResponseCache(Duration = 3600 * 24 * 30)]
@ -56,8 +56,8 @@ public class BackupContentController : ApiController
/// <param name="id">The ID of the backup.</param>
/// <param name="appId">The ID of the app.</param>
/// <param name="app">The name of the app.</param>
/// <response code="200">Backup found and content returned.</response>.
/// <response code="404">Backup or app not found.</response>.
/// <response code="200">Backup found and content returned.</response>
/// <response code="404">Backup or app not found.</response>
[HttpGet]
[Route("apps/backups/{id}")]
[ResponseCache(Duration = 3600 * 24 * 30)]

14
backend/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs

@ -34,8 +34,8 @@ public class BackupsController : ApiController
/// Get all backup jobs.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Backups returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Backups returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/backups/")]
[ProducesResponseType(typeof(BackupJobsDto), StatusCodes.Status200OK)]
@ -54,9 +54,9 @@ public class BackupsController : ApiController
/// Start a new backup.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="204">Backup started.</response>.
/// <response code="400">Backup contingent reached.</response>.
/// <response code="404">App not found.</response>.
/// <response code="204">Backup started.</response>
/// <response code="400">Backup contingent reached.</response>
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/backups/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -74,8 +74,8 @@ public class BackupsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the backup to delete.</param>
/// <response code="204">Backup deleted.</response>.
/// <response code="404">Backup or app not found.</response>.
/// <response code="204">Backup deleted.</response>
/// <response code="404">Backup or app not found.</response>
[HttpDelete]
[Route("apps/{app}/backups/{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]

4
backend/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs

@ -33,7 +33,7 @@ public class RestoreController : ApiController
/// <summary>
/// Get current restore status.
/// </summary>
/// <response code="200">Status returned.</response>.
/// <response code="200">Status returned.</response>
[HttpGet]
[Route("apps/restore/")]
[ProducesResponseType(typeof(RestoreJobDto), StatusCodes.Status200OK)]
@ -56,7 +56,7 @@ public class RestoreController : ApiController
/// Restore a backup.
/// </summary>
/// <param name="request">The backup to restore.</param>
/// <response code="204">Restore operation started.</response>.
/// <response code="204">Restore operation started.</response>
[HttpPost]
[Route("apps/restore/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]

150
backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs

@ -5,162 +5,40 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Comments.Models;
using Squidex.Domain.Apps.Entities.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Squidex.Web;
using YDotNet.Server.WebSockets;
namespace Squidex.Areas.Api.Controllers.Comments;
/// <summary>
/// Update and query comments for any kind of app resource.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Comments))]
public sealed class CommentsController : ApiController
{
private readonly ICommentsLoader commentsLoader;
private readonly IWatchingService watchingService;
private readonly ICollaborationService collaboration;
public CommentsController(ICommandBus commandBus, ICommentsLoader commentsLoader,
IWatchingService watchingService)
public CommentsController(ICommandBus commandBus, ICollaborationService collaboration)
: base(commandBus)
{
this.commentsLoader = commentsLoader;
this.watchingService = watchingService;
this.collaboration = collaboration;
}
/// <summary>
/// Get all watching users..
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="resource">The path to the resource.</param>
/// <response code="200">Watching users returned.</response>.
/// <response code="404">App not found.</response>.
[HttpGet]
[Route("apps/{app}/watching/{*resource}")]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(0)]
public async Task<IActionResult> GetWatchingUsers(string app, string? resource = null)
[Route("users/collaboration")]
[ApiPermission]
public IActionResult UserDocument()
{
var result = await watchingService.GetWatchingUsersAsync(App.Id, resource, UserId, HttpContext.RequestAborted);
return Ok(result);
return new YDotNetActionResult(collaboration.UserDocument(User.UserOrClientId()!));
}
/// <summary>
/// Get all comments.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="commentsId">The ID of the comments.</param>
/// <param name="version">The current version.</param>
/// <remarks>
/// When passing in a version you can retrieve all updates since then.
/// </remarks>
/// <response code="200">Comments returned.</response>.
/// <response code="404">App not found.</response>.
[HttpGet]
[Route("apps/{app}/comments/{commentsId}")]
[ProducesResponseType(typeof(CommentsDto), StatusCodes.Status200OK)]
[Route("apps/{app}/collaboration/{commentsId}")]
[ApiPermissionOrAnonymous(PermissionIds.AppCommentsRead)]
[ApiCosts(0)]
public async Task<IActionResult> GetComments(string app, DomainId commentsId, [FromQuery] long version = EtagVersion.Any)
{
var result = await commentsLoader.GetCommentsAsync(Id(commentsId), version, HttpContext.RequestAborted);
var response = Deferred.Response(() =>
{
return CommentsDto.FromDomain(result);
});
Response.Headers[HeaderNames.ETag] = result.Version.ToString(CultureInfo.InvariantCulture);
return Ok(response);
}
/// <summary>
/// Create a new comment.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="commentsId">The ID of the comments.</param>
/// <param name="request">The comment object that needs to created.</param>
/// <response code="201">Comment created.</response>.
/// <response code="400">Comment request not valid.</response>.
/// <response code="404">App not found.</response>.
[HttpPost]
[Route("apps/{app}/comments/{commentsId}")]
[ProducesResponseType(typeof(CommentDto), 201)]
[ApiPermissionOrAnonymous(PermissionIds.AppCommentsCreate)]
[ApiCosts(0)]
public async Task<IActionResult> PostComment(string app, DomainId commentsId, [FromBody] UpsertCommentDto request)
{
var command = request.ToCreateCommand(commentsId);
await CommandBus.PublishAsync(command, HttpContext.RequestAborted);
var response = CommentDto.FromDomain(command);
return CreatedAtAction(nameof(GetComments), new { app, commentsId }, response);
}
/// <summary>
/// Update a comment.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="commentsId">The ID of the comments.</param>
/// <param name="commentId">The ID of the comment.</param>
/// <param name="request">The comment object that needs to updated.</param>
/// <response code="204">Comment updated.</response>.
/// <response code="400">Comment request not valid.</response>.
/// <response code="404">Comment or app not found.</response>.
[HttpPut]
[Route("apps/{app}/comments/{commentsId}/{commentId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppCommentsUpdate)]
[ApiCosts(0)]
public async Task<IActionResult> PutComment(string app, DomainId commentsId, DomainId commentId, [FromBody] UpsertCommentDto request)
{
var command = request.ToUpdateComment(commentsId, commentId);
await CommandBus.PublishAsync(command, HttpContext.RequestAborted);
return NoContent();
}
/// <summary>
/// Delete a comment.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="commentsId">The ID of the comments.</param>
/// <param name="commentId">The ID of the comment.</param>
/// <response code="204">Comment deleted.</response>.
/// <response code="404">Comment or app not found.</response>.
[HttpDelete]
[Route("apps/{app}/comments/{commentsId}/{commentId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppCommentsDelete)]
[ApiCosts(0)]
public async Task<IActionResult> DeleteComment(string app, DomainId commentsId, DomainId commentId)
{
var command = new DeleteComment
{
CommentsId = commentsId,
CommentId = commentId
};
await CommandBus.PublishAsync(command, HttpContext.RequestAborted);
return NoContent();
}
private DomainId Id(DomainId commentsId)
public IActionResult CollaborationDocument(string app, DomainId commentsId)
{
return DomainId.Combine(App.Id, commentsId);
return new YDotNetActionResult(collaboration.ResourceDocument(App.NamedId(), commentsId));
}
}

56
backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs

@ -1,56 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Comments.Models;
public sealed class CommentDto
{
/// <summary>
/// The ID of the comment.
/// </summary>
public DomainId Id { get; set; }
/// <summary>
/// The time when the comment was created or updated last.
/// </summary>
public Instant Time { get; set; }
/// <summary>
/// The user who created or updated the comment.
/// </summary>
public RefToken User { get; set; }
/// <summary>
/// The text of the comment.
/// </summary>
public string Text { get; set; }
/// <summary>
/// The url where the comment is created.
/// </summary>
public Uri? Url { get; set; }
public static CommentDto FromDomain(Comment comment)
{
var result = SimpleMapper.Map(comment, new CommentDto());
return result;
}
public static CommentDto FromDomain(CreateComment command)
{
var time = SystemClock.Instance.GetCurrentInstant();
return SimpleMapper.Map(command, new CommentDto { Id = command.CommentId, User = command.Actor, Time = time });
}
}

47
backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentsDto.cs

@ -1,47 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments;
using Squidex.Infrastructure;
namespace Squidex.Areas.Api.Controllers.Comments.Models;
public sealed class CommentsDto
{
/// <summary>
/// The created comments including the updates.
/// </summary>
public CommentDto[]? CreatedComments { get; set; }
/// <summary>
/// The updates comments since the last version.
/// </summary>
public CommentDto[]? UpdatedComments { get; set; }
/// <summary>
/// The deleted comments since the last version.
/// </summary>
public List<DomainId>? DeletedComments { get; set; }
/// <summary>
/// The current version.
/// </summary>
public long Version { get; set; }
public static CommentsDto FromDomain(CommentsResult comments)
{
var result = new CommentsDto
{
CreatedComments = comments.CreatedComments.Select(CommentDto.FromDomain).ToArray(),
UpdatedComments = comments.UpdatedComments.Select(CommentDto.FromDomain).ToArray(),
DeletedComments = comments.DeletedComments,
Version = comments.Version
};
return result;
}
}

46
backend/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs

@ -1,46 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Comments.Models;
[OpenApiRequest]
public sealed class UpsertCommentDto
{
/// <summary>
/// The comment text.
/// </summary>
[LocalizedRequired]
public string Text { get; set; }
/// <summary>
/// The url where the comment is created.
/// </summary>
public Uri? Url { get; set; }
public CreateComment ToCreateCommand(DomainId commentsId)
{
return SimpleMapper.Map(this, new CreateComment
{
CommentsId = commentsId
});
}
public UpdateComment ToUpdateComment(DomainId commentsId, DomainId commentId)
{
return SimpleMapper.Map(this, new UpdateComment
{
CommentsId = commentsId,
CommentId = commentId
});
}
}

99
backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs

@ -1,99 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Comments.Models;
using Squidex.Domain.Apps.Entities.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Comments.Notifications;
/// <summary>
/// Update and query user notifications.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Notifications))]
public sealed class UserNotificationsController : ApiController
{
private readonly ICommentsLoader commentsLoader;
public UserNotificationsController(ICommandBus commandBus, ICommentsLoader commentsLoader)
: base(commandBus)
{
this.commentsLoader = commentsLoader;
}
/// <summary>
/// Get all notifications.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="version">The current version.</param>
/// <remarks>
/// When passing in a version you can retrieve all updates since then.
/// </remarks>
/// <response code="200">All comments returned.</response>.
[HttpGet]
[Route("users/{userId}/notifications")]
[ProducesResponseType(typeof(CommentsDto), StatusCodes.Status200OK)]
[ApiPermission]
public async Task<IActionResult> GetNotifications(DomainId userId, [FromQuery] long version = EtagVersion.Any)
{
CheckPermissions(userId);
var result = await commentsLoader.GetCommentsAsync(userId, version, HttpContext.RequestAborted);
var response = Deferred.Response(() =>
{
return CommentsDto.FromDomain(result);
});
Response.Headers[HeaderNames.ETag] = result.Version.ToString(CultureInfo.InvariantCulture);
return Ok(response);
}
/// <summary>
/// Delete a notification.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="commentId">The ID of the comment.</param>
/// <response code="204">Comment deleted.</response>.
/// <response code="404">Comment not found.</response>.
[HttpDelete]
[Route("users/{userId}/notifications/{commentId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermission]
public async Task<IActionResult> DeleteComment(DomainId userId, DomainId commentId)
{
CheckPermissions(userId);
var commmand = new DeleteComment
{
AppId = CommentsCommand.NoApp,
CommentsId = userId,
CommentId = commentId
};
await CommandBus.PublishAsync(commmand, HttpContext.RequestAborted);
return NoContent();
}
private void CheckPermissions(DomainId userId)
{
if (!string.Equals(userId.ToString(), User.OpenIdSubject(), StringComparison.Ordinal))
{
throw new DomainForbiddenException(T.Get("comments.noPermissions"));
}
}
}

96
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -43,8 +43,8 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="skip">The number of items to skip.</param>
/// <response code="200">Contents returned.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Contents returned.</response>
/// <response code="404">Schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -68,8 +68,8 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="ids">The optional ids of the content to fetch.</param>
/// <param name="q">The optional json query.</param>
/// <response code="200">Contents returned.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Contents returned.</response>
/// <response code="404">Schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -103,8 +103,8 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="query">The required query object.</param>
/// <response code="200">Contents returned.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Contents returned.</response>
/// <response code="404">Schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -138,8 +138,8 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content to fetch.</param>
/// <param name="version">The optional version.</param>
/// <response code="200">Content returned.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Content returned.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -175,9 +175,9 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content to fetch.</param>
/// <response code="204">Content is valid.</response>.
/// <response code="400">Content not valid.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="204">Content is valid.</response>
/// <response code="400">Content not valid.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -202,8 +202,8 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content to fetch.</param>
/// <param name="q">The optional json query.</param>
/// <response code="200">Contents returned.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Contents returned.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -237,8 +237,8 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content to fetch.</param>
/// <param name="q">The optional json query.</param>
/// <response code="200">Content returned.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Content returned.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -272,8 +272,8 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content to fetch.</param>
/// <param name="version">The version fo the content to fetch.</param>
/// <response code="200">Content version returned.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Content version returned.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -307,9 +307,9 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The request parameters.</param>
/// <response code="201">Content created.</response>.
/// <response code="400">Content request not valid.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="201">Content created.</response>
/// <response code="400">Content request not valid.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -335,9 +335,9 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The import request.</param>
/// <response code="200">Contents created.</response>.
/// <response code="400">Content request not valid.</response>.
/// <response code="404">Content references, schema or app not found.</response>.
/// <response code="200">Contents created.</response>
/// <response code="400">Content request not valid.</response>
/// <response code="404">Content references, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -365,9 +365,9 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The bulk update request.</param>
/// <response code="201">Contents created, update or delete.</response>.
/// <response code="400">Contents request not valid.</response>.
/// <response code="404">Contents references, schema or app not found.</response>.
/// <response code="201">Contents created, update or delete.</response>
/// <response code="400">Contents request not valid.</response>
/// <response code="404">Contents references, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -395,9 +395,9 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content item to update.</param>
/// <param name="request">The request parameters.</param>
/// <response code="200">Content created or updated.</response>.
/// <response code="400">Content request not valid.</response>.
/// <response code="404">Content references, schema or app not found.</response>.
/// <response code="200">Content created or updated.</response>
/// <response code="400">Content request not valid.</response>
/// <response code="404">Content references, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -424,9 +424,9 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content item to update.</param>
/// <param name="request">The full data for the content item.</param>
/// <response code="200">Content updated.</response>.
/// <response code="400">Content request not valid.</response>.
/// <response code="404">Content references, schema or app not found.</response>.
/// <response code="200">Content updated.</response>
/// <response code="400">Content request not valid.</response>
/// <response code="404">Content references, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -453,9 +453,9 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content item to patch.</param>
/// <param name="request">The patch for the content item.</param>
/// <response code="200">Content patched.</response>.
/// <response code="400">Content request not valid.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Content patched.</response>
/// <response code="400">Content request not valid.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -482,9 +482,9 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content item to change.</param>
/// <param name="request">The status request.</param>
/// <response code="200">Content status changed.</response>.
/// <response code="400">Content request not valid.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Content status changed.</response>
/// <response code="400">Content request not valid.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -510,9 +510,9 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content item to cancel.</param>
/// <response code="200">Content status change cancelled.</response>.
/// <response code="400">Content request not valid.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Content status change cancelled.</response>
/// <response code="400">Content request not valid.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -538,8 +538,8 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content item to create the draft for.</param>
/// <response code="200">Content draft created.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Content draft created.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -565,8 +565,8 @@ public sealed class ContentsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content item to delete the draft from.</param>
/// <response code="200">Content draft deleted.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="200">Content draft deleted.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -593,9 +593,9 @@ public sealed class ContentsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the content item to delete.</param>
/// <param name="request">The request parameters.</param>
/// <response code="204">Content deleted.</response>.
/// <response code="400">Content cannot be deleted.</response>.
/// <response code="404">Content, schema or app not found.</response>.
/// <response code="204">Content deleted.</response>
/// <response code="400">Content cannot be deleted.</response>
/// <response code="404">Content, schema or app not found.</response>
/// <remarks>
/// You can create an generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>

30
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs

@ -41,8 +41,8 @@ public sealed class ContentsSharedController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The request parameters.</param>
/// <response code="200">Contents returned or mutated.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Contents returned or mutated.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -66,8 +66,8 @@ public sealed class ContentsSharedController : ApiController
/// GraphQL endpoint.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Contents returned or mutated.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Contents returned or mutated.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -93,8 +93,8 @@ public sealed class ContentsSharedController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The request object.</param>
/// <response code="200">Contents returned or mutated.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Contents returned or mutated.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -118,8 +118,8 @@ public sealed class ContentsSharedController : ApiController
/// GraphQL batch endpoint.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Contents returned or mutated.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Contents returned or mutated.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -145,8 +145,8 @@ public sealed class ContentsSharedController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The query object.</param>
/// <response code="200">Contents returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Contents returned.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -178,8 +178,8 @@ public sealed class ContentsSharedController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The required query object.</param>
/// <response code="200">Contents returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Contents returned.</response>
/// <response code="404">App not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
@ -212,9 +212,9 @@ public sealed class ContentsSharedController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The bulk update request.</param>
/// <response code="201">Contents created, update or delete.</response>.
/// <response code="400">Contents request not valid.</response>.
/// <response code="404">Contents references, schema or app not found.</response>.
/// <response code="201">Contents created, update or delete.</response>
/// <response code="400">Contents request not valid.</response>
/// <response code="404">Contents references, schema or app not found.</response>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>

8
backend/src/Squidex/Areas/Api/Controllers/Diagnostics/DiagnosticsController.cs

@ -30,8 +30,8 @@ public sealed class DiagnosticsController : ApiController
/// <summary>
/// Creates a dump and writes it into storage..
/// </summary>
/// <response code="204">Dump created successful.</response>.
/// <response code="501">Not configured.</response>.
/// <response code="204">Dump created successful.</response>
/// <response code="501">Not configured.</response>
[HttpGet]
[Route("diagnostics/dump")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -51,8 +51,8 @@ public sealed class DiagnosticsController : ApiController
/// <summary>
/// Creates a gc dump and writes it into storage.
/// </summary>
/// <response code="204">Dump created successful.</response>.
/// <response code="501">Not configured.</response>.
/// <response code="204">Dump created successful.</response>
/// <response code="501">Not configured.</response>
[HttpGet]
[Route("diagnostics/gcdump")]
[ProducesResponseType(StatusCodes.Status204NoContent)]

14
backend/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs

@ -31,7 +31,7 @@ public sealed class EventConsumersController : ApiController
/// <summary>
/// Get event consumers.
/// </summary>
/// <response code="200">Event consumers returned.</response>.
/// <response code="200">Event consumers returned.</response>
[HttpGet]
[Route("event-consumers/")]
[ProducesResponseType(typeof(EventConsumersDto), StatusCodes.Status200OK)]
@ -49,8 +49,8 @@ public sealed class EventConsumersController : ApiController
/// Start an event consumer.
/// </summary>
/// <param name="consumerName">The name of the event consumer.</param>
/// <response code="200">Event consumer started asynchronously.</response>.
/// <response code="404">Event consumer not found.</response>.
/// <response code="200">Event consumer started asynchronously.</response>
/// <response code="404">Event consumer not found.</response>
[HttpPut]
[Route("event-consumers/{consumerName}/start/")]
[ProducesResponseType(typeof(EventConsumerDto), StatusCodes.Status200OK)]
@ -68,8 +68,8 @@ public sealed class EventConsumersController : ApiController
/// Stop an event consumer.
/// </summary>
/// <param name="consumerName">The name of the event consumer.</param>
/// <response code="200">Event consumer stopped asynchronously.</response>.
/// <response code="404">Event consumer not found.</response>.
/// <response code="200">Event consumer stopped asynchronously.</response>
/// <response code="404">Event consumer not found.</response>
[HttpPut]
[Route("event-consumers/{consumerName}/stop/")]
[ProducesResponseType(typeof(EventConsumerDto), StatusCodes.Status200OK)]
@ -87,8 +87,8 @@ public sealed class EventConsumersController : ApiController
/// Reset an event consumer.
/// </summary>
/// <param name="consumerName">The name of the event consumer.</param>
/// <response code="200">Event consumer resetted asynchronously.</response>.
/// <response code="404">Event consumer not found.</response>.
/// <response code="200">Event consumer resetted asynchronously.</response>
/// <response code="404">Event consumer not found.</response>
[HttpPut]
[Route("event-consumers/{consumerName}/reset/")]
[ProducesResponseType(typeof(EventConsumerDto), StatusCodes.Status200OK)]

8
backend/src/Squidex/Areas/Api/Controllers/History/HistoryController.cs

@ -33,8 +33,8 @@ public sealed class HistoryController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="channel">The name of the channel.</param>
/// <response code="200">Events returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Events returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/history/")]
[ProducesResponseType(typeof(HistoryEventDto[]), StatusCodes.Status200OK)]
@ -54,8 +54,8 @@ public sealed class HistoryController : ApiController
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <param name="channel">The name of the channel.</param>
/// <response code="200">Events returned.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">Events returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
[Route("teams/{team}/history/")]
[ProducesResponseType(typeof(HistoryEventDto[]), StatusCodes.Status200OK)]

2
backend/src/Squidex/Areas/Api/Controllers/Languages/LanguagesController.cs

@ -30,7 +30,7 @@ public sealed class LanguagesController : ApiController
/// <remarks>
/// Provide a list of supported language codes, following the ISO2Code standard.
/// </remarks>
/// <response code="200">Supported language codes returned.</response>.
/// <response code="200">Supported language codes returned.</response>
[HttpGet]
[Route("languages/")]
[ProducesResponseType(typeof(LanguageDto[]), StatusCodes.Status200OK)]

2
backend/src/Squidex/Areas/Api/Controllers/News/NewsController.cs

@ -31,7 +31,7 @@ public sealed class NewsController : ApiController
/// Get features since version.
/// </summary>
/// <param name="version">The latest received version.</param>
/// <response code="200">Latest features returned.</response>.
/// <response code="200">Latest features returned.</response>
[HttpGet]
[Route("news/features/")]
[ProducesResponseType(typeof(FeaturesDto), StatusCodes.Status200OK)]

6
backend/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs

@ -29,7 +29,7 @@ public sealed class PingController : ApiController
/// <summary>
/// Get API information.
/// </summary>
/// <response code="200">Infos returned.</response>.
/// <response code="200">Infos returned.</response>
[HttpGet]
[Route("info/")]
[ProducesResponseType(typeof(ExposedValues), StatusCodes.Status200OK)]
@ -41,7 +41,7 @@ public sealed class PingController : ApiController
/// <summary>
/// Get ping status of the API.
/// </summary>
/// <response code="204">Service ping successful.</response>.
/// <response code="204">Service ping successful.</response>
/// <remarks>
/// Can be used to test, if the Squidex API is alive and responding.
/// </remarks>
@ -57,7 +57,7 @@ public sealed class PingController : ApiController
/// Get ping status.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="204">Service ping successful.</response>.
/// <response code="204">Service ping successful.</response>
/// <remarks>
/// Can be used to test, if the Squidex API is alive and responding.
/// </remarks>

10
backend/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -43,8 +43,8 @@ public sealed class AppPlansController : ApiController
/// Get app plan information.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">App plan information returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">App plan information returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/plans/")]
[ProducesResponseType(typeof(PlansDto), StatusCodes.Status200OK)]
@ -101,9 +101,9 @@ public sealed class AppPlansController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">Plan object that needs to be changed.</param>
/// <response code="200">Plan changed or redirect url returned.</response>.
/// <response code="400">Plan not owned by user.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Plan changed or redirect url returned.</response>
/// <response code="400">Plan not owned by user.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("apps/{app}/plan/")]
[ProducesResponseType(typeof(PlanChangedDto), StatusCodes.Status200OK)]

8
backend/src/Squidex/Areas/Api/Controllers/Plans/TeamPlansController.cs

@ -43,8 +43,8 @@ public sealed class TeamPlansController : ApiController
/// Get team plan information.
/// </summary>
/// <param name="team">The name of the team.</param>
/// <response code="200">Team plan information returned.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">Team plan information returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
[Route("teams/{team}/plans/")]
[ProducesResponseType(typeof(PlansDto), StatusCodes.Status200OK)]
@ -90,8 +90,8 @@ public sealed class TeamPlansController : ApiController
/// </summary>
/// <param name="team">The name of the team.</param>
/// <param name="request">Plan object that needs to be changed.</param>
/// <response code="200">Plan changed or redirect url returned.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">Plan changed or redirect url returned.</response>
/// <response code="404">Team not found.</response>
[HttpPut]
[Route("teams/{team}/plan/")]
[ProducesResponseType(typeof(PlanChangedDto), StatusCodes.Status200OK)]

68
backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -58,7 +58,7 @@ public sealed class RulesController : ApiController
/// <summary>
/// Get supported rule actions.
/// </summary>
/// <response code="200">Rule actions returned.</response>.
/// <response code="200">Rule actions returned.</response>
[HttpGet]
[Route("rules/actions/")]
[ProducesResponseType(typeof(Dictionary<string, RuleElementDto>), StatusCodes.Status200OK)]
@ -82,8 +82,8 @@ public sealed class RulesController : ApiController
/// Get rules.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Rules returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Rules returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/rules/")]
[ProducesResponseType(typeof(RulesDto), StatusCodes.Status200OK)]
@ -106,9 +106,9 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The rule object that needs to be added to the app.</param>
/// <response code="201">Rule created.</response>.
/// <response code="400">Rule request not valid.</response>.
/// <response code="404">App not found.</response>.
/// <response code="201">Rule created.</response>
/// <response code="400">Rule request not valid.</response>
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/rules/")]
[ProducesResponseType(typeof(RuleDto), 201)]
@ -127,7 +127,7 @@ public sealed class RulesController : ApiController
/// Cancel the current run.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="204">Rule run cancelled.</response>.
/// <response code="204">Rule run cancelled.</response>
[HttpDelete]
[Route("apps/{app}/rules/run")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -146,9 +146,9 @@ public sealed class RulesController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to update.</param>
/// <param name="request">The rule object that needs to be added to the app.</param>
/// <response code="200">Rule updated.</response>.
/// <response code="400">Rule request not valid.</response>.
/// <response code="404">Rule or app not found.</response>.
/// <response code="200">Rule updated.</response>
/// <response code="400">Rule request not valid.</response>
/// <response code="404">Rule or app not found.</response>
[HttpPut]
[Route("apps/{app}/rules/{id}/")]
[ProducesResponseType(typeof(RuleDto), StatusCodes.Status200OK)]
@ -168,8 +168,8 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to enable.</param>
/// <response code="200">Rule enabled.</response>.
/// <response code="404">Rule or app not found.</response>.
/// <response code="200">Rule enabled.</response>
/// <response code="404">Rule or app not found.</response>
[HttpPut]
[Route("apps/{app}/rules/{id}/enable/")]
[ProducesResponseType(typeof(RuleDto), StatusCodes.Status200OK)]
@ -189,8 +189,8 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to disable.</param>
/// <response code="200">Rule disabled.</response>.
/// <response code="404">Rule or app not found.</response>.
/// <response code="200">Rule disabled.</response>
/// <response code="404">Rule or app not found.</response>
[HttpPut]
[Route("apps/{app}/rules/{id}/disable/")]
[ProducesResponseType(typeof(RuleDto), StatusCodes.Status200OK)]
@ -210,8 +210,8 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to disable.</param>
/// <response code="204">Rule triggered.</response>.
/// <response code="404">Rule or app not found.</response>.
/// <response code="204">Rule triggered.</response>
/// <response code="404">Rule or app not found.</response>
[HttpPut]
[Route("apps/{app}/rules/{id}/trigger/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -232,7 +232,7 @@ public sealed class RulesController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to run.</param>
/// <param name="fromSnapshots">Runs the rule from snapeshots if possible.</param>
/// <response code="204">Rule started.</response>.
/// <response code="204">Rule started.</response>
[HttpPut]
[Route("apps/{app}/rules/{id}/run")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -250,7 +250,7 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to cancel.</param>
/// <response code="204">Rule events cancelled.</response>.
/// <response code="204">Rule events cancelled.</response>
[HttpDelete]
[Route("apps/{app}/rules/{id}/events/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -268,8 +268,8 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The rule to simulate.</param>
/// <response code="200">Rule simulated.</response>.
/// <response code="404">Rule or app not found.</response>.
/// <response code="200">Rule simulated.</response>
/// <response code="404">Rule or app not found.</response>
[HttpPost]
[Route("apps/{app}/rules/simulate/")]
[ProducesResponseType(typeof(SimulatedRuleEventsDto), StatusCodes.Status200OK)]
@ -291,8 +291,8 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to simulate.</param>
/// <response code="200">Rule simulated.</response>.
/// <response code="404">Rule or app not found.</response>.
/// <response code="200">Rule simulated.</response>
/// <response code="404">Rule or app not found.</response>
[HttpGet]
[Route("apps/{app}/rules/{id}/simulate/")]
[ProducesResponseType(typeof(SimulatedRuleEventsDto), StatusCodes.Status200OK)]
@ -319,8 +319,8 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the rule to delete.</param>
/// <response code="204">Rule deleted.</response>.
/// <response code="404">Rule or app not found.</response>.
/// <response code="204">Rule deleted.</response>
/// <response code="404">Rule or app not found.</response>
[HttpDelete]
[Route("apps/{app}/rules/{id}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -342,8 +342,8 @@ public sealed class RulesController : ApiController
/// <param name="ruleId">The optional rule id to filter to events.</param>
/// <param name="skip">The number of events to skip.</param>
/// <param name="take">The number of events to take.</param>
/// <response code="200">Rule events returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Rule events returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/rules/events/")]
[ProducesResponseType(typeof(RuleEventsDto), StatusCodes.Status200OK)]
@ -363,8 +363,8 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The event to enqueue.</param>
/// <response code="204">Rule enqueued.</response>.
/// <response code="404">App or rule event not found.</response>.
/// <response code="204">Rule enqueued.</response>
/// <response code="404">App or rule event not found.</response>
[HttpPut]
[Route("apps/{app}/rules/events/{id}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -389,8 +389,8 @@ public sealed class RulesController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The event to cancel.</param>
/// <response code="204">Rule event cancelled.</response>.
/// <response code="404">App or rule event not found.</response>.
/// <response code="204">Rule event cancelled.</response>
/// <response code="404">App or rule event not found.</response>
[HttpDelete]
[Route("apps/{app}/rules/events/{id}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -414,7 +414,7 @@ public sealed class RulesController : ApiController
/// Cancels all events.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="204">Events cancelled.</response>.
/// <response code="204">Events cancelled.</response>
[HttpDelete]
[Route("apps/{app}/rules/events/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -430,7 +430,7 @@ public sealed class RulesController : ApiController
/// <summary>
/// Provide a list of all event types that are used in rules.
/// </summary>
/// <response code="200">Rule events returned.</response>.
/// <response code="200">Rule events returned.</response>
[HttpGet]
[Route("rules/eventtypes")]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
@ -446,8 +446,8 @@ public sealed class RulesController : ApiController
/// Provide the json schema for the event with the specified name.
/// </summary>
/// <param name="type">The type name of the event.</param>
/// <response code="200">Rule event type found.</response>.
/// <response code="404">Rule event not found.</response>.
/// <response code="200">Rule event type found.</response>
/// <response code="404">Rule event not found.</response>
[HttpGet]
[Route("rules/eventtypes/{type}")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]

118
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs

@ -32,10 +32,10 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The field object that needs to be added to the schema.</param>
/// <response code="201">Schema field created.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="409">Schema field name already in use.</response>.
/// <response code="201">Schema field created.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
/// <response code="409">Schema field name already in use.</response>
[HttpPost]
[Route("apps/{app}/schemas/{schema}/fields/")]
[ProducesResponseType(typeof(SchemaDto), 201)]
@ -57,10 +57,10 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="parentId">The parent field id.</param>
/// <param name="request">The field object that needs to be added to the schema.</param>
/// <response code="201">Schema field created.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="409">Schema field name already in use.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="201">Schema field created.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="409">Schema field name already in use.</response>
/// <response code="404">Schema, field or app not found.</response>
[HttpPost]
[Route("apps/{app}/schemas/{schema}/fields/{parentId:long}/nested/")]
[ProducesResponseType(typeof(SchemaDto), 201)]
@ -81,9 +81,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The request that contains the field names.</param>
/// <response code="200">Schema UI fields defined.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema UI fields defined.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/fields/ui/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -104,9 +104,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The request that contains the field ids.</param>
/// <response code="200">Schema fields reordered.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema fields reordered.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/fields/ordering/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -128,9 +128,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="parentId">The parent field id.</param>
/// <param name="request">The request that contains the field ids.</param>
/// <response code="200">Schema fields reordered.</response>.
/// <response code="400">Schema field request not valid.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema fields reordered.</response>
/// <response code="400">Schema field request not valid.</response>
/// <response code="404">Schema, field or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/fields/{parentId:long}/nested/ordering/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -152,9 +152,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the field to update.</param>
/// <param name="request">The field object that needs to be added to the schema.</param>
/// <response code="200">Schema field updated.</response>.
/// <response code="400">Schema field request not valid.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field updated.</response>
/// <response code="400">Schema field request not valid.</response>
/// <response code="404">Schema, field or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/fields/{id:long}/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -177,9 +177,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="parentId">The parent field id.</param>
/// <param name="id">The ID of the field to update.</param>
/// <param name="request">The field object that needs to be added to the schema.</param>
/// <response code="200">Schema field updated.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field updated.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/fields/{parentId:long}/nested/{id:long}/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -200,9 +200,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the field to lock.</param>
/// <response code="200">Schema field shown.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field shown.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
/// <remarks>
/// A locked field cannot be updated or deleted.
/// </remarks>
@ -227,9 +227,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="parentId">The parent field id.</param>
/// <param name="id">The ID of the field to lock.</param>
/// <response code="200">Schema field hidden.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Field, schema, or app not found.</response>.
/// <response code="200">Schema field hidden.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Field, schema, or app not found.</response>
/// <remarks>
/// A locked field cannot be edited or deleted.
/// </remarks>
@ -253,9 +253,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the field to hide.</param>
/// <response code="200">Schema field hidden.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field hidden.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
/// <remarks>
/// A hidden field is not part of the API response, but can still be edited in the portal.
/// </remarks>
@ -280,9 +280,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="parentId">The parent field id.</param>
/// <param name="id">The ID of the field to hide.</param>
/// <response code="200">Schema field hidden.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Field, schema, or app not found.</response>.
/// <response code="200">Schema field hidden.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Field, schema, or app not found.</response>
/// <remarks>
/// A hidden field is not part of the API response, but can still be edited in the portal.
/// </remarks>
@ -306,9 +306,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the field to show.</param>
/// <response code="200">Schema field shown.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field shown.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
/// <remarks>
/// A hidden field is not part of the API response, but can still be edited in the portal.
/// </remarks>
@ -333,9 +333,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="parentId">The parent field id.</param>
/// <param name="id">The ID of the field to show.</param>
/// <response code="200">Schema field shown.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field shown.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
/// <remarks>
/// A hidden field is not part of the API response, but can still be edited in the portal.
/// </remarks>
@ -359,9 +359,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the field to enable.</param>
/// <response code="200">Schema field enabled.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field enabled.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
/// <remarks>
/// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response.
/// </remarks>
@ -386,9 +386,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="parentId">The parent field id.</param>
/// <param name="id">The ID of the field to enable.</param>
/// <response code="200">Schema field enabled.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field enabled.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
/// <remarks>
/// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response.
/// </remarks>
@ -412,9 +412,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the field to disable.</param>
/// <response code="200">Schema field disabled.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field disabled.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
/// <remarks>
/// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response.
/// </remarks>
@ -439,9 +439,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="parentId">The parent field id.</param>
/// <param name="id">The ID of the field to disable.</param>
/// <response code="200">Schema field disabled.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field disabled.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
/// <remarks>
/// A disabled field cannot not be edited in the squidex portal anymore, but will be part of the API response.
/// </remarks>
@ -465,9 +465,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="id">The ID of the field to disable.</param>
/// <response code="200">Schema field deleted.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field deleted.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
[HttpDelete]
[Route("apps/{app}/schemas/{schema}/fields/{id:long}/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -489,9 +489,9 @@ public sealed class SchemaFieldsController : ApiController
/// <param name="schema">The name of the schema.</param>
/// <param name="parentId">The parent field id.</param>
/// <param name="id">The ID of the field to disable.</param>
/// <response code="200">Schema field deleted.</response>.
/// <response code="400">Schema field request not valid or field locked.</response>.
/// <response code="404">Schema, field or app not found.</response>.
/// <response code="200">Schema field deleted.</response>
/// <response code="400">Schema field request not valid or field locked.</response>
/// <response code="404">Schema, field or app not found.</response>
[HttpDelete]
[Route("apps/{app}/schemas/{schema}/fields/{parentId:long}/nested/{id:long}/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]

62
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -40,8 +40,8 @@ public sealed class SchemasController : ApiController
/// Get schemas.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Schemas returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Schemas returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/schemas/")]
[ProducesResponseType(typeof(SchemasDto), StatusCodes.Status200OK)]
@ -66,8 +66,8 @@ public sealed class SchemasController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema to retrieve.</param>
/// <response code="200">Schema found.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema found.</response>
/// <response code="404">Schema or app not found.</response>
[HttpGet]
[Route("apps/{app}/schemas/{schema}/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -90,9 +90,9 @@ public sealed class SchemasController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The schema object that needs to be added to the app.</param>
/// <response code="201">Schema created.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="409">Schema name already in use.</response>.
/// <response code="201">Schema created.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="409">Schema name already in use.</response>
[HttpPost]
[Route("apps/{app}/schemas/")]
[ProducesResponseType(typeof(SchemaDto), 201)]
@ -113,9 +113,9 @@ public sealed class SchemasController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The schema object that needs to updated.</param>
/// <response code="200">Schema updated.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema updated.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -136,9 +136,9 @@ public sealed class SchemasController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The schema object that needs to updated.</param>
/// <response code="200">Schema updated.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema updated.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/sync")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -159,9 +159,9 @@ public sealed class SchemasController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The schema object that needs to updated.</param>
/// <response code="200">Schema updated.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema updated.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/category")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -182,9 +182,9 @@ public sealed class SchemasController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The preview urls for the schema.</param>
/// <response code="200">Schema updated.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema updated.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/preview-urls")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -205,9 +205,9 @@ public sealed class SchemasController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The schema scripts object that needs to updated.</param>
/// <response code="200">Schema updated.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema updated.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/scripts/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -228,9 +228,9 @@ public sealed class SchemasController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The schema rules object that needs to updated.</param>
/// <response code="200">Schema updated.</response>.
/// <response code="400">Schema request not valid.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema updated.</response>
/// <response code="400">Schema request not valid.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/rules/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -250,8 +250,8 @@ public sealed class SchemasController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema to publish.</param>
/// <response code="200">Schema published.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema published.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/publish/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -271,8 +271,8 @@ public sealed class SchemasController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema to unpublish.</param>
/// <response code="200">Schema unpublished.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="200">Schema unpublished.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPut]
[Route("apps/{app}/schemas/{schema}/unpublish/")]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status200OK)]
@ -292,8 +292,8 @@ public sealed class SchemasController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema to delete.</param>
/// <response code="204">Schema deleted.</response>.
/// <response code="404">Schema or app not found.</response>.
/// <response code="204">Schema deleted.</response>
/// <response code="404">Schema or app not found.</response>
[HttpDelete]
[Route("apps/{app}/schemas/{schema}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]

4
backend/src/Squidex/Areas/Api/Controllers/Search/SearchController.cs

@ -33,8 +33,8 @@ public class SearchController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The search query.</param>
/// <response code="200">Search results returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Search results returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/search/")]
[ProducesResponseType(typeof(SearchResultDto[]), StatusCodes.Status200OK)]

28
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -56,8 +56,8 @@ public sealed class UsagesController : ApiController
/// Get api calls as log file.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Usage tracking results returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Usage tracking results returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/usages/log/")]
[ProducesResponseType(typeof(LogDownloadDto), StatusCodes.Status200OK)]
@ -103,8 +103,8 @@ public sealed class UsagesController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="fromDate">The from date.</param>
/// <param name="toDate">The to date.</param>
/// <response code="200">API call returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">API call returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/usages/calls/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(CallsUsageDtoDto), StatusCodes.Status200OK)]
@ -128,8 +128,8 @@ public sealed class UsagesController : ApiController
/// <param name="team">The name of the team.</param>
/// <param name="fromDate">The from date.</param>
/// <param name="toDate">The to date.</param>
/// <response code="200">API call returned.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">API call returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
[Route("teams/{team}/usages/calls/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(CallsUsageDtoDto), StatusCodes.Status200OK)]
@ -151,8 +151,8 @@ public sealed class UsagesController : ApiController
/// Get total asset size.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">Storage usage returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Storage usage returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/usages/storage/today/")]
[ProducesResponseType(typeof(CurrentStorageDto), StatusCodes.Status200OK)]
@ -174,8 +174,8 @@ public sealed class UsagesController : ApiController
/// Get total asset size by team.
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <response code="200">Storage usage returned.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">Storage usage returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
[Route("teams/{team}/usages/storage/today/")]
[ProducesResponseType(typeof(CurrentStorageDto), StatusCodes.Status200OK)]
@ -199,8 +199,8 @@ public sealed class UsagesController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="fromDate">The from date.</param>
/// <param name="toDate">The to date.</param>
/// <response code="200">Storage usage returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">Storage usage returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/usages/storage/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(StorageUsagePerDateDto[]), StatusCodes.Status200OK)]
@ -221,8 +221,8 @@ public sealed class UsagesController : ApiController
/// <param name="team">The ID of the team.</param>
/// <param name="fromDate">The from date.</param>
/// <param name="toDate">The to date.</param>
/// <response code="200">Storage usage returned.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">Storage usage returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
[Route("teams/{team}/usages/storage/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(StorageUsagePerDateDto[]), StatusCodes.Status200OK)]

18
backend/src/Squidex/Areas/Api/Controllers/Teams/TeamContributorsController.cs

@ -36,8 +36,8 @@ public sealed class TeamContributorsController : ApiController
/// Get team contributors.
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <response code="200">Contributors returned.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">Contributors returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
[Route("teams/{team}/contributors/")]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]
@ -60,9 +60,9 @@ public sealed class TeamContributorsController : ApiController
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <param name="request">Contributor object that needs to be added to the team.</param>
/// <response code="201">Contributor assigned to team.</response>.
/// <response code="400">Contributor request not valid.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="201">Contributor assigned to team.</response>
/// <response code="400">Contributor request not valid.</response>
/// <response code="404">Team not found.</response>
[HttpPost]
[Route("teams/{team}/contributors/")]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status201Created)]
@ -81,8 +81,8 @@ public sealed class TeamContributorsController : ApiController
/// Remove yourself.
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <response code="200">Contributor removed.</response>.
/// <response code="404">Contributor or team not found.</response>.
/// <response code="200">Contributor removed.</response>
/// <response code="404">Contributor or team not found.</response>
[HttpDelete]
[Route("teams/{team}/contributors/me/")]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]
@ -102,8 +102,8 @@ public sealed class TeamContributorsController : ApiController
/// </summary>
/// <param name="team">The ID of the team.</param>
/// <param name="id">The ID of the contributor.</param>
/// <response code="200">Contributor removed.</response>.
/// <response code="404">Contributor or team not found.</response>.
/// <response code="200">Contributor removed.</response>
/// <response code="404">Contributor or team not found.</response>
[HttpDelete]
[Route("teams/{team}/contributors/{id}/")]
[ProducesResponseType(typeof(ContributorsDto), StatusCodes.Status200OK)]

18
backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs

@ -33,7 +33,7 @@ public sealed class TeamsController : ApiController
/// <summary>
/// Get your teams.
/// </summary>
/// <response code="200">Teams returned.</response>.
/// <response code="200">Teams returned.</response>
/// <remarks>
/// You can only retrieve the list of teams when you are authenticated as a user (OpenID implicit flow).
/// You will retrieve all teams, where you are assigned as a contributor.
@ -61,8 +61,8 @@ public sealed class TeamsController : ApiController
/// Get an team by name.
/// </summary>
/// <param name="team">The name of the team.</param>
/// <response code="200">Teams returned.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">Teams returned.</response>
/// <response code="404">Team not found.</response>
[HttpGet]
[Route("teams/{team}")]
[ProducesResponseType(typeof(TeamDto), StatusCodes.Status200OK)]
@ -84,9 +84,9 @@ public sealed class TeamsController : ApiController
/// Create a new team.
/// </summary>
/// <param name="request">The team object that needs to be added to Squidex.</param>
/// <response code="201">Team created.</response>.
/// <response code="400">Team request not valid.</response>.
/// <response code="409">Team name is already in use.</response>.
/// <response code="201">Team created.</response>
/// <response code="400">Team request not valid.</response>
/// <response code="409">Team name is already in use.</response>
/// <remarks>
/// You can only create an team when you are authenticated as a user (OpenID implicit flow).
/// You will be assigned as owner of the new team automatically.
@ -108,9 +108,9 @@ public sealed class TeamsController : ApiController
/// </summary>
/// <param name="team">The name of the team to update.</param>
/// <param name="request">The values to update.</param>
/// <response code="200">Team updated.</response>.
/// <response code="400">Team request not valid.</response>.
/// <response code="404">Team not found.</response>.
/// <response code="200">Team updated.</response>
/// <response code="400">Team request not valid.</response>
/// <response code="404">Team not found.</response>
[HttpPut]
[Route("teams/{team}/")]
[ProducesResponseType(typeof(TeamDto), StatusCodes.Status200OK)]

6
backend/src/Squidex/Areas/Api/Controllers/Templates/TemplatesController.cs

@ -30,7 +30,7 @@ public sealed class TemplatesController : ApiController
/// <summary>
/// Get all templates.
/// </summary>
/// <response code="200">Templates returned.</response>.
/// <response code="200">Templates returned.</response>
[HttpGet]
[Route("templates/")]
[ProducesResponseType(typeof(TemplatesDto), StatusCodes.Status200OK)]
@ -48,8 +48,8 @@ public sealed class TemplatesController : ApiController
/// Get template details.
/// </summary>
/// <param name="name">The name of the template.</param>
/// <response code="200">Template returned.</response>.
/// <response code="404">Template not found.</response>.
/// <response code="200">Template returned.</response>
/// <response code="404">Template not found.</response>
[HttpGet]
[Route("templates/{name}")]
[ProducesResponseType(typeof(TemplateDetailsDto), StatusCodes.Status200OK)]

4
backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs

@ -37,7 +37,7 @@ public sealed class TranslationsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The translation request.</param>
/// <response code="200">Text translated.</response>.
/// <response code="200">Text translated.</response>
[HttpPost]
[Route("apps/{app}/translations/")]
[ProducesResponseType(typeof(TranslationDto), StatusCodes.Status200OK)]
@ -56,7 +56,7 @@ public sealed class TranslationsController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The question request.</param>
/// <response code="200">Question asked.</response>.
/// <response code="200">Question asked.</response>
[HttpPost]
[Route("apps/{app}/ask/")]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]

3
backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs

@ -53,6 +53,9 @@ public sealed record MyUIOptions
[JsonPropertyName("markerProject")]
public string MarkerProject { get; set; }
[JsonPropertyName("collaborationService")]
public string CollaborationService { get; set; }
public sealed class MapOptions
{
[JsonPropertyName("type")]

34
backend/src/Squidex/Areas/Api/Controllers/UI/UIController.cs

@ -36,7 +36,7 @@ public sealed class UIController : ApiController
/// <summary>
/// Get ui settings.
/// </summary>
/// <response code="200">UI settings returned.</response>.
/// <response code="200">UI settings returned.</response>
[HttpGet]
[Route("ui/settings/")]
[ProducesResponseType(typeof(Dictionary<string, JsonValue>), StatusCodes.Status200OK)]
@ -63,8 +63,8 @@ public sealed class UIController : ApiController
/// Get ui settings.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">UI settings returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">UI settings returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/ui/settings/")]
[ProducesResponseType(typeof(Dictionary<string, JsonValue>), StatusCodes.Status200OK)]
@ -80,8 +80,8 @@ public sealed class UIController : ApiController
/// Get my ui settings.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <response code="200">UI settings returned.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">UI settings returned.</response>
/// <response code="404">App not found.</response>
[HttpGet]
[Route("apps/{app}/ui/settings/me")]
[ProducesResponseType(typeof(Dictionary<string, JsonValue>), StatusCodes.Status200OK)]
@ -98,8 +98,8 @@ public sealed class UIController : ApiController
/// </summary>
/// <param name="key">The name of the setting.</param>
/// <param name="request">The request with the value to update.</param>
/// <response code="200">UI setting set.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">UI setting set.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("ui/settings/{key}")]
[ApiPermission]
@ -116,8 +116,8 @@ public sealed class UIController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <param name="request">The request with the value to update.</param>
/// <response code="200">UI setting set.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">UI setting set.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("apps/{app}/ui/settings/{key}")]
[ApiPermission]
@ -134,8 +134,8 @@ public sealed class UIController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <param name="request">The request with the value to update.</param>
/// <response code="200">UI setting set.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">UI setting set.</response>
/// <response code="404">App not found.</response>
[HttpPut]
[Route("apps/{app}/ui/settings/me/{key}")]
[ApiPermission]
@ -150,8 +150,8 @@ public sealed class UIController : ApiController
/// Remove ui settings.
/// </summary>
/// <param name="key">The name of the setting.</param>
/// <response code="200">UI setting removed.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">UI setting removed.</response>
/// <response code="404">App not found.</response>
[HttpDelete]
[Route("ui/settings/{key}")]
[ApiPermission]
@ -167,8 +167,8 @@ public sealed class UIController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <response code="200">UI setting removed.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">UI setting removed.</response>
/// <response code="404">App not found.</response>
[HttpDelete]
[Route("apps/{app}/ui/settings/{key}")]
[ApiPermission]
@ -184,8 +184,8 @@ public sealed class UIController : ApiController
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <response code="200">UI setting removed.</response>.
/// <response code="404">App not found.</response>.
/// <response code="200">UI setting removed.</response>
/// <response code="404">App not found.</response>
[HttpDelete]
[Route("apps/{app}/ui/settings/me/{key}")]
[ApiPermission]

34
backend/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs

@ -37,7 +37,7 @@ public sealed class UserManagementController : ApiController
/// <param name="query">Optional query to search by email address or username.</param>
/// <param name="skip">The number of users to skip.</param>
/// <param name="take">The number of users to return.</param>
/// <response code="200">Users returned.</response>.
/// <response code="200">Users returned.</response>
[HttpGet]
[Route("user-management/")]
[ProducesResponseType(typeof(UsersDto), StatusCodes.Status200OK)]
@ -55,8 +55,8 @@ public sealed class UserManagementController : ApiController
/// Get a user by ID.
/// </summary>
/// <param name="id">The ID of the user.</param>
/// <response code="200">User returned.</response>.
/// <response code="404">User not found.</response>.
/// <response code="200">User returned.</response>
/// <response code="404">User not found.</response>
[HttpGet]
[Route("user-management/{id}/")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
@ -79,8 +79,8 @@ public sealed class UserManagementController : ApiController
/// Create a new user.
/// </summary>
/// <param name="request">The user object that needs to be added.</param>
/// <response code="201">User created.</response>.
/// <response code="400">User request not valid.</response>.
/// <response code="201">User created.</response>
/// <response code="400">User request not valid.</response>
[HttpPost]
[Route("user-management/")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status201Created)]
@ -99,9 +99,9 @@ public sealed class UserManagementController : ApiController
/// </summary>
/// <param name="id">The ID of the user.</param>
/// <param name="request">The user object that needs to be updated.</param>
/// <response code="200">User created.</response>.
/// <response code="400">User request not valid.</response>.
/// <response code="404">User not found.</response>.
/// <response code="200">User created.</response>
/// <response code="400">User request not valid.</response>
/// <response code="404">User not found.</response>
[HttpPut]
[Route("user-management/{id}/")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
@ -119,9 +119,9 @@ public sealed class UserManagementController : ApiController
/// Lock a user.
/// </summary>
/// <param name="id">The ID of the user to lock.</param>
/// <response code="200">User locked.</response>.
/// <response code="403">User is the current user.</response>.
/// <response code="404">User not found.</response>.
/// <response code="200">User locked.</response>
/// <response code="403">User is the current user.</response>
/// <response code="404">User not found.</response>
[HttpPut]
[Route("user-management/{id}/lock/")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
@ -144,9 +144,9 @@ public sealed class UserManagementController : ApiController
/// Unlock a user.
/// </summary>
/// <param name="id">The ID of the user to unlock.</param>
/// <response code="200">User unlocked.</response>.
/// <response code="403">User is the current user.</response>.
/// <response code="404">User not found.</response>.
/// <response code="200">User unlocked.</response>
/// <response code="403">User is the current user.</response>
/// <response code="404">User not found.</response>
[HttpPut]
[Route("user-management/{id}/unlock/")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
@ -169,9 +169,9 @@ public sealed class UserManagementController : ApiController
/// Delete a User.
/// </summary>
/// <param name="id">The ID of the user to delete.</param>
/// <response code="204">User deleted.</response>.
/// <response code="403">User is the current user.</response>.
/// <response code="404">User not found.</response>.
/// <response code="204">User deleted.</response>
/// <response code="403">User is the current user.</response>
/// <response code="404">User not found.</response>
[HttpDelete]
[Route("user-management/{id}/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]

14
backend/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs

@ -60,7 +60,7 @@ public sealed class UsersController : ApiController
/// <summary>
/// Get the user resources.
/// </summary>
/// <response code="200">User resources returned.</response>.
/// <response code="200">User resources returned.</response>
[HttpGet]
[Route("")]
[ProducesResponseType(typeof(ResourcesDto), StatusCodes.Status200OK)]
@ -76,7 +76,7 @@ public sealed class UsersController : ApiController
/// Update the user profile.
/// </summary>
/// <param name="request">The values to update.</param>
/// <response code="204">User updated.</response>.
/// <response code="204">User updated.</response>
[HttpPost]
[Route("user")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
@ -95,7 +95,7 @@ public sealed class UsersController : ApiController
/// <remarks>
/// Search the user by query that contains the email address or the part of the email address.
/// </remarks>
/// <response code="200">Users returned.</response>.
/// <response code="200">Users returned.</response>
[HttpGet]
[Route("users/")]
[ProducesResponseType(typeof(UserDto[]), StatusCodes.Status200OK)]
@ -122,8 +122,8 @@ public sealed class UsersController : ApiController
/// Get user by id.
/// </summary>
/// <param name="id">The ID of the user (GUID).</param>
/// <response code="200">User found.</response>.
/// <response code="404">User not found.</response>.
/// <response code="200">User found.</response>
/// <response code="404">User not found.</response>
[HttpGet]
[Route("users/{id}/")]
[ProducesResponseType(typeof(UserDto), StatusCodes.Status200OK)]
@ -153,8 +153,8 @@ public sealed class UsersController : ApiController
/// Get user picture by id.
/// </summary>
/// <param name="id">The ID of the user (GUID).</param>
/// <response code="200">User found and image or fallback returned.</response>.
/// <response code="404">User not found.</response>.
/// <response code="200">User found and image or fallback returned.</response>
/// <response code="404">User not found.</response>
[HttpGet]
[Route("users/{id}/picture/")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]

10
backend/src/Squidex/Config/Domain/NotificationsServices.cs → backend/src/Squidex/Config/Domain/CollaborationServices.cs

@ -6,16 +6,17 @@
// ==========================================================================
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Entities.Invitation;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Infrastructure.Email;
using Squidex.Infrastructure.EventSourcing;
using YDotNet.Server;
namespace Squidex.Config.Domain;
public static class NotificationsServices
public static class CollaborationServices
{
public static void AddSquidexNotifications(this IServiceCollection services, IConfiguration config)
public static void AddSquidexCollaborations(this IServiceCollection services, IConfiguration config)
{
var emailOptions = config.GetSection("email:smtp").Get<SmtpOptions>() ?? new ();
@ -40,5 +41,8 @@ public static class NotificationsServices
services.AddSingletonAs<InvitationEventConsumer>()
.As<IEventConsumer>();
services.AddSingletonAs<CommentCollaborationHandler>()
.As<ICollaborationService>().As<IDocumentCallback>();
}
}

4
backend/src/Squidex/Config/Domain/CommandsServices.cs

@ -12,7 +12,6 @@ using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Domain.Apps.Entities.Apps.Templates;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Domain.Apps.Entities.Comments.DomainObject;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.DomainObject;
using Squidex.Domain.Apps.Entities.Invitation;
@ -106,9 +105,6 @@ public static class CommandsServices
services.AddSingletonAs<AssetCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<CommentsCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<ContentsBulkUpdateCommandMiddleware>()
.As<ICommandMiddleware>();

22
backend/src/Squidex/Config/Domain/CommentsServices.cs

@ -1,22 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments;
namespace Squidex.Config.Domain;
public static class CommentsServices
{
public static void AddSquidexComments(this IServiceCollection services)
{
services.AddSingletonAs<CommentsLoader>()
.As<ICommentsLoader>();
services.AddSingletonAs<WatchingService>()
.As<IWatchingService>();
}
}

16
backend/src/Squidex/Config/Domain/FontendServices.cs

@ -9,6 +9,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using Squidex.Areas.Api.Controllers.UI;
using Squidex.Domain.Apps.Entities.History;
using Squidex.Hosting;
using Squidex.Text.ChatBots;
using Squidex.Text.Translations;
using Squidex.Web;
@ -54,5 +55,20 @@ public static class FontendServices
options.More["canUseChatBot"] = chatBot.IsConfigured;
});
services.Configure<MyUIOptions>((services, options) =>
{
if (string.IsNullOrWhiteSpace(options.CollaborationService))
{
var urlGenerator = services.GetRequiredService<IUrlGenerator>();
var collaborationUrl =
urlGenerator.BuildUrl()
.Replace("https://", "wss://", StringComparison.OrdinalIgnoreCase)
.Replace("http://", "ws://", StringComparison.OrdinalIgnoreCase);
options.CollaborationService = collaborationUrl;
}
});
}
}

2
backend/src/Squidex/Config/Domain/RuleServices.cs

@ -13,7 +13,7 @@ using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Domain.Apps.Core.Templates;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Comments;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Queries;

7
backend/src/Squidex/Config/Domain/StoreServices.cs

@ -50,6 +50,7 @@ using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.MongoDb;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.UsageTracking;
using YDotNet.Server.MongoDB;
namespace Squidex.Config.Domain;
@ -68,6 +69,12 @@ public static class StoreServices
services.AddMongoAssetKeyValueStore();
services.AddSingleton(typeof(ISnapshotStore<>), typeof(MongoSnapshotStore<>));
services.AddYDotNet()
.AddMongoStorage(options =>
{
options.DatabaseName = mongoDatabaseName;
});
services.AddSingletonAs(c => GetMongoClient(mongoConfiguration))
.As<IMongoClient>();

3
backend/src/Squidex/Config/Web/WebServices.cs

@ -75,6 +75,9 @@ public static class WebServices
services.AddLocalization();
services.AddYDotNet()
.AddWebSockets();
services.AddMvc(options =>
{
// Never change this order here.

8
backend/src/Squidex/Squidex.csproj

@ -51,7 +51,7 @@
<PackageReference Include="Microsoft.CodeAnalysis.RulesetToEditorconfigConverter" Version="3.3.3" />
<PackageReference Include="Microsoft.Data.Edm" Version="5.8.5" />
<PackageReference Include="Microsoft.OData.Core" Version="7.17.0" />
<PackageReference Include="MongoDB.Driver" Version="2.20.0" />
<PackageReference Include="MongoDB.Driver" Version="2.22.0" />
<PackageReference Include="MongoDB.Driver.Core.Extensions.OpenTelemetry" Version="1.0.0" />
<PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="3.0.0" />
<PackageReference Include="NJsonSchema" Version="10.9.0" />
@ -75,6 +75,12 @@
<PackageReference Include="Squidex.Messaging.All" Version="5.19.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="5.19.0" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="4.0.1-dev" />
<PackageReference Include="Squidex.YDotNet" Version="0.2.9" />
<PackageReference Include="Squidex.YDotNet.Native" Version="0.2.9" />
<PackageReference Include="Squidex.YDotNet.Server" Version="0.2.9" />
<PackageReference Include="Squidex.YDotNet.Server.MongoDB" Version="0.2.9" />
<PackageReference Include="Squidex.YDotNet.Server.Redis" Version="0.2.9" />
<PackageReference Include="Squidex.YDotNet.Server.WebSockets" Version="0.2.9" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup>

3
backend/src/Squidex/Startup.cs

@ -45,8 +45,8 @@ public sealed class Startup
services.AddSquidexAssetInfrastructure(config);
services.AddSquidexAssets(config);
services.AddSquidexBackups();
services.AddSquidexCollaborations(config);
services.AddSquidexCommands(config);
services.AddSquidexComments();
services.AddSquidexContents(config);
services.AddSquidexControllerServices(config);
services.AddSquidexEventSourcing(config);
@ -59,7 +59,6 @@ public sealed class Startup
services.AddSquidexLocalization();
services.AddSquidexMessaging(config);
services.AddSquidexMigration(config);
services.AddSquidexNotifications(config);
services.AddSquidexOpenApiSettings();
services.AddSquidexQueries(config);
services.AddSquidexRules(config);

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/UsageNotifierWorkerTest.cs

@ -8,7 +8,7 @@
using NodaTime;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Notifications;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.TestHelpers;

335
backend/tests/Squidex.Domain.Apps.Entities.Tests/Collaboration/CommentCollaborationHandlerTests.cs

@ -0,0 +1,335 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using LoremNET;
using Microsoft.Extensions.Logging;
using NodaTime;
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Shared.Users;
using YDotNet.Document;
using YDotNet.Document.Cells;
using YDotNet.Extensions;
using YDotNet.Server;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public class CommentCollaborationHandlerTests : GivenContext
{
private readonly SimpleDocumentManager documentManager = new SimpleDocumentManager();
private readonly IClock clock = A.Fake<IClock>();
private readonly IEventFormatter eventFormatter = A.Fake<IEventFormatter>();
private readonly IEventStore eventStore = A.Fake<IEventStore>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly CommentCollaborationHandler sut;
public CommentCollaborationHandlerTests()
{
var now = SystemClock.Instance.GetCurrentInstant();
A.CallTo(() => clock.GetCurrentInstant())
.Returns(now);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>._, default))
.Returns(Task.FromResult<IUser?>(null));
var log = A.Fake<ILogger<CommentCollaborationHandler>>();
sut = new CommentCollaborationHandler(TestUtils.DefaultSerializer, eventStore, eventFormatter, userResolver, clock, log);
}
[Fact]
public void Should_provider_user_document_name()
{
var name = sut.UserDocument("user42");
Assert.Equal("users/user42", name);
}
[Fact]
public void Should_provider_app_document_name()
{
var name = sut.ResourceDocument(AppId, DomainId.Create("resource42"));
Assert.Equal($"apps/{AppId}/resource42", name);
}
[Fact]
public async Task Should_create_comment()
{
await sut.OnInitializedAsync(documentManager);
var commentsId = DomainId.Create("resource42");
var document = new Doc();
var docName = sut.ResourceDocument(AppId, commentsId);
documentManager.Doc = document;
Output? addedInput = null;
document.Array("stream").ObserveDeep(events =>
{
addedInput = events.Single().ArrayEvent.Delta.Single().Values.Single();
});
await sut.CommentAsync(AppId, commentsId, "My Comment", User, null, true, default);
var commentJson = addedInput!.ToJson(document);
var commentItem = TestUtils.DefaultSerializer.Deserialize<Comment>(commentJson);
commentItem.Should().BeEquivalentTo(
new Comment(clock.GetCurrentInstant(), User, "My Comment", null, true));
}
[Fact]
public async Task Should_create_notification()
{
await sut.OnInitializedAsync(documentManager);
var document = new Doc();
var docName = sut.UserDocument(User.Identifier);
documentManager.Doc = document;
Output? addedInput = null;
document.Array("stream").ObserveDeep(events =>
{
addedInput = events.Single().ArrayEvent.Delta.Single().Values.Single();
});
await sut.NotifyAsync(User.Identifier, "My Notification", User, null, true, default);
var commentJson = addedInput!.ToJson(document);
var commentItem = TestUtils.DefaultSerializer.Deserialize<Comment>(commentJson);
commentItem.Should().BeEquivalentTo(
new Comment(clock.GetCurrentInstant(), User, "My Notification", null, true));
}
[Fact]
public async Task Should_publish_event_for_comment()
{
var text = "My Comment";
var commentsId = DomainId.Create("resource42");
var commentItem = new Comment(clock.GetCurrentInstant(), User, text);
var storedEvent = await CreateCommentAsync(commentsId, commentItem);
storedEvent?.Payload.Should().BeEquivalentTo(
new CommentCreated
{
Actor = User,
AppId = AppId,
CommentId = default,
CommentsId = commentsId,
Text = commentItem.Text,
Mentions = null,
}, opts => opts.Excluding(x => x.CommentId));
}
[Fact]
public async Task Should_not_enrich_comment_with_mentioned_users_if_users_not_found()
{
var text = "Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io";
var commentsId = DomainId.Create("resource42");
var commentItem = new Comment(clock.GetCurrentInstant(), User, text);
var storedEvent = await CreateCommentAsync(commentsId, commentItem);
storedEvent?.Payload.Should().BeEquivalentTo(
new CommentCreated
{
Actor = User,
AppId = AppId,
CommentId = default,
CommentsId = commentsId,
Text = commentItem.Text,
Mentions = null,
}, opts => opts.Excluding(x => x.CommentId));
}
[Fact]
public async Task Should_enrich_comment_with_mentioned_users()
{
SetupUser("id1", "mail1@squidex.io");
SetupUser("id2", "mail2@squidex.io");
var text = "Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io";
var commentsId = DomainId.Create("resource42");
var commentItem = new Comment(clock.GetCurrentInstant(), User, text);
var storedEvent = await CreateCommentAsync(commentsId, commentItem);
storedEvent?.Payload.Should().BeEquivalentTo(
new CommentCreated
{
Actor = User,
AppId = AppId,
CommentId = default,
CommentsId = commentsId,
Text = commentItem.Text,
Mentions = new string[] { "id1", "id2" }
}, opts => opts.Excluding(x => x.CommentId));
}
[Fact]
public async Task Should_enrich_comment_with_mentioned_users_and_long_text()
{
SetupUser("id1", "mail1@squidex.io");
SetupUser("id2", "mail2@squidex.io");
var text = $"Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io {Lorem.Paragraph(200, 10)}";
var commentsId = DomainId.Create("resource42");
var commentItem = new Comment(clock.GetCurrentInstant(), User, text);
var storedEvent = await CreateCommentAsync(commentsId, commentItem);
storedEvent?.Payload.Should().BeEquivalentTo(
new CommentCreated
{
Actor = User,
AppId = AppId,
CommentId = default,
CommentsId = commentsId,
Text = commentItem.Text,
Mentions = new string[] { "id1", "id2" }
}, opts => opts.Excluding(x => x.CommentId));
}
private async Task<Envelope<IEvent>?> CreateCommentAsync(DomainId commentsId, Comment comment)
{
var document = new Doc();
var docName = sut.ResourceDocument(AppId, commentsId);
documentManager.Doc = document;
var stream = document.Array("stream");
await sut.OnDocumentLoadedAsync(new DocumentLoadEvent
{
Context = new DocumentContext(docName, 0),
Document = document,
Source = documentManager,
});
var commentJson = TestUtils.DefaultSerializer.Serialize(comment);
Envelope<IEvent>? storedEvent = null;
A.CallTo(() => eventFormatter.ToEventData(A<Envelope<IEvent>>._, A<Guid>._, true))
.Invokes(c =>
{
storedEvent = c.GetArgument<Envelope<IEvent>>(0);
});
await documentManager.UpdateDocAsync(null!, doc =>
{
using (var transaction = doc.WriteTransaction())
{
stream.InsertRange(transaction, 0, InputFactory.FromJson(commentJson));
}
}, default);
await sut.LastTask;
var streamName = $"comments-{DomainId.Combine(AppId.Id, commentsId)}";
A.CallTo(() => eventStore.AppendAsync(A<Guid>._, streamName, EtagVersion.Any, A<ICollection<EventData>>._, A<CancellationToken>._))
.MustHaveHappened();
return storedEvent;
}
private void SetupUser(string id, string email)
{
var user = UserMocks.User(id, email);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(email, default))
.Returns(user);
}
private sealed class SimpleDocumentManager : IDocumentManager
{
private readonly SemaphoreSlim lockObject = new SemaphoreSlim(1);
public Doc Doc { get; set; }
public async ValueTask UpdateDocAsync(DocumentContext context, Action<Doc> action,
CancellationToken ct = default)
{
await lockObject.WaitAsync(ct);
try
{
action(Doc);
}
finally
{
lockObject.Release();
}
}
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public ValueTask CleanupAsync(
CancellationToken ct = default)
{
return default;
}
public ValueTask PingAsync(DocumentContext context, ulong clock, string? state = null,
CancellationToken ct = default)
{
return default;
}
public ValueTask DisconnectAsync(DocumentContext context,
CancellationToken ct = default)
{
return default;
}
public ValueTask<IReadOnlyDictionary<ulong, ConnectedUser>> GetAwarenessAsync(DocumentContext context,
CancellationToken ct = default)
{
return default;
}
public ValueTask<byte[]> GetStateVectorAsync(DocumentContext context,
CancellationToken ct = default)
{
return default;
}
public ValueTask<byte[]> GetUpdateAsync(DocumentContext context, byte[] stateVector,
CancellationToken ct = default)
{
return default;
}
public ValueTask<UpdateResult> ApplyUpdateAsync(DocumentContext context, byte[] stateDiff,
CancellationToken ct = default)
{
return default;
}
}
}

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs → backend/tests/Squidex.Domain.Apps.Entities.Tests/Collaboration/CommentTriggerHandlerTests.cs

@ -20,7 +20,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public class CommentTriggerHandlerTests
{
@ -51,12 +51,6 @@ public class CommentTriggerHandlerTests
Assert.True(sut.Handles(new CommentCreated()));
}
[Fact]
public void Should_not_handle_comment_update_event()
{
Assert.False(sut.Handles(new CommentUpdated()));
}
[Fact]
public void Should_not_handle_other_event()
{

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Notifications/EmailUserNotificationsTests.cs → backend/tests/Squidex.Domain.Apps.Entities.Tests/Collaboration/EmailUserNotificationsTests.cs

@ -13,7 +13,7 @@ using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Email;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Notifications;
namespace Squidex.Domain.Apps.Entities.Collaboration;
public class EmailUserNotificationsTests : GivenContext
{

46
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs

@ -1,46 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Comments.DomainObject;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments;
public sealed class CommentsLoaderTests : GivenContext
{
private readonly IDomainObjectFactory domainObjectFactory = A.Fake<IDomainObjectFactory>();
private readonly CommentsLoader sut;
public CommentsLoaderTests()
{
sut = new CommentsLoader(domainObjectFactory);
}
[Fact]
public async Task Should_get_comments_from_domain_object()
{
var commentsId = DomainId.NewGuid();
var comments = new CommentsResult();
var domainObject = A.Fake<CommentsStream>();
A.CallTo(() => domainObjectFactory.Create<CommentsStream>(commentsId))
.Returns(domainObject);
A.CallTo(() => domainObject.GetComments(11))
.Returns(comments);
var actual = await sut.GetCommentsAsync(commentsId, 11, CancellationToken);
Assert.Same(comments, actual);
A.CallTo(() => domainObject.LoadAsync(CancellationToken))
.MustHaveHappened();
}
}

175
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsCommandMiddlewareTests.cs

@ -1,175 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using LoremNET;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject;
public class CommentsCommandMiddlewareTests : GivenContext
{
private readonly IDomainObjectFactory domainObjectFactory = A.Fake<IDomainObjectFactory>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly ICommandBus commandBus = A.Fake<ICommandBus>();
private readonly DomainId commentsId = DomainId.NewGuid();
private readonly DomainId commentId = DomainId.NewGuid();
private readonly CommentsCommandMiddleware sut;
public CommentsCommandMiddlewareTests()
{
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>._, default))
.Returns(Task.FromResult<IUser?>(null));
sut = new CommentsCommandMiddleware(domainObjectFactory, userResolver);
}
[Fact]
public async Task Should_invoke_domain_object_for_comments_command()
{
var command = CreateCommentsCommand(new CreateComment());
var context = CrateCommandContext(command);
var domainObject = A.Fake<CommentsStream>();
A.CallTo(() => domainObject.ExecuteAsync(command, CancellationToken))
.Returns(CommandResult.Empty(commentsId, 0, 0));
A.CallTo(() => domainObjectFactory.Create<CommentsStream>(commentsId))
.Returns(domainObject);
var isNextCalled = false;
await sut.HandleAsync(context, (c, ct) =>
{
isNextCalled = true;
return Task.CompletedTask;
}, CancellationToken);
Assert.True(isNextCalled);
}
[Fact]
public async Task Should_enrich_with_mentioned_user_ids_if_found()
{
SetupUser("id1", "mail1@squidex.io");
SetupUser("id2", "mail2@squidex.io");
var command = CreateCommentsCommand(new CreateComment
{
Text = "Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io",
IsMention = false
});
var context = CrateCommandContext(command);
await sut.HandleAsync(context, CancellationToken);
Assert.Equal(command.Mentions, new[] { "id1", "id2" });
}
[Fact]
public async Task Should_enrich_with_mentioned_user_ids_if_found_and_long_text()
{
SetupUser("id1", "mail1@squidex.io");
SetupUser("id2", "mail2@squidex.io");
var command = CreateCommentsCommand(new CreateComment
{
Text = $"Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io {Lorem.Paragraph(200, 10)}",
IsMention = false
});
var context = CrateCommandContext(command);
await sut.HandleAsync(context, CancellationToken);
Assert.Equal(command.Mentions, new[] { "id1", "id2" });
}
[Fact]
public async Task Should_not_invoke_commands_for_mentioned_users()
{
SetupUser("id1", "mail1@squidex.io");
SetupUser("id2", "mail2@squidex.io");
var command = CreateCommentsCommand(new CreateComment
{
Text = "Hi @mail1@squidex.io and @mail2@squidex.io",
IsMention = false
});
var context = CrateCommandContext(command);
await sut.HandleAsync(context, CancellationToken);
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_enrich_with_mentioned_user_ids_if_invalid_mentioned_tags_used()
{
var command = CreateCommentsCommand(new CreateComment
{
Text = "Hi invalid@squidex.io",
IsMention = false
});
var context = CrateCommandContext(command);
await sut.HandleAsync(context, CancellationToken);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_enrich_with_mentioned_user_ids_for_notification()
{
var command = CreateCommentsCommand(new CreateComment
{
Text = "Hi @invalid@squidex.io",
IsMention = true
});
var context = CrateCommandContext(command);
await sut.HandleAsync(context, CancellationToken);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
private CommandContext CrateCommandContext(ICommand command)
{
return new CommandContext(command, commandBus);
}
private void SetupUser(string id, string email)
{
var user = UserMocks.User(id, email);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(email, default))
.Returns(user);
}
private T CreateCommentsCommand<T>(T command) where T : CommentCommand
{
command.AppId = AppId;
command.CommentsId = commentsId;
command.CommentId = commentId;
command.Actor = User;
return command;
}
}

175
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/CommentsStreamTests.cs

@ -1,175 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject;
public class CommentsStreamTests
{
private readonly IEventFormatter eventFormatter = A.Fake<IEventFormatter>();
private readonly IEventStore eventStore = A.Fake<IEventStore>();
private readonly DomainId commentsId = DomainId.NewGuid();
private readonly DomainId commentId = DomainId.NewGuid();
private readonly RefToken actor = RefToken.User("me");
private readonly CommentsStream sut;
public IEnumerable<Envelope<IEvent>> LastEvents { get; private set; } = Enumerable.Empty<Envelope<IEvent>>();
public CommentsStreamTests()
{
A.CallTo(() => eventStore.AppendAsync(A<Guid>._, A<string>._, A<long>._, A<ICollection<EventData>>._, default))
.Invokes(x => LastEvents = sut!.GetUncommittedEvents().Select(x => x.To<IEvent>()).ToList());
sut = new CommentsStream(commentsId, eventFormatter, eventStore);
}
[Fact]
public async Task Create_should_create_events()
{
var command = new CreateComment { Text = "text1", Url = new Uri("http://uri") };
var actual = await sut.ExecuteAsync(CreateCommentsCommand(command), default);
actual.ShouldBeEquivalent(CommandResult.Empty(commentsId, 0, EtagVersion.Empty));
sut.GetComments(0).Should().BeEquivalentTo(new CommentsResult
{
Version = 0
});
sut.GetComments(-1).Should().BeEquivalentTo(new CommentsResult
{
CreatedComments = new List<Comment>
{
new Comment(command.CommentId, GetTime(), command.Actor, "text1", command.Url)
},
Version = 0
});
LastEvents
.ShouldHaveSameEvents(
CreateCommentsEvent(new CommentCreated { Text = command.Text, Url = command.Url })
);
}
[Fact]
public async Task Update_should_create_events()
{
await ExecuteCreateAsync();
var updateCommand = new UpdateComment { Text = "text2" };
var actual = await sut.ExecuteAsync(CreateCommentsCommand(updateCommand), default);
actual.ShouldBeEquivalent(CommandResult.Empty(commentsId, 1, 0));
sut.GetComments(-1).Should().BeEquivalentTo(new CommentsResult
{
CreatedComments = new List<Comment>
{
new Comment(commentId, GetTime(), updateCommand.Actor, "text2")
},
Version = 1
});
sut.GetComments(0).Should().BeEquivalentTo(new CommentsResult
{
UpdatedComments = new List<Comment>
{
new Comment(commentId, GetTime(), updateCommand.Actor, "text2")
},
Version = 1
});
LastEvents
.ShouldHaveSameEvents(
CreateCommentsEvent(new CommentUpdated { Text = updateCommand.Text })
);
}
[Fact]
public async Task Delete_should_create_events()
{
await ExecuteCreateAsync();
await ExecuteUpdateAsync();
var deleteCommand = new DeleteComment();
var actual = await sut.ExecuteAsync(CreateCommentsCommand(deleteCommand), default);
actual.ShouldBeEquivalent(CommandResult.Empty(commentsId, 2, 1));
sut.GetComments(-1).Should().BeEquivalentTo(new CommentsResult
{
Version = 2
});
sut.GetComments(0).Should().BeEquivalentTo(new CommentsResult
{
DeletedComments = new List<DomainId>
{
commentId
},
Version = 2
});
sut.GetComments(1).Should().BeEquivalentTo(new CommentsResult
{
DeletedComments = new List<DomainId>
{
commentId
},
Version = 2
});
LastEvents
.ShouldHaveSameEvents(
CreateCommentsEvent(new CommentDeleted())
);
}
private Task ExecuteCreateAsync()
{
return sut.ExecuteAsync(CreateCommentsCommand(new CreateComment { Text = "text1" }), default);
}
private Task ExecuteUpdateAsync()
{
return sut.ExecuteAsync(CreateCommentsCommand(new UpdateComment { Text = "text2" }), default);
}
private T CreateCommentsEvent<T>(T @event) where T : CommentsEvent
{
@event.Actor = actor;
@event.CommentsId = commentsId;
@event.CommentId = commentId;
return @event;
}
private T CreateCommentsCommand<T>(T command) where T : CommentCommand
{
command.Actor = actor;
command.CommentsId = commentsId;
command.CommentId = commentId;
return command;
}
private Instant GetTime()
{
return LastEvents.ElementAt(0).Headers.Timestamp();
}
}

191
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/DomainObject/Guards/GuardCommentsTests.cs

@ -1,191 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Comments.DomainObject.Guards;
public class GuardCommentsTests : IClassFixture<TranslationsFixture>
{
private readonly string commentsId = DomainId.NewGuid().ToString();
private readonly RefToken user1 = RefToken.User("1");
private readonly RefToken user2 = RefToken.User("2");
[Fact]
public void CanCreate_should_throw_exception_if_text_not_defined()
{
var command = new CreateComment();
ValidationAssert.Throws(() => GuardComments.CanCreate(command),
new ValidationError("Text is required.", "Text"));
}
[Fact]
public void CanCreate_should_not_throw_exception_if_text_defined()
{
var command = new CreateComment { Text = "text" };
GuardComments.CanCreate(command);
}
[Fact]
public void CanUpdate_should_throw_exception_if_text_not_defined()
{
var commentId = DomainId.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
ValidationAssert.Throws(() => GuardComments.CanUpdate(command, commentsId, events),
new ValidationError("Text is required.", "Text"));
}
[Fact]
public void CanUpdate_should_throw_exception_if_comment_from_another_user()
{
var commentId = DomainId.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user2, Text = "text2" };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
Assert.Throws<DomainException>(() => GuardComments.CanUpdate(command, commentsId, events));
}
[Fact]
public void CanUpdate_should_throw_exception_if_comment_not_found()
{
var commentId = DomainId.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>();
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(command, commentsId, events));
}
[Fact]
public void CanUpdate_should_throw_exception_if_comment_deleted_found()
{
var commentId = DomainId.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>(),
Envelope.Create<CommentsEvent>(new CommentDeleted { CommentId = commentId }).To<CommentsEvent>()
};
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(command, commentsId, events));
}
[Fact]
public void CanUpdate_should_not_throw_exception_if_comment_is_own_notification()
{
var commentId = DomainId.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1, Text = "text2" };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanUpdate(command, user1.Identifier, events);
}
[Fact]
public void CanUpdate_should_not_throw_exception_if_comment_from_same_user()
{
var commentId = DomainId.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1, Text = "text2" };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanUpdate(command, commentsId, events);
}
[Fact]
public void CanDelete_should_throw_exception_if_comment_from_another_user()
{
var commentId = DomainId.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user2 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
Assert.Throws<DomainException>(() => GuardComments.CanDelete(command, commentsId, events));
}
[Fact]
public void CanDelete_should_throw_exception_if_comment_not_found()
{
var commentId = DomainId.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>();
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(command, commentsId, events));
}
[Fact]
public void CanDelete_should_throw_exception_if_comment_deleted()
{
var commentId = DomainId.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }),
Envelope.Create<CommentsEvent>(new CommentDeleted { CommentId = commentId })
};
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(command, commentsId, events));
}
[Fact]
public void CanDelete_should_not_throw_exception_if_comment_is_own_notification()
{
var commentId = DomainId.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanDelete(command, user1.Identifier, events);
}
[Fact]
public void CanDelete_should_not_throw_exception_if_comment_from_same_user()
{
var commentId = DomainId.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanDelete(command, commentsId, events);
}
}

82
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/WatchingServiceTests.cs

@ -1,82 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.TestHelpers;
namespace Squidex.Domain.Apps.Entities.Comments;
public class WatchingServiceTests : GivenContext
{
private readonly TestState<WatchingService.State> state1;
private readonly TestState<WatchingService.State> state2;
private readonly IClock clock = A.Fake<IClock>();
private readonly string resource1 = "resource1";
private readonly string resource2 = "resource2";
private readonly WatchingService sut;
private Instant now = SystemClock.Instance.GetCurrentInstant();
public WatchingServiceTests()
{
A.CallTo(() => clock.GetCurrentInstant())
.ReturnsLazily(() => now);
state1 = new TestState<WatchingService.State>($"{AppId.Id}_{resource1}");
state2 = new TestState<WatchingService.State>($"{AppId.Id}_{resource2}", state1.PersistenceFactory);
sut = new WatchingService(state1.PersistenceFactory)
{
Clock = clock
};
}
[Fact]
public async Task Should_only_return_self_if_no_one_watching()
{
var watching = await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user1", CancellationToken);
Assert.Equal(new[] { "user1" }, watching);
}
[Fact]
public async Task Should_return_users_watching_on_same_resource()
{
await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user1", CancellationToken);
await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user2", CancellationToken);
var watching1 = await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user3", CancellationToken);
var watching2 = await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user4", CancellationToken);
Assert.Equal(new[] { "user1", "user3" }, watching1);
Assert.Equal(new[] { "user2", "user4" }, watching2);
}
[Fact]
public async Task Should_cleanup_old_users()
{
await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user1", CancellationToken);
await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user2", CancellationToken);
now = now.Plus(Duration.FromMinutes(2));
await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user3", CancellationToken);
await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user4", CancellationToken);
var watching1 = await sut.GetWatchingUsersAsync(AppId.Id, resource1, "user5", CancellationToken);
var watching2 = await sut.GetWatchingUsersAsync(AppId.Id, resource2, "user6", CancellationToken);
Assert.Equal(new[] { "user3", "user5" }, watching1);
Assert.Equal(new[] { "user4", "user6" }, watching2);
A.CallTo(() => state1.Persistence.WriteSnapshotAsync(A<WatchingService.State>._, CancellationToken))
.MustHaveHappened();
A.CallTo(() => state2.Persistence.WriteSnapshotAsync(A<WatchingService.State>._, CancellationToken))
.MustHaveHappened();
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save