Skip to main content

Content Moderation

Admins can review the flagged-content queue, act on reported content, and manage the user-facing report review queue, plus a real auto-moderation rule engine (this doc previously said auto-moderation was "declared in the schema but not implemented" — corrected, see below). Wired to dedicated /moderation/flagged and /moderation/rules pages.

Implementation checklist

Flagged content and actions

  • List/paginate the flagged content queue (adminGetFlaggedContent - fixed this session: it required a data-access-services/content-flags.access-service.js file that doesn't exist anywhere, and its fallback called contentReportManager.getContentReports() with the wrong argument signature - both paths always threw. There is no separate "content flag" concept implemented anywhere in the backend; this is now built directly on the same paginated ContentReport access service adminGetReports uses. Wired to the /moderation/flagged page.)
  • Get a flagged item's moderation context (adminGetContentDetails - wired to the /moderation/flagged page's "Ver" detail panel. Fixed this session, several bugs introduced by adminGetFlaggedContent's shape change above: it called this.getModerationHistory(contentId, {}, context) against a method whose real signature is (contentType, contentId, context), so it passed the content id and an empty object into the wrong slots; it called contentReportManager.getContentReports() with a filters object and context as its first two args instead of (contentType, contentId, options, context); and it called .some() directly on getFlaggedContent()'s new {content, total, limit, offset} return value instead of on its .content array - all three silently no-opped rather than crashing, thanks to surrounding try/catch blocks. Also added the schema's required non-null id/status fields, which were missing entirely. Separately, ContentModerationDetails.nsfwScore - previously always null, since nothing ever set it - now has a real field resolver in content-moderation.resolver.js that looks up data-access-services/admin/post-nsfw-score.access-service.js for the content and returns the worst-case confidence score; it's populated for posts only, sourced from nsfw-detection.manager.js's scan on post creation. The /moderation/flagged page's detail panel already had a badge for this value, it just never received data before. ContentModerationDetails.toxicityScore - also previously always null - now has a real field resolver too: it returns NsfwCommentScore.toxicityScore for contentType: COMMENT (the only content type where a toxicity signal is actually persisted, via database/models/NsfwCommentScore.js) and stays null for posts and everything else, since there's no equivalent toxicity model for posts - that's intentional, not a remaining gap.)
  • Get moderation history for a content item (adminGetContentModerationHistory - wired to the /moderation/flagged page's detail panel, and now actually returns data: data-access-services/content-moderation-log.access-service.js implements findByContent(contentType, contentId, options) against the real content_moderation_logs table (migration 20260717060000-create-content-moderation-logs.js), which getModerationHistory() reads from. This doc previously said findByContent didn't exist and the endpoint always returned [] - that's no longer the case, and is covered by tests/unit-test/admin-content-moderation.unit.test.js's getModerationHistory suite. Fixed this session: on top of that, the log rows were being mapped to {id, action, moderatorId, moderatorType, previousStatus, newStatus, metadata, createdAt}, a shape that doesn't exist on the schema's ModerationAction type at all (actionType/reason/performedBy/performedAt/notes) - so every real call threw a GraphQL serialization error on the non-null actionType/performedBy/performedAt fields, even with findByContent returning real rows. It's now mapped to the correct field names, with entries whose moderatorId can't be resolved to a real admin (e.g. 'system' auto-moderation) dropped rather than violating performedBy's non-null constraint.)
  • Content moderation stats for a period (adminGetContentModerationStats - wired to the /moderation/flagged page's stat cards. Fixed this session: getContentStats() treated getFlaggedContent()'s new {content, total, limit, offset} return value as a bare array (.length, .forEach) - same class of bug as adminGetContentDetails above.)
  • Trending/viral content for review (adminGetTrendingContent - wired to a collapsible "Trending content" section on the /moderation/flagged page, with a one-click "flag for review" action per row that reuses the existing adminFlagContent modal flow. Fixed this session: getTrendingContent() now also branches on contentType: COMMENT — there's no PostComment equivalent of postAccessService's getTrendingPosts, so this branch queries PostComment directly within the same trailing time window, scoring on likesCount/repliesCount with the same relative weights the post branch uses for likes/comments. Previously this branch didn't exist at all, so requesting contentType: "COMMENT" silently returned an empty list.)
  • Approve flagged content (adminApproveContent - resolver gated on MODERATE_CONTENT → contentModerationManager.approveContent, which clears the flag and logs the decision)
  • Reject flagged content (adminRejectContent - resolver gated on REMOVE_CONTENT → contentModerationManager.rejectContent, which removes the content and logs the decision)
  • Remove violating content (adminRemoveContent - wired to the /moderation/flagged page's "Eliminar" action; the moderation (report queue) page separately removes content indirectly via adminReviewReport's removeContent flag)
  • Restore previously removed content (adminRestoreContent - wired to the /moderation/flagged page's "Restaurar" action)
  • Bulk-remove content (super_admin only) (adminBulkRemoveContent - wired to the /moderation/flagged page's bulk-select action, super_admin only)
  • Flag content for review (adminFlagContent - wired to the /moderation/flagged page's "Marcar" action)
  • Unflag content (adminUnflagContent - wired to the /moderation/flagged page's "Desmarcar" action)

Auto-moderation rules

Corrected this pass — this section previously said all four operations were "declared but no resolver implements it." That was wrong: real resolvers exist in graphql/resolvers/admin/content-moderation.resolver.js, delegating to real CRUD in managers/admin-managers/content-moderation.manager.js against a real auto_moderation_rule DB table (model AutoModerationRule.js), and there's a real, working CRUD UI at /moderation/rules (table + create/edit modal) exercising all four.

Rules are also actually enforced, not just stored — services/auto-moderation.service.js is a rule engine matching keyword/regex/nsfw_score/report_threshold rule types against content and applying flag/remove/warn actions. It's called (best-effort, non-blocking) from post.manager.js on every post creation. Whether the nsfw_score/report_threshold rule types are evaluated on other paths (the NSFW scan, report creation) wasn't re-verified in this pass.

Content warnings

  • Add a content warning label (adminAddContentWarning - fixed this session: the resolver destructured warningType/message while the schema declares warning/severity - real calls silently passed undefined for both. warning maps to the manager's warningType (must be one of SENSITIVE/GRAPHIC/MISINFORMATION/VIOLENCE/ADULT), severity maps to its free-text message. Wired to the /moderation/flagged page's "Advertencia" action. Unlike admin-user.resolver.js and content-report.resolver.js, content-moderation.resolver.js has no real top-level duplicate to keep in sync - only the admin/ copy exists on disk.)
  • Remove a content warning (adminRemoveContentWarning - a content item carries at most one active warning at a time (flat columns on the row, not a per-warning table - see database/migrations/20260730180000-add-content-warning-to-post.js), so the schema's warningId: ID! argument this doc previously flagged as ignored had nothing to look up in the first place; it's been removed from the schema, matching what the resolver already did. ContentModerationDetails now exposes hasWarning/warningType/warningMessage, and the /moderation/flagged page's detail panel shows a "Remove warning" button whenever a warning is active.)

Report review queue

  • List/filter reports (adminGetReports - backend ready, wired to the moderation page; the same logic is also duplicated verbatim in the client-facing resolvers/content-report.resolver.js)
  • List reports by user (adminGetReportsByUser - lists reports filed by a user, via getByReporterPaginated; wired to a "Reports filed by this user" card on the /users/[id] page)
  • Report statistics by type/reason (adminGetReportStats - wired to stat cards at the top of the moderation page)
  • Full report detail with reporter history (adminGetReportDetails - wired to a "Ver detalles" modal per report on the moderation page, showing the reporter's other reports and other reports on the same content - useful for spotting a serial false reporter or a high-priority pile-up)
  • Approve/reject/escalate a report (adminReviewReport - backend ready, wired to the moderation page's Aprobar/Rechazar/Escalar buttons, all routed through this one mutation via the action param)
  • Bulk review multiple reports (adminBulkReviewReports - wired to the moderation page's bulk-select bar, alongside the per-row Aprobar/Rechazar/Escalar buttons; this doc previously said no frontend-admin UI consumed it - corrected)
  • Escalate a report to another admin (no longer a distinct GraphQL mutation - adminEscalateReport was removed from both graphql/types/admin/content-report.type.js and the client-facing graphql/types/content-report.type.js (and its resolvers deleted), so it no longer exists in the schema at all. content-report.manager.js still has an escalateReport() method, but it's unreachable dead code now - nothing calls it. Escalation in practice happens only through adminReviewReport with action: ESCALATE (sets the report's status to reviewing), which is what the moderation page's "Escalar" button already used.)
  • Dismiss a report (also no longer a distinct GraphQL mutation - adminDismissReport was removed from the schema alongside adminEscalateReport, same commit. content-report.manager.js's dismissReport() method still exists but is likewise unreachable dead code. Dismissal in practice happens via adminReviewReport with action: REJECT, which sets the report's status to dismissed.)

Comment controls

  • Disable comments on a content item (adminDisableComments - implemented inside content-moderation.resolver.js rather than a dedicated resolver file; wired to a toggle button on the /moderation/flagged page's detail panel, alongside isCommentsDisabled exposed on ContentModerationDetails. Only POST rows carry the underlying column, same as content warnings.)
  • Enable comments on a content item (adminEnableComments - same toggle button, flips based on the content's current isCommentsDisabled state)

Technical reference

See Admin Panel → Content moderation, Report review queue, and Comment controls for the full GraphQL API. For the user-facing side of reporting (submitting a report), see Content Moderation & Reporting.

A note on a related client-schema bug found this session: the client-facing updateReportStatus mutation (graphql/types/content-report.type.js / graphql/resolvers/content-report.resolver.js, admin-gated but not part of the admin schema) shares content-report.manager.js's updateReportStatus() method, which had the exact same reviewNotes-vs-adminNotes bug as adminEscalateReport/adminDismissReport above - it wrote to a non-existent reviewNotes attribute instead of the model's real adminNotes column, so review notes were silently dropped on every call. Fixed alongside the admin-schema bugs since it's the same manager method.