diff --git a/backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs b/backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs new file mode 100644 index 0000000000..7c90a3a14f --- /dev/null +++ b/backend/FwLite/FwLiteShared.Tests/Sync/SyncServiceTests.cs @@ -0,0 +1,175 @@ +using System.Diagnostics.CodeAnalysis; +using FwLiteShared.Sync; +using LcmCrdt.Changes.Comments; +using MiniLcm.Models; +using SIL.Harmony; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; + +namespace FwLiteShared.Tests.Sync; + +public class SyncServiceTests +{ + [Fact] + public void GetUnreadCommentsFromSyncResults_ReturnsCreatedUserComments() + { + var threadId = Guid.NewGuid(); + var comment = new UserComment + { + Id = Guid.NewGuid(), + CommentThreadId = threadId, + Text = "synced comment" + }; + var commentChange = new CreateUserCommentChange(comment); + var commitId = Guid.NewGuid(); + var commit = new FakeCommit(commitId, new HybridDateTime(DateTimeOffset.UtcNow, 0)) + { + ChangeEntities = + [ + new ChangeEntity + { + Change = commentChange, + CommitId = commitId, + EntityId = commentChange.EntityId, + Index = 0 + }, + new ChangeEntity + { + Change = new EditUserCommentChange(comment.Id, "edited", DateTimeOffset.UtcNow), + CommitId = commitId, + EntityId = comment.Id, + Index = 1 + } + ] + }; + + var results = new SyncResults([commit], [], true); + + var unreadComments = SyncService.GetUnreadCommentsFromSyncResults(results, currentUserId: "current-user").ToArray(); + + unreadComments.Should().ContainSingle() + .Which.Should().Be((comment.Id, threadId)); + } + + [Fact] + public void GetUnreadCommentsFromSyncResults_ExcludesCommentsAuthoredByCurrentUser() + { + var threadId = Guid.NewGuid(); + var mine = new CreateUserCommentChange(new UserComment + { + Id = Guid.NewGuid(), + CommentThreadId = threadId, + Text = "authored by me on another device", + AuthorId = "current-user" + }); + var theirs = new CreateUserCommentChange(new UserComment + { + Id = Guid.NewGuid(), + CommentThreadId = threadId, + Text = "authored by someone else", + AuthorId = "other-user" + }); + var commitId = Guid.NewGuid(); + var commit = new FakeCommit(commitId, new HybridDateTime(DateTimeOffset.UtcNow, 0)) + { + ChangeEntities = + [ + new ChangeEntity { Change = mine, CommitId = commitId, EntityId = mine.EntityId, Index = 0 }, + new ChangeEntity { Change = theirs, CommitId = commitId, EntityId = theirs.EntityId, Index = 1 } + ] + }; + + var results = new SyncResults([commit], [], true); + + var unreadComments = SyncService.GetUnreadCommentsFromSyncResults(results, currentUserId: "current-user").ToArray(); + + unreadComments.Should().ContainSingle("comments authored by other users are unread, the current user's own are not") + .Which.Should().Be((theirs.EntityId, threadId)); + } + + [Fact] + public void GetUnreadCommentsFromSyncResults_WithoutCurrentUser_IncludesCommentWithNullAuthor() + { + var threadId = Guid.NewGuid(); + var comment = new UserComment + { + Id = Guid.NewGuid(), + CommentThreadId = threadId, + Text = "synced comment with no author" + }; + var change = new CreateUserCommentChange(comment); + var commitId = Guid.NewGuid(); + var commit = new FakeCommit(commitId, new HybridDateTime(DateTimeOffset.UtcNow, 0)) + { + ChangeEntities = + [ + new ChangeEntity { Change = change, CommitId = commitId, EntityId = change.EntityId, Index = 0 } + ] + }; + + var results = new SyncResults([commit], [], true); + + var unreadComments = SyncService.GetUnreadCommentsFromSyncResults(results, currentUserId: null).ToArray(); + + unreadComments.Should().ContainSingle("with no current user, even an author-less synced comment is unread") + .Which.Should().Be((comment.Id, threadId)); + } + + [Fact] + public void GetDeletedCommentsFromSyncResults_ReturnsDeletedCommentsAndThreads() + { + var threadId = Guid.NewGuid(); + var commentId = Guid.NewGuid(); + var deletedThreadId = Guid.NewGuid(); + var commitId = Guid.NewGuid(); + var commit = new FakeCommit(commitId, new HybridDateTime(DateTimeOffset.UtcNow, 0)) + { + ChangeEntities = + [ + new ChangeEntity + { + Change = new DeleteChange(commentId), + CommitId = commitId, + EntityId = commentId, + Index = 0 + }, + new ChangeEntity + { + Change = new DeleteChange(deletedThreadId), + CommitId = commitId, + EntityId = deletedThreadId, + Index = 1 + }, + new ChangeEntity + { + Change = new CreateUserCommentChange(new UserComment + { + Id = Guid.NewGuid(), + CommentThreadId = threadId, + Text = "still here" + }), + CommitId = commitId, + EntityId = Guid.NewGuid(), + Index = 2 + } + ] + }; + + var results = new SyncResults([commit], [], true); + + var (deletedCommentIds, deletedThreadIds) = SyncService.GetDeletedCommentsFromSyncResults(results); + + deletedCommentIds.Should().ContainSingle().Which.Should().Be(commentId); + deletedThreadIds.Should().ContainSingle().Which.Should().Be(deletedThreadId); + } + + private class FakeCommit : Commit + { + [SetsRequiredMembers] + public FakeCommit(Guid id, HybridDateTime hybridDateTime) : base(id, "", NullParentHash, hybridDateTime) + { + HybridDateTime = hybridDateTime; + SetParentHash(NullParentHash); + } + } +} diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs index a56aa4817e..a5fcfffb44 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmJsInvokable.cs @@ -21,14 +21,14 @@ MiniLcmApiUserFacingWrappers userFacingWrappers { private readonly IMiniLcmApi _wrappedApi = userFacingWrappers.Apply(api, project, notificationWrapperFactory); - public record MiniLcmFeatures(bool? History, bool? Write, bool? OpenWithFlex, bool? Feedback, bool? Sync, bool? Audio, bool? CustomViews); + public record MiniLcmFeatures(bool? History, bool? Write, bool? OpenWithFlex, bool? Feedback, bool? Sync, bool? Audio, bool? CustomViews, bool? Comments); private bool SupportsSync => project.DataFormat == ProjectDataFormat.Harmony && api is CrdtMiniLcmApi; [JSInvokable] public MiniLcmFeatures SupportedFeatures() { var isCrdtProject = project.DataFormat == ProjectDataFormat.Harmony; var isFwDataProject = project.DataFormat == ProjectDataFormat.FwData; - return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync, Audio: true, CustomViews: isCrdtProject); + return new(History: isCrdtProject, Write: CanWrite, OpenWithFlex: isFwDataProject, Feedback: true, Sync: SupportsSync, Audio: true, CustomViews: isCrdtProject, Comments: isCrdtProject); } private bool CanWrite => @@ -270,6 +270,114 @@ public async Task DeleteCustomView(Guid id) OnDataChanged(); } + [JSInvokable] + public ValueTask GetCommentThreads(SubjectType subjectType, Guid subjectId, bool includeComments = false) + { + return _wrappedApi.GetCommentThreads(subjectType, subjectId, includeComments).ToArrayAsync(); + } + + [JSInvokable] + [TsFunction(Type = "Promise")] + public Task GetCommentThread(Guid id) + { + return _wrappedApi.GetCommentThread(id); + } + + [JSInvokable] + public ValueTask GetUserComments(Guid threadId) + { + return _wrappedApi.GetUserComments(threadId).ToArrayAsync(); + } + + [JSInvokable] + [TsFunction(Type = "Promise")] + public Task GetUserComment(Guid id) + { + return _wrappedApi.GetUserComment(id); + } + + [JSInvokable] + public ValueTask GetUnreadComments(Guid? threadId = null) + { + return _wrappedApi.GetUnreadComments(threadId).ToArrayAsync(); + } + + [JSInvokable] + public ValueTask GetUnreadCommentsForSubject(SubjectType subjectType, Guid subjectId) + { + return _wrappedApi.GetUnreadCommentsForSubject(subjectType, subjectId).ToArrayAsync(); + } + + [JSInvokable] + public Task CountUnreadComments(Guid? threadId = null) + { + return _wrappedApi.CountUnreadComments(threadId); + } + + [JSInvokable] + public async Task CreateCommentThread(CommentThread thread, UserComment firstComment) + { + var createdThread = await _wrappedApi.CreateCommentThread(thread, firstComment); + OnDataChanged(); + return createdThread; + } + + [JSInvokable] + public async Task AddUserComment(Guid threadId, UserComment comment) + { + var createdComment = await _wrappedApi.AddUserComment(threadId, comment); + OnDataChanged(); + return createdComment; + } + + [JSInvokable] + public async Task EditUserComment(Guid commentId, string text) + { + var updatedComment = await _wrappedApi.EditUserComment(commentId, text); + OnDataChanged(); + return updatedComment; + } + + [JSInvokable] + public async Task SetCommentThreadStatus(Guid threadId, ThreadStatus status) + { + var updatedThread = await _wrappedApi.SetCommentThreadStatus(threadId, status); + OnDataChanged(); + return updatedThread; + } + + [JSInvokable] + public async Task DeleteUserComment(Guid commentId) + { + await _wrappedApi.DeleteUserComment(commentId); + OnDataChanged(); + } + + [JSInvokable] + public async Task DeleteCommentThread(Guid threadId) + { + await _wrappedApi.DeleteCommentThread(threadId); + OnDataChanged(); + } + + [JSInvokable] + public Task MarkCommentRead(Guid commentId) + { + return _wrappedApi.MarkCommentRead(commentId); + } + + [JSInvokable] + public Task MarkCommentThreadRead(Guid threadId) + { + return _wrappedApi.MarkCommentThreadRead(threadId); + } + + [JSInvokable] + public Task MarkAllCommentsRead() + { + return _wrappedApi.MarkAllCommentsRead(); + } + [JSInvokable] public async Task CreateEntry(Entry entry, CreateEntryOptions options) { diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index f8501ecac3..e02da191a7 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -4,6 +4,7 @@ using FwLiteShared.Services; using LexCore.Sync; using LcmCrdt; +using LcmCrdt.Changes.Comments; using LcmCrdt.Data; using LcmCrdt.MediaServer; using LcmCrdt.RemoteSync; @@ -28,7 +29,8 @@ public class SyncService( LcmMediaService lcmMediaService, IOptions authOptions, ILogger logger, - SyncRepository syncRepository) + SyncRepository syncRepository, + LocalCommentReadStatusService commentReadStatusService) { public async Task SafeExecuteSync(bool skipNotifications = false) { @@ -105,6 +107,7 @@ public async Task ExecuteSync(bool skipNotifications = false) } logger.LogInformation("Synced project {ProjectName} with server", project.Name); UpdateSyncStatus(SyncStatus.Success); + await ApplySyncedCommentReadStatus(syncResults, project.LastUserId); await syncRepository.UpdateSyncDate(syncDate); // Best-effort: if the push listener failed to start at project-open (e.g. user was offline), this // restarts it now that we know auth + network are healthy. If it's already running, the cache @@ -233,6 +236,37 @@ private async Task SendNotifications(SyncResults syncResults) } } + private async Task ApplySyncedCommentReadStatus(SyncResults syncResults, string? currentUserId) + { + await commentReadStatusService.MarkCommentsUnread(GetUnreadCommentsFromSyncResults(syncResults, currentUserId)); + var (deletedCommentIds, deletedThreadIds) = GetDeletedCommentsFromSyncResults(syncResults); + await commentReadStatusService.RemoveUnreadComments(deletedCommentIds); + foreach (var threadId in deletedThreadIds) + { + await commentReadStatusService.MarkThreadRead(threadId); + } + } + + public static IEnumerable<(Guid CommentId, Guid CommentThreadId)> GetUnreadCommentsFromSyncResults(SyncResults syncResults, string? currentUserId) + { + return syncResults.MissingFromLocal + .SelectMany(c => c.ChangeEntities, (_, change) => change.Change) + .OfType() + //with no current user (not signed in) nothing is "ours", so every synced comment is unread + .Where(change => currentUserId is null || change.AuthorId != currentUserId) + .Select(change => (CommentId: change.EntityId, change.CommentThreadId)); + } + + public static (IEnumerable CommentIds, IEnumerable ThreadIds) GetDeletedCommentsFromSyncResults( + SyncResults syncResults) + { + var changes = syncResults.MissingFromLocal + .SelectMany(c => c.ChangeEntities, (_, change) => change.Change); + var commentIds = changes.OfType>().Select(change => change.EntityId); + var threadIds = changes.OfType>().Select(change => change.EntityId); + return (commentIds, threadIds); + } + private async IAsyncEnumerable GetEntryId(IObjectWithId? entity) { switch (entity) diff --git a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs index 7fbeef2a93..18bdb27bac 100644 --- a/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs +++ b/backend/FwLite/FwLiteShared/TypeGen/ReinforcedFwLiteTypingConfig.cs @@ -118,6 +118,8 @@ private static void ConfigureMiniLcmTypes(ConfigurationBuilder builder) builder.ExportAsEnum(); builder.ExportAsEnum().UseString(); builder.ExportAsEnum().UseString(); + builder.ExportAsEnum().UseString(); + builder.ExportAsEnum().UseString(); builder.ExportAsInterface() .FlattenHierarchy() .WithPublicProperties() diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt b/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt index b28168b1ba..d9295d1c9a 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.latest.verified.txt @@ -1293,5 +1293,58 @@ { "$type": "SetMainPublicationChange", "EntityId": "c775e310-fc5c-6d01-5a02-146d579a88e5" + }, + { + "$type": "CreateCommentThreadChange", + "SubjectId": "31bb6dfb-9987-6fd7-9ee3-92d6bce75611", + "SubjectType": "ExampleSentence", + "Status": "Closed", + "AuthorId": null, + "AuthorName": null, + "CreatedAt": "2026-07-01T17:07:13.0291493+07:00", + "UpdatedAt": "2026-07-02T07:20:03.8842896+07:00", + "EntityId": "ed481334-f9d2-ec59-fa0d-70d7dc2e2aa2" + }, + { + "$type": "CreateCommentThreadChange", + "SubjectId": "1fc16c5c-ea2b-92b8-9af6-26404afcf856", + "SubjectType": "Unknown", + "Status": "Closed", + "AuthorId": "Licensed Steel Chicken", + "AuthorName": "Loop", + "CreatedAt": "2026-07-06T10:16:45.6785696+07:00", + "UpdatedAt": "2026-07-05T21:39:14.8355266+07:00", + "EntityId": "3381486c-2101-43ff-b508-82bd515eb950" + }, + { + "$type": "CreateUserCommentChange", + "CommentThreadId": "a82b4c24-dec6-38c9-b69f-97b78457a395", + "PreviousCommentId": "4e472c9a-0274-ad6a-30a5-a89b27e221e0", + "Text": "composite", + "AuthorId": "Bolivar Fuerte", + "AuthorName": "Via", + "CreatedAt": "2026-07-01T21:49:53.1491117+07:00", + "UpdatedAt": "2026-07-02T02:56:57.3826486+07:00", + "EntityId": "1dadd431-cc82-d12c-8cc0-738cf0d15f09" + }, + { + "$type": "EditUserCommentChange", + "Text": "Swiss Franc", + "UpdatedAt": "2026-07-01T19:07:09.8540119+07:00", + "EntityId": "41249114-1628-085e-fddd-a2afa9050490" + }, + { + "$type": "SetCommentThreadStatusChange", + "Status": "Open", + "UpdatedAt": "2026-07-02T00:41:37.9431171+07:00", + "EntityId": "caab8a36-76c5-3ff7-1a98-593b697358c8" + }, + { + "$type": "delete:CommentThread", + "EntityId": "5fd5cf80-bd5f-a0ad-e79c-4bfdb0973f9b" + }, + { + "$type": "delete:UserComment", + "EntityId": "4be242d0-b1a6-986f-cc0d-b35860e69e60" } ] \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.legacy.verified.txt b/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.legacy.verified.txt index 19edc4c876..f05a2d1748 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.legacy.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/Changes/ChangeDeserializationRegressionData.legacy.verified.txt @@ -625,5 +625,49 @@ ], "EntityId": "f27a032b-e0f6-f389-b466-959bdb17086d" } + }, + { + "Input": { + "$type": "CreateCommentThreadChange", + "SubjectId": "31bb6dfb-9987-6fd7-9ee3-92d6bce75611", + "SubjectType": "ExampleSentence", + "Status": "Closed", + "CreatedAt": "2026-07-01T17:07:13.0291493+07:00", + "UpdatedAt": "2026-07-02T07:20:03.8842896+07:00", + "EntityId": "ed481334-f9d2-ec59-fa0d-70d7dc2e2aa2" + }, + "Output": { + "$type": "CreateCommentThreadChange", + "SubjectId": "31bb6dfb-9987-6fd7-9ee3-92d6bce75611", + "SubjectType": "ExampleSentence", + "Status": "Closed", + "AuthorId": null, + "AuthorName": null, + "CreatedAt": "2026-07-01T17:07:13.0291493+07:00", + "UpdatedAt": "2026-07-02T07:20:03.8842896+07:00", + "EntityId": "ed481334-f9d2-ec59-fa0d-70d7dc2e2aa2" + } + }, + { + "Input": { + "$type": "CreateCommentThreadChange", + "SubjectId": "31bb6dfb-9987-6fd7-9ee3-92d6bce75611", + "SubjectType": "ExampleSentence", + "Status": "Closed", + "CreatedAt": "2026-07-01T17:07:13.0291493+07:00", + "UpdatedAt": "2026-07-02T07:20:03.8842896+07:00", + "EntityId": "ed481334-f9d2-ec59-fa0d-70d7dc2e2aa2" + }, + "Output": { + "$type": "CreateCommentThreadChange", + "SubjectId": "31bb6dfb-9987-6fd7-9ee3-92d6bce75611", + "SubjectType": "ExampleSentence", + "Status": "Closed", + "AuthorId": null, + "AuthorName": null, + "CreatedAt": "2026-07-01T17:07:13.0291493+07:00", + "UpdatedAt": "2026-07-02T07:20:03.8842896+07:00", + "EntityId": "ed481334-f9d2-ec59-fa0d-70d7dc2e2aa2" + } } ] \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs index 7b81d89da0..247ca900c3 100644 --- a/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Changes/UseChangesTests.cs @@ -3,6 +3,7 @@ using Bogus; using FluentAssertions.Execution; using LcmCrdt.Changes; +using LcmCrdt.Changes.Comments; using LcmCrdt.Changes.CustomJsonPatches; using LcmCrdt.Changes.Entries; using LcmCrdt.Changes.ExampleSentences; @@ -316,5 +317,38 @@ customView with Analysis = null }); yield return new ChangeWithDependencies(editCustomViewChange, [createCustomViewChange]); + + var commentThread = new CommentThread + { + Id = Guid.NewGuid(), + SubjectId = entry.Id, + SubjectType = SubjectType.Entry, + Status = ThreadStatus.Open, + AuthorId = "author-id", + AuthorName = "Author", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + var createCommentThreadChange = new CreateCommentThreadChange(commentThread); + yield return new ChangeWithDependencies(createCommentThreadChange, [createEntryChange]); + + var userComment = new UserComment + { + Id = Guid.NewGuid(), + CommentThreadId = commentThread.Id, + Text = "Test comment", + AuthorId = "author-id", + AuthorName = "Author", + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + var createUserCommentChange = new CreateUserCommentChange(userComment); + yield return new ChangeWithDependencies(createUserCommentChange, [createCommentThreadChange]); + + var editUserCommentChange = new EditUserCommentChange(userComment.Id, "Updated comment", DateTimeOffset.UtcNow); + yield return new ChangeWithDependencies(editUserCommentChange, [createUserCommentChange]); + + var closeCommentThreadChange = new SetCommentThreadStatusChange(commentThread.Id, ThreadStatus.Closed, DateTimeOffset.UtcNow); + yield return new ChangeWithDependencies(closeCommentThreadChange, [createCommentThreadChange]); } } diff --git a/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs b/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs index de8092deca..ecc27e0cef 100644 --- a/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/ConfigRegistrationTests.cs @@ -15,6 +15,8 @@ public class ConfigRegistrationTests typeof(JsonPatchChange), //not supported typeof(JsonPatchChange), //replaced by JsonPatchExampleSentenceChange typeof(JsonPatchChange), //not supported. Use EditCustomViewChange + typeof(JsonPatchChange), //not supported. Use SetCommentThreadStatusChange + typeof(JsonPatchChange), //not supported. Use EditUserCommentChange typeof(DeleteChange), //MorphTypes cannot be deleted ]; diff --git a/backend/FwLite/LcmCrdt.Tests/Data/BaseSerializationTest.cs b/backend/FwLite/LcmCrdt.Tests/Data/BaseSerializationTest.cs index 93d2eda5a4..520a70bb73 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/BaseSerializationTest.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/BaseSerializationTest.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using LcmCrdt.Changes; +using MiniLcm.Models; using MiniLcm.Tests.AutoFakerHelpers; using Soenneker.Utils.AutoBogus; using Soenneker.Utils.AutoBogus.Config; @@ -64,6 +65,14 @@ private static AutoFakerConfig GetAutoFakerConfig() } morphType.Id = canonical.Id; } + }, true), + new SimpleOverride(context => + { + if (context.Instance is CommentThread thread && thread.Comments is { Count: > 0 }) + { + foreach (var comment in thread.Comments) + comment.CommentThreadId = thread.Id; + } }, true) ); return config; diff --git a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt index b7e45c5eb4..da0cb7652f 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.latest.verified.txt @@ -3935,5 +3935,159 @@ }, "Id": "edd0c7a1-e37c-c67b-7522-eed5ec86fde8", "DeletedAt": null + }, + { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "CommentThread", + "Id": "8f911a35-f5c5-f686-4ae7-51edce857df1", + "DeletedAt": null, + "SubjectId": "53b9eb28-12b0-2840-eaf2-7fc58c40a185", + "SubjectType": "Sense", + "Status": "Open", + "AuthorId": null, + "AuthorName": null, + "CreatedAt": "2026-07-02T09:46:27.5880032+07:00", + "UpdatedAt": "2026-07-02T04:31:08.1939565+07:00", + "Comments": [ + { + "Id": "3f947d22-70e0-d30d-62c5-2b50f0ad7030", + "DeletedAt": null, + "CommentThreadId": "b0f6e6fe-e118-26b7-b286-9db69a140309", + "PreviousCommentId": "dfcc8e8c-2ec6-c2d4-a794-53a5a5915b50", + "Text": "microchip", + "AuthorId": "bus", + "AuthorName": "Saint Helena Pound", + "CreatedAt": "2026-07-01T18:18:32.8141612+07:00", + "UpdatedAt": "2026-07-02T11:46:28.7172532+07:00" + } + ] + }, + "Id": "8f911a35-f5c5-f686-4ae7-51edce857df1", + "DeletedAt": null + }, + { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "CommentThread", + "Id": "e1e30272-9a49-2644-eef3-f36caa061eae", + "DeletedAt": null, + "SubjectId": "bbd65c4d-2448-6222-a207-d301428d881d", + "SubjectType": "ExampleSentence", + "Status": "Closed", + "AuthorId": "back-end", + "AuthorName": "Fantastic Rubber Tuna", + "CreatedAt": "2026-07-05T12:17:02.0060498+07:00", + "UpdatedAt": "2026-07-06T04:04:55.5381107+07:00", + "Comments": [ + { + "Id": "33961abf-15c1-71b8-ff24-be0bd5987b6f", + "DeletedAt": null, + "CommentThreadId": "e1e30272-9a49-2644-eef3-f36caa061eae", + "PreviousCommentId": "6f6d1c85-1d21-3659-a8be-234ba476d167", + "Text": "Awesome Soft Hat", + "AuthorId": "Strategist", + "AuthorName": "redundant", + "CreatedAt": "2026-07-05T17:15:38.2078204+07:00", + "UpdatedAt": "2026-07-06T00:23:27.650184+07:00" + } + ] + }, + "Id": "e1e30272-9a49-2644-eef3-f36caa061eae", + "DeletedAt": null + }, + { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "UserComment", + "Id": "85ef40c2-e168-7cdd-e302-d3edc18c38a5", + "DeletedAt": null, + "CommentThreadId": "bcea6bb9-1378-e3cd-b9d0-7c87b2ae9968", + "PreviousCommentId": "b87d624d-9827-4172-0beb-7d1f2d8e028c", + "Text": "target", + "AuthorId": "strategy", + "AuthorName": "Internal", + "CreatedAt": "2026-07-02T14:57:23.0439447+07:00", + "UpdatedAt": "2026-07-02T12:32:51.0578643+07:00" + }, + "Id": "85ef40c2-e168-7cdd-e302-d3edc18c38a5", + "DeletedAt": null + }, + { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "CommentThread", + "Id": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "DeletedAt": null, + "SubjectId": "8f24e142-5373-8369-f828-417b81833e27", + "SubjectType": "Entry", + "Status": "Open", + "AuthorId": null, + "AuthorName": null, + "CreatedAt": "2026-07-03T04:57:05.9016384+07:00", + "UpdatedAt": "2026-07-03T16:30:59.5749065+07:00", + "Comments": [ + { + "Id": "39408d18-4d46-a112-78eb-24c7c00d8d12", + "DeletedAt": null, + "CommentThreadId": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "PreviousCommentId": "b8099120-2b7c-5e86-da14-f5ab0e1b4bcd", + "Text": "Lek", + "AuthorId": "Cross-platform", + "AuthorName": "Functionality", + "CreatedAt": "2026-07-02T23:31:01.4636808+07:00", + "UpdatedAt": "2026-07-03T03:11:05.4871024+07:00" + } + ] + }, + "Id": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "DeletedAt": null + }, + { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "CommentThread", + "Id": "2afa7c96-656a-536d-705f-b7a60a64bed2", + "DeletedAt": null, + "SubjectId": "2fac2398-0791-6c39-9d6e-fae54133d59f", + "SubjectType": "ExampleSentence", + "Status": "Open", + "AuthorId": "Frozen", + "AuthorName": "Handcrafted Soft Chips", + "CreatedAt": "2026-07-06T10:31:41.0420447+07:00", + "UpdatedAt": "2026-07-05T13:20:51.1147632+07:00", + "Comments": [ + { + "Id": "c830a70c-ae94-2b3f-6994-cc17334649df", + "DeletedAt": null, + "CommentThreadId": "2afa7c96-656a-536d-705f-b7a60a64bed2", + "PreviousCommentId": "040ce2bb-b5e4-e532-8f65-5f25062e79fb", + "Text": "withdrawal", + "AuthorId": "Human", + "AuthorName": "Island", + "CreatedAt": "2026-07-06T02:54:36.0758935+07:00", + "UpdatedAt": "2026-07-05T23:32:25.6629513+07:00" + } + ] + }, + "Id": "2afa7c96-656a-536d-705f-b7a60a64bed2", + "DeletedAt": null + }, + { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "UserComment", + "Id": "4f5808a8-bbf1-e23d-e5fe-5a997f13bf3a", + "DeletedAt": null, + "CommentThreadId": "fdff5d2b-4405-84fa-40b9-bfa2c1302828", + "PreviousCommentId": "1e01381d-2b48-63f5-bc31-99710cea2695", + "Text": "Phased", + "AuthorId": "driver", + "AuthorName": "Home Loan Account", + "CreatedAt": "2026-07-02T22:19:48.4007941+07:00", + "UpdatedAt": "2026-07-03T13:54:23.9312531+07:00" + }, + "Id": "4f5808a8-bbf1-e23d-e5fe-5a997f13bf3a", + "DeletedAt": null } ] \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.legacy.verified.txt b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.legacy.verified.txt index a3fe21d28d..625c969cc8 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.legacy.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationRegressionData.legacy.verified.txt @@ -3900,5 +3900,125 @@ "Id": "853b91e6-d738-7d5d-cc98-e62904160c35", "DeletedAt": null } + }, + { + "Input": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "CommentThread", + "Id": "8f911a35-f5c5-f686-4ae7-51edce857df1", + "DeletedAt": null, + "SubjectId": "53b9eb28-12b0-2840-eaf2-7fc58c40a185", + "SubjectType": "Sense", + "Status": "Open", + "CreatedAt": "2026-07-02T09:46:27.5880032+07:00", + "UpdatedAt": "2026-07-02T04:31:08.1939565+07:00", + "Comments": [ + { + "Id": "3f947d22-70e0-d30d-62c5-2b50f0ad7030", + "DeletedAt": null, + "CommentThreadId": "b0f6e6fe-e118-26b7-b286-9db69a140309", + "PreviousCommentId": "dfcc8e8c-2ec6-c2d4-a794-53a5a5915b50", + "Text": "microchip", + "AuthorId": "bus", + "AuthorName": "Saint Helena Pound", + "CreatedAt": "2026-07-01T18:18:32.8141612+07:00", + "UpdatedAt": "2026-07-02T11:46:28.7172532+07:00" + } + ] + }, + "Id": "8f911a35-f5c5-f686-4ae7-51edce857df1", + "DeletedAt": null + }, + "Output": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "CommentThread", + "Id": "8f911a35-f5c5-f686-4ae7-51edce857df1", + "DeletedAt": null, + "SubjectId": "53b9eb28-12b0-2840-eaf2-7fc58c40a185", + "SubjectType": "Sense", + "Status": "Open", + "AuthorId": null, + "AuthorName": null, + "CreatedAt": "2026-07-02T09:46:27.5880032+07:00", + "UpdatedAt": "2026-07-02T04:31:08.1939565+07:00", + "Comments": [ + { + "Id": "3f947d22-70e0-d30d-62c5-2b50f0ad7030", + "DeletedAt": null, + "CommentThreadId": "b0f6e6fe-e118-26b7-b286-9db69a140309", + "PreviousCommentId": "dfcc8e8c-2ec6-c2d4-a794-53a5a5915b50", + "Text": "microchip", + "AuthorId": "bus", + "AuthorName": "Saint Helena Pound", + "CreatedAt": "2026-07-01T18:18:32.8141612+07:00", + "UpdatedAt": "2026-07-02T11:46:28.7172532+07:00" + } + ] + }, + "Id": "8f911a35-f5c5-f686-4ae7-51edce857df1", + "DeletedAt": null + } + }, + { + "Input": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "CommentThread", + "Id": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "DeletedAt": null, + "SubjectId": "8f24e142-5373-8369-f828-417b81833e27", + "SubjectType": "Entry", + "Status": "Open", + "CreatedAt": "2026-07-03T04:57:05.9016384+07:00", + "UpdatedAt": "2026-07-03T16:30:59.5749065+07:00", + "Comments": [ + { + "Id": "39408d18-4d46-a112-78eb-24c7c00d8d12", + "DeletedAt": null, + "CommentThreadId": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "PreviousCommentId": "b8099120-2b7c-5e86-da14-f5ab0e1b4bcd", + "Text": "Lek", + "AuthorId": "Cross-platform", + "AuthorName": "Functionality", + "CreatedAt": "2026-07-02T23:31:01.4636808+07:00", + "UpdatedAt": "2026-07-03T03:11:05.4871024+07:00" + } + ] + }, + "Id": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "DeletedAt": null + }, + "Output": { + "$type": "MiniLcmCrdtAdapter", + "Obj": { + "$type": "CommentThread", + "Id": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "DeletedAt": null, + "SubjectId": "8f24e142-5373-8369-f828-417b81833e27", + "SubjectType": "Entry", + "Status": "Open", + "AuthorId": null, + "AuthorName": null, + "CreatedAt": "2026-07-03T04:57:05.9016384+07:00", + "UpdatedAt": "2026-07-03T16:30:59.5749065+07:00", + "Comments": [ + { + "Id": "39408d18-4d46-a112-78eb-24c7c00d8d12", + "DeletedAt": null, + "CommentThreadId": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "PreviousCommentId": "b8099120-2b7c-5e86-da14-f5ab0e1b4bcd", + "Text": "Lek", + "AuthorId": "Cross-platform", + "AuthorName": "Functionality", + "CreatedAt": "2026-07-02T23:31:01.4636808+07:00", + "UpdatedAt": "2026-07-03T03:11:05.4871024+07:00" + } + ] + }, + "Id": "b14e4cf6-2b50-74a4-e21e-482b38efd849", + "DeletedAt": null + } } ] \ No newline at end of file diff --git a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationTests.cs b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationTests.cs index e6ee0f894a..efb93edb3d 100644 --- a/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationTests.cs +++ b/backend/FwLite/LcmCrdt.Tests/Data/SnapshotDeserializationTests.cs @@ -37,6 +37,21 @@ private static IObjectBase GenerateSnapshotForType(Type type) } } + [Fact] + public void AutoFaker_GeneratedCommentThread_CommentsHaveMatchingThreadId() + { + for (var i = 0; i < 20; i++) + { + var thread = Faker.Generate(); + if (thread.Comments is not { Count: > 0 }) continue; + + thread.Comments.Should().OnlyContain(comment => comment.CommentThreadId == thread.Id); + return; + } + + throw new Exception("AutoFaker did not generate a CommentThread with comments after 20 attempts"); + } + [Fact] public void CanDeserializeProjectDumpRegressionData() { diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt index f11904f409..4d9da1f85e 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyChangeModels.verified.txt @@ -220,6 +220,30 @@ DerivedType: DeleteChange, TypeDiscriminator: delete:CustomView }, + { + DerivedType: CreateCommentThreadChange, + TypeDiscriminator: CreateCommentThreadChange + }, + { + DerivedType: CreateUserCommentChange, + TypeDiscriminator: CreateUserCommentChange + }, + { + DerivedType: EditUserCommentChange, + TypeDiscriminator: EditUserCommentChange + }, + { + DerivedType: SetCommentThreadStatusChange, + TypeDiscriminator: SetCommentThreadStatusChange + }, + { + DerivedType: DeleteChange, + TypeDiscriminator: delete:CommentThread + }, + { + DerivedType: DeleteChange, + TypeDiscriminator: delete:UserComment + }, { DerivedType: CreateMorphTypeChange, TypeDiscriminator: CreateMorphTypeChange diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt index 6eaccd9213..d17e1227f1 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyDbModel.verified.txt @@ -1,4 +1,20 @@ Model: + EntityType: UnreadComment + Properties: + CommentId (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd + CommentThreadId (Guid) Required Index + MarkedUnreadAt (DateTimeOffset) Required + Keys: + CommentId PK + Indexes: + CommentThreadId + Annotations: + Relational:FunctionName: + Relational:Schema: + Relational:SqlQuery: + Relational:TableName: UnreadComments + Relational:ViewName: + Relational:ViewSchema: EntityType: EntrySearchRecord Properties: Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd @@ -38,6 +54,35 @@ Relational:TableName: ProjectData Relational:ViewName: Relational:ViewSchema: + EntityType: CommentThread + Properties: + Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd + AuthorId (string) + AuthorName (string) + CreatedAt (DateTimeOffset) Required Index + DeletedAt (DateTimeOffset?) + SnapshotId (no field, Guid?) Shadow FK Index + Status (ThreadStatus) Required + SubjectId (Guid) Required Index + SubjectType (SubjectType) Required Index + UpdatedAt (DateTimeOffset) Required + Navigations: + Comments (List) Collection ToDependent UserComment + Keys: + Id PK + Foreign keys: + CommentThread {'SnapshotId'} -> ObjectSnapshot {'Id'} Unique SetNull + Indexes: + CreatedAt + SnapshotId Unique + SubjectType, SubjectId + Annotations: + Relational:FunctionName: + Relational:Schema: + Relational:SqlQuery: + Relational:TableName: CommentThread + Relational:ViewName: + Relational:ViewSchema: EntityType: ComplexFormComponent Properties: Id (_id, Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd @@ -344,6 +389,34 @@ Relational:TableName: Sense Relational:ViewName: Relational:ViewSchema: + EntityType: UserComment + Properties: + Id (Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd + AuthorId (string) + AuthorName (string) + CommentThreadId (Guid) Required FK Index + CreatedAt (DateTimeOffset) Required Index + DeletedAt (DateTimeOffset?) + PreviousCommentId (Guid?) + SnapshotId (no field, Guid?) Shadow FK Index + Text (string) Required + UpdatedAt (DateTimeOffset) Required + Keys: + Id PK + Foreign keys: + UserComment {'CommentThreadId'} -> CommentThread {'Id'} Required Cascade ToDependent: Comments + UserComment {'SnapshotId'} -> ObjectSnapshot {'Id'} Unique SetNull + Indexes: + CommentThreadId + CreatedAt + SnapshotId Unique + Annotations: + Relational:FunctionName: + Relational:Schema: + Relational:SqlQuery: + Relational:TableName: UserComment + Relational:ViewName: + Relational:ViewSchema: EntityType: WritingSystem Properties: Id (_id, Guid) Required PK AfterSave:Throw ValueGenerated.OnAdd diff --git a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt index 048b425e86..c94eb71fdf 100644 --- a/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt +++ b/backend/FwLite/LcmCrdt.Tests/DataModelSnapshotTests.VerifyIObjectWithIdModels.verified.txt @@ -40,6 +40,14 @@ DerivedType: CustomView, TypeDiscriminator: CustomView }, + { + DerivedType: CommentThread, + TypeDiscriminator: CommentThread + }, + { + DerivedType: UserComment, + TypeDiscriminator: UserComment + }, { DerivedType: MorphType, TypeDiscriminator: MorphType diff --git a/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs new file mode 100644 index 0000000000..963dc74176 --- /dev/null +++ b/backend/FwLite/LcmCrdt.Tests/MiniLcmTests/CommentTests.cs @@ -0,0 +1,480 @@ +using FluentValidation; +using LcmCrdt.Data; +using Microsoft.EntityFrameworkCore; + +namespace LcmCrdt.Tests.MiniLcmTests; + +public class CommentTests(MiniLcmApiFixture fixture) : IClassFixture +{ + private async Task SetCurrentUser(string userId, string? name = null) + { + var projectService = fixture.GetService(); + await projectService.UpdateLastUser(name ?? userId, userId); + await projectService.UpdateUserRole(UserProjectRole.Editor); + } + + private Task MarkCommentsUnread(params UserComment[] comments) + { + var readStatusService = fixture.GetService(); + return readStatusService.MarkCommentsUnread(comments.Select(comment => (comment.Id, comment.CommentThreadId))); + } + + private Task ClearUnreadComments() + { + return fixture.Api.MarkAllCommentsRead(); + } + + private async Task ClearLastUserId() + { + await fixture.DbContext.ProjectData.ExecuteUpdateAsync(calls => calls + .SetProperty(p => p.LastUserId, (string?)null) + .SetProperty(p => p.LastUserName, (string?)null)); + await fixture.GetService().RefreshProjectData(); + } + + private static CommentThread NewThread(SubjectType subjectType = SubjectType.Entry, Guid? subjectId = null) + { + return new CommentThread + { + Id = Guid.NewGuid(), + SubjectType = subjectType, + SubjectId = subjectId ?? Guid.NewGuid() + }; + } + + private static UserComment NewComment(string text = "Comment", Guid? id = null) + { + return new UserComment + { + Id = id ?? Guid.NewGuid(), + Text = text + }; + } + + private async Task<(CommentThread Thread, UserComment FirstComment)> CreateThreadWithComment( + string authorId, + SubjectType subjectType = SubjectType.Entry, + Guid? subjectId = null, + string text = "First") + { + await SetCurrentUser(authorId); + var thread = await fixture.Api.CreateCommentThread(NewThread(subjectType, subjectId), NewComment(text)); + var firstComment = (await fixture.Api.GetUserComments(thread.Id).ToArrayAsync()).Single(); + return (thread, firstComment); + } + + [Fact] + public async Task CreateThreadWithFirstComment_PopulatesAuthorAndTimestamps() + { + var authorId = $"author-{Guid.NewGuid()}"; + await SetCurrentUser(authorId, "Author Name"); + + var thread = await fixture.Api.CreateCommentThread(NewThread(), NewComment("hello")); + var comments = await fixture.Api.GetUserComments(thread.Id).ToArrayAsync(); + + thread.Status.Should().Be(ThreadStatus.Open); + thread.CreatedAt.Should().NotBe(default); + thread.AuthorId.Should().Be(authorId); + thread.AuthorName.Should().Be("Author Name"); + comments.Should().ContainSingle(); + comments[0].Text.Should().Be("hello"); + comments[0].AuthorId.Should().Be(authorId); + comments[0].AuthorName.Should().Be("Author Name"); + comments[0].CreatedAt.Should().NotBe(default); + } + + [Fact] + public async Task CreateThreadWithFirstComment_ThreadAndCommentShareAuthor() + { + var authorId = $"author-{Guid.NewGuid()}"; + await SetCurrentUser(authorId, "Thread Author"); + + var thread = await fixture.Api.CreateCommentThread(NewThread(), NewComment("hello")); + var reloaded = await fixture.Api.GetCommentThread(thread.Id); + + reloaded.Should().NotBeNull(); + reloaded!.AuthorId.Should().Be(authorId); + reloaded.AuthorName.Should().Be("Thread Author"); + } + + [Fact] + public async Task RepliesEditStatusAndDeleteComment_Work() + { + var authorId = $"author-{Guid.NewGuid()}"; + var (thread, firstComment) = await CreateThreadWithComment(authorId); + + var reply = await fixture.Api.AddUserComment(thread.Id, NewComment("reply") with + { + PreviousCommentId = firstComment.Id + }); + var edited = await fixture.Api.EditUserComment(reply.Id, "edited reply"); + var closed = await fixture.Api.SetCommentThreadStatus(thread.Id, ThreadStatus.Closed); + await fixture.Api.DeleteUserComment(reply.Id); + + edited.Text.Should().Be("edited reply"); + edited.UpdatedAt.Should().BeAfter(edited.CreatedAt); + closed.Status.Should().Be(ThreadStatus.Closed); + var remaining = await fixture.Api.GetUserComments(thread.Id).ToArrayAsync(); + remaining.Should().ContainSingle(c => c.Id == firstComment.Id); + } + + [Fact] + public async Task GetCommentThreads_CanIncludeComments() + { + var authorId = $"author-{Guid.NewGuid()}"; + var subjectId = Guid.NewGuid(); + var (thread, firstComment) = await CreateThreadWithComment(authorId, SubjectType.Entry, subjectId); + var reply = await fixture.Api.AddUserComment(thread.Id, NewComment("reply") with + { + PreviousCommentId = firstComment.Id + }); + var (_, otherComment) = await CreateThreadWithComment(authorId, SubjectType.Entry, Guid.NewGuid(), "other"); + + var withoutComments = await fixture.Api.GetCommentThreads(SubjectType.Entry, subjectId).ToArrayAsync(); + var withComments = await fixture.Api.GetCommentThreads(SubjectType.Entry, subjectId, includeComments: true).ToArrayAsync(); + + withoutComments.Should().ContainSingle(); + withoutComments[0].Comments.Should().BeNull(); + withComments.Should().ContainSingle(); + var includedComments = withComments[0].Comments; + includedComments.Should().NotBeNull(); + includedComments!.Should().Contain(c => c.Id == firstComment.Id); + includedComments.Should().Contain(c => c.Id == reply.Id); + includedComments.Should().NotContain(c => c.Id == otherComment.Id); + } + + [Fact] + public async Task DeleteThread_CascadesToComments() + { + var (thread, _) = await CreateThreadWithComment($"author-{Guid.NewGuid()}"); + await fixture.Api.AddUserComment(thread.Id, NewComment("reply")); + + await fixture.Api.DeleteCommentThread(thread.Id); + + (await fixture.Api.GetCommentThread(thread.Id)).Should().BeNull(); + (await fixture.Api.GetUserComments(thread.Id).ToArrayAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task DeletingParentEntry_DoesNotDeleteThreadOrComments() + { + var authorId = $"author-{Guid.NewGuid()}"; + await SetCurrentUser(authorId); + var entry = await fixture.Api.CreateEntry(new Entry + { + Id = Guid.NewGuid(), + LexemeForm = { { "en", "commented entry" } } + }); + var (thread, firstComment) = await CreateThreadWithComment(authorId, SubjectType.Entry, entry.Id); + + await fixture.Api.DeleteEntry(entry.Id); + + (await fixture.Api.GetCommentThread(thread.Id)).Should().NotBeNull(); + (await fixture.Api.GetUserComment(firstComment.Id)).Should().NotBeNull(); + } + + [Fact] + public async Task CanCreateThreadForAlreadyDeletedSubject() + { + var authorId = $"author-{Guid.NewGuid()}"; + await SetCurrentUser(authorId); + var entry = await fixture.Api.CreateEntry(new Entry + { + Id = Guid.NewGuid(), + LexemeForm = { { "en", "deleted entry" } } + }); + await fixture.Api.DeleteEntry(entry.Id); + + var thread = await fixture.Api.CreateCommentThread(NewThread(SubjectType.Entry, entry.Id), NewComment("after delete")); + + thread.SubjectId.Should().Be(entry.Id); + (await fixture.Api.GetUserComments(thread.Id).ToArrayAsync()).Should().ContainSingle(); + } + + [Fact] + public async Task ClosedThread_AllowsRepliesAndUnreadTracking() + { + var firstAuthor = $"author-{Guid.NewGuid()}"; + var secondAuthor = $"author-{Guid.NewGuid()}"; + var (thread, _) = await CreateThreadWithComment(firstAuthor); + await fixture.Api.SetCommentThreadStatus(thread.Id, ThreadStatus.Closed); + + await SetCurrentUser(secondAuthor); + var reply = await fixture.Api.AddUserComment(thread.Id, NewComment("late reply")); + await MarkCommentsUnread(reply); + + await SetCurrentUser(firstAuthor); + var comments = await fixture.Api.GetUserComments(thread.Id).ToArrayAsync(); + var unread = await fixture.Api.GetUnreadComments(thread.Id).ToArrayAsync(); + + comments.Should().Contain(c => c.Id == reply.Id); + unread.Should().ContainSingle(c => c.Id == reply.Id); + } + + [Fact] + public async Task SupportsAllSubjectTypes() + { + var authorId = $"author-{Guid.NewGuid()}"; + foreach (var subjectType in Enum.GetValues()) + { + var thread = await CreateThreadWithComment(authorId, subjectType, Guid.NewGuid(), subjectType.ToString()); + thread.Thread.SubjectType.Should().Be(subjectType); + } + } + + [Fact] + public async Task OnlyAuthorCanEditOrDeleteComment() + { + var authorId = $"author-{Guid.NewGuid()}"; + var otherUserId = $"other-{Guid.NewGuid()}"; + var (_, firstComment) = await CreateThreadWithComment(authorId); + + await SetCurrentUser(otherUserId); + + await Assert.ThrowsAsync(() => fixture.Api.EditUserComment(firstComment.Id, "not allowed")); + await Assert.ThrowsAsync(() => fixture.Api.DeleteUserComment(firstComment.Id)); + } + + [Fact] + public async Task ReadStatus_CanMarkOneThreadAndAll() + { + var readerId = $"reader-{Guid.NewGuid()}"; + var authorId = $"author-{Guid.NewGuid()}"; + await ClearUnreadComments(); + var (thread1, firstComment1) = await CreateThreadWithComment(authorId); + var reply1 = await fixture.Api.AddUserComment(thread1.Id, NewComment("reply 1")); + var (thread2, firstComment2) = await CreateThreadWithComment(authorId); + await MarkCommentsUnread(firstComment1, reply1, firstComment2); + + await SetCurrentUser(readerId); + (await fixture.Api.CountUnreadComments()).Should().Be(3); + + await fixture.Api.MarkCommentRead(firstComment1.Id); + (await fixture.Api.CountUnreadComments()).Should().Be(2); + (await fixture.Api.CountUnreadComments(thread1.Id)).Should().Be(1); + + await fixture.Api.MarkCommentThreadRead(thread1.Id); + (await fixture.Api.CountUnreadComments()).Should().Be(1); + (await fixture.Api.GetUnreadComments().ToArrayAsync()).Should().ContainSingle(c => c.Id == firstComment2.Id); + (await fixture.Api.CountUnreadComments(thread2.Id)).Should().Be(1); + + await fixture.Api.MarkAllCommentsRead(); + (await fixture.Api.CountUnreadComments()).Should().Be(0); + (await fixture.Api.GetUnreadComments().ToArrayAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task ReadStatus_CanGetUnreadCommentsBySubject() + { + var readerId = $"reader-{Guid.NewGuid()}"; + var authorId = $"author-{Guid.NewGuid()}"; + var subjectId = Guid.NewGuid(); + await ClearUnreadComments(); + var (_, firstComment) = await CreateThreadWithComment(authorId, SubjectType.Entry, subjectId, "target 1"); + var (_, secondComment) = await CreateThreadWithComment(authorId, SubjectType.Entry, subjectId, "target 2"); + var (_, otherSubjectComment) = await CreateThreadWithComment(authorId, SubjectType.Entry, Guid.NewGuid(), "other subject"); + var (_, otherTypeComment) = await CreateThreadWithComment(authorId, SubjectType.Sense, subjectId, "other type"); + await MarkCommentsUnread(firstComment, secondComment, otherSubjectComment, otherTypeComment); + + await SetCurrentUser(readerId); + var unread = await fixture.Api.GetUnreadCommentsForSubject(SubjectType.Entry, subjectId).ToArrayAsync(); + + unread.Should().HaveCount(2); + unread.Should().Contain(c => c.Id == firstComment.Id); + unread.Should().Contain(c => c.Id == secondComment.Id); + unread.Should().NotContain(c => c.Id == otherSubjectComment.Id); + unread.Should().NotContain(c => c.Id == otherTypeComment.Id); + } + + [Fact] + public async Task ReadStatus_IsLocalNotPerUser() + { + var reader1 = $"reader-{Guid.NewGuid()}"; + var reader2 = $"reader-{Guid.NewGuid()}"; + var authorId = $"author-{Guid.NewGuid()}"; + await ClearUnreadComments(); + var (_, firstComment) = await CreateThreadWithComment(authorId); + await MarkCommentsUnread(firstComment); + + await SetCurrentUser(reader1); + await fixture.Api.MarkCommentRead(firstComment.Id); + + await SetCurrentUser(reader2); + (await fixture.Api.GetUnreadComments().ToArrayAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task MarkCommentsUnread_ConcurrentDuplicate_DoesNotThrowAndLeavesSingleRow() + { + var authorId = $"author-{Guid.NewGuid()}"; + await ClearUnreadComments(); + var (thread, firstComment) = await CreateThreadWithComment(authorId); + var readStatusService = fixture.GetService(); + var commentRef = (firstComment.Id, firstComment.CommentThreadId); + + var tasks = Enumerable.Range(0, 10) + .Select(_ => readStatusService.MarkCommentsUnread([commentRef])) + .ToArray(); + + await Task.WhenAll(tasks); + + fixture.DbContext.UnreadComments.Count(c => c.CommentId == firstComment.Id).Should().Be(1); + (await readStatusService.CountUnreadComments(thread.Id)).Should().Be(1); + } + + [Fact] + public async Task MarkCommentsUnread_BatchWithPartialDuplicate_InsertsRemainingNewComments() + { + var authorId = $"author-{Guid.NewGuid()}"; + await ClearUnreadComments(); + var (_, comment1) = await CreateThreadWithComment(authorId, text: "one"); + var (_, comment2) = await CreateThreadWithComment(authorId, text: "two"); + var (_, comment3) = await CreateThreadWithComment(authorId, text: "three"); + var readStatusService = fixture.GetService(); + var batch = new[] + { + (comment1.Id, comment1.CommentThreadId), + (comment2.Id, comment2.CommentThreadId), + (comment3.Id, comment3.CommentThreadId) + }; + + await Task.WhenAll( + readStatusService.MarkCommentsUnread(batch), + readStatusService.MarkCommentsUnread([(comment1.Id, comment1.CommentThreadId)])); + + (await readStatusService.CountUnreadComments()).Should().Be(3); + fixture.DbContext.UnreadComments.Select(c => c.CommentId) + .Should().BeEquivalentTo([comment1.Id, comment2.Id, comment3.Id]); + } + + [Fact] + public async Task CreateCommentThread_WithoutLastUserId_ThrowsValidationException() + { + await ClearLastUserId(); + + var act = () => fixture.Api.CreateCommentThread(NewThread(), NewComment("hello")); + + await act.Should().ThrowAsync() + .WithMessage("*known user identity*"); + } + + [Fact] + public async Task AddUserComment_WithoutLastUserId_ThrowsValidationException() + { + var authorId = $"author-{Guid.NewGuid()}"; + var (thread, _) = await CreateThreadWithComment(authorId); + await ClearLastUserId(); + + var act = () => fixture.Api.AddUserComment(thread.Id, NewComment("reply")); + + await act.Should().ThrowAsync() + .WithMessage("*known user identity*"); + } + + [Fact] + public async Task ReadStatus_IncomingCommentsBecomeUnreadAndMarkingIsIdempotent() + { + var readerId = $"reader-{Guid.NewGuid()}"; + var authorId = $"author-{Guid.NewGuid()}"; + await ClearUnreadComments(); + var (thread, _) = await CreateThreadWithComment(authorId); + + await SetCurrentUser(authorId); + var comment = await fixture.Api.AddUserComment(thread.Id, NewComment("arrived later")); + await MarkCommentsUnread(comment); + await MarkCommentsUnread(comment); + + await SetCurrentUser(readerId); + (await fixture.Api.GetUnreadComments(thread.Id).ToArrayAsync()).Should().ContainSingle(c => c.Id == comment.Id); + + await fixture.Api.MarkCommentRead(comment.Id); + await fixture.Api.MarkCommentRead(comment.Id); + + (await fixture.Api.GetUnreadComments(thread.Id).ToArrayAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task ReadStatus_LocalCreatedCommentsStartRead() + { + var authorId = $"author-{Guid.NewGuid()}"; + await ClearUnreadComments(); + var (thread, firstComment) = await CreateThreadWithComment(authorId); + await fixture.Api.AddUserComment(thread.Id, NewComment("reply")); + + (await fixture.Api.GetUnreadComments().ToArrayAsync()).Should().BeEmpty(); + fixture.DbContext.UnreadComments.Should().BeEmpty(); + (await fixture.Api.GetUserComment(firstComment.Id)).Should().NotBeNull(); + } + + [Fact] + public async Task GetCommentThreads_CommentsAreSortedChronologically() + { + var authorId = $"author-{Guid.NewGuid()}"; + await SetCurrentUser(authorId); + var subjectId = Guid.NewGuid(); + var baseTime = DateTimeOffset.UtcNow.AddHours(-5); + var threadId = Guid.NewGuid(); + var firstCommentId = Guid.NewGuid(); + var secondCommentId = Guid.NewGuid(); + var thirdCommentId = Guid.NewGuid(); + + await fixture.Api.CreateCommentThread( + NewThread(SubjectType.Entry, subjectId) with { Id = threadId }, + NewComment("first") with + { + Id = firstCommentId, + CreatedAt = baseTime, + UpdatedAt = baseTime + }); + await fixture.Api.AddUserComment(threadId, NewComment("third") with + { + Id = thirdCommentId, + CreatedAt = baseTime.AddHours(2), + UpdatedAt = baseTime.AddHours(2) + }); + await fixture.Api.AddUserComment(threadId, NewComment("second") with + { + Id = secondCommentId, + CreatedAt = baseTime.AddHours(1), + UpdatedAt = baseTime.AddHours(1) + }); + + var thread = (await fixture.Api.GetCommentThreads(SubjectType.Entry, subjectId, includeComments: true) + .ToArrayAsync()).Single(); + var commentIds = thread.Comments!.Select(c => c.Id).ToArray(); + + commentIds.Should().Equal([firstCommentId, secondCommentId, thirdCommentId]); + } + + [Fact] + public async Task ReadStatus_DeletedThreadRemovesUnreadRows() + { + var authorId = $"author-{Guid.NewGuid()}"; + await ClearUnreadComments(); + var (thread, firstComment) = await CreateThreadWithComment(authorId); + var reply = await fixture.Api.AddUserComment(thread.Id, NewComment("reply")); + await MarkCommentsUnread(firstComment, reply); + (await fixture.Api.CountUnreadComments(thread.Id)).Should().Be(2); + + await fixture.Api.DeleteCommentThread(thread.Id); + + (await fixture.Api.GetUnreadComments(thread.Id).ToArrayAsync()).Should().BeEmpty(); + (await fixture.Api.CountUnreadComments(thread.Id)).Should().Be(0); + fixture.DbContext.UnreadComments.Where(c => c.CommentThreadId == thread.Id).Should().BeEmpty(); + } + + [Fact] + public async Task ReadStatus_DeletedUnreadCommentsDoNotInflateUnreadResults() + { + var authorId = $"author-{Guid.NewGuid()}"; + await ClearUnreadComments(); + var (thread, firstComment) = await CreateThreadWithComment(authorId); + await MarkCommentsUnread(firstComment); + (await fixture.Api.CountUnreadComments(thread.Id)).Should().Be(1); + + await fixture.Api.DeleteUserComment(firstComment.Id); + + (await fixture.Api.GetUnreadComments(thread.Id).ToArrayAsync()).Should().BeEmpty(); + (await fixture.Api.CountUnreadComments(thread.Id)).Should().Be(0); + } +} diff --git a/backend/FwLite/LcmCrdt/Changes/Comments/CreateCommentThreadChange.cs b/backend/FwLite/LcmCrdt/Changes/Comments/CreateCommentThreadChange.cs new file mode 100644 index 0000000000..7c5378c139 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Changes/Comments/CreateCommentThreadChange.cs @@ -0,0 +1,52 @@ +using System.Text.Json.Serialization; +using SIL.Harmony; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Entities; + +namespace LcmCrdt.Changes.Comments; + +public class CreateCommentThreadChange : CreateChange, ISelfNamedType +{ + public CreateCommentThreadChange(CommentThread thread) : base(thread.Id == Guid.Empty ? Guid.NewGuid() : thread.Id) + { + thread.Id = EntityId; + SubjectId = thread.SubjectId; + SubjectType = thread.SubjectType; + Status = thread.Status; + AuthorId = thread.AuthorId; + AuthorName = thread.AuthorName; + CreatedAt = thread.CreatedAt == default ? DateTimeOffset.UtcNow : thread.CreatedAt; + UpdatedAt = thread.UpdatedAt == default ? CreatedAt : thread.UpdatedAt; + } + + [JsonConstructor] + private CreateCommentThreadChange(Guid entityId, Guid subjectId, SubjectType subjectType) : base(entityId) + { + SubjectId = subjectId; + SubjectType = subjectType; + } + + public Guid SubjectId { get; init; } + public SubjectType SubjectType { get; init; } + public ThreadStatus Status { get; set; } + public string? AuthorId { get; set; } + public string? AuthorName { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public override ValueTask NewEntity(Commit commit, IChangeContext context) + { + return ValueTask.FromResult(new CommentThread + { + Id = EntityId, + SubjectId = SubjectId, + SubjectType = SubjectType, + Status = Status, + AuthorId = AuthorId, + AuthorName = AuthorName, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt + }); + } +} diff --git a/backend/FwLite/LcmCrdt/Changes/Comments/CreateUserCommentChange.cs b/backend/FwLite/LcmCrdt/Changes/Comments/CreateUserCommentChange.cs new file mode 100644 index 0000000000..63d1f983d1 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Changes/Comments/CreateUserCommentChange.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; +using SIL.Harmony; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Entities; + +namespace LcmCrdt.Changes.Comments; + +public class CreateUserCommentChange : CreateChange, ISelfNamedType +{ + public CreateUserCommentChange(UserComment comment) : base(comment.Id == Guid.Empty ? Guid.NewGuid() : comment.Id) + { + comment.Id = EntityId; + CommentThreadId = comment.CommentThreadId; + PreviousCommentId = comment.PreviousCommentId; + Text = comment.Text; + AuthorId = comment.AuthorId; + AuthorName = comment.AuthorName; + CreatedAt = comment.CreatedAt == default ? DateTimeOffset.UtcNow : comment.CreatedAt; + UpdatedAt = comment.UpdatedAt == default ? CreatedAt : comment.UpdatedAt; + } + + [JsonConstructor] + private CreateUserCommentChange(Guid entityId, Guid commentThreadId, string text) : base(entityId) + { + CommentThreadId = commentThreadId; + Text = text; + } + + public Guid CommentThreadId { get; init; } + public Guid? PreviousCommentId { get; set; } + public string Text { get; set; } + public string? AuthorId { get; set; } + public string? AuthorName { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public override async ValueTask NewEntity(Commit commit, IChangeContext context) + { + return new UserComment + { + Id = EntityId, + CommentThreadId = CommentThreadId, + PreviousCommentId = PreviousCommentId, + Text = Text, + AuthorId = AuthorId, + AuthorName = AuthorName, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = await context.IsObjectDeleted(CommentThreadId) ? commit.DateTime : null + }; + } +} diff --git a/backend/FwLite/LcmCrdt/Changes/Comments/EditUserCommentChange.cs b/backend/FwLite/LcmCrdt/Changes/Comments/EditUserCommentChange.cs new file mode 100644 index 0000000000..5a9738617a --- /dev/null +++ b/backend/FwLite/LcmCrdt/Changes/Comments/EditUserCommentChange.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Entities; + +namespace LcmCrdt.Changes.Comments; + +public class EditUserCommentChange : EditChange, ISelfNamedType +{ + public EditUserCommentChange(Guid entityId, string text, DateTimeOffset updatedAt) : base(entityId) + { + Text = text; + UpdatedAt = updatedAt; + } + + [JsonConstructor] + private EditUserCommentChange(Guid entityId, string text) : base(entityId) + { + Text = text; + } + + public string Text { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public override ValueTask ApplyChange(UserComment entity, IChangeContext context) + { + entity.Text = Text; + entity.UpdatedAt = UpdatedAt; + return ValueTask.CompletedTask; + } +} diff --git a/backend/FwLite/LcmCrdt/Changes/Comments/SetCommentThreadStatusChange.cs b/backend/FwLite/LcmCrdt/Changes/Comments/SetCommentThreadStatusChange.cs new file mode 100644 index 0000000000..fe1b26ab77 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Changes/Comments/SetCommentThreadStatusChange.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; +using SIL.Harmony.Changes; +using SIL.Harmony.Core; +using SIL.Harmony.Entities; + +namespace LcmCrdt.Changes.Comments; + +public class SetCommentThreadStatusChange : EditChange, ISelfNamedType +{ + public SetCommentThreadStatusChange(Guid entityId, ThreadStatus status, DateTimeOffset updatedAt) : base(entityId) + { + Status = status; + UpdatedAt = updatedAt; + } + + [JsonConstructor] + private SetCommentThreadStatusChange(Guid entityId, ThreadStatus status) : base(entityId) + { + Status = status; + } + + public ThreadStatus Status { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public override ValueTask ApplyChange(CommentThread entity, IChangeContext context) + { + entity.Status = Status; + entity.UpdatedAt = UpdatedAt; + return ValueTask.CompletedTask; + } +} diff --git a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs index 1aa48ada4a..72e3f85cc8 100644 --- a/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs +++ b/backend/FwLite/LcmCrdt/CrdtMiniLcmApi.cs @@ -1,7 +1,9 @@ using System.Data; +using FluentValidation; using SIL.Harmony; using SIL.Harmony.Changes; using LcmCrdt.Changes; +using LcmCrdt.Changes.Comments; using LcmCrdt.Changes.CustomJsonPatches; using LcmCrdt.Changes.Entries; using LcmCrdt.Changes.ExampleSentences; @@ -30,6 +32,7 @@ public class CrdtMiniLcmApi( IOptions config, ILogger logger, LcmMediaService lcmMediaService, + LocalCommentReadStatusService commentReadStatusService, CommitMetadataInterceptor commitMetadataInterceptor, EntrySearchService? entrySearchService = null) : IMiniLcmApi { @@ -1065,6 +1068,174 @@ private void AssertManagerRoleForCustomViewWrite() $"Only managers can manage custom views."); } + public async IAsyncEnumerable GetCommentThreads(SubjectType subjectType, Guid subjectId, bool includeComments = false) + { + await using var repo = await repoFactory.CreateRepoAsync(); + var threads = repo.CommentThreads + .Where(t => t.SubjectType == subjectType && t.SubjectId == subjectId); + if (includeComments) + { + threads = Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.Include(threads, t => t.Comments!.OrderBy(c => c.CreatedAt).ThenBy(c => c.Id)); + } + + threads = threads.OrderBy(t => t.CreatedAt).ThenBy(t => t.Id); + await foreach (var thread in threads.AsAsyncEnumerable()) + { + yield return thread; + } + } + + public async Task GetCommentThread(Guid id) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.GetCommentThread(id); + } + + public async IAsyncEnumerable GetUserComments(Guid threadId) + { + await using var repo = await repoFactory.CreateRepoAsync(); + var comments = repo.UserComments + .Where(c => c.CommentThreadId == threadId) + .OrderBy(c => c.CreatedAt).ThenBy(c => c.Id); + await foreach (var comment in comments.AsAsyncEnumerable()) + { + yield return comment; + } + } + + public async Task GetUserComment(Guid id) + { + await using var repo = await repoFactory.CreateRepoAsync(); + return await repo.GetUserComment(id); + } + + public async IAsyncEnumerable GetUnreadComments(Guid? threadId = null) + { + foreach (var comment in await commentReadStatusService.GetUnreadComments(threadId)) + { + yield return comment; + } + } + + public async IAsyncEnumerable GetUnreadCommentsForSubject(SubjectType subjectType, Guid subjectId) + { + foreach (var comment in await commentReadStatusService.GetUnreadCommentsForSubject(subjectType, subjectId)) + { + yield return comment; + } + } + + public Task CountUnreadComments(Guid? threadId = null) + { + return commentReadStatusService.CountUnreadComments(threadId); + } + + public async Task CreateCommentThread(CommentThread thread, UserComment firstComment) + { + if (thread.Id == Guid.Empty) thread.Id = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + StampCommentThreadAuthor(thread, now); + firstComment.CommentThreadId = thread.Id; + StampCommentAuthor(firstComment, now); + + await AddChanges([ + new CreateCommentThreadChange(thread), + new CreateUserCommentChange(firstComment) + ]); + return await GetCommentThread(thread.Id) ?? throw NotFoundException.ForType(thread.Id); + } + + public async Task AddUserComment(Guid threadId, UserComment comment) + { + await using var repo = await repoFactory.CreateRepoAsync(); + _ = await repo.GetCommentThread(threadId) ?? throw NotFoundException.ForType(threadId); + comment.CommentThreadId = threadId; + StampCommentAuthor(comment, DateTimeOffset.UtcNow); + + await AddChange(new CreateUserCommentChange(comment)); + return await repo.GetUserComment(comment.Id) ?? throw NotFoundException.ForType(comment.Id); + } + + public async Task EditUserComment(Guid commentId, string text) + { + await using var repo = await repoFactory.CreateRepoAsync(); + var comment = await repo.GetUserComment(commentId) ?? throw NotFoundException.ForType(commentId); + AssertCurrentUserCanChangeComment(comment); + await AddChange(new EditUserCommentChange(commentId, text, DateTimeOffset.UtcNow)); + return await repo.GetUserComment(commentId) ?? throw NotFoundException.ForType(commentId); + } + + public async Task SetCommentThreadStatus(Guid threadId, ThreadStatus status) + { + await using var repo = await repoFactory.CreateRepoAsync(); + _ = await repo.GetCommentThread(threadId) ?? throw NotFoundException.ForType(threadId); + await AddChange(new SetCommentThreadStatusChange(threadId, status, DateTimeOffset.UtcNow)); + return await repo.GetCommentThread(threadId) ?? throw NotFoundException.ForType(threadId); + } + + public async Task DeleteUserComment(Guid commentId) + { + await using var repo = await repoFactory.CreateRepoAsync(); + var comment = await repo.GetUserComment(commentId) ?? throw NotFoundException.ForType(commentId); + AssertCurrentUserCanChangeComment(comment); + await AddChange(new DeleteChange(commentId)); + await commentReadStatusService.RemoveUnreadComments([commentId]); + } + + public async Task DeleteCommentThread(Guid threadId) + { + await using var repo = await repoFactory.CreateRepoAsync(); + _ = await repo.GetCommentThread(threadId) ?? throw NotFoundException.ForType(threadId); + await AddChange(new DeleteChange(threadId)); + await commentReadStatusService.MarkThreadRead(threadId); + } + + public Task MarkCommentRead(Guid commentId) + { + return commentReadStatusService.MarkCommentRead(commentId); + } + + public Task MarkCommentThreadRead(Guid threadId) + { + return commentReadStatusService.MarkThreadRead(threadId); + } + + public Task MarkAllCommentsRead() + { + return commentReadStatusService.MarkAllRead(); + } + + private void StampCommentThreadAuthor(CommentThread thread, DateTimeOffset now) + { + if (thread.Id == Guid.Empty) thread.Id = Guid.NewGuid(); + thread.AuthorId = RequireCommentUserId(); + thread.AuthorName = ProjectData.LastUserName; + thread.CreatedAt = thread.CreatedAt == default ? now : thread.CreatedAt; + thread.UpdatedAt = thread.UpdatedAt == default ? thread.CreatedAt : thread.UpdatedAt; + } + + private void StampCommentAuthor(UserComment comment, DateTimeOffset now) + { + if (comment.Id == Guid.Empty) comment.Id = Guid.NewGuid(); + comment.AuthorId = RequireCommentUserId(); + comment.AuthorName = ProjectData.LastUserName; + comment.CreatedAt = comment.CreatedAt == default ? now : comment.CreatedAt; + comment.UpdatedAt = comment.UpdatedAt == default ? comment.CreatedAt : comment.UpdatedAt; + } + + private string RequireCommentUserId() + { + if (string.IsNullOrEmpty(ProjectData.LastUserId)) + throw new ValidationException("Cannot create or modify comments without a known user identity."); + return ProjectData.LastUserId; + } + + private void AssertCurrentUserCanChangeComment(UserComment comment) + { + if (comment.AuthorId == RequireCommentUserId()) return; + throw new UnauthorizedAccessException("Only the comment author can edit or delete this comment."); + } + public void Dispose() { } diff --git a/backend/FwLite/LcmCrdt/Data/LocalCommentReadStatusService.cs b/backend/FwLite/LcmCrdt/Data/LocalCommentReadStatusService.cs new file mode 100644 index 0000000000..dd5a200b1b --- /dev/null +++ b/backend/FwLite/LcmCrdt/Data/LocalCommentReadStatusService.cs @@ -0,0 +1,128 @@ +using LcmCrdt.Utils; +using Microsoft.EntityFrameworkCore; + +namespace LcmCrdt.Data; + +public class LocalCommentReadStatusService(IDbContextFactory dbContextFactory) +{ + public async Task GetUnreadComments(Guid? threadId = null) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + var query = UnreadComments(dbContext); + if (threadId is not null) + query = query.Where(c => c.CommentThreadId == threadId); + return await query.ToArrayAsync(); + } + + public async Task GetUnreadCommentsForSubject(SubjectType subjectType, Guid subjectId) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + var query = + from unreadComment in UnreadComments(dbContext) + join thread in dbContext.CommentThreads on unreadComment.CommentThreadId equals thread.Id + where thread.SubjectType == subjectType && thread.SubjectId == subjectId + select unreadComment; + return await query.ToArrayAsync(); + } + + public async Task CountUnreadComments(Guid? threadId = null) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + var query = UnreadComments(dbContext); + if (threadId is not null) + query = query.Where(c => c.CommentThreadId == threadId); + return await query.CountAsync(); + } + + public async Task MarkCommentRead(Guid commentId) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + await dbContext.UnreadComments + .Where(c => c.CommentId == commentId) + .ExecuteDeleteAsync(); + } + + public async Task MarkThreadRead(Guid threadId) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + await dbContext.UnreadComments + .Where(c => c.CommentThreadId == threadId) + .ExecuteDeleteAsync(); + } + + public async Task MarkAllRead() + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + await dbContext.UnreadComments.ExecuteDeleteAsync(); + } + + public async Task RemoveUnreadComments(IEnumerable commentIds) + { + var ids = commentIds.Distinct().ToArray(); + if (ids.Length == 0) return; + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + await dbContext.UnreadComments + .Where(c => ids.Contains(c.CommentId)) + .ExecuteDeleteAsync(); + } + + public async Task MarkCommentsUnread(IEnumerable<(Guid CommentId, Guid CommentThreadId)> comments) + { + var commentsToMark = comments + .DistinctBy(c => c.CommentId) + .ToArray(); + if (commentsToMark.Length == 0) return; + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + var commentIds = commentsToMark.Select(c => c.CommentId).ToArray(); + var alreadyUnread = await dbContext.UnreadComments + .Where(c => commentIds.Contains(c.CommentId)) + .Select(c => c.CommentId) + .ToArrayAsync(); + var alreadyUnreadSet = alreadyUnread.ToHashSet(); + var now = DateTimeOffset.UtcNow; + var toInsert = commentsToMark + .Where(comment => !alreadyUnreadSet.Contains(comment.CommentId)) + .Select(comment => new UnreadComment + { + CommentId = comment.CommentId, + CommentThreadId = comment.CommentThreadId, + MarkedUnreadAt = now + }) + .ToArray(); + if (toInsert.Length == 0) return; + + dbContext.UnreadComments.AddRange(toInsert); + try + { + await dbContext.SaveChangesAsync(); + } + catch (DbUpdateException e) when (e.CausedByUniqueConstraintViolation()) + { + // Concurrent callers can both pass the existence check; CommentId is the PK. + dbContext.ChangeTracker.Clear(); + foreach (var unread in toInsert) + { + dbContext.UnreadComments.Add(unread); + try + { + await dbContext.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (ex.CausedByUniqueConstraintViolation()) + { + dbContext.ChangeTracker.Clear(); + } + } + } + } + + private static IQueryable UnreadComments(LcmCrdtDbContext dbContext) + { + return + from unread in dbContext.UnreadComments + join comment in dbContext.UserComments on unread.CommentId equals comment.Id + orderby comment.CreatedAt, comment.Id + select comment; + } +} diff --git a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs index 4ed9fb5a2d..afbfe4aa2c 100644 --- a/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs +++ b/backend/FwLite/LcmCrdt/Data/MiniLcmRepository.cs @@ -75,6 +75,8 @@ public void Dispose() public IQueryable PartsOfSpeech => dbContext.PartsOfSpeech; public IQueryable Publications => dbContext.Publications; public IQueryable CustomViews => dbContext.CustomViews; + public IQueryable CommentThreads => dbContext.CommentThreads; + public IQueryable UserComments => dbContext.UserComments; private WritingSystem? _defaultVernacularWs; @@ -314,4 +316,14 @@ public async Task GetEntryIndex(Guid entryId, string? query = null, IndexQu .AsQueryable(), cv => cv.Id == customViewId); return customView; } + + public async Task GetCommentThread(Guid threadId) + { + return await AsyncExtensions.SingleOrDefaultAsync(CommentThreads.AsQueryable(), t => t.Id == threadId); + } + + public async Task GetUserComment(Guid commentId) + { + return await AsyncExtensions.SingleOrDefaultAsync(UserComments.AsQueryable(), c => c.Id == commentId); + } } diff --git a/backend/FwLite/LcmCrdt/Data/UnreadComment.cs b/backend/FwLite/LcmCrdt/Data/UnreadComment.cs new file mode 100644 index 0000000000..0618736a6a --- /dev/null +++ b/backend/FwLite/LcmCrdt/Data/UnreadComment.cs @@ -0,0 +1,8 @@ +namespace LcmCrdt.Data; + +public class UnreadComment +{ + public Guid CommentId { get; set; } + public Guid CommentThreadId { get; set; } + public DateTimeOffset MarkedUnreadAt { get; set; } +} diff --git a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs index af8cd93174..e1fcf6eac3 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtDbContext.cs @@ -1,4 +1,6 @@ +using System.ComponentModel; using System.Text.Json; +using LcmCrdt.Data; using LcmCrdt.FullTextSearch; using SIL.Harmony; using SIL.Harmony.Db; @@ -28,6 +30,9 @@ IOptions options public IQueryable PartsOfSpeech => Set().AsNoTracking(); public IQueryable Publications => Set().AsNoTracking(); public IQueryable CustomViews => Set().AsNoTracking(); + public IQueryable CommentThreads => Set().AsNoTracking(); + public IQueryable UserComments => Set().AsNoTracking(); + public DbSet UnreadComments => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -45,6 +50,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) var morphTypeModel = modelBuilder.Entity(); morphTypeModel.HasIndex(m => m.Kind).IsUnique(); + var unreadCommentModel = modelBuilder.Entity(); + unreadCommentModel.HasKey(c => c.CommentId); + unreadCommentModel.HasIndex(c => c.CommentThreadId); + var senseModel = modelBuilder.Entity(); senseModel.Property(s => s.Pictures).HasColumnType("jsonb").HasDefaultValueSql("'[]'"); } @@ -62,6 +71,8 @@ protected override void ConfigureConventions(ModelConfigurationBuilder builder) .HaveConversion(); builder.Properties() .HaveConversion(); + builder.Properties() + .HaveConversion(); builder.Properties>() .HaveConversion(); } @@ -97,6 +108,11 @@ private class WritingSystemIdConverter() : ValueConverter id.Code, code => new WritingSystemId(code)); + private class DateTimeOffsetDbConverter() : ValueConverter( + d => d.UtcDateTime, + //need to use ticks here because the DateTime is stored as UTC, but the db records it as unspecified + d => new DateTimeOffset(d.Ticks, TimeSpan.Zero)); + private class PictureListDbConverter() : ValueConverter, string>( pic => JsonSerializer.Serialize(pic, (JsonSerializerOptions?)null), json => JsonSerializer.Deserialize>(json, (JsonSerializerOptions?)null) ?? new()); diff --git a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs index d6282371ed..970116a127 100644 --- a/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs +++ b/backend/FwLite/LcmCrdt/LcmCrdtKernel.cs @@ -6,6 +6,7 @@ using SIL.Harmony.Changes; using LcmCrdt.Changes; using LcmCrdt.Changes.CustomJsonPatches; +using LcmCrdt.Changes.Comments; using LcmCrdt.Changes.Entries; using LcmCrdt.Changes.ExampleSentences; using LcmCrdt.Data; @@ -75,6 +76,7 @@ public static IServiceCollection AddLcmCrdtClientCore(this IServiceCollection se services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); @@ -287,6 +289,20 @@ public static void ConfigureCrdt(CrdtConfig config) .HasColumnType("jsonb") .HasConversion(writingSystemArrayConverter); }) + .Add(builder => + { + builder.HasIndex(t => new { t.SubjectType, t.SubjectId }); + builder.HasIndex(t => t.CreatedAt); + builder.HasMany(t => t.Comments) + .WithOne() + .HasForeignKey(c => c.CommentThreadId) + .OnDelete(DeleteBehavior.Cascade); + }) + .Add(builder => + { + builder.HasIndex(c => c.CommentThreadId); + builder.HasIndex(c => c.CreatedAt); + }) .Add() .Add(builder => { @@ -364,6 +380,12 @@ public static void ConfigureCrdt(CrdtConfig config) .Add() .Add() .Add>() + .Add() + .Add() + .Add() + .Add() + .Add>() + .Add>() .Add() .Add>() .Add>() diff --git a/backend/FwLite/LcmCrdt/Migrations/20260629042525_AddComments.Designer.cs b/backend/FwLite/LcmCrdt/Migrations/20260629042525_AddComments.Designer.cs new file mode 100644 index 0000000000..7ab223c8e2 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260629042525_AddComments.Designer.cs @@ -0,0 +1,969 @@ +// +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20260629042525_AddComments")] + partial class AddComments + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("LcmCrdt.Data.UnreadComment", b => + { + b.Property("CommentId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("MarkedUnreadAt") + .HasColumnType("TEXT"); + + b.HasKey("CommentId"); + + b.HasIndex("CommentThreadId"); + + b.ToTable("UnreadComments"); + }); + + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Headword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EntrySearchRecord", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("CommentThread"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Analysis") + .HasColumnType("jsonb"); + + b.Property("Base") + .HasColumnType("INTEGER"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExampleFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SenseFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Vernacular") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("CustomView"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("HomographNumber") + .HasColumnType("INTEGER"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MorphType") + .HasColumnType("INTEGER"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("jsonb"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translations") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Postfix") + .HasColumnType("TEXT"); + + b.Property("Prefix") + .HasColumnType("TEXT"); + + b.Property("SecondaryOrder") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Kind") + .IsUnique(); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("MorphType"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("PreviousCommentId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommentThreadId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("UserComment"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + + b.HasAnnotation("CustomIndex:CompositeIndexes", "[{\"paths\":[\"HybridDateTime.DateTime\",\"HybridDateTime.Counter\",\"Id\"],\"unique\":false,\"name\":\"IX_Commits_DateTime_Counter_Id\"}]"); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.LocalResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LocalResource"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("RemoteResource"); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CommentThread", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CustomView", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.MorphType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.HasOne("MiniLcm.Models.CommentThread", null) + .WithMany() + .HasForeignKey("CommentThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.UserComment", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("SIL.Harmony.Resource.RemoteResource", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260629042525_AddComments.cs b/backend/FwLite/LcmCrdt/Migrations/20260629042525_AddComments.cs new file mode 100644 index 0000000000..69e0d2266f --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260629042525_AddComments.cs @@ -0,0 +1,134 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + public partial class AddComments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CommentThread", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DeletedAt = table.Column(type: "TEXT", nullable: true), + SubjectId = table.Column(type: "TEXT", nullable: false), + SubjectType = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + SnapshotId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CommentThread", x => x.Id); + table.ForeignKey( + name: "FK_CommentThread_Snapshots_SnapshotId", + column: x => x.SnapshotId, + principalTable: "Snapshots", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "UnreadComments", + columns: table => new + { + CommentId = table.Column(type: "TEXT", nullable: false), + CommentThreadId = table.Column(type: "TEXT", nullable: false), + MarkedUnreadAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UnreadComments", x => x.CommentId); + }); + + migrationBuilder.CreateTable( + name: "UserComment", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + DeletedAt = table.Column(type: "TEXT", nullable: true), + CommentThreadId = table.Column(type: "TEXT", nullable: false), + PreviousCommentId = table.Column(type: "TEXT", nullable: true), + Text = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "TEXT", nullable: true), + AuthorName = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false), + SnapshotId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_UserComment", x => x.Id); + table.ForeignKey( + name: "FK_UserComment_CommentThread_CommentThreadId", + column: x => x.CommentThreadId, + principalTable: "CommentThread", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_UserComment_Snapshots_SnapshotId", + column: x => x.SnapshotId, + principalTable: "Snapshots", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_CommentThread_CreatedAt", + table: "CommentThread", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_CommentThread_SnapshotId", + table: "CommentThread", + column: "SnapshotId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CommentThread_SubjectType_SubjectId", + table: "CommentThread", + columns: new[] { "SubjectType", "SubjectId" }); + + migrationBuilder.CreateIndex( + name: "IX_UnreadComments_CommentThreadId", + table: "UnreadComments", + column: "CommentThreadId"); + + migrationBuilder.CreateIndex( + name: "IX_UserComment_CommentThreadId", + table: "UserComment", + column: "CommentThreadId"); + + migrationBuilder.CreateIndex( + name: "IX_UserComment_CreatedAt", + table: "UserComment", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_UserComment_SnapshotId", + table: "UserComment", + column: "SnapshotId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UnreadComments"); + + migrationBuilder.DropTable( + name: "UserComment"); + + migrationBuilder.DropTable( + name: "CommentThread"); + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260706032956_AddCommentThreadAuthor.Designer.cs b/backend/FwLite/LcmCrdt/Migrations/20260706032956_AddCommentThreadAuthor.Designer.cs new file mode 100644 index 0000000000..afb0da4f44 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260706032956_AddCommentThreadAuthor.Designer.cs @@ -0,0 +1,989 @@ +// +using System; +using System.Collections.Generic; +using LcmCrdt; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + [DbContext(typeof(LcmCrdtDbContext))] + [Migration("20260706032956_AddCommentThreadAuthor")] + partial class AddCommentThreadAuthor + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("LcmCrdt.Data.UnreadComment", b => + { + b.Property("CommentId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("MarkedUnreadAt") + .HasColumnType("TEXT"); + + b.HasKey("CommentId"); + + b.HasIndex("CommentThreadId"); + + b.ToTable("UnreadComments"); + }); + + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Headword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("EntrySearchRecord", null, t => + { + t.ExcludeFromMigrations(); + }); + }); + + modelBuilder.Entity("LcmCrdt.ProjectData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FwProjectId") + .HasColumnType("TEXT"); + + b.Property("LastUserId") + .HasColumnType("TEXT"); + + b.Property("LastUserName") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginDomain") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Editor"); + + b.HasKey("Id"); + + b.ToTable("ProjectData"); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("CommentThread"); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ComplexFormEntryId") + .HasColumnType("TEXT"); + + b.Property("ComplexFormHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentEntryId") + .HasColumnType("TEXT"); + + b.Property("ComponentHeadword") + .HasColumnType("TEXT"); + + b.Property("ComponentSenseId") + .HasColumnType("TEXT") + .HasColumnName("ComponentSenseId"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ComponentEntryId"); + + b.HasIndex("ComponentSenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId") + .IsUnique() + .HasFilter("ComponentSenseId IS NULL"); + + b.HasIndex("ComplexFormEntryId", "ComponentEntryId", "ComponentSenseId") + .IsUnique() + .HasFilter("ComponentSenseId IS NOT NULL"); + + b.ToTable("ComplexFormComponents", (string)null); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ComplexFormType"); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Analysis") + .HasColumnType("jsonb"); + + b.Property("Base") + .HasColumnType("INTEGER"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ExampleFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SenseFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Vernacular") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("CustomView"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CitationForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ComplexFormTypes") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("HomographNumber") + .HasColumnType("INTEGER"); + + b.Property("LexemeForm") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LiteralMeaning") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MorphType") + .HasColumnType("INTEGER"); + + b.Property("Note") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("PublishIn") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Entry"); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("Reference") + .HasColumnType("jsonb"); + + b.Property("SenseId") + .HasColumnType("TEXT"); + + b.Property("Sentence") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Translations") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SenseId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("ExampleSentence"); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Postfix") + .HasColumnType("TEXT"); + + b.Property("Prefix") + .HasColumnType("TEXT"); + + b.Property("SecondaryOrder") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Kind") + .IsUnique(); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("MorphType"); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsMain") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Publication"); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Predefined") + .HasColumnType("INTEGER"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("SemanticDomain"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Gloss") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("PartOfSpeechId") + .HasColumnType("TEXT"); + + b.Property("Pictures") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("jsonb") + .HasDefaultValueSql("'[]'"); + + b.Property("SemanticDomains") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntryId"); + + b.HasIndex("PartOfSpeechId"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("Sense"); + }); + + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("PreviousCommentId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommentThreadId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("UserComment"); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Abbreviation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Exemplars") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Font") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("REAL"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WsId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("WsId", "Type") + .IsUnique(); + + b.ToTable("WritingSystem"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ParentHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.ComplexProperty(typeof(Dictionary), "HybridDateTime", "SIL.Harmony.Commit.HybridDateTime#HybridDateTime", b1 => + { + b1.IsRequired(); + + b1.Property("Counter") + .HasColumnType("INTEGER") + .HasColumnName("Counter"); + + b1.Property("DateTime") + .HasColumnType("TEXT") + .HasColumnName("DateTime"); + }); + + b.HasKey("Id"); + + b.ToTable("Commits", (string)null); + + b.HasAnnotation("CustomIndex:CompositeIndexes", "[{\"paths\":[\"HybridDateTime.DateTime\",\"HybridDateTime.Counter\",\"Id\"],\"unique\":false,\"name\":\"IX_Commits_DateTime_Counter_Id\"}]"); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Change") + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.HasKey("CommitId", "Index"); + + b.ToTable("ChangeEntities", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommitId") + .HasColumnType("TEXT"); + + b.Property("Entity") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityIsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsRoot") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("References") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EntityId"); + + b.HasIndex("CommitId", "EntityId") + .IsUnique(); + + b.ToTable("Snapshots", (string)null); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.LocalResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("LocalResource"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("RemoteId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("RemoteResource"); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CommentThread", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Components") + .HasForeignKey("ComplexFormEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("ComplexForms") + .HasForeignKey("ComponentEntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany() + .HasForeignKey("ComponentSenseId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormComponent", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ComplexFormType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ComplexFormType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CustomView", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CustomView", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Entry", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.ExampleSentence", b => + { + b.HasOne("MiniLcm.Models.Sense", null) + .WithMany("ExampleSentences") + .HasForeignKey("SenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.ExampleSentence", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.MorphType", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.MorphType", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.PartOfSpeech", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.PartOfSpeech", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Publication", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Publication", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.SemanticDomain", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.SemanticDomain", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.HasOne("MiniLcm.Models.Entry", null) + .WithMany("Senses") + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MiniLcm.Models.PartOfSpeech", "PartOfSpeech") + .WithMany() + .HasForeignKey("PartOfSpeechId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.Sense", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("PartOfSpeech"); + }); + + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.HasOne("MiniLcm.Models.CommentThread", null) + .WithMany("Comments") + .HasForeignKey("CommentThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.UserComment", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.WritingSystem", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("SIL.Harmony.Core.ChangeEntity", b => + { + b.HasOne("SIL.Harmony.Commit", null) + .WithMany("ChangeEntities") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SIL.Harmony.Db.ObjectSnapshot", b => + { + b.HasOne("SIL.Harmony.Commit", "Commit") + .WithMany("Snapshots") + .HasForeignKey("CommitId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Commit"); + }); + + modelBuilder.Entity("SIL.Harmony.Resource.RemoteResource", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("SIL.Harmony.Resource.RemoteResource", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Navigation("Comments"); + }); + + modelBuilder.Entity("MiniLcm.Models.Entry", b => + { + b.Navigation("ComplexForms"); + + b.Navigation("Components"); + + b.Navigation("Senses"); + }); + + modelBuilder.Entity("MiniLcm.Models.Sense", b => + { + b.Navigation("ExampleSentences"); + }); + + modelBuilder.Entity("SIL.Harmony.Commit", b => + { + b.Navigation("ChangeEntities"); + + b.Navigation("Snapshots"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/20260706032956_AddCommentThreadAuthor.cs b/backend/FwLite/LcmCrdt/Migrations/20260706032956_AddCommentThreadAuthor.cs new file mode 100644 index 0000000000..2e4a062d73 --- /dev/null +++ b/backend/FwLite/LcmCrdt/Migrations/20260706032956_AddCommentThreadAuthor.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LcmCrdt.Migrations +{ + /// + public partial class AddCommentThreadAuthor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AuthorId", + table: "CommentThread", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "AuthorName", + table: "CommentThread", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AuthorId", + table: "CommentThread"); + + migrationBuilder.DropColumn( + name: "AuthorName", + table: "CommentThread"); + } + } +} diff --git a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs index 650a5b9420..ae72a45791 100644 --- a/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs +++ b/backend/FwLite/LcmCrdt/Migrations/LcmCrdtDbContextModelSnapshot.cs @@ -18,6 +18,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + modelBuilder.Entity("LcmCrdt.Data.UnreadComment", b => + { + b.Property("CommentId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("MarkedUnreadAt") + .HasColumnType("TEXT"); + + b.HasKey("CommentId"); + + b.HasIndex("CommentThreadId"); + + b.ToTable("UnreadComments"); + }); + modelBuilder.Entity("LcmCrdt.FullTextSearch.EntrySearchRecord", b => { b.Property("Id") @@ -92,6 +111,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ProjectData"); }); + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("CommentThread"); + }); + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => { b.Property("Id") @@ -114,7 +178,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("ComponentSenseId"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("Order") @@ -149,7 +213,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("Name") @@ -179,7 +243,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Base") .HasColumnType("INTEGER"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("EntryFields") @@ -226,7 +290,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("jsonb"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("HomographNumber") @@ -268,7 +332,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("Order") @@ -311,7 +375,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("jsonb"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("Description") @@ -354,7 +418,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("Name") @@ -381,7 +445,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("IsMain") @@ -412,7 +476,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("Name") @@ -443,7 +507,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("jsonb"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("EntryId") @@ -484,6 +548,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Sense"); }); + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .HasColumnType("TEXT"); + + b.Property("CommentThreadId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("PreviousCommentId") + .HasColumnType("TEXT"); + + b.Property("SnapshotId") + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CommentThreadId"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("SnapshotId") + .IsUnique(); + + b.ToTable("UserComment"); + }); + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => { b.Property("Id") @@ -494,7 +604,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("TEXT"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("Exemplars") @@ -654,7 +764,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("DeletedAt") + b.Property("DeletedAt") .HasColumnType("TEXT"); b.Property("RemoteId") @@ -671,6 +781,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RemoteResource"); }); + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.CommentThread", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + modelBuilder.Entity("MiniLcm.Models.ComplexFormComponent", b => { b.HasOne("MiniLcm.Models.Entry", null) @@ -787,6 +905,20 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("PartOfSpeech"); }); + modelBuilder.Entity("MiniLcm.Models.UserComment", b => + { + b.HasOne("MiniLcm.Models.CommentThread", null) + .WithMany("Comments") + .HasForeignKey("CommentThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) + .WithOne() + .HasForeignKey("MiniLcm.Models.UserComment", "SnapshotId") + .OnDelete(DeleteBehavior.SetNull); + }); + modelBuilder.Entity("MiniLcm.Models.WritingSystem", b => { b.HasOne("SIL.Harmony.Db.ObjectSnapshot", null) @@ -823,6 +955,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.SetNull); }); + modelBuilder.Entity("MiniLcm.Models.CommentThread", b => + { + b.Navigation("Comments"); + }); + modelBuilder.Entity("MiniLcm.Models.Entry", b => { b.Navigation("ComplexForms"); diff --git a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs index 36523faf83..827482aa83 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmReadApi.cs @@ -49,6 +49,34 @@ IAsyncEnumerable GetCustomViews() { throw new NotSupportedException("Custom views are only supported by CRDT projects"); } + IAsyncEnumerable GetCommentThreads(SubjectType subjectType, Guid subjectId, bool includeComments = false) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task GetCommentThread(Guid id) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + IAsyncEnumerable GetUserComments(Guid threadId) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task GetUserComment(Guid id) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + IAsyncEnumerable GetUnreadComments(Guid? threadId = null) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + IAsyncEnumerable GetUnreadCommentsForSubject(SubjectType subjectType, Guid subjectId) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task CountUnreadComments(Guid? threadId = null) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } } public record IndexQueryOptions( diff --git a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs index 310095dd30..a09f955573 100644 --- a/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs +++ b/backend/FwLite/MiniLcm/IMiniLcmWriteApi.cs @@ -174,6 +174,45 @@ Task DeleteCustomView(Guid id) } #endregion + #region Comments + Task CreateCommentThread(CommentThread thread, UserComment firstComment) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task AddUserComment(Guid threadId, UserComment comment) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task EditUserComment(Guid commentId, string text) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task SetCommentThreadStatus(Guid threadId, ThreadStatus status) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task DeleteUserComment(Guid commentId) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task DeleteCommentThread(Guid threadId) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task MarkCommentRead(Guid commentId) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task MarkCommentThreadRead(Guid threadId) + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + Task MarkAllCommentsRead() + { + throw new NotSupportedException("Comments are only supported by CRDT projects"); + } + #endregion + /// /// Imports the provided semantic domains in bulk. diff --git a/backend/FwLite/MiniLcm/Models/Comments.cs b/backend/FwLite/MiniLcm/Models/Comments.cs new file mode 100644 index 0000000000..3f1c9c3508 --- /dev/null +++ b/backend/FwLite/MiniLcm/Models/Comments.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; + +namespace MiniLcm.Models; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ThreadStatus +{ + Open, + Closed +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SubjectType +{ + Unknown = 0, + Entry, + Sense, + ExampleSentence +} + +public record CommentThread : IObjectWithId +{ + public Guid Id { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public Guid SubjectId { get; set; } + public SubjectType SubjectType { get; set; } + public ThreadStatus Status { get; set; } = ThreadStatus.Open; + public string? AuthorId { get; set; } + public string? AuthorName { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public List? Comments { get; set; } + + public Guid[] GetReferences() + { + return []; + } + + public void RemoveReference(Guid id, DateTimeOffset time) + { + } + + public CommentThread Copy() + { + return this with + { + Comments = Comments?.Select(comment => comment.Copy()).ToList() + }; + } +} + +public record UserComment : IObjectWithId +{ + public Guid Id { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public Guid CommentThreadId { get; set; } + public Guid? PreviousCommentId { get; set; } + public required string Text { get; set; } + public string? AuthorId { get; set; } + public string? AuthorName { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public Guid[] GetReferences() + { + return [CommentThreadId]; + } + + public void RemoveReference(Guid id, DateTimeOffset time) + { + if (id == CommentThreadId) + DeletedAt = time; + } + + public UserComment Copy() + { + return this with { }; + } +} diff --git a/backend/FwLite/MiniLcm/Models/IObjectWithId.cs b/backend/FwLite/MiniLcm/Models/IObjectWithId.cs index 606dca4192..4fd7db364a 100644 --- a/backend/FwLite/MiniLcm/Models/IObjectWithId.cs +++ b/backend/FwLite/MiniLcm/Models/IObjectWithId.cs @@ -13,6 +13,8 @@ namespace MiniLcm.Models; [JsonDerivedType(typeof(ComplexFormType), nameof(ComplexFormType))] [JsonDerivedType(typeof(ComplexFormComponent), nameof(ComplexFormComponent))] [JsonDerivedType(typeof(CustomView), nameof(CustomView))] +[JsonDerivedType(typeof(CommentThread), nameof(CommentThread))] +[JsonDerivedType(typeof(UserComment), nameof(UserComment))] [JsonDerivedType(typeof(MorphType), nameof(MorphType))] public interface IObjectWithId { diff --git a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs index dfb231e83d..a43847bb50 100644 --- a/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs +++ b/backend/FwLite/MiniLcm/Normalization/MiniLcmApiWriteNormalizationWrapper.cs @@ -532,6 +532,63 @@ public Task DeleteCustomView(Guid id) #endregion + #region Comments + + public Task CreateCommentThread(CommentThread thread, UserComment firstComment) + { + return _api.CreateCommentThread(thread, NormalizeUserComment(firstComment)); + } + + public Task AddUserComment(Guid threadId, UserComment comment) + { + return _api.AddUserComment(threadId, NormalizeUserComment(comment)); + } + + public Task EditUserComment(Guid commentId, string text) + { + return _api.EditUserComment(commentId, StringNormalizer.Normalize(text)); + } + + public Task SetCommentThreadStatus(Guid threadId, ThreadStatus status) + { + return _api.SetCommentThreadStatus(threadId, status); + } + + public Task DeleteUserComment(Guid commentId) + { + return _api.DeleteUserComment(commentId); + } + + public Task DeleteCommentThread(Guid threadId) + { + return _api.DeleteCommentThread(threadId); + } + + public Task MarkCommentRead(Guid commentId) + { + return _api.MarkCommentRead(commentId); + } + + public Task MarkCommentThreadRead(Guid threadId) + { + return _api.MarkCommentThreadRead(threadId); + } + + public Task MarkAllCommentsRead() + { + return _api.MarkAllCommentsRead(); + } + + private static UserComment NormalizeUserComment(UserComment comment) + { + return comment with + { + Text = StringNormalizer.Normalize(comment.Text) + }; + } + + #endregion + #region File Operations public Task SaveFile(Stream stream, LcmFileMetadata metadata) diff --git a/frontend/viewer/src/lib/components/ui/input-group/index.ts b/frontend/viewer/src/lib/components/ui/input-group/index.ts new file mode 100644 index 0000000000..dfde139344 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/input-group/index.ts @@ -0,0 +1,22 @@ +import Root from './input-group.svelte'; +import Addon from './input-group-addon.svelte'; +import Button from './input-group-button.svelte'; +import Input from './input-group-input.svelte'; +import Text from './input-group-text.svelte'; +import Textarea from './input-group-textarea.svelte'; + +export { + Root, + Addon, + Button, + Input, + Text, + Textarea, + // + Root as InputGroup, + Addon as InputGroupAddon, + Button as InputGroupButton, + Input as InputGroupInput, + Text as InputGroupText, + Textarea as InputGroupTextarea, +}; diff --git a/frontend/viewer/src/lib/components/ui/input-group/input-group-addon.svelte b/frontend/viewer/src/lib/components/ui/input-group/input-group-addon.svelte new file mode 100644 index 0000000000..8ce9e914ca --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/input-group/input-group-addon.svelte @@ -0,0 +1,52 @@ + + + + +
{ + if ((e.target as HTMLElement).closest('button')) { + return; + } + e.currentTarget.parentElement?.querySelector('input')?.focus(); + }} + {...restProps} +> + {@render children?.()} +
diff --git a/frontend/viewer/src/lib/components/ui/input-group/input-group-button.svelte b/frontend/viewer/src/lib/components/ui/input-group/input-group-button.svelte new file mode 100644 index 0000000000..57be2a633d --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/input-group/input-group-button.svelte @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/viewer/src/lib/components/ui/input-group/input-group-input.svelte b/frontend/viewer/src/lib/components/ui/input-group/input-group-input.svelte new file mode 100644 index 0000000000..d4403f6dd4 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/input-group/input-group-input.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/viewer/src/lib/components/ui/input-group/input-group-text.svelte b/frontend/viewer/src/lib/components/ui/input-group/input-group-text.svelte new file mode 100644 index 0000000000..98491a2602 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/input-group/input-group-text.svelte @@ -0,0 +1,22 @@ + + + + {@render children?.()} + diff --git a/frontend/viewer/src/lib/components/ui/input-group/input-group-textarea.svelte b/frontend/viewer/src/lib/components/ui/input-group/input-group-textarea.svelte new file mode 100644 index 0000000000..35e60ac244 --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/input-group/input-group-textarea.svelte @@ -0,0 +1,23 @@ + + +