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