From 988d2268c2ea4d49b19b4c9f06e7bf09d8922b1a Mon Sep 17 00:00:00 2001 From: MOHAMED RASHED Date: Tue, 30 Jun 2026 18:30:16 +0300 Subject: [PATCH] fix/bugs related to ai service and tags insert --- .../ApproveModerationRecordCommandHandler.cs | 21 ++++++++++++++++ .../RejectModerationRecordCommandHandler.cs | 25 +++++++++++++++++++ .../GetPublicPostByIdQueryHandler.cs | 3 +++ .../CreateEvent/CreateEventCommandHandler.cs | 3 +++ .../CreateNews/CreateNewsCommandHandler.cs | 4 +++ .../UpdateEvent/UpdateEventCommandHandler.cs | 13 ++++++++-- .../UpdateNews/UpdateNewsCommandHandler.cs | 14 +++++++++-- .../Moderation/ContentModerationConsumer.cs | 4 ++- .../Moderation/OllamaModerationProvider.cs | 22 +++++++++++++--- ...ntentRejectedAuthorNotificationConsumer.cs | 8 ++++++ 10 files changed, 109 insertions(+), 8 deletions(-) diff --git a/backend/src/CCE.Application/Community/Commands/ApproveModerationRecord/ApproveModerationRecordCommandHandler.cs b/backend/src/CCE.Application/Community/Commands/ApproveModerationRecord/ApproveModerationRecordCommandHandler.cs index a23170ec..d5b84c46 100644 --- a/backend/src/CCE.Application/Community/Commands/ApproveModerationRecord/ApproveModerationRecordCommandHandler.cs +++ b/backend/src/CCE.Application/Community/Commands/ApproveModerationRecord/ApproveModerationRecordCommandHandler.cs @@ -1,5 +1,6 @@ using CCE.Application.Common; using CCE.Application.Common.Interfaces; +using CCE.Application.Common.Realtime; using CCE.Application.Messages; using CCE.Domain.Common; using CCE.Domain.Community; @@ -15,6 +16,7 @@ public sealed class ApproveModerationRecordCommandHandler private readonly ICommunityModerationService _service; private readonly IPostRepository _postRepo; private readonly IRedisFeedStore _feedStore; + private readonly ICommunityRealtimePublisher _realtime; private readonly ICurrentUserAccessor _currentUser; private readonly ISystemClock _clock; private readonly MessageFactory _msg; @@ -24,6 +26,7 @@ public ApproveModerationRecordCommandHandler( ICommunityModerationService service, IPostRepository postRepo, IRedisFeedStore feedStore, + ICommunityRealtimePublisher realtime, ICurrentUserAccessor currentUser, ISystemClock clock, MessageFactory msg) @@ -32,6 +35,7 @@ public ApproveModerationRecordCommandHandler( _service = service; _postRepo = postRepo; _feedStore = feedStore; + _realtime = realtime; _currentUser = currentUser; _clock = clock; _msg = msg; @@ -91,6 +95,14 @@ public async Task> Handle( var publishedOn = post.PublishedOn ?? post.CreatedOn; await _feedStore.AddToCommunityFeedAsync(post.CommunityId, post.Id, publishedOn, cancellationToken).ConfigureAwait(false); await _feedStore.AddToHotLeaderboardAsync(post.CommunityId, post.Id, post.Score, cancellationToken).ConfigureAwait(false); + + // Realtime: content is back — tell viewers (post + community) and moderators. + var restored = RealtimeEnvelope.Wrap(new PostModeratedRealtime(post.Id, null, "Restored")); + await _realtime.PublishToPostAsync(post.Id, RealtimeEvents.PostModerated, restored, cancellationToken).ConfigureAwait(false); + await _realtime.PublishToCommunityAsync(post.CommunityId, RealtimeEvents.PostModerated, restored, cancellationToken).ConfigureAwait(false); + + var moderated = RealtimeEnvelope.Wrap(new ContentModeratedRealtime("Post", post.Id, post.Id, reviewerId, "Restored")); + await _realtime.PublishToModeratorsAsync(RealtimeEvents.ContentModerated, moderated, cancellationToken).ConfigureAwait(false); } } else @@ -120,6 +132,15 @@ public async Task> Handle( await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await _service.ReIndexReplyAsync(existing.ContentId, cancellationToken).ConfigureAwait(false); + + if (wasDeleted) + { + var restored = RealtimeEnvelope.Wrap(new PostModeratedRealtime(reply.PostId, reply.Id, "Restored")); + await _realtime.PublishToPostAsync(reply.PostId, RealtimeEvents.PostModerated, restored, cancellationToken).ConfigureAwait(false); + + var moderated = RealtimeEnvelope.Wrap(new ContentModeratedRealtime("Reply", reply.Id, reply.PostId, reviewerId, "Restored")); + await _realtime.PublishToModeratorsAsync(RealtimeEvents.ContentModerated, moderated, cancellationToken).ConfigureAwait(false); + } } return _msg.Ok(MessageKeys.General.SUCCESS_UPDATED); diff --git a/backend/src/CCE.Application/Community/Commands/RejectModerationRecord/RejectModerationRecordCommandHandler.cs b/backend/src/CCE.Application/Community/Commands/RejectModerationRecord/RejectModerationRecordCommandHandler.cs index d85fb7f9..89470b8c 100644 --- a/backend/src/CCE.Application/Community/Commands/RejectModerationRecord/RejectModerationRecordCommandHandler.cs +++ b/backend/src/CCE.Application/Community/Commands/RejectModerationRecord/RejectModerationRecordCommandHandler.cs @@ -2,6 +2,7 @@ using CCE.Application.Common.Interfaces; using CCE.Application.Common.Messaging; using CCE.Application.Common.Messaging.IntegrationEvents; +using CCE.Application.Common.Realtime; using CCE.Application.Messages; using CCE.Application.Search; using CCE.Domain.Common; @@ -22,6 +23,7 @@ public sealed class RejectModerationRecordCommandHandler private readonly ISearchClient _search; private readonly IRedisFeedStore _feedStore; private readonly IIntegrationEventPublisher _publisher; + private readonly ICommunityRealtimePublisher _realtime; private readonly MessageFactory _msg; public RejectModerationRecordCommandHandler( @@ -33,6 +35,7 @@ public RejectModerationRecordCommandHandler( ISearchClient search, IRedisFeedStore feedStore, IIntegrationEventPublisher publisher, + ICommunityRealtimePublisher realtime, MessageFactory msg) { _db = db; @@ -43,6 +46,7 @@ public RejectModerationRecordCommandHandler( _search = search; _feedStore = feedStore; _publisher = publisher; + _realtime = realtime; _msg = msg; } @@ -103,6 +107,18 @@ await _publisher.PublishAsync(new ContentRejectedIntegrationEvent( // Idempotent side effects — safe to run even if the content was already removed. await _search.DeleteAsync(SearchableType.CommunityPosts, post.Id, cancellationToken).ConfigureAwait(false); await _feedStore.RemovePostFromAllFeedsAsync(post.CommunityId, post.Id, cancellationToken).ConfigureAwait(false); + + // Realtime: tell viewers (post + community rooms) it was removed, and moderators who did it. + // Only on a real takedown — re-rejecting already-removed content needs no broadcast. + if (!wasDeleted) + { + var removed = RealtimeEnvelope.Wrap(new PostModeratedRealtime(post.Id, null, "Rejected")); + await _realtime.PublishToPostAsync(post.Id, RealtimeEvents.PostModerated, removed, cancellationToken).ConfigureAwait(false); + await _realtime.PublishToCommunityAsync(post.CommunityId, RealtimeEvents.PostModerated, removed, cancellationToken).ConfigureAwait(false); + + var moderated = RealtimeEnvelope.Wrap(new ContentModeratedRealtime("Post", post.Id, post.Id, reviewerId, "Rejected")); + await _realtime.PublishToModeratorsAsync(RealtimeEvents.ContentModerated, moderated, cancellationToken).ConfigureAwait(false); + } } else { @@ -135,6 +151,15 @@ await _publisher.PublishAsync(new ContentRejectedIntegrationEvent( await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await _search.DeleteAsync(SearchableType.CommunityReplies, reply.Id, cancellationToken).ConfigureAwait(false); + + if (!wasDeleted) + { + var removed = RealtimeEnvelope.Wrap(new PostModeratedRealtime(reply.PostId, reply.Id, "Rejected")); + await _realtime.PublishToPostAsync(reply.PostId, RealtimeEvents.PostModerated, removed, cancellationToken).ConfigureAwait(false); + + var moderated = RealtimeEnvelope.Wrap(new ContentModeratedRealtime("Reply", reply.Id, reply.PostId, reviewerId, "Rejected")); + await _realtime.PublishToModeratorsAsync(RealtimeEvents.ContentModerated, moderated, cancellationToken).ConfigureAwait(false); + } } return _msg.Ok(MessageKeys.General.SUCCESS_UPDATED); diff --git a/backend/src/CCE.Application/Community/Public/Queries/GetPublicPostById/GetPublicPostByIdQueryHandler.cs b/backend/src/CCE.Application/Community/Public/Queries/GetPublicPostById/GetPublicPostByIdQueryHandler.cs index 0dcaab1b..be1fccf7 100644 --- a/backend/src/CCE.Application/Community/Public/Queries/GetPublicPostById/GetPublicPostByIdQueryHandler.cs +++ b/backend/src/CCE.Application/Community/Public/Queries/GetPublicPostById/GetPublicPostByIdQueryHandler.cs @@ -56,6 +56,7 @@ from ep in epGroup.DefaultIfEmpty() AuthorId = u.Id, AuthorFirst = u.FirstName, AuthorLast = u.LastName, + AuthorEmail = u.Email, u.AvatarUrl, u.PostsCount, u.FollowerCount, p.Type, p.Title, p.Content, p.Locale, p.IsAnswerable, p.AnsweredReplyId, @@ -119,6 +120,8 @@ orderby a.SortOrder var pollSummary = pollsByPost.GetValueOrDefault(raw.Id); var authorName = $"{raw.AuthorFirst} {raw.AuthorLast}".Trim(); + if (string.IsNullOrWhiteSpace(authorName)) + authorName = raw.AuthorEmail ?? string.Empty; var dto = new PostDetailDto( raw.Id, raw.CommunityId, raw.TopicId, new PostAuthorDto(raw.AuthorId, authorName, raw.AvatarUrl, raw.IsExpert, diff --git a/backend/src/CCE.Application/Content/Commands/CreateEvent/CreateEventCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreateEvent/CreateEventCommandHandler.cs index dd8bb72a..7588d4e5 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateEvent/CreateEventCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateEvent/CreateEventCommandHandler.cs @@ -55,6 +55,9 @@ public async Task> Handle(CreateEventCommand request, Cancell { var tags = await _db.Tags.Where(t => request.TagIds.Contains(t.Id)) .ToListAsyncEither(cancellationToken).ConfigureAwait(false); + // Tags load detached (ICceDbContext exposes DbSets AsNoTracking). Attach as Unchanged so + // EF only writes the event_tag join rows instead of INSERTing existing tags (PK violation). + foreach (var tag in tags) _db.Attach(tag); ev.SetTags(tags); } diff --git a/backend/src/CCE.Application/Content/Commands/CreateNews/CreateNewsCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/CreateNews/CreateNewsCommandHandler.cs index 580b6ec5..dc1eefff 100644 --- a/backend/src/CCE.Application/Content/Commands/CreateNews/CreateNewsCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/CreateNews/CreateNewsCommandHandler.cs @@ -58,6 +58,10 @@ public async Task> Handle(CreateNewsCommand request, Cancellat { var tags = await _db.Tags.Where(t => request.TagIds.Contains(t.Id)) .ToListAsyncEither(cancellationToken).ConfigureAwait(false); + // Tags load detached (ICceDbContext exposes DbSets AsNoTracking). Attach as Unchanged so + // EF only writes the news_tag join rows — without this it tries to INSERT the existing + // tags and hits a PK violation on pk_tags. + foreach (var tag in tags) _db.Attach(tag); news.SetTags(tags); } diff --git a/backend/src/CCE.Application/Content/Commands/UpdateEvent/UpdateEventCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdateEvent/UpdateEventCommandHandler.cs index 80783b56..ef095da2 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateEvent/UpdateEventCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateEvent/UpdateEventCommandHandler.cs @@ -56,9 +56,18 @@ public async Task> Handle(UpdateEventCommand request, Cancell if (request.TagIds is not null) { - var tags = await _db.Tags.Where(t => request.TagIds.Contains(t.Id)) + var requested = await _db.Tags.Where(t => request.TagIds.Contains(t.Id)) .ToListAsyncEither(cancellationToken).ConfigureAwait(false); - ev.SetTags(tags); + // Reuse tag instances already tracked via Include(e => e.Tags); attach the rest as + // Unchanged so EF writes only event_tag rows (avoids pk_tags INSERT and double-tracking). + var current = ev.Tags.ToDictionary(t => t.Id); + var resolved = new System.Collections.Generic.List(requested.Count); + foreach (var tag in requested) + { + if (current.TryGetValue(tag.Id, out var tracked)) { resolved.Add(tracked); } + else { _db.Attach(tag); resolved.Add(tag); } + } + ev.SetTags(resolved); } _db.SetExpectedRowVersion(ev, expectedRowVersion); diff --git a/backend/src/CCE.Application/Content/Commands/UpdateNews/UpdateNewsCommandHandler.cs b/backend/src/CCE.Application/Content/Commands/UpdateNews/UpdateNewsCommandHandler.cs index 95402ba2..b13781ae 100644 --- a/backend/src/CCE.Application/Content/Commands/UpdateNews/UpdateNewsCommandHandler.cs +++ b/backend/src/CCE.Application/Content/Commands/UpdateNews/UpdateNewsCommandHandler.cs @@ -53,9 +53,19 @@ public async Task> Handle(UpdateNewsCommand request, Cancellat if (request.TagIds is not null) { - var tags = await _db.Tags.Where(t => request.TagIds.Contains(t.Id)) + var requested = await _db.Tags.Where(t => request.TagIds.Contains(t.Id)) .ToListAsyncEither(cancellationToken).ConfigureAwait(false); - news.SetTags(tags); + // Tags load detached (ICceDbContext is AsNoTracking). Reuse the instances already tracked + // via Include(n => n.Tags); attach the genuinely-new ones as Unchanged. This avoids both + // the pk_tags INSERT (PK violation) and the "instance already tracked" error on re-link. + var current = news.Tags.ToDictionary(t => t.Id); + var resolved = new System.Collections.Generic.List(requested.Count); + foreach (var tag in requested) + { + if (current.TryGetValue(tag.Id, out var tracked)) { resolved.Add(tracked); } + else { _db.Attach(tag); resolved.Add(tag); } + } + news.SetTags(resolved); } _db.SetExpectedRowVersion(news, expectedRowVersion); diff --git a/backend/src/CCE.Infrastructure/Moderation/ContentModerationConsumer.cs b/backend/src/CCE.Infrastructure/Moderation/ContentModerationConsumer.cs index 7a1fd4f5..d655990c 100644 --- a/backend/src/CCE.Infrastructure/Moderation/ContentModerationConsumer.cs +++ b/backend/src/CCE.Infrastructure/Moderation/ContentModerationConsumer.cs @@ -36,7 +36,9 @@ public sealed class ContentModerationConsumer : IConsumer ModerateAsync(string content, CancellationTok internal static string BuildPrompt(string content) => $$""" - You are a content moderator. Reply ONLY with valid JSON and nothing else. - Example: {"safe":true,"confidence":0.95,"category":"safe","reason":""} + You are a strict content moderator for an online knowledge community. + Respond with ONLY a JSON object and nothing else. + Schema: {"safe":,"confidence":<0.0-1.0>,"category":"","reason":""} - Categories: safe, spam, hate, explicit, harassment + Category definitions: + - spam: advertising/promotion, scams, "buy now" pitches, prize/lottery/giveaway bait, get-rich-quick, repeated keywords, or content that is mostly links. + - hate: attacks or dehumanizes people based on a protected trait (race, religion, nationality, gender, etc.). + - explicit: sexual or pornographic content. + - harassment: targeted insults, threats, or bullying of a specific person. + - safe: none of the above. + + Rules: + - If the content shows ANY clear sign of a violation, set safe=false and choose that category. Do NOT default to safe when unsure — lower the confidence instead. + - confidence reflects how certain you are of the classification. + + Examples: + Content: Buy cheap meds now!!! Click here to win a huge prize, limited time offer! + {"safe":false,"confidence":0.96,"category":"spam","reason":"promotional scam with prize bait"} + Content: Urban biodiversity supports sustainable cities and improves air quality for residents. + {"safe":true,"confidence":0.97,"category":"safe","reason":""} Classify this content: {{content}} diff --git a/backend/src/CCE.Infrastructure/Notifications/Messaging/Consumers/ContentRejectedAuthorNotificationConsumer.cs b/backend/src/CCE.Infrastructure/Notifications/Messaging/Consumers/ContentRejectedAuthorNotificationConsumer.cs index 604ddec0..b1c31ed8 100644 --- a/backend/src/CCE.Infrastructure/Notifications/Messaging/Consumers/ContentRejectedAuthorNotificationConsumer.cs +++ b/backend/src/CCE.Infrastructure/Notifications/Messaging/Consumers/ContentRejectedAuthorNotificationConsumer.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using CCE.Application.Common.Messaging.IntegrationEvents; using CCE.Application.Notifications.Messages; using CCE.Domain.Notifications; @@ -35,6 +36,13 @@ await _dispatcher.DispatchAsync(new NotificationMessage( TemplateCode: TemplateCode, RecipientUserId: evt.AuthorId, EventType: NotificationEventType.CommunityContentRejected, + // Parity with the other notifications (post-created/reply): the client builds the + // notification's link/render from MetaData, so always supply the content identifiers. + MetaData: new Dictionary + { + ["contentId"] = evt.ContentId.ToString(), + ["contentType"] = evt.ContentType, + }, Channels: new[] { NotificationChannel.InApp }, Locale: string.IsNullOrWhiteSpace(evt.Locale) ? "en" : evt.Locale), context.CancellationToken).ConfigureAwait(false);