mirror of https://github.com/Squidex/squidex.git
Browse Source
# Conflicts: # backend/src/Squidex/wwwroot/scripts/editor-sdk.d.ts # backend/src/Squidex/wwwroot/scripts/editor-sdk.jspull/1039/head
530 changed files with 13405 additions and 7291 deletions
@ -0,0 +1,29 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
using Squidex.Domain.Apps.Core.HandleRules; |
||||
|
using Squidex.Domain.Apps.Core.Rules; |
||||
|
|
||||
|
namespace Squidex.Extensions.Actions.DeepDetect; |
||||
|
|
||||
|
[RuleAction( |
||||
|
Title = "DeepDetect", |
||||
|
IconImage = "<svg viewBox='0 0 28 28' xmlns='http://www.w3.org/2000/svg'><g style='stroke-width:1.24962' fill='none'><path fill='#ff5252' d='M13 21.92H0v-8.032h9.386V10.92h3.57v11zm-9.386-4.889v1.702H9.43v-1.702z' style='stroke-width:1.24962' transform='matrix(.78667 0 0 .81405 2.529 2.668)'/><path fill='#fff' d='M29.164 21.92h-13V14.028H25.7V5.92h3.464zm-9.536-4.804v1.673H25.7v-1.673z' style='stroke-width:1.24962' transform='matrix(.78667 0 0 .81405 2.529 2.668)'/></g></svg>", |
||||
|
IconColor = "#526a75", |
||||
|
Display = "Annotate image", |
||||
|
Description = "Annotate an image using deep detect.")] |
||||
|
public sealed record DeepDetectAction : RuleAction |
||||
|
{ |
||||
|
[Display(Name = "Min Probability", Description = "The minimum probability for objects to be recognized (0 - 100).")] |
||||
|
[Editor(RuleFieldEditor.Number)] |
||||
|
public long MinimumProbability { get; set; } |
||||
|
|
||||
|
[Display(Name = "Max Tags", Description = "The maximum number of tags to use.")] |
||||
|
[Editor(RuleFieldEditor.Number)] |
||||
|
public long MaximumTags { get; set; } |
||||
|
} |
||||
@ -0,0 +1,197 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Net.Http.Json; |
||||
|
using System.Text.RegularExpressions; |
||||
|
using Squidex.Domain.Apps.Core; |
||||
|
using Squidex.Domain.Apps.Core.Assets; |
||||
|
using Squidex.Domain.Apps.Core.HandleRules; |
||||
|
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; |
||||
|
using Squidex.Domain.Apps.Entities; |
||||
|
using Squidex.Domain.Apps.Entities.Assets; |
||||
|
using Squidex.Domain.Apps.Entities.Assets.Commands; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Commands; |
||||
|
using Squidex.Infrastructure.Json; |
||||
|
using Squidex.Text; |
||||
|
|
||||
|
namespace Squidex.Extensions.Actions.DeepDetect; |
||||
|
|
||||
|
#pragma warning disable MA0048 // File name must match type name
|
||||
|
|
||||
|
internal partial class DeepDetectActionHandler : RuleActionHandler<DeepDetectAction, DeepDetectJob> |
||||
|
{ |
||||
|
private const string Description = "Analyze Image"; |
||||
|
private readonly IHttpClientFactory httpClientFactory; |
||||
|
private readonly IJsonSerializer jsonSerializer; |
||||
|
private readonly IAppProvider appProvider; |
||||
|
private readonly IAssetQueryService assetQuery; |
||||
|
private readonly ICommandBus commandBus; |
||||
|
private readonly IUrlGenerator urlGenerator; |
||||
|
|
||||
|
public DeepDetectActionHandler(RuleEventFormatter formatter, IHttpClientFactory httpClientFactory, |
||||
|
IJsonSerializer jsonSerializer, |
||||
|
IAppProvider appProvider, |
||||
|
IAssetQueryService assetQuery, |
||||
|
ICommandBus commandBus, |
||||
|
IUrlGenerator urlGenerator) |
||||
|
: base(formatter) |
||||
|
{ |
||||
|
this.httpClientFactory = httpClientFactory; |
||||
|
this.jsonSerializer = jsonSerializer; |
||||
|
this.appProvider = appProvider; |
||||
|
this.assetQuery = assetQuery; |
||||
|
this.commandBus = commandBus; |
||||
|
this.urlGenerator = urlGenerator; |
||||
|
} |
||||
|
|
||||
|
protected override Task<(string Description, DeepDetectJob Data)> CreateJobAsync(EnrichedEvent @event, DeepDetectAction action) |
||||
|
{ |
||||
|
if (@event is not EnrichedAssetEvent assetEvent) |
||||
|
{ |
||||
|
return Task.FromResult(("Ignore", new DeepDetectJob())); |
||||
|
} |
||||
|
|
||||
|
if (assetEvent.AssetType != AssetType.Image) |
||||
|
{ |
||||
|
return Task.FromResult(("Ignore", new DeepDetectJob())); |
||||
|
} |
||||
|
|
||||
|
var ruleJob = new DeepDetectJob |
||||
|
{ |
||||
|
Actor = assetEvent.Actor, |
||||
|
AppId = assetEvent.AppId.Id, |
||||
|
AssetId = assetEvent.Id, |
||||
|
MaximumTags = action.MaximumTags, |
||||
|
MinimumPropability = action.MinimumProbability, |
||||
|
Url = urlGenerator.AssetContent(assetEvent.AppId, assetEvent.Id.ToString(), assetEvent.FileVersion) |
||||
|
}; |
||||
|
|
||||
|
return Task.FromResult((Description, ruleJob)); |
||||
|
} |
||||
|
|
||||
|
protected override async Task<Result> ExecuteJobAsync(DeepDetectJob job, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(job.Url)) |
||||
|
{ |
||||
|
return Result.Ignored(); |
||||
|
} |
||||
|
|
||||
|
var httpClient = httpClientFactory.CreateClient("DeepDetect"); |
||||
|
|
||||
|
var response = await httpClient.PostAsJsonAsync("predict", new |
||||
|
{ |
||||
|
service = "squidexdetector", |
||||
|
output = new |
||||
|
{ |
||||
|
best = job.MaximumTags, |
||||
|
confidence_threshold = job.MinimumPropability / 100d, |
||||
|
}, |
||||
|
data = new[] |
||||
|
{ |
||||
|
job.Url, |
||||
|
} |
||||
|
}, ct); |
||||
|
|
||||
|
var body = await response.Content.ReadAsStringAsync(ct); |
||||
|
|
||||
|
if (!response.IsSuccessStatusCode) |
||||
|
{ |
||||
|
return Result.Failed(new InvalidOperationException($"Failed with status code {response.StatusCode}\n\n{body}")); |
||||
|
} |
||||
|
|
||||
|
var responseJson = jsonSerializer.Deserialize<DetectResponse>(body); |
||||
|
|
||||
|
var tags = responseJson!.Body.Predictions.SelectMany(x => x.Classes); |
||||
|
|
||||
|
if (!tags.Any()) |
||||
|
{ |
||||
|
return Result.Success(body); |
||||
|
} |
||||
|
|
||||
|
var app = await appProvider.GetAppAsync(job.AppId, true, ct); |
||||
|
if (app == null) |
||||
|
{ |
||||
|
return Result.Failed(new InvalidOperationException("App not found.")); |
||||
|
} |
||||
|
|
||||
|
var context = Context.Admin(app); |
||||
|
|
||||
|
var asset = await assetQuery.FindAsync(context, job.AssetId, ct: ct); |
||||
|
if (asset == null) |
||||
|
{ |
||||
|
return Result.Failed(new InvalidOperationException("Asset not found.")); |
||||
|
} |
||||
|
|
||||
|
var command = new AnnotateAsset |
||||
|
{ |
||||
|
Tags = asset.TagNames, |
||||
|
AssetId = asset.AssetId, |
||||
|
AppId = asset.AppId, |
||||
|
Actor = job.Actor, |
||||
|
FromRule = true |
||||
|
}; |
||||
|
|
||||
|
foreach (var tag in tags) |
||||
|
{ |
||||
|
var tagParts = tag.Cat.Split(',')[0].Split(' ', StringSplitOptions.RemoveEmptyEntries); |
||||
|
|
||||
|
if (IdRegex().IsMatch(tagParts[0])) |
||||
|
{ |
||||
|
tagParts = tagParts.Skip(1).ToArray(); |
||||
|
} |
||||
|
|
||||
|
var tagName = string.Join('_', tagParts.Select(x => x.Slugify())); |
||||
|
|
||||
|
command.Tags.Add($"ai/{tagName}"); |
||||
|
} |
||||
|
|
||||
|
await commandBus.PublishAsync(command, ct); |
||||
|
return Result.Success(body); |
||||
|
} |
||||
|
|
||||
|
private sealed class DetectResponse |
||||
|
{ |
||||
|
public DetectBody Body { get; set; } |
||||
|
} |
||||
|
|
||||
|
private sealed class DetectBody |
||||
|
{ |
||||
|
public DetectPredications[] Predictions { get; set; } |
||||
|
} |
||||
|
|
||||
|
private sealed class DetectPredications |
||||
|
{ |
||||
|
public DetectClass[] Classes { get; set; } |
||||
|
} |
||||
|
|
||||
|
private sealed class DetectClass |
||||
|
{ |
||||
|
public double Prob { get; set; } |
||||
|
|
||||
|
public string Cat { get; set; } |
||||
|
} |
||||
|
|
||||
|
[GeneratedRegex("^n[0-9]+$")]
|
||||
|
private static partial Regex IdRegex(); |
||||
|
} |
||||
|
|
||||
|
public sealed class DeepDetectJob |
||||
|
{ |
||||
|
public DomainId AppId { get; set; } |
||||
|
|
||||
|
public DomainId AssetId { get; set; } |
||||
|
|
||||
|
public RefToken Actor { get; set; } |
||||
|
|
||||
|
public long MaximumTags { get; set; } |
||||
|
|
||||
|
public long MinimumPropability { get; set; } |
||||
|
|
||||
|
public string? Url { get; set; } |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Microsoft.Extensions.Configuration; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Squidex.Infrastructure.Plugins; |
||||
|
|
||||
|
namespace Squidex.Extensions.Actions.DeepDetect; |
||||
|
|
||||
|
internal class DeepDetectPlugin : IPlugin |
||||
|
{ |
||||
|
public void ConfigureServices(IServiceCollection services, IConfiguration config) |
||||
|
{ |
||||
|
var url = config.GetValue<string>("deepdetect:url"); |
||||
|
|
||||
|
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
services.AddHttpClient("DeepDetect", client => |
||||
|
{ |
||||
|
client.BaseAddress = uri; |
||||
|
}); |
||||
|
|
||||
|
services.AddRuleAction<DeepDetectAction, DeepDetectActionHandler>(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,66 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using MongoDB.Bson; |
||||
|
using MongoDB.Bson.Serialization.Attributes; |
||||
|
using MongoDB.Driver; |
||||
|
using Squidex.Domain.Apps.Entities.Rules; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Migrations; |
||||
|
using Squidex.Infrastructure.MongoDb; |
||||
|
|
||||
|
namespace Migrations.Migrations.MongoDb; |
||||
|
|
||||
|
public sealed class CopyRuleStatistics : IMigration |
||||
|
{ |
||||
|
private readonly IMongoDatabase database; |
||||
|
private readonly IRuleUsageTracker ruleUsageTracker; |
||||
|
|
||||
|
[BsonIgnoreExtraElements] |
||||
|
public class Document |
||||
|
{ |
||||
|
public DomainId AppId { get; private set; } |
||||
|
|
||||
|
public DomainId RuleId { get; private set; } |
||||
|
|
||||
|
public int NumFailed { get; private set; } |
||||
|
|
||||
|
public int NumSucceeded { get; private set; } |
||||
|
} |
||||
|
|
||||
|
public CopyRuleStatistics(IMongoDatabase database, IRuleUsageTracker ruleUsageTracker) |
||||
|
{ |
||||
|
this.database = database; |
||||
|
this.ruleUsageTracker = ruleUsageTracker; |
||||
|
} |
||||
|
|
||||
|
public async Task UpdateAsync( |
||||
|
CancellationToken ct) |
||||
|
{ |
||||
|
var collectionName = "RuleStatistics"; |
||||
|
|
||||
|
// Do not create the collection if not needed.
|
||||
|
if (!await database.CollectionExistsAsync(collectionName, ct)) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var collection = database.GetCollection<Document>(collectionName); |
||||
|
|
||||
|
await foreach (var document in collection.Find(new BsonDocument()).ToAsyncEnumerable(ct)) |
||||
|
{ |
||||
|
await ruleUsageTracker.TrackAsync( |
||||
|
document.AppId, |
||||
|
document.RuleId, |
||||
|
default, |
||||
|
0, |
||||
|
document.NumSucceeded, |
||||
|
document.NumFailed, |
||||
|
ct); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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; } |
|
||||
} |
|
||||
@ -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(); |
|
||||
} |
|
||||
} |
|
||||
@ -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; } |
|
||||
} |
|
||||
@ -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); |
|
||||
} |
|
||||
} |
|
||||
@ -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; |
|
||||
} |
|
||||
} |
|
||||
@ -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(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -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.QueryReverseAsync(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); |
|
||||
} |
|
||||
} |
|
||||
@ -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; |
|
||||
} |
|
||||
} |
|
||||
@ -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); |
|
||||
} |
|
||||
@ -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); |
|
||||
} |
|
||||
@ -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); |
|
||||
} |
|
||||
} |
|
||||
@ -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; } |
|
||||
} |
|
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue