Saltar al contenido principal

Panel de administración

Closegram incluye un panel de administración (frontend-admin) que utiliza su propio esquema de GraphQL, para dashboards, moderación de usuarios y revisión de contenido.

Roles de administrador

RolDescripción
moderatorPuede revisar reportes y moderar contenido
adminModerador + gestión de usuarios
super_adminAcceso completo, incluyendo configuración del sistema y otras cuentas de administrador

Las cuentas de administrador se almacenan por separado de los usuarios regulares (modelo AdminUser, no User). Admiten:

  • 2FA (TOTP) — opcional por cuenta de administrador; puede hacerse obligatorio para super_admin mediante la variable de entorno ADMIN_REQUIRE_2FA_SUPER_ADMIN (desactivada por defecto)
  • Lista blanca de IP — un modelo AdminIPWhitelist, access service y manager, expuestos mediante adminIPWhitelist (query) y adminAddIPWhitelist / adminRemoveIPWhitelist / adminSetIPWhitelistActive (mutations), con una interfaz real de agregar/eliminar/alternar-activo en /ip-whitelist (esta documentación decía antes que no era alcanzable desde el panel de administración — corregido)
  • Seguimiento de sesiones — las sesiones activas de cada administrador, visibles y revocables mediante adminActiveSessions / adminRevokeSession / adminRevokeAllSessions
  • Registro de actividadadminGetActivityLog registra las acciones de los administradores

Endpoint de GraphQL

El panel de administración (frontend-admin) utiliza su propio endpoint de GraphQL, separado del que usan los clientes:

POST /admin/graphql # http://localhost:8000/admin/graphql en desarrollo

Es un esquema genuinamente separado (no solo una ruta distinta sobre el mismo esquema) que contiene únicamente las queries y mutations con el prefijo admin* documentadas en esta página — las operaciones normales de cliente no son alcanzables aquí, y las operaciones de administrador no son alcanzables en /web/graphql (el endpoint del cliente). Ver GraphQL y Apollo → Endpoints para más detalles. frontend-admin apunta a este endpoint mediante NEXT_PUBLIC_GRAPHQL_URL en su .env.local.

Páginas de funcionalidades

Cada área a continuación tiene su propia página de checklist de implementación, siguiendo la misma estructura que las páginas de funcionalidades orientadas al usuario:

La cola de revisión de administrador para la verificación con insignia (/verification) está documentada en la referencia técnica de la página orientada al usuario Verificación de usuario, en lugar de tener una página de administrador independiente, ya que es principalmente una envoltura delgada sobre la lógica de negocio ya existente de esa funcionalidad.

El resto de esta página es la referencia técnica de GraphQL a la que enlazan esos checklists.

Consultas de analítica granular

No existe una única query compuesta de "dashboard" — cada métrica a continuación es un campo de GraphQL separado. adminGetContentStats tiene un resolver real (admin/admin-dashboard.resolver.jsadminDashboardManager.getContentStats, protegido con VIEW_ANALYTICS) que devuelve conteos reales de publicaciones/medios/hashtags. adminGetSystemHealth también devuelve valores reales en todos los campos — tiempo de ida y vuelta a la BD + tiempo de respuesta promedio del plugin de Apollo, estado de Redis/almacenamiento, uso de memoria/CPU/disco del SO, y conexiones activas del pool de la BD — no solo databaseStatus.

# Platform-wide counters
query PlatformStats { adminGetPlatformStats {
totalUsers activeUsersToday activeUsersWeek activeUsersMonth
totalPosts postsToday totalComments commentsToday
totalConversations conversationsToday
totalReports pendingReports
totalRevenue revenueToday revenueMonth
}}

# User growth over time
query UserGrowth($period: String!, $startDate: DateTime, $endDate: DateTime) {
adminGetUserGrowth(period: $period, startDate: $startDate, endDate: $endDate) {
period totalGrowth growthRate
data { date newUsers activeUsers retentionRate }
}
}

# Engagement per day
query EngagementStats($period: String!, $contentType: String) {
adminGetEngagementStats(period: $period, contentType: $contentType) {
period averageEngagement engagementRate
data { date posts comments likes shares messages }
}
}

# Revenue breakdown
query RevenueStats($period: String!, $startDate: DateTime, $endDate: DateTime) {
adminGetRevenueStats(period: $period, startDate: $startDate, endDate: $endDate) {
period totalRevenue growthRate
data { date revenue transactions averageTransaction }
}
}

# Content creation stats by period
query ContentCreationStats($period: String!, $startDate: DateTime, $endDate: DateTime) {
adminGetContentCreationStats(period: $period, startDate: $startDate, endDate: $endDate) {
period data { date posts media }
}
}

# Moderation queue stats
query ModerationQueueStats { adminGetModerationQueueStats {
pendingReports urgentReports highPriorityReports normalPriorityReports
}}

# Top content and users
query TopContent($metric: String!, $contentType: String, $limit: Int, $period: String) {
adminGetTopContent(metric: $metric, contentType: $contentType, limit: $limit, period: $period) {
id type title metricValue author { username }
}
}
query TopUsers($metric: String!, $limit: Int, $period: String) {
adminGetTopUsers(metric: $metric, limit: $limit, period: $period) {
rank metricValue user { username }
}
}

# Subscription analytics
query SubscriptionStats($period: String!) {
adminGetSubscriptionStats(period: $period) {
totalSubscriptions newSubscriptions cancelledSubscriptions revenue growthRate
}
}

# System health - all fields return real values (DB, Redis/storage, OS memory/CPU/disk, DB pool connections, error rate)
query SystemHealth { adminGetSystemHealth {
cpuUsage memoryUsage diskUsage databaseStatus
errorRate requestsPerMinute avgResponseTime
}}

# Hashtag trends
query HashtagStats($limit: Int, $period: String) {
adminGetHashtagStats(limit: $limit, period: $period) { hashtag usageCount trendingScore recentPosts }
}

Exportación de analítica

adminExportAnalytics construye filas reales a partir de los métodos de analítica (usuarios/contenido/interacción/ingresos/moderación), las serializa a CSV o JSON, las sube a S3 (o escribe un archivo local como respaldo), y devuelve un downloadUrl + fileSize reales.

query AdminExportAnalytics($from: DateTime!, $to: DateTime!, $format: String) {
adminExportAnalytics(from: $from, to: $to, format: $format) {
downloadUrl generatedAt
}
}

query AdminActivityLog($limit: Int, $offset: Int, $adminId: ID) {
adminGetActivityLog(limit: $limit, offset: $offset, adminId: $adminId) {
id action targetType targetId performedAt
admin { id username }
}
}

Gestión de usuarios

Buscar y ver usuarios

query AdminGetUsers($search: String, $status: String, $limit: Int, $offset: Int, $orderBy: String) {
adminGetUsers(search: $search, status: $status, limit: $limit, offset: $offset, orderBy: $orderBy) {
total limit offset
users { id status user { id username email } }
}
}

query AdminGetUserDetails($userId: ID!) { adminGetUserDetails(userId: $userId) {
id status suspensionCount warningCount
user { id username email accountStatus }
}}

query AdminGetUserModerationHistory($userId: ID!, $limit: Int, $offset: Int) {
adminGetUserModerationHistory(userId: $userId, limit: $limit, offset: $offset) {
id action reason performedAt admin { username }
}
}

query AdminGetSuspendedUsers($limit: Int, $offset: Int) { adminGetSuspendedUsers(limit: $limit, offset: $offset) { total users { id user { username } } } }
query AdminGetBannedUsers($limit: Int, $offset: Int) { adminGetBannedUsers(limit: $limit, offset: $offset) { total users { id user { username } } } }
query AdminGetUserStats($startDate: DateTime, $endDate: DateTime) { adminGetUserStats(startDate: $startDate, endDate: $endDate) { totalUsers suspendedUsers bannedUsers } }

Suspender, advertir y banear

mutation AdminSuspendUser($userId: ID!, $reason: String!, $durationDays: Int) {
adminSuspendUser(userId: $userId, reason: $reason, durationDays: $durationDays) { success }
}

mutation AdminWarnUser($userId: ID!, $reason: String!, $message: String) {
adminWarnUser(userId: $userId, reason: $reason, message: $message) { success }
}

mutation AdminBanUser($userId: ID!, $reason: String!, $permanent: Boolean) {
adminBanUser(userId: $userId, reason: $reason, permanent: $permanent) { success }
}

mutation AdminUnsuspendUser($userId: ID!, $reason: String) { adminUnsuspendUser(userId: $userId, reason: $reason) { success } }
mutation AdminUnbanUser($userId: ID!, $reason: String) { adminUnbanUser(userId: $userId, reason: $reason) { success } }
mutation AdminRemoveUserContent($userId: ID!, $contentType: String!, $contentIds: [ID!]!) {
adminRemoveUserContent(userId: $userId, contentType: $contentType, contentIds: $contentIds) { success }
}

Moderación masiva

mutation AdminBulkSuspend($userIds: [ID!]!, $reason: String!, $durationDays: Int) {
adminBulkSuspendUsers(userIds: $userIds, reason: $reason, durationDays: $durationDays) {
successCount failedCount
}
}

mutation AdminBulkBan($userIds: [ID!]!, $reason: String!, $permanent: Boolean) {
adminBulkBanUsers(userIds: $userIds, reason: $reason, permanent: $permanent) {
successCount failedCount
}
}

adminBulkBanUsers está protegido para super_admin y llama a banUser por cada usuario (deleteContent: false, notifyUser: false); listo en el backend pero aún no conectado a ninguna interfaz de frontend-admin.

Verificación

mutation AdminVerifyUser($userId: ID!) { adminVerifyUser(userId: $userId) { success } }
mutation AdminRemoveVerification($userId: ID!, $reason: String) { adminRemoveVerification(userId: $userId, reason: $reason) { success } }

Estas mutations son independientes del flujo de solicitud de verificación del esquema de cliente (getPendingVerificationRequests / grantVerification / removeVerification / rejectVerificationRequest, documentado en Verificación) — dos implementaciones independientes de una funcionalidad similar, en esquemas distintos.

Moderación de contenido

Contenido marcado y acciones

adminApproveContent (protegido con MODERATE_CONTENT) y adminRejectContent (protegido con REMOVE_CONTENT) tienen ambos resolvers reales que delegan en contentModerationManager.approveContent/rejectContent.

query AdminGetFlaggedContent($contentType: String, $limit: Int, $offset: Int) {
adminGetFlaggedContent(contentType: $contentType, limit: $limit, offset: $offset) {
total items { id contentType contentId reason flaggedAt }
}
}
query AdminGetContentDetails($contentType: String!, $contentId: ID!) {
adminGetContentDetails(contentType: $contentType, contentId: $contentId) { id contentType }
}
query AdminGetContentModerationHistory($contentType: String!, $contentId: ID!) {
adminGetContentModerationHistory(contentType: $contentType, contentId: $contentId) { id action performedAt }
}
query AdminGetContentModerationStats($startDate: DateTime, $endDate: DateTime) {
adminGetContentModerationStats(startDate: $startDate, endDate: $endDate) { totalReviewed totalRemoved }
}
query AdminGetTrendingContent($limit: Int) { adminGetTrendingContent(limit: $limit) { id contentType } }

mutation AdminRemoveContent($contentType: String!, $contentId: ID!, $reason: String!) {
adminRemoveContent(contentType: $contentType, contentId: $contentId, reason: $reason) { success }
}
mutation AdminRestoreContent($contentType: String!, $contentId: ID!) {
adminRestoreContent(contentType: $contentType, contentId: $contentId) { success }
}
mutation AdminBulkRemoveContent($items: [ContentIdentifierInput!]!, $reason: String!) {
adminBulkRemoveContent(items: $items, reason: $reason) { successCount failedCount }
}
mutation AdminFlagContent($contentType: String!, $contentId: ID!, $reason: String!) {
adminFlagContent(contentType: $contentType, contentId: $contentId, reason: $reason) { success }
}
mutation AdminUnflagContent($contentType: String!, $contentId: ID!) {
adminUnflagContent(contentType: $contentType, contentId: $contentId) { success }
}

Reglas de auto-moderación

Existen resolvers reales en graphql/resolvers/admin/content-moderation.resolver.js, que delegan en operaciones CRUD reales en managers/admin-managers/content-moderation.manager.js contra una tabla real auto_moderation_rule (modelo AutoModerationRule.js), con una interfaz de listado/creación/edición funcional en /moderation/rules. Las reglas además se aplican de verdad, no solo se almacenan — services/auto-moderation.service.js compara tipos de regla keyword/regex/nsfw_score/report_threshold contra el contenido y aplica acciones flag/remove/warn, llamado (best-effort, no bloqueante) desde post.manager.js en cada creación de publicación.

query AdminGetAutoModerationRules { adminGetAutoModerationRules { id name enabled } }
mutation AdminCreateAutoModerationRule($input: AutoModerationRuleInput!) { adminCreateAutoModerationRule(input: $input) { id } }
mutation AdminUpdateAutoModerationRule($id: ID!, $input: AutoModerationRuleInput!) { adminUpdateAutoModerationRule(id: $id, input: $input) { id } }
mutation AdminDeleteAutoModerationRule($id: ID!) { adminDeleteAutoModerationRule(id: $id) { success } }

Advertencias de contenido

El bug de nombres de argumentos de adminAddContentWarning ya está corregido — el resolver ahora desestructura warning/severity, igual que el esquema. El resolver de adminRemoveContentWarning todavía ignora por completo el argumento warningId del esquema (el removeContentWarning del manager no tiene ese parámetro) — una llamada real todavía le pasa undefined silenciosamente.

mutation AdminAddContentWarning($contentType: String!, $contentId: ID!, $warning: String!, $severity: String) {
adminAddContentWarning(contentType: $contentType, contentId: $contentId, warning: $warning, severity: $severity) { success }
}
mutation AdminRemoveContentWarning($contentType: String!, $contentId: ID!, $warningId: ID!) {
adminRemoveContentWarning(contentType: $contentType, contentId: $contentId, warningId: $warningId) { success }
}

Cola de revisión de reportes

adminEscalateReport y adminDismissReport se eliminaron por completo del esquema (tanto la copia de administrador como la de cliente en content-report.type.js, con sus resolvers borrados) — ya no se pueden invocar. La escalación ocurre mediante adminReviewReport con action: ESCALATE (pone el estado en reviewing), y el descarte mediante adminReviewReport con action: REJECT (pone el estado en dismissed) — ambos son lo que ya usan los botones Escalar/Rechazar de la página de moderación.

query AdminGetReports($status: ReportStatus, $contentType: ContentType, $reportType: ReportReason, $limit: Int, $offset: Int) {
adminGetReports(status: $status, contentType: $contentType, reportType: $reportType, limit: $limit, offset: $offset) {
total limit offset
reports { id reporterId contentType contentId reason status createdAt }
}
}
query AdminGetReportsByUser($userId: ID!, $limit: Int, $offset: Int) {
adminGetReportsByUser(userId: $userId, limit: $limit, offset: $offset) { total reports { id status } }
}
query AdminGetReportStats($startDate: DateTime, $endDate: DateTime) {
adminGetReportStats(startDate: $startDate, endDate: $endDate) { total pending resolved }
}
query AdminGetReportDetails($reportId: ID!) { adminGetReportDetails(reportId: $reportId) { id status } }

mutation AdminReviewReport($reportId: ID!, $action: String!, $notes: String, $removeContent: Boolean) {
adminReviewReport(reportId: $reportId, action: $action, notes: $notes, removeContent: $removeContent) { success }
}
mutation AdminBulkReviewReports($reportIds: [ID!]!, $action: String!, $notes: String) {
adminBulkReviewReports(reportIds: $reportIds, action: $action, notes: $notes) { successCount failedCount }
}

Controles de comentarios

Implementado dentro de content-moderation.resolver.js, a pesar de estar declarado en un archivo post-actions.type.js separado (no existe un post-actions.resolver.js dedicado).

mutation AdminDisableComments($contentType: String!, $contentId: ID!) { adminDisableComments(contentType: $contentType, contentId: $contentId) { success } }
mutation AdminEnableComments($contentType: String!, $contentId: ID!) { adminEnableComments(contentType: $contentType, contentId: $contentId) { success } }

Gestión de cuentas de administrador

Las cuentas de administrador se autogestionan a través de este mismo esquema de administrador. Existen tres roles: moderator, admin, super_admin (ver Roles de administrador).

Perfil propio e inicio de sesión

query AdminMe { adminMe { id username email role permissions twoFactorEnabled lastLoginAt } }

mutation AdminLogin($input: AdminLoginInput!) {
adminLogin(input: $input) { success token refreshToken admin requires2FA tempToken }
}
mutation AdminUpdateProfile($input: AdminProfileUpdateInput!) { adminUpdateProfile(input: $input) { id email } }
mutation AdminChangePassword($input: AdminPasswordChangeInput!) { adminChangePassword(input: $input) }

# Sin autenticación - el propio refresh token es la credencial. Siempre
# rota: el refresh token recibido deja de funcionar en cuanto esta mutation
# tiene éxito, lo use o no el llamador. Nunca lanza un error de GraphQL - un
# refresh token revocado o expirado vuelve como {success: false, message}.
mutation AdminRefreshToken($refreshToken: String!) {
adminRefreshToken(refreshToken: $refreshToken) { success token refreshToken expiresAt message }
}

2FA de administrador

Todas las mutations de 2FA de administrador funcionan correctamente, incluyendo adminConfirm2FA, adminDisable2FA y adminRegenerateBackupCodes — cada una devolvía antes un objeto envoltorio (por ejemplo { success: result } o { backupCodes }) donde el esquema declara un escalar simple (Boolean! / [String!]!), lo cual el serializador de escalares de graphql-js rechazaba; las tres ahora devuelven el escalar puro.

mutation AdminVerify2FA($tempToken: String!, $code: String!) {
adminVerify2FA(tempToken: $tempToken, code: $code) { success token refreshToken admin }
}
mutation AdminVerifyBackupCode($tempToken: String!, $backupCode: String!) {
adminVerifyBackupCode(tempToken: $tempToken, backupCode: $backupCode) { success token refreshToken remainingBackupCodes }
}
mutation AdminEnable2FA { adminEnable2FA { secret qrCode backupCodes } }
mutation AdminConfirm2FA($code: String!) { adminConfirm2FA(code: $code) }
mutation AdminDisable2FA($password: String!) { adminDisable2FA(password: $password) }
mutation AdminRegenBackupCodes($password: String!) { adminRegenerateBackupCodes(password: $password) }

Sesiones propias

adminActiveSessions, adminRevokeSession y adminRevokeAllSessions operan sobre las sesiones de inicio de sesión del propio administrador que llama (identificadas por admin.adminId del JWT) — no sobre las de otros usuarios ni otros administradores. Las tres funcionan correctamente; adminRevokeSession y adminRevokeAllSessions tenían antes el mismo problema de objeto envoltorio vs. escalar descrito arriba y ahora devuelven el Boolean! puro.

query AdminActiveSessions { adminActiveSessions { id deviceName browser os ip location lastActivity isCurrent } }
mutation AdminRevokeSession($sessionId: ID!) { adminRevokeSession(sessionId: $sessionId) }
mutation AdminRevokeAllSessions { adminRevokeAllSessions }

Gestión de otros administradores

Solo para super_admin.

adminActivate y adminDelete ahora funcionan correctamente — ambos tenían antes el mismo problema de objeto envoltorio vs. escalar y ahora devuelven el Boolean! puro.

query AdminUser($id: ID!) { adminUser(id: $id) { id username role isActive } }
query AdminUsers($role: AdminRole, $isActive: Boolean) {
adminUsers(role: $role, isActive: $isActive) { id username email role isActive lastLoginAt }
}
query AdminStats { adminStats { totalAdmins activeAdmins adminsByRole } }

mutation AdminRegister($input: AdminRegistrationInput!) { adminRegister(input: $input) { id username role } }
mutation AdminDeactivate($adminId: ID!) { adminDeactivate(adminId: $adminId) }
mutation AdminActivate($adminId: ID!) { adminActivate(adminId: $adminId) }
mutation AdminDelete($adminId: ID!, $reason: String) { adminDelete(adminId: $adminId, reason: $reason) }
mutation AdminBulkDeactivate($adminIds: [ID!]!, $reason: String) { adminBulkDeactivate(adminIds: $adminIds, reason: $reason) { successCount failedCount } }
mutation AdminBulkActivate($adminIds: [ID!]!, $reason: String) { adminBulkActivate(adminIds: $adminIds, reason: $reason) { successCount failedCount } }

Permisos y roles

query AdminHasPermission($permission: String!) { adminHasPermission(permission: $permission) }
query AdminPermissions($adminId: ID) { adminPermissions(adminId: $adminId) { permission description category hasPermission } }

mutation AdminUpdateRole($adminId: ID!, $input: AdminRoleUpdateInput!) {
adminUpdateRoleAndPermissions(adminId: $adminId, input: $input) { id role permissions }
}
mutation AdminBulkUpdatePermissions($updates: [AdminBulkPermissionUpdate!]!, $reason: String) {
adminBulkUpdatePermissions(updates: $updates, reason: $reason) { successCount failedCount }
}