Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -24,6 +26,7 @@ public ApproveModerationRecordCommandHandler(
ICommunityModerationService service,
IPostRepository postRepo,
IRedisFeedStore feedStore,
ICommunityRealtimePublisher realtime,
ICurrentUserAccessor currentUser,
ISystemClock clock,
MessageFactory msg)
Expand All @@ -32,6 +35,7 @@ public ApproveModerationRecordCommandHandler(
_service = service;
_postRepo = postRepo;
_feedStore = feedStore;
_realtime = realtime;
_currentUser = currentUser;
_clock = clock;
_msg = msg;
Expand Down Expand Up @@ -91,6 +95,14 @@ public async Task<Response<VoidData>> 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
Expand Down Expand Up @@ -120,6 +132,15 @@ public async Task<Response<VoidData>> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -33,6 +35,7 @@ public RejectModerationRecordCommandHandler(
ISearchClient search,
IRedisFeedStore feedStore,
IIntegrationEventPublisher publisher,
ICommunityRealtimePublisher realtime,
MessageFactory msg)
{
_db = db;
Expand All @@ -43,6 +46,7 @@ public RejectModerationRecordCommandHandler(
_search = search;
_feedStore = feedStore;
_publisher = publisher;
_realtime = realtime;
_msg = msg;
}

Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ public async Task<Response<EventDto>> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ public async Task<Response<NewsDto>> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,18 @@ public async Task<Response<EventDto>> 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<Tag>(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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,19 @@ public async Task<Response<NewsDto>> 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<Tag>(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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ public sealed class ContentModerationConsumer : IConsumer<ContentModerationReque
private static readonly System.Guid SystemActorId =
System.Guid.Parse("00000000-0000-0000-0000-000000000001");

private const float SafeConfidenceThreshold = 0.6f;
// Only confidently-safe content auto-approves; safe-but-uncertain (< 0.75) is sent to human
// review instead of slipping through. Raised from 0.6 to reduce false-negatives.
private const float SafeConfidenceThreshold = 0.75f;
private const float RejectConfidenceThreshold = 0.7f;

private readonly IServiceScopeFactory _scopeFactory;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,26 @@ public async Task<ModerationScore> 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":<true|false>,"confidence":<0.0-1.0>,"category":"<safe|spam|hate|explicit|harassment>","reason":"<short>"}

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}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using CCE.Application.Common.Messaging.IntegrationEvents;
using CCE.Application.Notifications.Messages;
using CCE.Domain.Notifications;
Expand Down Expand Up @@ -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<string, string>
{
["contentId"] = evt.ContentId.ToString(),
["contentType"] = evt.ContentType,
},
Channels: new[] { NotificationChannel.InApp },
Locale: string.IsNullOrWhiteSpace(evt.Locale) ? "en" : evt.Locale),
context.CancellationToken).ConfigureAwait(false);
Expand Down