Moderation features are what distinguish a production classified website from a hobby project. Without moderation, bad actors post fraudulent listings, spam, and illegal content. The approval workflow (auto-approve verified sellers, review new sellers’ first listings) balances user experience (verified sellers get instant publishing) with safety (new sellers get a quick human review). The audit log ensures accountability — every moderation action is traceable.
Moderation and Admin Layer
// ── Domain — listing status includes moderation states ────────────────────
public enum ListingStatus
{
Draft,
PendingReview, // new seller's first listing — awaits moderator approval
Active,
Expired,
Sold,
Suspended, // moderator action — listing hidden pending review
Rejected, // moderator rejected — seller notified with reason
}
// ── Application/Admin/Commands/ModerateListingCommand.cs ─────────────────
public record ModerateListingCommand(
Guid ListingId,
ModerationAction Action,
string? Reason = null // required for Reject and Suspend
) : IRequest;
public enum ModerationAction { Approve, Reject, Suspend, Restore }
// ── Application/Admin/Commands/ModerateListingCommandHandler.cs ───────────
public class ModerateListingCommandHandler
: IRequestHandler<ModerateListingCommand>
{
private readonly IListingRepository _repo;
private readonly IUnitOfWork _uow;
private readonly IPublisher _publisher;
private readonly IAuditLogService _audit;
private readonly ICurrentUserService _user;
public async Task Handle(ModerateListingCommand cmd, CancellationToken ct)
{
var listing = await _repo.GetByIdAsync(cmd.ListingId, ct)
?? throw new NotFoundException($"Listing {cmd.ListingId} not found.");
switch (cmd.Action)
{
case ModerationAction.Approve:
listing.Approve();
break;
case ModerationAction.Reject:
if (string.IsNullOrWhiteSpace(cmd.Reason))
throw new ValidationException("Reason is required for rejection.");
listing.Reject(cmd.Reason);
break;
case ModerationAction.Suspend:
if (string.IsNullOrWhiteSpace(cmd.Reason))
throw new ValidationException("Reason is required for suspension.");
listing.Suspend(cmd.Reason);
break;
case ModerationAction.Restore:
listing.Restore();
break;
}
// Audit log — who did what, when, to which listing
await _audit.LogAsync(new AuditEntry(
EntityType: "Listing",
EntityId: cmd.ListingId.ToString(),
Action: cmd.Action.ToString(),
PerformedBy: _user.UserId!,
PerformedAt: DateTime.UtcNow,
Details: cmd.Reason));
await _uow.SaveChangesAsync(ct);
foreach (var evt in listing.DomainEvents)
await _publisher.Publish(evt, ct);
listing.ClearDomainEvents();
}
}
// ── Api/Controllers/AdminController.cs (moderation endpoints) ────────────
[ApiController, Route("api/admin"), Authorize(Policy = "RequireModerator")]
public class AdminController : ControllerBase
{
private readonly IMediator _mediator;
// GET /api/admin/listings — all listings including deleted and pending
[HttpGet("listings")]
public async Task<IActionResult> GetAllListings(
[FromQuery] ListingStatus? status = null,
[FromQuery] int page = 1,
CancellationToken ct = default)
=> Ok(await _mediator.Send(
new AdminSearchListingsQuery(status, page), ct));
// PATCH /api/admin/listings/{id}/moderate
[HttpPatch("listings/{id:guid}/moderate")]
public async Task<IActionResult> Moderate(
Guid id, [FromBody] ModerateListingRequest req,
CancellationToken ct)
{
await _mediator.Send(new ModerateListingCommand(
id, req.Action, req.Reason), ct);
return NoContent();
}
// PATCH /api/admin/users/{id}/verify-seller [RequireAdmin only]
[HttpPatch("users/{id}/verify-seller"),
Authorize(Policy = "RequireAdmin")]
public async Task<IActionResult> VerifySeller(
string id, CancellationToken ct)
{
await _mediator.Send(new VerifySellerCommand(id), ct);
return NoContent();
}
}
IAuditLogService interface in Application is implemented in Infrastructure — typically writing to a dedicated audit table with strict append-only access (no updates or deletes allowed on audit records).CreateListingCommandHandler: if the owner is a verified seller, call listing.AutoApprove() (sets status to PendingReview then immediately Active); if not, leave as PendingReview and notify moderators. This logic lives in Application, is unit-testable, and can be changed without touching controllers.AdminSearchListingsQuery must use IgnoreQueryFilters() to see soft-deleted and suspended listings that are normally hidden by the global query filter. Admin endpoints have a different view of the data than public endpoints — they see everything. If the admin query handler uses the same filtered DbContext as public queries, administrators cannot find and restore accidentally deleted listings or review suspended content. Always explicitly add IgnoreQueryFilters() to admin queries that need to see all records.Common Mistakes
Mistake 1 — Admin queries using the same filtered DbContext as public queries
❌ Wrong — admin listing search doesn’t call IgnoreQueryFilters(); deleted and suspended listings invisible to moderators.
✅ Correct — admin query handlers call .IgnoreQueryFilters(); see all listings regardless of soft-delete or suspension status.
Mistake 2 — No audit log for moderation actions (accountability gap)
❌ Wrong — listings are approved/rejected without logging who did it; no paper trail for appeals or regulatory compliance.
✅ Correct — every moderation action writes to the audit log with moderator ID, timestamp, listing ID, action, and reason.