Moderación de Contenido y Reportes — Referencia Técnica
← Volver a Moderación de Contenido y Reportes
Dónde vive esto
Backend
apps/backend/graphql/resolvers/content-report.resolver.js- reportes de usuarios (createContentReport,myReports) y mutaciones admin de cola de reportes/revisiónapps/backend/graphql/resolvers/admin/content-moderation.resolver.js- cola de contenido marcado, acciones sobre contenido, advertencias de contenido, habilitar/deshabilitar comentarios, y CRUD de reglas de auto-moderación (este archivo ahora vive bajoadmin/; ya no existe uncontent-moderation.resolver.jsde nivel superior)apps/backend/graphql/types/content-report.type.js- tipos del esquema de reportesapps/backend/graphql/types/admin/content-moderation.type.js- tipos del esquema de contenido marcado / moderación (también se movió bajoadmin/)apps/backend/managers/admin-managers/content-report.manager.js- lógica de negocio de reportes usada por el resolverapps/backend/managers/admin-managers/content-moderation.manager.js- lógica de contenido marcado / acciones admin de contenido / CRUD de reglas de auto-moderaciónapps/backend/managers/admin-managers/user-moderation.manager.js- lee/escribe advertencias de usuario y el registro de moderación (resolver:graphql/resolvers/admin/user-moderation.resolver.js)apps/backend/data-access-services/admin/user-warnings.access-service.js- acceso a BD para advertencias por usuario (movido bajoadmin/)apps/backend/data-access-services/admin/user-moderation-log.access-service.js- acceso a BD para el registro de auditoría de moderación de usuarios (movido bajoadmin/)apps/backend/data-access-services/content-moderation-log.access-service.js- servicio de acceso a BD independiente para el registro de auditoría de moderación de contenido, escrito por cada acción sobre contenido marcadoapps/backend/services/auto-moderation.service.js- motor de reglas que compara condicioneskeyword/regex/nsfw_score/report_thresholdcontra el contenido y aplica accionesflag/remove/warn; se llama (best-effort, no bloqueante) desdepost.manager.jsen cada creación de postapps/backend/services/content-preview.service.js- construye elContentPreview(fragmento de texto + miniatura) adjunto a reportes y contenido marcadoapps/backend/services/nsfw-detection.service.js- envuelve las llamadas a Google Cloud Vision/Video Intelligence (gc-vision.js,gc-streaming-video-intelligence.jsmás abajo) en puntajes NSFW
Frontend
apps/frontend-nextjs/src/components/PostOptionsMenu.tsx— modal de reporte real con una lista de motivos, llama acreateContentReport; se renderiza desdePostCard.tsx,PostModal.tsx, yHomePage.tsx(este documento antes decía que no existía ningún botón de reporte en el frontend — corregido)apps/frontend-admin/src/app/moderation/page.tsx— cola de revisión de reportes: lista/filtra víaadminGetReports, aprueba/rechaza/escala víaadminReviewReport, y acciones en lote víaadminBulkReviewReports(este documento antes decía que no existía un panel de administración para reportes — corregido)apps/frontend-admin/src/app/moderation/flagged/page.tsx— cola de contenido marcado y panel de detalle:adminGetFlaggedContent,adminGetContentDetails,adminGetContentModerationStats, además de acciones aprobar/rechazar/eliminar/restaurar/marcar/desmarcar/advertir/eliminar en lote (este documento antes decía que no existía frontend para la cola de contenido marcado — corregido)apps/frontend-admin/src/app/moderation/rules/page.tsx— interfaz CRUD de reglas de auto-moderación:adminGetAutoModerationRules,adminCreateAutoModerationRule,adminUpdateAutoModerationRule,adminDeleteAutoModerationRule(este documento antes decía que el motor de reglas de auto-moderación no era alcanzable vía el API en absoluto — corregido)
Checklist de implementación técnica
-
createContentReport/myReports— resolvers conectados encontent-report.resolver.js;PostOptionsMenu.tsxllama acreateContentReport(corregido — ver arriba) -
adminGetReports/adminReviewReport/adminBulkReviewReports— resolvers conectados; ahora conectados a la página/moderationde frontend-admin (los botones Aprobar/Rechazar/Escalar pasan todos por el parámetroactiondeadminReviewReport) — este documento antes decía que no existía panel de administración para esto; corregido.adminEscalateReportyadminDismissReportse han eliminado por completo del esquema (ya no están encontent-report.type.jsni en el resolver) — ahora la escalación y el descarte solo ocurren víaadminReviewReportconaction: ESCALATE/action: REJECT - Detección de NSFW (
nsfw-detection.service.js) — totalmente automatizado; está realmente conectado al flujo de subida enupload.service.jsy se ejecuta en cada subida de imagen o video; esta capacidad no necesita interfaz dedicada -
adminWarnUser— resolver conectado engraphql/resolvers/admin/user-moderation.resolver.js+data-access-services/admin/user-warnings.access-service.js; el backend está listo, no se encontró frontend -
adminGetFlaggedContent/adminApproveContent/adminRemoveContent/ etc. — resolvers conectados; ahora conectados a la página/moderation/flaggedde frontend-admin (lista, panel de detalle, y todas las acciones de contenido) — este documento antes decía que no existía frontend para esto; corregido -
adminAddContentWarning— resolver conectado y ahora conectado a la acción "Advertencia" de la página/moderation/flagged(antes decía que no se encontró frontend; corregido) -
adminRemoveContentWarning— resolver conectado; el backend está listo, no se encontró frontend - Motor de reglas de auto-moderación — ahora es real y alcanzable (antes decía que no estaba conectado a ningún resolver; corregido).
adminGetAutoModerationRules/adminCreateAutoModerationRule/adminUpdateAutoModerationRule/adminDeleteAutoModerationRuleestán implementados encontent-moderation.manager.jscontra una tabla de BD realauto_moderation_rule, y conectados a una interfaz CRUD completa en/moderation/rules. Las reglas también se aplican realmente medianteservices/auto-moderation.service.js, llamado desdepost.manager.jsen cada creación de post.
Tipos de contenido reportable
enum ContentType { post comment message user }
article/blast/tale también estaban listados aquí antes, pero nunca tuvieron un modelo de base de datos real - eran stub access-services muertos (data-access-services/admin/{blast,tale,article}.access-service.js, que siempre devolvían resultados vacíos) mantenidos únicamente para que el código de moderación de administradores no fallara al toparse con ellos. Tanto los stubs como los valores del enum fueron eliminados. Reportar contenido cuyo medio es un único video (un "Clip") ya usa contentType: post, ya que los Clips son simplemente filas de Post con type: 'clip' — ver Clips.
Motivos de reporte
enum ReportReason {
spam · nudity · hate_speech · violence · harassment
false_information · scam · intellectual_property
self_harm · terrorism · other
}
Estados de reporte
enum ReportStatus { pending reviewing resolved dismissed }
Enviar un reporte
createContentReport envía un reporte de usuario para cualquier pieza de contenido. contentType identifica qué se está reportando; contentId es el ID en base de datos de ese elemento. reason y la description opcional se almacenan para revisión del admin. La mutación devuelve el ID del reporte y su status inicial (pending).
mutation CreateContentReport($input: ContentReportCreateInput!) {
createContentReport(input: $input) { id contentType reason status createdAt }
}
Consultas de usuario
myReports permite a un usuario revisar los reportes que ha enviado y sus estados actuales — útil para una pantalla de configuración "Tus reportes".
contentReportCount devuelve el número total de reportes sobre una pieza de contenido. Los admins usan esto para priorizar la revisión, y puede mostrarse en la cola de contenido marcado.
query MyReports($limit: Int) {
myReports(limit: $limit) { id contentType reason status createdAt }
}
query ContentReportCount($contentType: ContentType!, $contentId: ID!) {
contentReportCount(contentType: $contentType, contentId: $contentId)
}
Consultas de administrador
adminGetReports es la cola de moderación principal. Filtra por status: pending para mostrar reportes sin revisar; filtra por contentType para enfocarte en un tipo específico de contenido. Los resultados incluyen quién presentó el reporte y cuándo se revisó por última vez.
adminGetReportStats devuelve contadores agregados por tipo y motivo — usado para construir la vista general del panel de moderación.
adminGetReportDetails obtiene un único reporte con contexto completo: el contenido original, el historial del reportero (cuántos reportes ha presentado), y otros reportes similares sobre el mismo contenido. Se usa cuando un admin abre un reporte para revisarlo.
query AdminGetReports($status: ReportStatus, $contentType: ContentType, $limit: Int) {
adminGetReports(status: $status, contentType: $contentType, limit: $limit) {
reports { id contentType reason status reporter { username } reviewedAt }
total
}
}
query AdminGetReportStats {
adminGetReportStats {
totalReports pendingReports resolvedReports dismissedReports
reportsByType { post comment message user }
reportsByReason { spam nudity harassment violence }
}
}
query AdminGetReportDetails($reportId: ID!) {
adminGetReportDetails(reportId: $reportId) {
report { id reason status reviewNotes }
content
reporterHistory { id reason createdAt }
similarReports { id reason }
}
}
Acciones de administrador
adminReviewReport resuelve un único reporte. El campo action acepta valores como approve, reject, o remove — también cubre la escalación (action: ESCALATE, establece el estado en reviewing) y el descarte (action: REJECT, establece el estado en dismissed); las mutaciones dedicadas adminEscalateReport/adminDismissReport existían anteriormente pero se han eliminado del esquema (los métodos subyacentes escalateReport()/dismissReport() de content-report.manager.js todavía existen pero ahora son código muerto inalcanzable). Pasa removeContent: true para eliminar también el elemento reportado. adminBulkReviewReports aplica la misma acción a múltiples reportes a la vez — útil para limpiar lotes de spam.
mutation AdminReviewReport($reportId: ID!, $action: String!, $notes: String, $removeContent: Boolean) {
adminReviewReport(reportId: $reportId, action: $action, notes: $notes, removeContent: $removeContent) {
success report { status reviewNotes }
}
}
mutation AdminBulkReviewReports($reportIds: [ID!]!, $action: String!) {
adminBulkReviewReports(reportIds: $reportIds, action: $action) { success }
}
Controles de comentarios (admin)
adminDisableComments desactiva los comentarios en un post específico — útil cuando está generando acoso pero no debería eliminarse. adminEnableComments lo revierte.
mutation AdminDisableComments($contentType: String!, $contentId: ID!, $reason: String) {
adminDisableComments(contentType: $contentType, contentId: $contentId, reason: $reason) { success }
}
mutation AdminEnableComments($contentType: String!, $contentId: ID!) {
adminEnableComments(contentType: $contentType, contentId: $contentId) { success }
}
Cola de contenido marcado
adminGetFlaggedContent devuelve una cola unificada de todo el contenido marcado sin importar el origen del reporte (reporte de usuario, auto-moderación, puntaje NSFW). Filtra por contentType o status. Cada elemento incluye un preview (fragmento de texto/miniatura, construido por content-preview.service.js) y el nombre del moderador que lo revisó — FlaggedContent en sí no tiene campos nsfwScore/toxicityScore; esos viven en ContentModerationDetails más abajo.
adminGetContentDetails obtiene el contexto completo de una sola pieza de contenido: sus datos en bruto, puntajes de IA, todos los reportes asociados, y el historial completo de moderación. Esta es la vista de detalle que ve un admin al abrir un elemento marcado. nsfwScore es un resolver de campo real que busca el peor puntaje de confianza NSFW registrado para el contenido (solo posts, a partir del escaneo de nsfw-detection.manager.js en la creación del post). toxicityScore ahora también tiene un resolver de campo real, corregido en esta sesión: la toxicidad de texto solo se persiste en NsfwCommentScore.toxicityScore, escrito únicamente para comentarios (ver database/models/NsfwCommentScore.js) — no existe un modelo/columna de toxicidad equivalente para posts — así que el resolver devuelve ese valor para contentType: COMMENT y null para cualquier otro tipo de contenido. Ese null en posts es intencional (no existe ninguna señal que devolver), no un bug. moderationHistory (vía adminGetContentModerationHistory) ahora devuelve datos reales con la forma correcta — corregido en esta sesión: el manager antes mapeaba las filas del registro a {id, action, moderatorId, moderatorType, previousStatus, newStatus, metadata, createdAt}, una forma que no existe en absoluto en el tipo ModerationAction del esquema (actionType/reason/performedBy/performedAt/notes), así que cada llamada real lanzaba un error de serialización de GraphQL en los campos no-nulos actionType/performedBy/performedAt. Ahora se mapea a los nombres de campo reales, y las entradas cuyo moderatorId no se puede resolver a un admin real (por ejemplo la auto-moderación con 'system') se descartan en lugar de violar la restricción no-nula de performedBy.
adminGetContentModerationStats devuelve métricas de moderación período a período para el panel — cuánto se auto-moderó frente a cuánto se revisó manualmente, y cuánto se aprobó frente a cuánto se eliminó.
query AdminFlaggedContent($contentType: String, $status: String, $limit: Int, $offset: Int) {
adminGetFlaggedContent(contentType: $contentType, status: $status, limit: $limit, offset: $offset) {
total content {
id contentType contentId status flagReason flagCategory flaggedAt
author { username }
reviewedBy { username }
moderationAction moderationNotes
}
}
}
query AdminContentDetails($contentType: String!, $contentId: ID!) {
adminGetContentDetails(contentType: $contentType, contentId: $contentId) {
contentType contentId content status
nsfwScore toxicityScore
author { username }
reports { id reason status }
moderationHistory { actionType reason performedAt performedBy { username } }
}
}
query AdminModerationStats($period: String) {
adminGetContentModerationStats(period: $period) {
totalFlagged pendingReview approvedContent removedContent
autoModerated manualModerated
}
}
Acciones de contenido admin
adminApproveContent marca un elemento marcado como seguro y lo despeja de la cola de revisión. adminRejectContent lo marca para eliminación pero no lo elimina todavía — útil para un flujo de confirmación en dos pasos.
adminRemoveContent elimina el contenido de forma permanente. Pasa notifyUser: true para enviarle al autor una notificación in-app explicando la eliminación. adminRestoreContent revierte una eliminación previa.
adminBulkRemoveContent elimina múltiples elementos en una sola llamada — cada elemento en items es un objeto JSON con contentType y contentId.
adminFlagContent añade manualmente un elemento de contenido a la cola de revisión (por ejemplo, cuando un admin detecta un problema durante la navegación normal). adminUnflagContent lo elimina.
mutation AdminApproveContent($input: ModerationActionInput!) { adminApproveContent(input: $input) { success } }
mutation AdminRejectContent($input: ModerationActionInput!) { adminRejectContent(input: $input) { success } }
# Permanently delete content; optionally notify the author
mutation AdminRemoveContent($contentType: String!, $contentId: ID!, $reason: String!, $notifyUser: Boolean) {
adminRemoveContent(contentType: $contentType, contentId: $contentId, reason: $reason, notifyUser: $notifyUser) { success }
}
# Restore previously removed content
mutation AdminRestoreContent($contentType: String!, $contentId: ID!, $reason: String!) {
adminRestoreContent(contentType: $contentType, contentId: $contentId, reason: $reason) { success }
}
# Bulk-delete multiple items (items: [{ contentType, contentId }])
mutation AdminBulkRemoveContent($items: [JSON!]!, $reason: String!) {
adminBulkRemoveContent(items: $items, reason: $reason) { success }
}
mutation AdminFlagContent($contentType: String!, $contentId: ID!, $reason: String!, $priority: String) {
adminFlagContent(contentType: $contentType, contentId: $contentId, reason: $reason, priority: $priority) { success }
}
mutation AdminUnflagContent($contentType: String!, $contentId: ID!, $reason: String) {
adminUnflagContent(contentType: $contentType, contentId: $contentId, reason: $reason) { success }
}
Advertencias de contenido
Los admins pueden adjuntar advertencias etiquetadas por severidad al contenido (por ejemplo, "contenido sensible", "imágenes gráficas"). La advertencia se muestra a los usuarios antes de que vean el contenido. severity acepta valores como low, medium, high.
adminRemoveContentWarning elimina la advertencia que esté activa en ese momento sobre una pieza de contenido. Investigado en esta sesión: esta sección antes describía que el resolver "ignoraba" un argumento warningId. Eso no se reproduce — el esquema no declara ningún argumento warningId, y nunca lo hizo; es adminRemoveContentWarning(contentType: String!, contentId: ID!): ModerationActionResponse!. Esto es intencional, no un bug: un elemento de contenido tiene como máximo una advertencia activa a la vez (columnas planas en la fila, no una tabla por advertencia — ver database/migrations/20260730180000-add-content-warning-to-post.js), así que no hay un ID de advertencia individual que apuntar en primer lugar.
mutation AddContentWarning($contentType: String!, $contentId: ID!, $warning: String!, $severity: String) {
adminAddContentWarning(contentType: $contentType, contentId: $contentId, warning: $warning, severity: $severity) { success }
}
mutation RemoveContentWarning($contentType: String!, $contentId: ID!) {
adminRemoveContentWarning(contentType: $contentType, contentId: $contentId) { success }
}
Reglas de auto-moderación
Las reglas de auto-moderación se ejecutan del lado del servidor y aplican acciones (eliminar, marcar, advertir) automáticamente en base a conditions JSON (por ejemplo, nsfwScore > 0.9) y actions JSON (por ejemplo, { "action": "remove" }). Esto reduce la carga de trabajo de los moderadores para infracciones evidentes. Esta es una funcionalidad real y funcional: el CRUD está respaldado por una tabla de BD real auto_moderation_rule (data-access-services/admin/auto-moderation-rule.access-service.js, modelo AutoModerationRule.js), y las reglas se aplican realmente mediante services/auto-moderation.service.js — un motor de reglas que compara tipos de regla keyword/regex/nsfw_score/report_threshold, llamado (best-effort, no bloqueante) desde post.manager.js en cada creación de post. Hay una interfaz CRUD completa en apps/frontend-admin/src/app/moderation/rules/page.tsx.
adminGetAutoModerationRules lista todas las reglas configuradas con su estado activo/inactivo. adminCreateAutoModerationRule añade una nueva regla (solo super_admin). adminUpdateAutoModerationRule edita una existente (por ejemplo, para ajustar el umbral; solo super_admin). adminDeleteAutoModerationRule elimina la regla de forma permanente (solo super_admin).
query AutoModerationRules { adminGetAutoModerationRules { id name ruleType conditions actions isActive } }
mutation CreateAutoModerationRule($input: AutoModerationRuleInput!) {
adminCreateAutoModerationRule(input: $input) { id name ruleType isActive }
}
mutation UpdateAutoModerationRule($id: ID!, $input: AutoModerationRuleInput!) {
adminUpdateAutoModerationRule(id: $id, input: $input) { id isActive }
}
mutation DeleteAutoModerationRule($id: ID!) {
adminDeleteAutoModerationRule(id: $id) { success }
}
Campos de AutoModerationRule: name, description, ruleType, conditions (JSON), actions (JSON), isActive.
Detección automática de NSFW
El backend integra Google Cloud AI para escanear automáticamente el contenido subido:
| Módulo | Propósito |
|---|---|
utils/apis-google/gc-vision.js | Análisis NSFW de imágenes vía Cloud Vision |
utils/apis-google/gc-streaming-video-intelligence.js | Análisis NSFW de video vía Video Intelligence |
Los puntajes (nsfwScore, toxicityScore) se adjuntan al contenido en ContentModerationDetails y pueden disparar reglas de auto-moderación o revisión manual.