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 adata-access-services/content-flags.access-service.jsfile that doesn't exist anywhere, and its fallback calledcontentReportManager.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 paginatedContentReportaccess serviceadminGetReportsuses. Wired to the/moderation/flaggedpage.) - Get a flagged item's moderation context (
adminGetContentDetails- wired to the/moderation/flaggedpage's "Ver" detail panel. Fixed this session, several bugs introduced byadminGetFlaggedContent's shape change above: it calledthis.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 calledcontentReportManager.getContentReports()with a filters object andcontextas its first two args instead of(contentType, contentId, options, context); and it called.some()directly ongetFlaggedContent()'s new{content, total, limit, offset}return value instead of on its.contentarray - all three silently no-opped rather than crashing, thanks to surrounding try/catch blocks. Also added the schema's required non-nullid/statusfields, which were missing entirely. Separately,ContentModerationDetails.nsfwScore- previously alwaysnull, since nothing ever set it - now has a real field resolver incontent-moderation.resolver.jsthat looks updata-access-services/admin/post-nsfw-score.access-service.jsfor the content and returns the worst-case confidence score; it's populated for posts only, sourced fromnsfw-detection.manager.js's scan on post creation. The/moderation/flaggedpage's detail panel already had a badge for this value, it just never received data before.ContentModerationDetails.toxicityScore- also previously alwaysnull- now has a real field resolver too: it returnsNsfwCommentScore.toxicityScoreforcontentType: COMMENT(the only content type where a toxicity signal is actually persisted, viadatabase/models/NsfwCommentScore.js) and staysnullfor 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/flaggedpage's detail panel, and now actually returns data:data-access-services/content-moderation-log.access-service.jsimplementsfindByContent(contentType, contentId, options)against the realcontent_moderation_logstable (migration20260717060000-create-content-moderation-logs.js), whichgetModerationHistory()reads from. This doc previously saidfindByContentdidn't exist and the endpoint always returned[]- that's no longer the case, and is covered bytests/unit-test/admin-content-moderation.unit.test.js'sgetModerationHistorysuite. 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'sModerationActiontype at all (actionType/reason/performedBy/performedAt/notes) - so every real call threw a GraphQL serialization error on the non-nullactionType/performedBy/performedAtfields, even withfindByContentreturning real rows. It's now mapped to the correct field names, with entries whosemoderatorIdcan't be resolved to a real admin (e.g.'system'auto-moderation) dropped rather than violatingperformedBy's non-null constraint.) - Content moderation stats for a period (
adminGetContentModerationStats- wired to the/moderation/flaggedpage's stat cards. Fixed this session:getContentStats()treatedgetFlaggedContent()'s new{content, total, limit, offset}return value as a bare array (.length,.forEach) - same class of bug asadminGetContentDetailsabove.) - Trending/viral content for review (
adminGetTrendingContent- wired to a collapsible "Trending content" section on the/moderation/flaggedpage, with a one-click "flag for review" action per row that reuses the existingadminFlagContentmodal flow. Fixed this session:getTrendingContent()now also branches oncontentType: COMMENT— there's noPostCommentequivalent ofpostAccessService'sgetTrendingPosts, so this branch queriesPostCommentdirectly within the same trailing time window, scoring onlikesCount/repliesCountwith the same relative weights the post branch uses for likes/comments. Previously this branch didn't exist at all, so requestingcontentType: "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/flaggedpage's "Eliminar" action; the moderation (report queue) page separately removes content indirectly viaadminReviewReport'sremoveContentflag) - Restore previously removed content (
adminRestoreContent- wired to the/moderation/flaggedpage's "Restaurar" action) - Bulk-remove content (super_admin only) (
adminBulkRemoveContent- wired to the/moderation/flaggedpage's bulk-select action,super_adminonly) - Flag content for review (
adminFlagContent- wired to the/moderation/flaggedpage's "Marcar" action) - Unflag content (
adminUnflagContent- wired to the/moderation/flaggedpage'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.
- List auto-moderation rules (
adminGetAutoModerationRules- real resolver + manager + DB table, wired to the/moderation/rulespage) - Create an auto-moderation rule (
adminCreateAutoModerationRule- wired to the/moderation/rulespage's create modal) - Update an auto-moderation rule (
adminUpdateAutoModerationRule- wired to the/moderation/rulespage's edit modal) - Delete an auto-moderation rule (
adminDeleteAutoModerationRule- wired to the/moderation/rulespage) - Aggregate moderation stats (no-arg variant) (
adminGetModerationStats- resolver gated on VIEW_ANALYTICS →contentModerationManager.getContentStats({}), the same working method behindadminGetContentModerationStats)
Content warnings
- Add a content warning label (
adminAddContentWarning- fixed this session: the resolver destructuredwarningType/messagewhile the schema declareswarning/severity- real calls silently passedundefinedfor both.warningmaps to the manager'swarningType(must be one ofSENSITIVE/GRAPHIC/MISINFORMATION/VIOLENCE/ADULT),severitymaps to its free-textmessage. Wired to the/moderation/flaggedpage's "Advertencia" action. Unlikeadmin-user.resolver.jsandcontent-report.resolver.js,content-moderation.resolver.jshas no real top-level duplicate to keep in sync - only theadmin/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 - seedatabase/migrations/20260730180000-add-content-warning-to-post.js), so the schema'swarningId: 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.ContentModerationDetailsnow exposeshasWarning/warningType/warningMessage, and the/moderation/flaggedpage'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-facingresolvers/content-report.resolver.js) - List reports by user (
adminGetReportsByUser- lists reports filed by a user, viagetByReporterPaginated; 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 theactionparam) - 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 -
adminEscalateReportwas removed from bothgraphql/types/admin/content-report.type.jsand the client-facinggraphql/types/content-report.type.js(and its resolvers deleted), so it no longer exists in the schema at all.content-report.manager.jsstill has anescalateReport()method, but it's unreachable dead code now - nothing calls it. Escalation in practice happens only throughadminReviewReportwithaction: ESCALATE(sets the report's status toreviewing), which is what the moderation page's "Escalar" button already used.) - Dismiss a report (also no longer a distinct GraphQL mutation -
adminDismissReportwas removed from the schema alongsideadminEscalateReport, same commit.content-report.manager.js'sdismissReport()method still exists but is likewise unreachable dead code. Dismissal in practice happens viaadminReviewReportwithaction: REJECT, which sets the report's status todismissed.)
Comment controls
- Disable comments on a content item (
adminDisableComments- implemented insidecontent-moderation.resolver.jsrather than a dedicated resolver file; wired to a toggle button on the/moderation/flaggedpage's detail panel, alongsideisCommentsDisabledexposed onContentModerationDetails. 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 currentisCommentsDisabledstate)
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.