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
| Rol | Descripción |
|---|---|
moderator | Puede revisar reportes y moderar contenido |
admin | Moderador + gestión de usuarios |
super_admin | Acceso 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_adminmediante la variable de entornoADMIN_REQUIRE_2FA_SUPER_ADMIN(desactivada por defecto) - Lista blanca de IP — un modelo
AdminIPWhitelist, access service y manager, expuestos medianteadminIPWhitelist(query) yadminAddIPWhitelist/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 actividad —
adminGetActivityLogregistra 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:
- Dashboard de analítica
- Moderación de usuarios
- Moderación de contenido
- Cuentas de administrador
- Pagos y retiros
- Revisión de verificación de identidad
- Disputas de la tienda
- Revisión de promociones de publicaciones
- Revisión de apelaciones
- Estado del entorno
- Control de versiones de la app
- Comentarios de usuarios
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.js → adminDashboardManager.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 }
}