Hoja de ruta — Features pendientes por implementar
Una lista de trabajo genuinamente incompleto o faltante, compilada leyendo el código actual directamente (no confiando en documentación anterior — varios "huecos" documentados anteriormente resultaron estar ya arreglados para cuando se escribió esta lista, p. ej. storyViewers, getCurrentSession, terminateAllSessions, la aplicación de canStory, las pestañas de descubrimiento de RecommendationsSection, y la UI de invitar/eliminar de CollaboratorsModal). Marca los elementos a medida que se completen, y vuelve a verificar contra el código antes de asumir que un elemento sigue pendiente — esta lista se desactualizará de la misma forma que las demás.
Cada elemento incluye su impacto real y la evidencia de por qué sigue incompleto.
Seguridad y prevención de abuso
- No hay protección contra fuerza bruta en el login de usuarios normales. El login de administrador sí tiene un bloqueo real por intentos fallidos (
MAX_FAILED_LOGIN_ATTEMPTS,admin-user.manager.js), pero la mutationloginde cara al cliente (authentication.manager.js) no tiene ninguno — sin bloqueo, sin límite de tasa, sin CAPTCHA como respaldo. Impacto: Alto — un atacante puede intentar contraseñas ilimitadas contra cualquier cuenta. Arreglado:login()ahora replica el bloqueo de administrador (nuevas columnasUser.failedLoginAttempts/lockedUntil+ migración, límite de tasa por usuario en memoria víarate-limiter.service.js, mensajes traducidos de bloqueo/intentos restantes en en/es — lo cual también arregló claves de traducción faltantes que el propio flujo de administrador nunca tuvo para esos mismos mensajes). - No hay límite de tasa por IP en los endpoints sensibles de autenticación. El directive
@rateLimitde GraphQL ya está conectado en el esquema (api/server.js) y funciona (respaldado por Redis en producción, con reserva en memoria en desarrollo), pero se aplica a exactamente dos campos en todo el backend (recordAdImpression/recordAdClick,ad-revenue.type.js).login,requestPhoneOtp(cuesta dinero real por SMS en cada llamada),forgotPassword,register,verifyLoginTwoFactor, yadminLoginno tienen ningún límite de tasa. Impacto: Alto para OTP (costo directo por cada llamada de abuso) y login (relleno de credenciales); Medio para registro/reseteo de contraseña (spam/enumeración). Arreglado: se agregó@rateLimitalogin,register,requestPasswordReset,verifyLoginTwoFactor,requestPhoneOtp,loginWithPhone,adminLogin,adminVerify2FA. También se arregló un bug real encontrado de paso: elkeyGeneratorcompartido enapi/server.jstenía un cuerpo de bloque sinreturn, así que todo campo@rateLimitexistente (incluyendo los dos de arriba) estaba usandoundefinedcomo clave en silencio, en vez de por IP. - No hay middleware global de límite de tasa de peticiones.
express-rate-limites una dependencia instalada pero no está montada en ningún lugar deapi/server.js— no hay ningún límite general por IP sobre el tráfico de/graphqlo/admin/graphql, solo los dos campos decorados con@rateLimitde arriba. Arreglado: montado en/web/graphqly/admin/graphql(instancias de límite separadas), reutilizando el umbral ya existenteRATE_LIMITS.API_GENERALdeconstants/admin-security.js. - Los valores de límite de tasa / umbral de abuso no son configurables desde la UI de administración.
RATE_LIMITSes un objeto plano fijado enconstants/admin-security.js, consumido directamente porrate-limiter.service.js— cambiar un umbral durante un incidente activo de abuso significa un despliegue de código, no una edición en el panel de administración. No existe ninguna query/mutation ni página de frontend para esto. Impacto: Bajo-Medio. Arreglado:rate-limiter.service.jsahora superpone anulaciones de administrador (persistidas vía el mecanismo de configuración JSON desystem-settings.manager.js, que ya existía pero no estaba conectado) sobre los valores predeterminados fijos, aplicadas de inmediato en memoria sin necesidad de reiniciar; nuevo esquemaadminGetRateLimits/adminUpdateRateLimit/adminResetRateLimitsolo para super-admin + una nueva página de administración/system/rate-limits(RateLimitsContent.tsx) para editarlos. - No hay lista de bloqueo de IP/dispositivo para usuarios finales (prevención de evasión de bans). Un usuario baneado puede simplemente volver a registrarse desde la misma IP/dispositivo hoy — no hay nada que lo impida. La única funcionalidad de lista de IP en el código (
admin-ip-whitelist.manager.js, la página/ip-whitelist) está enfocada por completo en poner en lista blanca IPs para el acceso de login de administradores, no para bloquear cuentas de usuarios finales; el modeloUserno tiene ningún campo tipolastLoginIp/deviceFingerprint, y no existe ninguna mutation de ban por IP/dispositivo en ningún lugar. Impacto: Alto — esta es una de las necesidades de confianza y seguridad más comunes para una plataforma social/de creadores, y esta plataforma no tiene nada para ello. Arreglado: nuevas columnasUser.lastLoginIp/lastLoginDeviceId(pobladas en registro e inicio de sesión en todos los caminos de creación de cuenta: email/contraseña, Firebase, Apple, OTP por teléfono) más una nueva tablablocked_identifier+ban-evasion.manager.js, verificada enregister()/login()antes de cualquier otro trabajo. Nuevo esquemaadminBlockIdentifier/adminUnblockIdentifier/adminGetBlockedIdentifiersprotegido porBAN_USERS, una nueva página de administración/moderation/blocklist, y la última IP/dispositivo conocidos ahora se muestran enadminGetUserDetailscon una acción en línea "Bloquear IP"/"Bloquear dispositivo" directamente en la página de detalle del usuario. Hueco conocido, revelado honestamente: el bloqueo a nivel de dispositivo solo funciona si el cliente envía undeviceId(opcional) enUserRegistrationInput— el formulario de registro deapps/frontend-nextjstodavía no genera/envía uno, así que hoy el bloqueo por dispositivo está listo en el backend pero no se ejerce de extremo a extremo desde la web; el bloqueo por IP no tiene ese hueco porque la IP la deriva el servidor, no la envía el cliente, así que es totalmente efectivo hoy. -
grantVerification/removeVerificationno realizan ninguna verificación real de autorización de administrador.graphql/resolvers/verification.resolver.jsprotege ambas mutations solo conif (!context.user)(nocontext.admin), y luego pasa el propio id de usuario regular de quien llama como si fuera el administrador que actúa. La verificación internaisAdmin()del manager (managers/user-managers/verification-badges.manager.js) compruebauser.accountType === 'admin' || user.is_admin === true, pero nada en el código jamás fijaaccountTypea'admin'yis_adminno es una columna real — así que hoy las mutations son autodestructivas (nadie puede pasar la verificación), pero el resolver en sí no tiene ninguna autorización real, y el propio comentario de una mutation hermana confirma que el camino de rechazo ya tuvo este mismo hueco arreglado. Impacto: Alto — un bug latente de verificación de privilegios que se reabriría para cualquier usuario autenticado en el momento en que se arregle la verificación interna sin arreglar también el resolver. Arreglado: se retiraron ambas mutations del esquema/resolver por completo en vez de parchear la verificación en su lugar — esta ruta nunca puede llevar un contexto de administrador real (context.adminsolo se llena en/admin/graphql, vergraphql/context/index.js), y el equivalente correctamente protegido (adminVerifyUser/adminRemoveVerification) ya existe y es lo que realmente llama el panel de administración. - Seguir/dejar de seguir/bloquear en masa tienen un límite de tamaño por llamada pero ninguna aplicación de límite de abuso diario/por hora.
managers/user-managers/bulk-operations.manager.js#getBulkLimitscalculadailyFollowLimit/hourlyFollowLimit/dailyBlockLimit, etc., pero la verificación de uso está comentada (// TODO: Check current usage against limits).bulkFollowUsers/bulkUnfollowUsers/bulkBlockUserssolo limitan el tamaño del lote de una sola llamada (50 por defecto) — nada impide llamarlas cientos de veces por hora. Impacto: Alto — un vector real de spam de seguir/bloquear en masa, distinto de los huecos de límite de tasa de login/OTP de arriba. Arreglado: las tres ahora verifican el uso real (sumado desde filas debulk_operation) contra el límite antes de hacer cualquier trabajo;getBulkOperationLimitstambién ahora devuelvecurrentUsage/remainingreales en vez del stub comentado. También se arreglaron dos bugs adyacentes:bulkUnfollowUsers/bulkBlockUsersnunca verificaban siquiera que el usuario que actúa existiera, ybatch_size_exceededno tenía traducción (mostraba la clave cruda en silencio). - Las transferencias de billetera de plataforma de administrador (
transferToUser/takeFromUser/topUpenmanagers/coin-managers/platform-wallet.manager.js) no registran qué administrador actuó. Cada una acepta un parámetroadminIdpero nunca lo persiste — no hay columnaadminId/performedByenCoinTransaction, ni entrada de registro de auditoría. Un super-admin puede mover monedas hacia o desde el saldo de cualquier usuario sin ningún registro de quién lo hizo. Impacto: Medio-Alto — financieramente sensible y no cubierto por ningún otro sistema de registro de acciones de administrador. Arreglado:transferToUser/takeFromUserahora estampan el administrador que actuó enCoinTransaction.relatedId; las tres (incluyendotopUp, que no tiene fila de libro contable por usuario a la cual adjuntarse) también registran víaanalyticsService.trackAdminAction— el mismo mecanismo que respalda el registro de actividad de administración.
Huecos de lógica de backend (stubs reales, no solo UI faltante)
- Todo el sistema de restricción de cuentas no tiene capa de persistencia.
restrictUser/unrestrictUser/addRestriction/removeRestriction/getUserRestrictions/getRestrictionStats/cleanupExpiredRestrictionstienen todos// TODO: store/deactivate/get restriction details in databasey no hacen nada o devuelven un objetomockRestrictions/estadísticas en cero fabricado;checkRateLimitusa unmockCurrentUsage = { hourly: 5, daily: 25 }fijo sin importar el uso real.getRestrictedUsers(admin) siempre devuelve[]. Archivo:managers/user-managers/restrictions-limits.manager.js. Impacto: Alto — toda la herramienta de moderación/soft-ban "restringir usuario" no registra nada, no envía notificaciones, y las verificaciones de límite de tasa son falsas. Arreglado: nueva tablaaccount_restriction(con ese nombre para no chocar con el modeloUserRestrictionya existente y real — la función social "Restringir" entre usuarios) respalda cada método de verdad;checkRateLimitahora cuenta filas reales de las tablas de posts/comentarios/mensajes/follows/likes en vez de un mock fijo. También se arreglaron varios bugs descubiertos al conectar esto: la verificación interna de admin comprobabaUser.accountType==='admin'(el mismo patrón muerto ya retirado de grantVerification/removeVerification, reemplazado por una búsqueda real en AdminUser); la forma de retorno degetUserRestrictionsnunca pudo haber serializado contra su propio tipo de esquema[UserRestriction!]!;getRestrictedUsersestaba conectado permanentemente al método de manager equivocado (un objeto de estadísticas, no una lista de usuarios) y la verificaciónuser.isAdminde su resolver nunca podía ser verdadera en esa ruta — reemplazado por queries/mutations propiamenteadmin-prefijadas (adminGetRestrictedUsers,adminGetRestrictionStats,adminRestrictUser,adminUnrestrictUser,adminAddUserRestriction,adminRemoveUserRestriction) protegidas por una verificación real del permisoBAN_USERS; y dos discordancias silenciosas de nombres de acción (postvsposting_disabled,postvsposts_per_hour) que un valor de respaldo|| 10/|| 50había estado ocultando. No se construyó UI de panel de administración nueva para las nuevas mutations en esta pasada — un admin ya puede invocarlas hoy, pero todavía no hay ninguna página de/moderationconectada a ellas. - Menciones/Tags: "quién me mencionó", "a quién mencioné", y la aprobación de etiquetas nunca tocan datos reales.
getUsersMentionedBy/getUsersIMentioned/getTaggedInPosts(mentions-tags.manager.js) siempre devuelven arreglos vacíos simulados sin importar el input — la consulta real contraPostMention/MessageMention(comentada en el código) nunca se escribió;approveTag/rejectTag/getTagPermissions/updateTagPermissionsnunca persisten (solo hacen log a consola). El frontend (Configuración → Historial de menciones,MentionHistoryPage.tsx) está completamente conectado y las llama correctamente; simplemente siempre renderiza vacío. Impacto: Medio-Alto — una funcionalidad real, visible, siempre rota, más un flujo de aprobación de etiquetas pendientes que sería un no-op silencioso si se construyera. Corregido:getUsersMentionedBy/getUsersIMentionedahora ejecutan consultas reales contraPostMention(tags/menciones en captions de posts) yMessageMention(@menciones en chat - nuevodata-access-services/message/message-mention.access-service.js, ese modelo ya existía pero no tenía todavía un access-service de lectura), combinadas y deduplicadas a una entrada por mencionador/mencionado, de más reciente a más antiguo - coincidiendo con la forma[User!]!del schema que el resolver ya esperaba pero el mock viejo nunca producía.getTaggedInPostsahora ejecuta una consulta real dePostMentioncon la forma de los tiposTaggedPostsResponse/TaggedPostdel schema. Al investigarapproveTag/rejectTagse descubrió que nunca fueron realmente alcanzables (ningún campo del schema los declara -graphql/types/user-features.type.jssolo declarabaupdateTagPermissions) y están completamente superados por un flujo ya real, con nombres distintos,approvePostTag/rejectPostTag(post.manager.js,graphql/types/tag-review.type.js) construido para exactamente la misma funcionalidad de revisión de etiquetas - así que en vez de resucitar stubs duplicados y muertos, se eliminaron (junto con el también muerto y duplicadomentions-tags.manager.js#canTagUser- una copia distinta e inalcanzable delcanTagUserreal ya corregido encontent-tag.manager.js, ver el ítem de comprobaciones de bloqueo/privacidad arriba), junto con sus wrappers inalcanzables en la fachadamanagers/user-managers/index.js.getTagPermissions/updateTagPermissionsahora leen/escriben a través de la misma configuración de privacidad real ya persistida (who_can_tag/require_approval_for_tagsenUser.settings.privacy, víaprivacy-settings.manager.js) que ya aplica cada ruta de código real de creación de etiquetas/menciones, en vez de un objeto paralelo que nunca se guardaba en ningún lado; también se corrigió el resolver deupdateTagPermissions, que antes tragaba un error del manager en un objeto{success:false, message}aunque el tipo de retornoTagPermissions!del schema no tiene esa forma (ahora lanza el error, como cualquier otra mutación del archivo). De paso, se corrigió el mismo bug de desplazamiento de argumentos encontrado repetidamente en este pase engetMentionTagStats: el resolver ignoraba por completo el argumentotimeframedel schema y llamaba al manager con 2 argumentos posicionales cuando la firma real necesita 3;getMentionTagStatsen sí ahora se respalda con conteos reales de ambas tablas de menciones (antes ceros fijos), dejandotopMentioners/topTaggerscomo un arreglo vacío honesto ya que ordenarlos necesita una consulta agregada de agrupación que no existe en ningún lugar de este código y este campo no tiene ningún llamador de frontend hoy que justifique construirla especulativamente. No se necesitó UI de frontend nueva:MentionHistoryPage.tsxya consumeusersMentionedBy/usersIMentioneddirectamente (confirmado real según la propia nota del roadmap arriba) y ahora renderiza datos reales en vez de listas siempre vacías;taggedInPosts/tagPermissions/updateTagPermissions/getMentionTagStatssiguen sin un consumidor de frontend, pero la investigación encontró que esto es intencional, no un hueco -TagsScreenPage.tsx(pestañas Etiquetado/Pendiente) yTagsAndMentionsPage.tsx(controles de privacidad de etiquetas/menciones) ya implementan completamente las funcionalidades equivalentes de cara al usuario a través de un par distinto y ya correcto de campos de GraphQL (taggedPosts/pendingTagReviews+approvePostTag/rejectPostTag, yprivacySettings+updatePrivacySettings/updateExtendedPrivacySettings, respectivamente) - corregir estos campos igual importa para un futuro cliente de iOS/Android o cualquier otro llamador que los alcance directamente, según la regla de completitud del schema de este proyecto. Se añadieron nuevos archivos de operación.graphqlpara las seis queries/mutations corregidas (Web/Profile/{UsersMentionedByInline,UsersIMentioned,TagPermissionsInline,UpdateTagPermissionsInline,TaggedInPostsInline,GetMentionTagStatsInline}.graphql, dos de las cuales ya existían) y se recompilóapollo-web. - El conteo de conexiones mutuas está fijado en 0.
advanced-social.manager.js#getMutualConnectionsCountes un// TODO: Implement efficient mutual connections counting … return 0de una línea, que alimentanetworkMetrics.mutualConnectionsCounty el mapeo de sugerencias de contactos (mutualConnectionsenContactSuggestiontambién está fijado por separado en0, encontact-import.manager.js). Impacto: Medio — un número visible que siempre es cero donde sea que se muestren las conexiones mutuas. Arreglado:getMutualConnectionsCountahora usa un nuevouser-follow.access-service.js#countReciprocalFollows(conteo real de follows recíprocos); el hardcode deContactSuggestion.mutualConnectionsen realidad ya se había movido agraphql/resolvers/user-contact-import.resolver.jspara cuando se arregló esto (ya no encontact-import.manager.js— la nota del roadmap estaba desactualizada) y ahora usa el ya existente y ya realgetMutualFollowing(viewerId, targetId). También se encontraron y arreglaron dos lugares más con el mismo hardcode a 0 en la zona, ambos ensearch-discovery.manager.js(getPopularUserSuggestions,getNewUserSuggestions) — arreglados reutilizando el propio_computeSearchRelationsde ese archivo, la misma consulta agrupada quesearchUsersya usaba. Un tercer lugar sospechoso (search-discovery.manager.js:1147, referenciado por un comentario como todavía roto) resultó ya estar correcto:searchUserssobrescribe ese valor fijo por defecto con el valor real calculado inmediatamente después, así que solo quedó como un comentario desactualizado para futuros lectores, no un bug real en producción. - Los niveles de privacidad de ubicación no funcionan.
user-location.manager.js#canSeeLocationtiene tres comentarios// TODOy siemprereturn truedespués de la verificación del propio usuario;setLocationPrivacyescribe un valorprivacy_levelen una columna que no existe en el modeloUserLocation, así que Sequelize lo descarta en silencio — un no-op. El único control de privacidad de ubicación real y aplicado hoy es el booleano simpleisPublic. Impacto: Medio. Corregido: nueva columnaUserLocation.privacyLevel(public/friends/private, la migración establecepublicpor defecto en las filas existentes para preservar el comportamiento real actual, ya que la verificación anterior siempre devolvíatrue);canSeeLocationahora sí evalúa el valor (private→ false,friends→ delega en elclose-friends.manager.js#isCloseFriendreal,public/por defecto → true); se corrigiósetLocationPrivacypara escribir el atributo camelCaseprivacyLevelen lugar del snake_case que se descartaba en silencio. Se expusieron en el schema la nueva querycanSeeLocation(userId)y la mutaciónsetLocationPrivacy(privacyLevel)(antes ninguna existía en el tipo orientado al cliente). Implementado de punta a punta: la página ya existente deapps/frontend-nextjsenConfiguración → Compartir ubicación(LocationSharingPage.tsx) ahora tiene un control real de público/amigos cercanos/privado conectado a la nueva mutación, con textos i18n enen.jsonyes.json. -
getDataPortabilityInfodevuelve una forma completamente equivocada, y la salida de exportación de datos en CSV/XML es de calidad de relleno incluso donde es alcanzable.getDataPortabilityInforeenvía agetDataExportStatus, que no tiene ninguna de las clavescanExport/exportFormats/retentionPeriod/lastExportque el esquema declara no-nulas, así que consultarlo lanza un error. Por separado,DataExportRequestInputno tiene ningún campoformat— no hay forma de solicitar CSV/XML vía GraphQL — y hasta el propioconvertToCSV/convertToXMLdel manager (data-export.manager.js) solo emiten un resumen de conteo de 2-3 líneas, no los datos exportados reales, a diferencia del camino JSON que sí es completamente real. Impacto: Bajo-Medio. Corregido:getDataPortabilityInfoahora es un método real dedata-export.manager.jsque devuelve la forma real deDataPortabilityInfo(canExportenfalsemientras hay una exportación pendiente/procesándose,exportFormats: ['json','csv','xml'], unretentionPeriodde 30 días acorde con la ventanaexpiresAtya existente,lastExporta partir de la filareadymás reciente vía un nuevo métodofindLatestCompletedByUserIden el access-service). Se añadióDataExportRequestInput.format(y el correspondienteDataExportStatus.format) al schema (backend y los dos espejosschema.web.graphqls/schema.admin.graphqls) y se conectó arequestDataExport, con validación real (validation.invalid_export_format) que rechaza cualquier valor fuera de json/csv/xml.convertToCSV/convertToXMLahora emiten los datos reales fila por fila (perfil/posts/comentarios/mensajes/seguidores/seguidos) con el escapado correspondiente, en lugar de un resumen de conteo de 2-3 líneas. Implementado de punta a punta: la página ya existenteConfiguración → Descargar tu información(DownloadDataPage.tsx) ahora tiene un selector de formato real conectado a la mutación de solicitud y muestra la información de portabilidad en vivo, con textos i18n enen.jsonyes.json. - Un par de interruptores de notificaciones de seguridad muerto y roto convive con uno que sí funciona.
enableSecurityNotifications/disableSecurityNotifications/getSecurityNotificationSettingsnunca persisten (TODO: Update user security notification settings, siempre devuelve valores predeterminados) mientras una implementación separada y real (getSecurityNotificationPrefs/updateSecurityNotificationPrefs) existe en el mismo archivo y es la que realmente está conectada a la UI. Ambos pares están expuestos vía GraphQL. Archivo:managers/user-managers/security-alerts.manager.js. Impacto: Medio — llamar al par muerto devuelvesuccess: truepero no hace nada en silencio. Corregido: se retiraron por completoenableSecurityNotifications/disableSecurityNotifications/getSecurityNotificationSettings(métodos del manager, mutaciones GraphQL, espejo del schema) en lugar de parchearlos - el mismo precedente que el retiro anterior degrantVerification/removeVerification, ya que ya existía un equivalente real y conectado a la UI que funcionaba correctamente. Se encontró un bug más serio al rastrear al único llamador real del par muerto: el código real de envío de alertas (sendSecurityNotification, usado por cada llamada acreateSecurityAlert) evaluaba la configuración falsa y fija del stub muerto, no las preferencias reales degetSecurityNotificationPrefs— es decir, desactivar tipos de notificación en la UI nunca suprimió nada en realidad, para ningún usuario, nunca. Se corrigió reconectandosendSecurityNotificationpara verificar la preferencia real por tipo (vía un nuevo mapaALERT_TYPE_TO_NOTIFICATION_PREF) antes de enviar; los tipos de alerta sin una preferencia correspondiente (phone_change, eventos de API key) no tienen interruptor que verificar y siempre se envían, y el envío por SMS ya no depende de la banderasmsdel stub antiguo (que siempre erafalse, es decir que nunca se había enviado ningún SMS de seguridad) — ahora se envía siempre que el usuario tenga un teléfono verificado. -
getDeviceStatsfabrica su respuesta. Devuelve números fijos (totalDevices: 3, activeDevices: 2, cadenas de ubicación falsas) sin importar los dispositivos reales del usuario — no es solo "sin UI" (ver abajo), el propio backend nunca consulta datos reales de dispositivos. Archivo:managers/user-managers/sessions-devices.manager.js. Impacto: Medio — cualquier widget de "tus dispositivos" en una página de seguridad construido contra esto mostraría datos falsos desde el primer día. Corregido: ahora deriva cada número del historial real deuser_sessiondel usuario mediante un nuevoUserSessionAccessService.getAllForStats. Los dispositivos se agrupan por la tupladeviceType/deviceName/browser/os(no existe una columna dedicada de id de dispositivo);totalDevices/activeDevices/deviceTypes/platforms/browsersprovienen de esa agrupación,locationsde la geolocalización real de las sesiones, ysuspiciousActivity.newDevices/newLocationsde comparar la ventana solicitada contra el historial de sesiones del usuario anterior a ella — un dispositivo/ubicación sin sesión previa se considera "nuevo".failedLoginsse reporta honestamente como0en lugar de un número fabricado, ya que (como ya se documentaba engetLoginHistory) todavía no existe un registro de intentos de inicio de sesión — solo los inicios de sesión exitosos crean una fila de sesión. También se corrigió el resolver (user-sessions.resolver.js): descartaba en silencio el propio argumentotimeframedel schema y pasabacontexten la posición del parámetrooptionsdel manager, por lo que elcontextreal del manager siempre era{}— la misma clase de bug de desplazamiento de argumentos documentada más abajo parauserStats. Ninguna página del frontend consume todavía este campo (getDeviceStats(timeframe: String): JSON!no tiene llamadores enfrontend-nextjs/frontend-admin), así que no se construyó UI en este pase — este ítem trataba específicamente sobre la fabricación en el backend. -
reviewVerificationRequest(una mutation expuesta en el esquema) es un stub fijo que opera sobre el usuario equivocado. Nunca busca la solicitud real; usamockRequest = { userId: 'user_123', ... }, así que aprobar/rechazar cualquierrequestIdreal en realidad opera sobre unuser_123inexistente. Existe una alternativa que sí funciona para el panel de administración real (adminVerifyUser/adminRejectVerificationRequest), así que esta mutation específica es redundante-pero-rota en vez de ser el único camino. Archivo:managers/user-managers/verification-badges.manager.js. Impacto: Medio (expuesta en el esquema, se comportaría mal en silencio para cualquier invocador). Corregido: se retiró por completo (schema, resolver, método del manager) en lugar de parchearse - resultó tener la misma causa raíz que las ya retiradasgrantVerification/removeVerification: estaba protegida solo porcontext.user.isAdmin, que nunca puede ser verdadero en esta ruta orientada al cliente web (context.adminsolo se popula en/admin/graphql), así que estaba doblemente rota (inalcanzable y operando sobre un usuario simulado aunque fuera alcanzable).adminVerifyUser/adminRejectVerificationRequestya cubren el camino real y funcional de revisión de administrador. Al investigar esto, se encontró y corrigió un segundo bug, más urgente, en el mismo archivo:verificationRequest(requestId)— la query que un usuario normal necesita para consultar el estado de su propia solicitud enviada — también estaba permanentemente rota, ya que sugetVerificationRequestByIdde respaldo era un stub fijo que siempre devolvía null. A diferencia de la mutation, esta no tenía un reemplazo funcional y no existe una tabla de solicitudes separada (una "solicitud" es simplemente el estado pendiente en la propia fila deUser, igual que ya usa elgetVerificationRequest(userId)que sí funciona) — se corrigió buscando al usuario directamente, ya querequestIdyuserIdson el mismo id en este esquema. -
reportConversationnunca persiste el reporte. Construye un objeto de reporte con// TODO: Create report in content_reports table / Save to database and notify moderatorsy simplemente lo devuelve — los moderadores nunca lo ven. Esta es una mutation en vivo, expuesta en el esquema, de confianza y seguridad que parece tener éxito pero no hace nada. Archivo:managers/message-managers/conversation.manager.js. Impacto: Alto. Corregido: ahora pasa por el mismo pipeline real decontent_reportque ya usan los reportes de posts/comentarios/mensajes/usuarios (managers/admin-managers/content-report.manager.js#createReport→ContentReportAccessService) — persistencia real, la verificación de duplicados ya existente ("ya reportaste esto") y la alerta administrativa para reportes urgentes ya existente, todo gratis. Se necesitaron dos extensiones del schema, ya quecontent_report.reported_user_id/content_typeson columnas reales, no-nulas y respaldadas por un enum que antes no contemplaban conversaciones: tanto el enumContentTypecomo el tipoReportsByTypede las estadísticas de admin ganaron un valor/campoconversation(reflejado enschema.web.graphqls/schema.admin.graphqlsy en la lista permitida decontent-report.validator.js), de modo que un reporte de conversación persistido realmente pueda listarse/filtrarse/contarse a través de la UI de reportes de admin ya existente, en lugar de quedar en la tabla sin que nadie lo vea. Dado quereported_user_ides singular pero una conversación puede tener varios participantes, el reporte apunta al otro participante en una conversación directa (1:1), o al creador del grupo en caso contrario. La razón'inappropriate', específica de esta mutation (parte de su contrato de entrada ya existente), no tiene equivalente en el enum compartidoReportReason- en lugar de ampliar ese enum (y cada consumidor deReportsByReason/codegen que dependa de él) por el sinónimo de un solo tipo de contenido, se mapea al bucket'other'ya existente al persistir, mientras que la respuesta de la mutation sigue devolviendo la redacción original de quien la invocó. -
refundMessagePurchaseno tiene ninguna verificación de propiedad/admin. Cualquier usuario autenticado puede pasar unpurchaseIdarbitrario y el manager reembolsa monedas del vendedor de vuelta al comprador sin ninguna verificación de que quien llama es el comprador, el vendedor, o un administrador (// TODO: Add admin checkestá literalmente en el resolver). Actualmente inalcanzable desde ambos frontends (ver abajo), pero es una mutation en vivo, invocable, y financieramente sensible hoy. Archivo:graphql/resolvers/message-purchase.resolver.js,managers/message-managers/message-purchase.manager.js. Impacto: Alto. Corregido: la "verificación de admin" que pedía el TODO nunca podría haber funcionado realmente en esta ruta -context.adminsolo se popula en/admin/graphql, y esta mutation vive en el schema de cliente - exactamente el mismo bug ya corregido una vez para las compras de publicaciones exclusivas (refundPostPurchase/adminRefundPostPurchase), así que se aplicó aquí el mismo remedio de dos caminos:refundMessagePurchaseahora está limitada al autoservicio del comprador con una ventana de reembolso de 24h (message_purchase.not_owner/refund_window_expired), y un nuevo paradminRefundMessagePurchase/adminGetMessagePurchasesen el schema de admin (protegido por una verificación real del permisoMODERATE_CONTENT) le da a los moderadores un camino sin límite de tiempo más un listado sobre el cual actuar. También se corrigió un bug real y separado encontrado al tocar este archivo: cada clave de errormessage_purchase.*referenciada por este manager (not_found,already_refunded,message_not_paid,cannot_purchase_own_message,invalid_amount,already_purchased,access_denied) había estado cayendo en silencio a la cadena de la clave cruda, ya que no existía ninguna traducciónerrors.message_purchaseen absoluto - se agregó texto real en inglés y español para el conjunto completo. Implementado de punta a punta: la página ya existente del panel de admin/moderation/refunds(construida para reembolsos de compras de publicaciones) ahora tiene una pestaña de compras de Publicaciones/Mensajes, respaldada por un nuevo campoMessagePurchase.selleragregado para paridad de visualización comprador/vendedor conPostPurchase. -
userStats/getUserStatsnunca devuelve los campos relativos al espectador que declara el esquema.UserStats.isFollowing/isFollower/isCloseFriend/isBlocked/isBlockedBy/hasPendingRequestsiempre sonnull: el resolver llama al manager con(userId, viewerId, context)pero el wrapper delegador solo acepta(userId, context), así queviewerIdsobrescribe acontexty se descarta en silencio — lo que significa que el contexto real de la petición (idioma, etc.) también se pierde para los mensajes de error de esta llamada. Archivo:managers/user-managers/index.js(wrapper) /managers/user-managers/statistics.manager.js(nunca establece los campos de todos modos). Impacto: Medio-Alto — cualquier vista de "estadísticas" de perfil que dependa del estado de seguir/bloquear/close-friend de esta query siempre está equivocada. Corregido: el wrapper ahora acepta(userId, viewerId, context)correspondiendo con la llamada real del resolver, ygetUserStatsreutiliza el ya-correctogetRelationshipStatus(viewerId, userId, context)(el mismo método que respaldaRelationshipStatus) para poblar los seis campos de verdad, en lugar de dejarlos sin establecer. Semántica:isFollowing/isFollowerson relativos al espectador ("¿el espectador sigue a este usuario?" / "¿este usuario sigue de vuelta al espectador?"), y los campos permanecen ennull(su significado real y nulificable en el esquema de "no aplica") para una petición anónima o al ver tus propias estadísticas, en lugar de fabricarfalse. Se actualizó el único otro llamador interno de este método del manager (getAccountAnalytics, auto-análisis sin un espectador separado) para pasarnullexplícitamente, de modo que siga obteniendo el resultado correcto de "no aplica" en lugar de tratar accidentalmente su propio objetocontextcomo un id de espectador verdadero. -
canTagUser/canMentionUserse saltan por completo las verificaciones de bloqueo y privacidad. Ambos protegen el flujo real de creación de etiquetas/menciones pero solo verifican "el usuario existe / la cuenta está activa" — los pasos de "verificar si quien etiqueta está bloqueado" y "verificar configuración de privacidad" son// TODOy nunca se ejecutan. Archivos:managers/post-managers/content-tag.manager.js,managers/post-managers/post-mention.manager.js. Impacto: Medio — un usuario puede bloquear a alguien y esa persona todavía puede etiquetarlo/mencionarlo en publicaciones. Corregido: resultó que ambos TODO pedían algo que ya existía y ya era correcto -privacy-settings.manager.js#canPerformActiontiene casos reales y funcionales para'tag_user'/'mention_user'(verificación de bloqueo + la configuración de privacidadwho_can_tag/who_can_mention), ya usados en otro lugar (p. ej. la privacidad de invitaciones a grupos enconversation.manager.js), simplemente nunca se había conectado a estos dos stubs. Ambos ahora delegan directamente a él - no se necesitó lógica nueva, solo conectar una primitiva ya existente y ya probada. - La eliminación de cuenta nunca se propaga a los datos relacionados.
deleteAccount/deleteAccountImmediatelyanonimizan la filaUserpero dejan publicaciones, comentarios, mensajes, medios, y relaciones de seguimiento completamente intactos y atribuibles (// TODO: Delete or anonymize related data). Archivo:managers/user-managers/account-management.manager.js. Impacto: Alto — un hueco real de cumplimiento RGPD, no solo algo deseable. Corregido: ambos métodos ahora llaman a un nuevo_cascadeDeleteUserData(userId)justo después de anonimizar la filaUser. Los posts, comentarios y mensajes (todos modelosparanoid: true) se eliminan de forma suave - se establecedeleted_at, la fila en sí se conserva, siguiendo el mismo enfoque de "anonimizar/ocultar en vez de borrar por completo" ya usado para la propia filaUser(conservada por integridad referencial) - mediante tres nuevos métodos de acceso masivos (post.access-service.js#deleteAllByUser,message.access-service.js#deleteAllBySender, y el ya existente pero nunca usadocomment.access-service.js#deleteByUser). Las filas puras de relación/metadatos sin valor de auditoría independiente - me gusta/reacciones, relaciones de seguimiento (en ambas direcciones), membresía de conversaciones, publicaciones guardadas y notificaciones - se eliminan por completo mediante métodos masivos nuevos/extendidos en sus respectivos servicios de acceso. Toda la cascada se ejecuta dentro de una únicasequelize.transaction()para que un fallo a mitad de camino revierta limpiamente en lugar de dejar unas tablas limpias y otras no (esta es una transacción separada de la propia actualización de anonimización de la filaUser, ya queuser.access-service.js#updatese usa en demasiados lugares como para retroadaptarle soporte de transacciones de forma segura solo para este llamador). - La desactivación de cuenta no oculta el contenido, y las acciones de contacto legado no notifican ni registran auditoría. Desactivar una cuenta deja sus publicaciones/comentarios completamente visibles (
// TODO: Hide user content without deleting);addLegacyContact/removeLegacyContactnunca envían la notificación requerida ni registran la acción del admin. Archivos:managers/user-managers/account-management.manager.js,managers/user-managers/memorialized-accounts.manager.js. Impacto: Medio. Corregido: la visibilidad del contenido ahora se verifica en vivo a partir deUser.accountStatusen el momento de la consulta - el filtrocanView()del feed, los joins de creador degetTrendingPosts()/getByType()(ambos ya tenían un join conisPrivate: false) y degetByHashtag()(que antes no tenía ningún filtro de visibilidad de creador) ahora también excluyenaccountStatus: 'deactivated', yprofile.manager.js#getProfileahora rechaza ver el perfil desactivado de otro usuario (los propietarios sí pueden ver el suyo propio). Como todo esto es en tiempo de consulta, el// TODO: Restore user content visibilitycorrespondiente dereactivateAccountno necesitó ningún código en absoluto - al volver a poneraccountStatusfuera de'deactivated'ya se hace todo visible de nuevo, así que ese TODO se convirtió en un comentario explicativo en lugar de lógica nueva.addLegacyContact/removeLegacyContactahora envían una notificación real al contacto legado (best-effort, siguiendo la forma ya establecida denotificationAccessService.createen el código base) y registran auditoría víaanalyticsService.trackAdminActiona través de un nuevo helper_auditAction(mismo patrón queplatform-wallet.manager.js/restrictions-limits.manager.js). Al conectar esto, se encontró que todo el namespace de traducciónmemorialized.*de errores/éxitos faltaba tanto enen.jsoncomo enes.json- cada mensaje dememorialized-accounts.manager.js(solicitudes de conmemoración, revisión, contactos legados) había estado cayendo en silencio a la cadena de la clave cruda; se agregó el conjunto completo de texto real en inglés y español. - La supresión de notificaciones en horas de silencio usa la hora local del servidor, no la zona horaria propia del usuario. Archivo:
managers/user-managers/notification-settings.manager.js#isInQuietHours. Impacto: Bajo-Medio. Corregido:isInQuietHoursahora toma laUser.timezonedel usuario (una cadena IANA, p. ej.America/New_York- una columna que ya existía pero que esta verificación nunca leía) y evalúa la ventana de horas de silencio en esa zona horaria mediante un nuevo helpergetCurrentTimeInTimezone(Intl.DateTimeFormatcon la opcióntimeZone- sin necesidad de una dependencia nueva), recurriendo a la hora local del servidor solo cuando el usuario no tiene zona horaria configurada o es una cadena no reconocida.shouldReceiveNotification(el único llamador real) ahora obtiene la fila del usuario y pasauser.timezone. - Las estadísticas de búsqueda / el reporte de resultados de búsqueda son no-ops silenciosos.
getSearchStatssiempre devuelve{ totalSearches: 0, recentSearches: [] };reportSearchResultsiempre devuelve{ success: true }sin persistir nada. Ambos están expuestos en GraphQL pero no tienen ningún invocador en el frontend hoy. Impacto: Bajo. Corregido: resultó quegetSearchStatsya tenía una fuente de datos real y funcional justo al lado - la función "Búsquedas recientes" (search-history.manager.js, respaldada por la tabla realuser_search_history) ya registra cada búsqueda víarecordSearch, simplemente nunca alimentaba agetSearchStats. Ahora devuelve untotalSearchesreal (nuevoUserSearchHistoryAccessService#countSince, acotado por el argumentotimeframede la query, 30 días por defecto) yrecentSearchesreales. También se corrigió el mismo bug de desplazamiento de argumentos encontrado repetidamente en este pase: el resolver llamaba al manager como(userId, {timeframe}, context)pero el wrapper delegador solo aceptaba(userId, context).reportSearchResultahora persiste a través del mismo pipeline real decontent_reportque usareportConversation(un resultado de búsqueda ES un usuario, así quecontentType: 'user'/contentId: resultId- con verificación de duplicados y alerta administrativa para reportes urgentes incluidas, con validación real de la razón en lugar del no-op anterior que aceptaba cualquier cosa). -
POST /uploadsustituye en silencio una foto de stock pública aleatoria cuando S3 falla, y aun así devuelve HTTP 200. El manejador de subida deapi/server.js: ante cualquier error de S3 (credenciales incorrectas, falla de red, o S3 deshabilitado) el bloque catch responde con{ url: 'https://picsum.photos/400/400?random=...' }— una foto aleatoria sin relación de un servicio externo — en vez de un error, incluso en la ruta de adjuntos multimediamessages/user-.... Un cliente no tiene forma de distinguir una subida real de una sustitución silenciosa. Impacto: Alto — un problema silencioso de integridad de datos en una ruta de la que los clientes reales dependen para adjuntos de mensajes/multimedia. Corregido: la lógica de decisión se extrajo a un módulo puro y sin dependencia de Express (api/upload-response.util.js-server.jsen sí arranca Apollo/conexiones a la BD al hacer require y no se puede probar unitariamente de forma directa) con tres desenlaces reales en vez de uno falso: una subida realmente exitosa devuelve la URL real de S3; que el almacenamiento esté deshabilitado intencionalmente (DISABLE_S3, dev/test) ahora devuelve la URL simulada ya determinística que el propio servicio de almacenamiento ya generaba, en vez de llamar a una API externa de fotos de stock en cada subida; y cualquier fallo real (credenciales incorrectas, falla de red, o el almacenamiento devolviendo una forma inesperada estando habilitado) ahora devuelve un502real con el mensaje de error real, nunca una foto sustituida con un200falso. Se verificó que el frontend no necesitaba ningún cambio: los dos puntos de subida deChatView.tsxya hacíanif (!res.ok) throw new Error('Upload failed')- ya estaban preparados para manejar un fallo real correctamente, simplemente nunca habían recibido uno hasta ahora. -
payment-customer.manager.jstiene varios métodos financieros fabricados, ydeleteCustomernunca elimina al cliente en Stripe.getCustomerStatssiempre devuelve ceros (// TODO: Get actual stats from payment provider and database),getBalancesolo repite el campo local de la BD (// TODO: Get actual balance from payment provider),syncWithProvideres un no-op, ydeleteCustomersolo elimina la fila local (// TODO: Delete customer from payment provider (Stripe)), dejando un cliente de Stripe huérfano para siempre. Todavía no está expuesto vía ningún resolver de GraphQL, pero el manager es infraestructura interna en vivo (usada porpayment-method.manager.js/coin-purchase.manager.jsvíagetOrCreateCustomer). Impacto: Medio — reportará mal en silencio en el momento en que cualquiera de estos métodos se conecte a un resolver. Corregido:deleteCustomerahora elimina de verdad al cliente en Stripe (services/stripe#customersStripe.deleteCustomer) antes de borrar la fila local, trata el código de errorresource_missingde Stripe como "ya no existe" (un éxito benigno, no un fallo), y aborta sin tocar la fila local cuando Stripe falla por cualquier otro motivo - se acabaron tanto los clientes huérfanos en Stripe como las filas locales huérfanas.syncWithProviderahora consulta de verdad al cliente vivo en Stripe (customersStripe.getCustomer) y reconcilia el email local si divergió, degradándose con gracia (devuelve el registro local sin cambios) si Stripe no responde.getCustomerStatsahora calcula números reales a partir del propio libro de transacciones de la app víaPaymentTransactionAccessService(extendido con una nueva consulta agregadagetStats(userId)- conteos de transacciones totales/completadas/fallidas, monto completado, primera/última fecha completada) y conteos reales de métodos de pago víaPaymentMethodAccessService#getByUser;active_subscriptionsse deja honestamente en0porque todavía no existe una fuente de datos alcanzable de "suscripciones activas por cliente de pago" (las suscripciones a creadores se rastrean por par suscriptor/creador, no porPaymentCustomer).getBalancese revisó y se dejó tal cual: repetir el campo local de la BD es correcto hoy, ya que este saldo de crédito emitido por la app no tiene un equivalente del lado de Stripe con el cual sincronizarse. Se añadieron las claves de traducción faltanteserrors.payment_customer.*(not_found/create_failed/provider_delete_failed) yerrors.validation.{customer_id_required,provider_customer_id_required}aen.json/es.json- cada ruta de error de este manager caía en silencio a la clave cruda como texto. No se construyó UI de frontend en este pase: este manager sigue sin exposición vía resolver de GraphQL, exactamente igual que antes - la corrección se limita a hacer confiables a sus llamadores internos existentes (payment-method.manager.js,coin-purchase.manager.js). También se actualizótests/unit-test/payments-subscriptions.unit.test.js(la suite de este repo deliberadamente diseñada para ejercitar managers reales, que había fijado a propósito el comportamiento roto anterior según su propio comentario de cabecera) para verificar el comportamiento nuevo y correcto en vez de los bugs anteriores. - Varios managers agregados recientemente lanzan cadenas de error fijas y sin traducir en vez de usar el servicio de i18n del backend — mezclando idiomas según el manager.
note.manager.js,story-highlight.manager.js, ysubscription-bundle.manager.jstodos hacenthrow new Error('...')con texto en español fijo (sin claves correspondientes entranslations/en.json);message-translation.manager.jshace lo contrario con texto en inglés fijo. Todos los demás managers del código pasan port.error('key', context)contratranslations/{en,es}.json. Resultado: un usuario en idioma inglés recibe texto de error en español crudo de Notas/Story Highlights/Paquetes de suscripción, y viceversa para errores de traducción de mensajes. Impacto: Medio — una regresión real de i18n en funcionalidades recién lanzadas, exactamente la clase de hueco que el CLAUDE.md de este proyecto marca como "no terminado." Corregido: los cuatro managers ahora pasan port.error('key', context)como cualquier otro manager. Se añadieron los nuevos namespaceserrors.note.*(empty/too_long),errors.story_highlight.*(title_required/title_too_long/not_found/not_owner) yerrors.subscription_bundle.*(name_required/not_found/not_owner/has_active_buyers/not_available/cannot_purchase_own) aen.json/es.json; la comprobación "no eres participante" demessage-translation.manager.js#setSettingahora reutiliza la clave ya existenteerrors.conversation.not_participant(mismo texto en inglés, ahora realmente traducible) en vez de un literal fijo. Propagarcontextrequirió añadir un parámetrocontexta varios métodos de manager que antes no lo aceptaban (note.manager.js#createNote,story-highlight.manager.js#createHighlight/updateHighlight/deleteHighlight,subscription-bundle.manager.js#createBundle/updateBundle/deleteBundle,message-translation.manager.js#setSetting) y actualizar sus resolvers (note.resolver.js,story-highlight.resolver.js,subscription-bundle.resolver.js,message-translation.resolver.js) para pasarlo -deleteHighlight/deleteBundleya recibían un argumentocontextdesde sus resolvers que el método del manager descartaba en silencio (una instancia más de la clase de bug de desplazamiento de argumentos recurrente en este pase). Los mensajes con límite de caracteres ({max}en títulos de notas/highlights) ahora usan la misma convención de interpolaciónt.error(key, context).replace('{max}', N)ya establecida porvalidators/password-policy.validator.js. Se actualizaron las aserciones de los tests unitarios existentes enuntested-managers.unit.test.js,payments-subscriptions.unit.test.js,message-translation-manager.unit.test.jsyresolvers/message-translation.resolver.test.jsen consecuencia - el mock global de Jest paratranslation.servicede este repo (tests/setup.js) devuelve la clave cruda sin modificar, así que los tests de todos los demás managers ya afirman sobre la clave con puntos en sí (p. ej.'story_highlight.not_owner') en vez de prosa traducida, y estos tests ahora siguen la misma convención. - Elementos menores de backend que vale la pena revisar juntos:
deleteFeedback(user-feedback.manager.js) está documentado como "propietario o admin" pero no tiene ningún bypass real de admin (// TODO: Check if user is admin) — solo el propietario puede eliminar su propio feedback hoy; el comentario de documentación de la clase depayment-method.manager.jsanunciadetectFraudulentCard/detectSuspiciousActivity/getMostUsedMethod/getFailureRate/getUsageHistory/bulkDeleteMethods/exportPaymentMethods, ninguno de los cuales existe realmente en la clase. Impacto: Bajo individualmente. Corregido:deleteFeedbackahora tiene un bypass de admin real, reutilizando exactamente el mismo primitivo de permisos quegraphql/resolvers/admin/user-feedback-admin.resolver.jsya usa para el resto de mutaciones de triage de feedback -adminUserManager.hasPermission(admin.adminId, 'MANAGE_FEEDBACK')contracontext.admin- en vez de la comprobación heredada al estilouser.accountType === 'admin'usada (y ella misma marcada "TODO: Implement proper admin role checking") en otras partes de este código.deleteFeedbackno tenía ninguna exposición GraphQL antes de este pase (igual quepayment-customer.manager.jsantes de la corrección anterior), así que el bypass era inalcanzable e imposible de probar de punta a punta; se conectó con una nueva mutacióndeleteMyFeedbacksolo para el propietario (siguiendo el mismo patrón queupvoteFeedback/downvoteFeedback) y una nueva mutaciónadminDeleteFeedback(siguiendo el mismo patrón queadminUpdateFeedbackStatus/adminRespondToFeedback, protegida por el mismo helperrequireFeedbackPermission), ambas reflejadas enschema.web.graphqls/schema.admin.graphqlsmás nuevos archivos de operación.graphqlbajopackages/graphql/operations/{Web,Admin}y conapollo-web/apollo-adminrecompilados. Se extendió la página existente de triage de Feedback en el panel de administración (apps/frontend-admin/src/app/feedback/FeedbackContent.tsx) con una acción de eliminar + modal de confirmación, siguiendo el mismo patrón de eliminar-con-confirmación ya usado en la página de lista de bloqueo de moderación. Al tocar este manager, se encontró que todo el namespace de traducción de erroresuser_feedback.*(además devalidation.feedback_id_required/admin_id_required/query_required) faltaba enen.json/es.json- cada ruta de error enuser-feedback.manager.jscaía en silencio a la clave cruda como texto; se añadió el conjunto completo. Por separado, se recortó el comentario de documentación de la clase depayment-method.manager.jsa los ~15 métodos que realmente existen en la clase, eliminando los ~25 ficticios (detectFraudulentCard,bulkDeleteMethods, etc.) que podrían inducir a un futuro lector a pensar que podía llamarlos - no se construyó ningún método nuevo, ya que ninguna de esas capacidades fue solicitada ni es necesaria para ningún llamador real hoy.
Backend listo, sin interfaz de frontend
Un cruce de cada campo Query/Mutation en el esquema web compartido contra el uso real en apps/frontend-nextjs/src reveló un conjunto grande de funcionalidades con un backend completo y real y cero UI. Agrupadas por área:
- La llamada de voz 1:1 es unidireccional — sin ninguna UI de llamada entrante.
AnswerCall/DeclineCall/JoinCall/LeaveCally la suscripciónCallIncomingtienen documentos de operación listos, yChatView.tsxincluso importaCallIncomingDocument— pero nunca se suscribe realmente a ella. Solo el lado de quien llama (StartCall/EndCall) funciona; quien recibe no tiene ninguna UI de timbrar/aceptar/rechazar. Impacto: Alto. Corregido: al investigar esto apareció un segundo bug, más grande, detrás -VoiceCallModal.tsx(la UI "funcional" del lado de quien llama) nunca se unía realmente a la sala de LiveKit que creaba el backend; abría unRTCPeerConnectioncrudo solo con servidores STUN, registraba en consola eltoken/wsUrl/roomNamereales que LiveKit necesita, y nunca los usaba, así que nunca se intercambiaba audio real ni siquiera en una llamada saliente. Se reescribió para usarlivekit-client(ya es una dependencia, ya probada y funcionando para transmisiones en vivo enLiveRoomPage.tsx) en ambas direcciones: conectarse a la sala, publicar una pista de audio local, suscribirse a la pista remota, silenciar de verdad víalocalParticipant.setMicrophoneEnabled, y reportar la calidad de conexión de verdad víaRoomEvent.ConnectionQualityChanged(antes una etiqueta fija "Excellent"/solo en español sin ningúnt()). Se añadió un nuevoIncomingCallContext.tsxde alcance global (montado una sola vez enProviders.tsx, el mismo patrón que el ya existenteNotificationsRealtimeContext.tsx) que se suscribe acallIncomingpara el usuario actual sin importar en qué página esté, muestra un banner de timbrado con el nombre/avatar de quien llama, y conectaAnswerCall/DeclineCall- responder abre el mismoVoiceCallModal(ahora real). Se eliminó el import no usado deCallIncomingDocumentenChatView.tsxahora que la suscripción vive en el provider global. Se añadió un nuevo namespace de i18ncalls.*aen.json/es.json(el modal viejo tenía varias cadenas fijas solo en español, sin ninguna traducción, sin importar el idioma). Tests nuevos:VoiceCallModal.test.tsx(conexión/publicación en LiveKit, fallo de permiso de micrófono, silenciar, mapeo de calidad de conexión, finalizar llamada) eIncomingCallContext.test.tsx(banner de timbrado, fallback de nombre, ramas de responder/rechazar), ambos conlivekit-clientsimulado.JoinCall/LeaveCall(unirse/salir de una llamada grupal) siguen sin conectarse a ninguna UI - las llamadas grupales no tienen ningún punto de entrada en el frontend hoy, lo cual en realidad es el hueco separado de "gestión de conversaciones" listado más abajo, no parte de esta corrección de llamadas 1:1. - El sistema de "solicitud para hablar" de salas de audio en vivo (estilo Clubhouse) no tiene ninguna UI.
requestToSpeak/approveSpeakerRequest/denySpeakerRequest/cancelSpeakerRequest/promoteToSpeaker/demoteToViewer/joinAsViewer/pendingSpeakerRequests/mySpeakerRequestsestán todos completos en el backend (call-managers/speaker-request.manager.js) con documentos de operación listos; ningún componente hace referencia a ninguno. Impacto: Medio-Alto. Abordado parcialmente - se encontraron y corrigieron bugs reales del backend, la UI deliberadamente no se construyó en este pase (ver abajo). Al investigar se descubrió que este backend no estaba realmente "completo" como se afirmaba - tres bugs reales significaban que la funcionalidad no podría haber funcionado ni con una UI delante. (1)requestToSpeak/approveSpeakerRequest/denySpeakerRequest/getPendingRequestsForCallverificabanparticipant.status === 'JOINED'para significar "es un speaker", perocall.manager.js#joinAsViewertambién ponestatus: 'JOINED'en una fila de espectador normal (la distinción real speaker/espectador esrole:CALLER/RECEIVERvsPARTICIPANT, según la propia definición degetCallParticipantsen el mismo archivo) - así que la propia llamadarequestToSpeakde cualquier espectador real fallaba de inmediato con "You are already a speaker", y cualquier participante (incluido el propio solicitante) podía aprobar/rechazar/ver cualquier solicitud, no solo el host/los speakers. (2)approveSpeakerRequestllamaba acallManager.promoteToSpeaker(request.callId, request.userId, context)- un bug de desplazamiento de argumentos (la firma real es(callId, userId, promotedBy, context)) - así que la propia comprobación interna de la promocióncall.callerId !== promotedBycomparaba el id real de quien llama contra un objeto de contexto y siempre lanzaba, tragado en silencio por un bloque catch: una solicitud "aprobada" nunca promovía realmente a nadie en la sala real de LiveKit. (3)promoteToSpeaker/demoteToViewersolo permitían al creador original de la llamada, contradiciendo la propia regla queapproveSpeakerRequestdeclara ("Only the call host or speakers can approve requests") - corregido para permitir también a un speaker existente (roleCALLER/RECEIVER), en consistencia con el resto del modelo de autorización. Los tres corregidos encall.manager.js/speaker-request.manager.js, con tests nuevos que cubren la autorización real basada en roles y la corrección del argumento de promoción. La UI NO se construyó en este pase:requestToSpeaksolo es alcanzable para alguien que se unió a una llamada víajoinAsViewer(el único camino que crea una fila conrole: 'PARTICIPANT') - yjoinAsVieweren sí no tiene ningún llamador en ningún lugar de este código, porque no existe ningún concepto de "sala de audio" en el frontend todavía (sin flujo de creación/descubrimiento de salas, sin forma de unirse a una llamada en curso de la que no fuiste uno de los participantes originales destartCall- el único punto de entrada de llamadas deChatView.tsxes una llamada 1:1 conreceiverId). Publicar hoy un botón "Levantar la mano" siempre lanzaría "You are already a speaker" para cualquier usuario real, ya que nadie puede alcanzar el estado de espectador - sería una UI conectada a una mutación real pero de todas formas inalcanzable de punta a punta, la misma clase de problema que este pase sigue encontrando y se niega a maquillar. Construir el prerrequisito real (UI de creación/descubrimiento de salas/unirse como espectador) es una funcionalidad materialmente más grande y separada, más cercana en alcance a la sección "Funcionalidades de consumidor que no existen hoy" que a conectar campos de backend ya alcanzables. - Mensajes de DM pagos/bloqueados: el lado de "desbloquear" funciona, el lado de "bloquear" no existe en el compositor.
purchaseMessageestá conectado enChatView.tsx, pero el compositor de mensajes fija cada mensaje saliente aisLocked: false, unlockPrice: null(useChatMessages.ts) — así que ningún mensaje puede llegar a ser comprable realmente.LockMessage,GetMyMessagePurchases,HasMessageAccess,GetMyCreatorEarningsestán todos definidos y sin usar. Impacto: Alto — toda una funcionalidad de monetización de DM pagos es inalcanzable para los creadores. Investigado - este ítem ya estaba desactualizado cuando se escribió (la propia introducción del roadmap advierte que algunas entradas terminan estando ya corregidas). Los camposisLocked/unlockPriceque nombra son campos GraphQL deMessagemuertos, nunca respaldados por datos (no existe ninguna columna correspondiente - las columnas reales del modelo sonisPaid/coinPrice, expuestas comoMessage.isPaid/Message.price) - el código deuseChatMessages.tscitado es solo un relleno de forma de caché de Apollo para campos que un payload de la suscripciónmessageAddedno selecciona, no el camino real de envío, y ya pone por defecto correctamente los campos reales (isPaid: false, price: null) también, sobrescritos por el payload real de la suscripción vía un spread posterior. La UI real para "bloquear un mensaje" ya existe y está completamente conectada: el flujo "Fijar precio" deChatView.tsx/MessageInputArea.tsx(showPaidMediaModal,pendingPaidPrice, cuatro puntos de entrada distintos en los menús de adjuntar/acciones rápidas) envíaisPaid/pricedirectamente en elMessageCreateInputdesendMessage, validado y persistido en el servidor (message.resolver.js), y protegido en lectura vía el resolver del campoMessage.mediaUrlsque llama amessagePurchaseManager.verifyAccess- no tiene relación con la mutación separadaLockMessageque el roadmap marcó como sin usar (esa existe para bloquear un mensaje ya enviado después del hecho, un caso de uso distinto y legítimamente todavía sin usar). Al verificar esto, se encontró un bug real y separado en ese caminolockMessagesin usar, que vale la pena corregir de todos modos por ser un agujero de seguridad activo: el propio comentario de documentación del resolver afirma "Only message sender can lock messages", pero nada lo aplicaba - cualquier usuario autenticado podía llamar alockMessagesobre cualquier mensaje, incluido uno que no envió, volviendo pago retroactivamente el mensaje de otra persona detrás de un precio arbitrario. Corregido pasando el id de quien llama y verificandomessage.senderId, reutilizando la claveerrors.message.not_senderya existente (no se necesitó ninguna traducción nueva).myMessagePurchases/hasMessageAccess/myCreatorEarningssiguen sin usarse por ninguna página del frontend - se rastrean junto con el hueco más amplio de dashboard financiero de abajo, en vez de duplicarlo aquí. - La eliminación de comentarios está completamente ausente de la app web.
deleteComment(commentId, actAsUserId): Boolean!tiene un resolver/manager real pero cero referencias en ningún lugar deapps/frontend-nextjs,apps/frontend-admin, o los paquetes de operaciones — los usuarios aparentemente no pueden eliminar sus propios comentarios desde la UI web. Impacto: Alto para una app social. Corregido: se añadió una acción "Eliminar" junto a "Responder" tanto en comentarios de nivel superior como en respuestas dentro dePostModal.tsx(la lista de comentarios compartida que usa el modal de post, la vista solo-comentarios, y cualquier punto de entrada que lo renderiza), visible cuando quien llama es el propio autor del comentario o el dueño del post - reflejando la autorización real ya aplicada en el servidor enpost-comment.manager.js#deleteComment. Confirmar pasa por un diálogo de confirmación compartidoModal(reutilizado de@/components/ui/Modal, el mismo primitivo quePostOptionsMenu.tsxya usa para eliminar posts) antes de llamar a la nueva mutaciónDeleteCommentInliney volver a consultar la lista de comentarios. Se añadió un nuevo archivo de operación.graphql(Web/Posts/DeleteCommentInline.graphql) y se recompilóapollo-web. Se añadieron nuevas claves de i18npost.delete_comment*aen.json/es.json. - La gestión de conversaciones está en gran parte sin construir en la UI de chat:
leaveConversation,archiveConversation/unarchiveConversation,pinConversation/unpinConversation,unblockConversation,clearConversationHistory,updateParticipantRole,transferAdmin, además descheduleMessage/cancelScheduledMessage/editScheduledMessage(mensajes programados/autodestructibles) — todos tienen documentos de operación reales, ninguno referenciado en ningún componente. Impacto: Medio — archivar/fijar/salir/roles/programación son expectativas comunes de app de chat, listas en el backend y ausentes. Corregido: las nueve conectadas enConversationDetailsPanel.tsx(el panel lateral compartido de detalles/ajustes del chat), reutilizando datos que la queryGetConversationya obtenía (isPinned/isArchived/isBlockedya se seleccionaban enConversationParticipantpara el toggle de silenciar - solo nunca se leían para nada más). Se añadió: toggles de Fijar/Archivar junto al toggle de Silenciar ya existente; Bloquear ahora cambia a una acción real de Desbloquear una vez queme.isBlocked; una nueva acción destructiva "Borrar historial del chat" con su propio diálogo de confirmación; una acción "Salir del grupo" (solo conversaciones de grupo, oculta para el último administrador restante - reflejando la misma protección queleaveConversationya aplica en el servidor); botones "Hacer administrador"/"Quitar como administrador" por miembro junto al control de quitar-miembro ya existente (visibles para los mismos administradores/creador de grupo concanManageMembers); una acción "Transferir propiedad" solo para el creador por cada administrador, con su propia confirmación; y un panel autocontenido de "Mensajes programados" (compositor de texto +<input type="datetime-local">, lista con Editar/Cancelar por elemento) construido alrededor de las operaciones ya existentesGetScheduledMessages/ScheduleMessage/CancelScheduledMessage/EditScheduledMessage, deliberadamente mantenido independiente del flujo de envío del compositor principal en vez de enhebrar un nuevo modo a través de la ya extensa superficie de props deMessageInputArea.tsx. Se añadieron nuevas claves de i18nmessages.*/members.*aen.json/es.json. - No hay verificación en vivo de disponibilidad de usuario/correo al registrarse, a pesar de que
isUsernameAvailable/isEmailAvailableestán completamente implementadas —OnboardingPage.tsxno tiene ninguna llamada de validación en tiempo real, así que un nombre de usuario ya tomado presumiblemente solo se descubre después de enviar. Lo mismo para las queries de validación en tiempo real más ampliasvalidateUserData/validateProfileContent/validateFieldRealtime— ninguna se llama desde ningún formulario de edición de perfil. Impacto: Medio (pulido de UX de registro/edición de perfil, listo en el backend). Corregido — y la afirmación original del roadmap sobre dónde estaba el problema era incorrecta. El formulario de registro real esLogin.tsx(OnboardingPage.tsxes un asistente posterior al registro sin campos de usuario/correo), y ya tenía una UI de validación en vivo con debounce completamente construida, conectada avalidateUsername/validateEmail(víaValidateUsernameInline/ValidateEmailInline) — con chips de sugerencias incluidos. Simplemente fallaba silenciosamente en cada llamada: el esquema de GraphQL declaraValidationResult { valid: Boolean!, message, suggestions }, peromanagers/user-managers/index.js'svalidateUsername/validateEmail/validateFieldRealtimedevolvían la otra forma ({available, message}deisUsernameAvailable/isEmailAvailable) directamente — el campo no-nulovalidnunca estaba presente, así que GraphQL fallaba en cada petición y la UI ya construida nunca funcionó. Corregido añadiendo métodos propiosvalidateUsername/validateEmailenvalidation.manager.jsque mapean{available} → {valid}, con la fachada enindex.jsreducida a una delegación simple (siguiendo la convención de manager/fachada de este código base). También se conectó la query de validación en tiempo real más ampliavalidateProfileContent— confirmada como genuinamente sin uso, tal como se afirmaba — al campo de biografía enEditProfilePage.tsx, y se añadió la misma verificación en vivo devalidateUsernamecon debounce (con chips de sugerencias, replicandoLogin.tsx) al campo de nombre de usuario de esa página, ya que antes solo mostraba un error de nombre de usuario ya en uso después de un guardado fallido. Nuevos documentos de operación:ValidateFieldRealtimeInline.graphql,ValidateProfileContentInline.graphql. Nuevas pruebas envalidation-manager.unit.test.jsque cubren el mapeo{available}→{valid}para ambos campos. - No hay panel de billetera de monedas / propinas / historial de compras.
myTransactionStats,myPurchaseHistory,coinPurchaseStats,mySentTips,contentTips,contentTipTotal,recentTipsestán todos listos en el backend sin ningún documento de operación en ningún lugar. Enviar propinas funciona (TipModal.tsx); no hay ninguna vista de "mis propinas enviadas/recibidas" o "historial de compras". Impacto: Medio. Corregido — varios de los campos mencionados ya tenían vistas reales, los huecos reales eran más específicos.CoinTransactionsPage.tsx(una vista real de "historial de compras", en/coins/transactions) yTipsPage.tsx(una vista real de "propinas recibidas" con estadísticas y mejores supporters, en/settings/tips) ya existían —myPurchaseHistoryen sí es un duplicado exacto demyCoinPurchases(ya en uso, misma llamada al manager, mismos argumentos), así que no necesitaba nueva UI. Los huecos reales:mySentTipsno tenía ningún consumidor en ningún lugar, así que se añadió un selector de pestañas Recibidas/Enviadas aTipsPage.tsx— la pestaña Enviadas lista las propinas que este usuario envió a otros creadores, con sus propios totales extraídos de los campostotalSent/totalSentAmountdemyTipStats(antes no seleccionados).myTransactionStatsycoinPurchaseStatsno tenían ningún consumidor en ningún lugar, así que se añadió un encabezado de estadísticas de billetera aCoinTransactionsPage.tsx(saldo actual, total de monedas compradas, total gastado, total de transacciones).contentTips/contentTipTotalyrecentTipsse dejaron deliberadamente sin construir: los dos primeros son desgloses de propinas por contenido específico (por ejemplo, "quién dio propina a este mensaje de chat específico"), que no encajan en la forma de un panel de billetera personal y no tienen ninguna superficie de UI por mensaje existente a la cual adjuntar un total sin un cambio materialmente separado al renderizador de burbujas de mensaje;recentTipsno toma ningún argumento de usuario/autenticación (un feed de propinas recientes de toda la plataforma, sin autenticación) sin ninguna página de feed de actividad pública comparable en ningún lugar de la app para alojarlo. Nuevos documentos de operación:MySentTipsInline.graphql,MyTransactionStatsInline.graphql,CoinPurchaseStatsInline.graphql; se extendióMyTipStatsInline.graphqlcon los campos del lado enviado. Nuevas pruebas:TipsPage.test.tsx,CoinTransactionsPage.test.tsx. - Un panel de seguridad completo no tiene ningún consumidor de frontend en ninguna de las dos apps:
securityStats,sessionDetails,getSecurityAlertsStats,terminateSession/terminateOtherSessions(distintos derevokeSession/revokeAllOtherSessions, que sí están conectados) no se usan. Impacto: Medio. Corregido — y el panel de seguridad en sí ya existía.SecurityAlertsPage.tsx(tarjeta de puntuación, lista de alertas, registro de eventos persistido) ySessionsSettingsPage.tsx(sesiones activas, revocar/revocar-todas-las-demás, detalle expandible por sesión, historial de inicios de sesión) ya eran páginas reales y construidas — la premisa de este punto ("sin consumidor de frontend") era incorrecta para el panel en su conjunto, solo cierta para los cuatro campos específicos que mencionaba. De esos:securityStats(conteos reales derivados del registro de auditoría persistidosecurity_event— inicios de sesión totales, intentos fallidos, actividad sospechosa, intentos bloqueados, dispositivos vistos) no tenía ningún consumidor, así que se añadió una cuadrícula de "Actividad de la cuenta" aSecurityAlertsPage.tsx.getSecurityAlertsStats(un desglose por severidad de las alertas de este usuario) no tenía ningún consumidor, así que se añadieron chips de conteo alta/crítica junto a la insignia de no leídas ya existente en esa misma página.sessionDetailsyterminateSession/terminateOtherSessionsse dejaron deliberadamente sin construir:sessionDetails(sessionId)devuelve exactamente la misma formaUserSessionqueactiveSessionsya devuelve, y la fila expandible "Ver detalles" que ya existe enSessionsSettingsPage.tsxya muestra IP/primera vez vista/expiración directamente desde la lista que ya obtuvo — una segunda consulta para datos que ya se tienen agregaría una ida y vuelta de red sin información nueva.terminateSession/terminateOtherSessionsson, a nivel de resolver, alias literales que llaman exactamente a los mismos métodos del manager querevokeSession/revokeAllOtherSessions(ya conectados,user-sessions.resolver.js) — mutaciones duplicadas de verdad, no una capacidad faltante. Nuevos documentos de operación:SecurityStatsInline.graphql,SecurityAlertsStatsInline.graphql. Nuevas pruebas:SecurityAlertsPage.test.tsx. - Las acciones de analítica/moderación de transmisiones en vivo están inactivas:
liveStreamStats,liveStreamViewers,banLiveViewer/unbanLiveViewer,cancelLiveStream/deleteLiveStream—LiveRoomPageya tiene funcionalidad en vivo sustancial, estas específicas simplemente no se llaman. Impacto: Medio. Corregido. Las seis quedaron conectadas enLiveRoomPage.tsx. La insignia de conteo de espectadores (arriba a la derecha), visible solo para el propietario, ahora es un botón que abre un nuevo panel de "Espectadores y estadísticas": un resumen deliveStreamStats(pico de espectadores, vistas totales, reacciones, comentarios, compartidos, tiempo promedio de vista) junto con una lista deliveStreamViewers, cada fila con un control de expulsar/readmitir. La moderación por comentario ganó un botónbanLiveViewerjunto a la acción de Premiar ya existente (se revela al pasar el cursor, solo para el propietario, oculto en los comentarios del propio propietario) —unbanLiveVieweres alcanzable desde esa misma fila en la lista de espectadores una vez que se emitió una expulsión en esta sesión (el esquema no tiene ninguna query de "¿este usuario está expulsado actualmente?" de la cual obtener ese estado al cargar la página de nuevo, así que el interruptor se inicializa a partir de la acción recién tomada, no se fabrica como un estado persistente).cancelLiveStream(para una transmisión aún programada) obtuvo un nuevo botón "Cancelar" junto a "Comenzar transmisión" en la superposición previa al directo.deleteLiveStreamobtuvo un nuevo botón "Eliminar esta transmisión" en la superposición posterior al final, solo para el propietario. Nuevos documentos de operación:LiveStreamViewersInline.graphql,LiveStreamStatsInline.graphql,BanLiveViewerInline.graphql,UnbanLiveViewerInline.graphql,CancelLiveStreamInline.graphql,DeleteLiveStreamInline.graphql. Se añadieron dos íconos pequeños (Ban,BarChart3) al set de íconos derivado de lucide del proyecto, que no tenía ninguno de los dos. Aún no existe un archivo de pruebas paraLiveRoomPage.tsx(más de 1000 líneas, ya requiere un mockeo pesado delivekit-client/MediaRecorder/suscripciones solo para renderizarlo, la misma clase de configuración que ya requirióVoiceCallModal.test.tsx) — escribir uno queda diferido a la pasada final de pruebas junto con los demás archivos sin pruebas que esta sesión tocó (PostModal.tsx,ConversationDetailsPanel.tsx), de forma consistente con cómo se manejaron esos. - Compartir ubicación en vivo no tiene UI de "actualizar" o "detener".
updateLiveLocation/stopLiveLocationestán completamente implementados (message.resolver.js/messageManager) — compartir estático de una sola vez e iniciar un compartir en vivo funcionan, pero no hay ningún invocador para actualizar o detener explícitamente un compartir en vivo en curso. Impacto: Medio. Corregido — y "el compartir estático funciona" también era incorrecto, un bug más grave que la UI de actualizar/detener que faltaba según este punto.shareLocation(input: LocationInput!): Message!nunca aceptaba un argumentoconversationIden absoluto — la acción "Compartir ubicación" ya existente enChatView.tsxla llamaba solo con{latitude, longitude}, y el resolver pasaba eso directamente al manager, que requiereconversationIdpara crear el mensaje (conversation_ides una columna no-nula). Cada clic en "Compartir ubicación" fallaba del lado del servidor, no funcionaba como se afirmaba. Corregido añadiendoconversationId: ID!a la firma de la mutación (esquema + resolver + el documento de operaciónShareLocation) y pasándolo desde el id de conversación ya conocido enChatView.tsx. Al corregir esto, se encontraron y corrigieron dos huecos de autorización relacionados en la misma ruta de código:shareLocationnunca verificaba que el remitente fuera realmente participante de la conversación destino (cualquier usuario autenticado podía inyectar un mensaje de ubicación en una conversación arbitraria de la que no forma parte), yupdateLiveLocationnunca verificaba que quien llamaba fuera el dueño del mensaje que se actualizaba (cualquier usuario autenticado podía redirigir el compartir de ubicación en vivo activo de otra persona a coordenadas falsas) — ambos corregidos con las mismas verificaciones de participante/propiedad que este código base usa en otros lugares. ConshareLocationya alcanzable, se construyó la mitad "en vivo": una nueva acción del compositor "Compartir ubicación en vivo" (locationType: 'live') que inicia un buclenavigator.geolocation.watchPositionque llama aupdateLiveLocationen cada cambio de posición, y un botón "Dejar de compartir" en la burbuja del propio mensaje de ubicación en vivo activo del remitente que llama astopLiveLocation. Nuevas pruebas enmessage-manager.unit.test.js(la verificación de participante de shareLocation, expiración estática/en vivo, la verificación de propiedad + expiración de updateLiveLocation) y enresolvers/message.resolver.test.js(paso de argumentos para ambas mutaciones);MessageBubble.test.tsxextendido con el nuevo botón de Dejar de compartir (visible solo para el remitente, solo mientras está en vivo y no ha expirado). - Elementos más pequeños sin usar pero listos:
usersWhoReacted/messageThread/pollResults(votar en encuestas funciona, ver resultados/reacciones no);deleteNotification/deleteAllNotifications(marcar como leído funciona, eliminar no — se confirmó queNotificationsPage.tsxno tiene ninguna funcionalidad de eliminar/descartar/"borrar todo" a pesar de que ambas mutations están completamente implementadas); queriesmutualFollowers/mutualFollowing;bulkFollowUsers/bulkMuteUsers;myReports/contentReports(enviar funciona, ver tu propio historial de reportes no);verificationBadgeInfo;productReviewSummary/deleteProductReview;updatePostPromotion/promotionById. Impacto: Bajo individualmente, vale la pena revisarlos juntos. Mayormente corregido - se conectó UI real para las piezas genuinamente faltantes, varias resultaron ser redundantes.deleteNotification/deleteAllNotifications:NotificationsPage.tsxobtuvo un botón "Borrar todo" en el encabezado (con diálogo de confirmación) y un botón de eliminar por fila que se revela al pasar el cursor.mutualFollowers:PublicProfilePage.tsxobtuvo una línea "Seguido por alice, bob y otros" debajo de la biografía (diseños móvil y de escritorio) - solo se muestran los nombres realmente devueltos, sin fabricar un conteo "+N otros" ya que el campo no tiene un total.bulkFollowUsers/bulkMuteUsers: la ya existenteBulkFollowerActionsPage.tsx(desseguir/bloquear/quitar seguidores/procesar solicitudes en masa) obtuvo "Seguir de vuelta" en la pestaña de seguidores y "Silenciar" en la de seguidos - la misma infraestructura de selección masiva, solo dos acciones más.usersWhoReacted: cada chip de reacción enMessageBubble.tsxahora obtiene de forma diferida y muestra quién reaccionó como un tooltip al pasar el cursor, sin cambiar el comportamiento existente de clic para alternar la propia reacción.myReports: nueva página/settings/my-reports(añadida a la navegación de configuración bajo "Tus reportes") que lista los reportes de contenido enviados por el usuario con motivo, tipo de contenido, estado y notas de revisión una vez resueltos.verificationBadgeInfo: la insignia de verificación enPublicProfilePage.tsx(instancias móvil, de escritorio compacta y de encabezado de escritorio) ahora está envuelta en un botón que obtiene de forma diferida la información de la insignia al pasar el cursor/hacer clic y la muestra como tooltip.deleteProductReview: las filas de pedidos deShopManagePage.tsxantes solo ocultaban "Dejar una reseña" una vez reseñado, sin forma de ver o eliminar lo enviado - ahora muestra la reseña (estrellas + comentario) con un botón de eliminar.updatePostPromotion: los nombres de campaña enMyPromotionsPage.tsxahora son editables en línea (ícono de lápiz → renombrar) - el único campo queUpdatePostPromotionInputrealmente expone además del blob JSON sin estructuratargetAudience, que se dejó tal cual en lugar de construir una UI para un campo no tipado. Tres campos resultaron ser duplicados genuinos de datos ya entregados en otro lugar, sin necesitar nueva UI (el mismo patrón de "afirmación obsoleta" que varios ítems anteriores de esta sesión):pollResultsduplicaMessage.poll(ya seleccionado en cada mensaje y ya la fuente de los porcentajes/conteos de votos renderizados en línea enMessageBubble.tsx);productReviewSummaryduplicaProduct.averageRating/Product.reviewCount(ya usado enPurchaseProductModal.tsx);promotionByIdduplicamyPromotions+promotionStats(el flujo ya existente de lista + expandir-para-ver-estadísticas deMyPromotionsPage.tsxya devuelve los mismos datos). Tres se dejaron deliberadamente sin construir:mutualFollowingno tiene un espacio de UI comparativamente estándar como sí lo tiene la convención "Seguido por" demutualFollowers;messageThread(una vista completa de hilo de respuestas) es una funcionalidad materialmente más grande que el resto de este lote;contentReports(ver todos los reportes sobre un contenido, no solo los propios) expondría quién más reportó qué entre usuarios, una preocupación exclusiva de moderación que no pertenece a una superficie normal orientada al usuario. Nuevos documentos de operación:MutualFollowersForProfile.graphql,BulkFollowUsersAction.graphql,BulkMuteUsersAction.graphql,MyReports.graphql,VerificationBadgeInfoInline.graphql,MyProductReviewInline.graphql,DeleteProductReviewInline.graphql,UpdatePostPromotionInline.graphql. Nuevo ícono añadido al set derivado de lucide del proyecto (Flag). Nuevas pruebas:NotificationsPage.test.tsx,MyReportsPage.test.tsx,MessageBubble.test.tsxextendido con cobertura del tooltip de reacciones; la cobertura de pruebas deBulkFollowerActionsPage.tsx/ShopManagePage.tsx/MyPromotionsPage.tsx/PublicProfilePage.tsxqueda diferida a la pasada final de pruebas dado su tamaño (todos archivos grandes ya existentes sin archivo de pruebas previo).
Huecos solo de frontend (UI muerta o permanentemente deshabilitada)
-
CoinsModal.tsxes código de demostración falso, no un flujo de compra real. Lista de paquetes fija, lee un campobalanceque no existe en el tipo de usuario real, y su manejador de "compra" esawait new Promise(resolve => setTimeout(resolve, 1500))seguido dealert('Purchase successful! (Demo)')— cero llamadas GraphQL. Solo referenciado desde una historia de Storybook hoy, no desde ninguna página real (el flujo real esGetCoinsModal.tsx/GetCoinsPage.tsx/CoinExpressCheckout.tsx). Impacto: Bajo, pero vale la pena eliminarlo para que nadie lo conecte accidentalmente pensando que es real. Corregido. Se confirmó mediante grep que su única referencia real era el barrelcoins/index.ts(que existía únicamente para reexportarlo) y su historia de Storybook - se eliminaron tantoCoinsModal.tsxcomocoins/index.ts, y se quitó la historiaWalletdeCoinsModals.stories.tsx(las historiasGetCoins/Insufficientde los modales reales quedan intactas). - Un botón de "Apodos" en los detalles de conversación está permanentemente deshabilitado sin ninguna funcionalidad de respaldo (sin campo de esquema, sin método de manager para apodos por conversación) — solo
disabled title="Coming soon". Archivo:ConversationDetailsPanel.tsx. Impacto: Bajo, pero es un callejón sin salida visible y descubrible para los usuarios. Corregido - se construyó la versión real y mínima de la funcionalidad en lugar de simplemente eliminar el botón muerto. Se añadió un apodo de visualización por conversación, autoasignado: una nueva columna nullablenicknameenconversation_participant(migración + modelo), un camponickname: Stringen el tipo GraphQLConversationParticipant, y una nueva mutaciónupdateMyNickname(conversationId, nickname)(null/vacío lo restablece al nombre real) - replicando las acciones de participante autoalcanzadas ya existentes (pinConversation/muteConversation) tanto para la verificación de membresía del manager como para su forma de autorización. Se eligió un apodo autoasignado "visible para todos en este chat" (como un apodo de servidor de Discord) en lugar de un apodo por espectador ("el nombre que le doy a esta otra persona"), ya que este último necesita un modelo de datos materialmente diferente (una tabla de tres vías espectador/objetivo/conversación, no una columna en la tabla existente de una fila por participante) para un ítem de impacto bajo. El botón ahora abre un editor real (prellenado con el valor actual) en lugar de estar permanentementedisabled, y la propia fila muestra el apodo activo en línea una vez establecido. Nuevos documentos de operación: se añadióUpdateMyNicknameaConversationParticipants.graphql, se añadiónicknamea las selecciones de participantes deGetConversationParticipantsyGetConversation. Nuevas pruebas:conversation-manager.unit.test.js(verificación de participante, validación de longitud, recorte, limpieza vía null, limpieza vía cadena en blanco) yresolvers/conversation.resolver.test.js(guardia de autenticación + delegación) -ConversationDetailsPanel.tsxen sí aún no tiene archivo de pruebas (hueco preexistente, diferido a la pasada final de pruebas junto con los demás archivos grandes sin pruebas que se tocaron en esta sesión). - El
CardElementde Stripe se renderiza en blanco sobre blanco e ilegible en modo claro, en ambos flujos de pago que lo usan.StripeCheckout.tsx(compra de monedas) y elAddCardFormdePaymentMethodsPage.tsx(agregar una tarjeta guardada) fijan amboscolor: '#ffffff'/iconColor: '#ffffff'para el input de tarjeta, mientras el contenedor circundante esbg-whiteen modo claro.ThemeContextsoporta un tema claro real resuelto (incluso víaprefers-color-schemedel sistema operativo), así que cualquier usuario en modo claro que escriba un número de tarjeta ve texto invisible mientras ingresa sus datos de pago. Impacto: Alto — bloquea/oscurece los flujos de compra de monedas y agregar tarjeta, dos rutas centrales de monetización, para cualquier usuario en modo claro. Corregido - y se encontró una tercera instancia que el roadmap no mencionaba. Un grep de todos los usos deCardElementencontró elAddCardFormdePaymentsPage.tsx(el flujo de "agregar tarjeta" propio de la página/payments, distinto del de configuración) con el mismo#fffffffijo. Las tres ahora leenuseTheme()y eligen#111827(gris oscuro legible) en modo claro vs#ffffffen modo oscuro, tanto paracolorcomo paraiconColor. También se eliminó unconsole.log('CardElement onChange: ...')suelto que quedaba enStripeCheckout.tsxal tocar ese bloque (la limpieza más amplia de console.logs se rastrea por separado más abajo). Nuevas pruebas:StripeCheckout.test.tsxyPaymentMethodsPage.test.tsxambas extendidas con un par claro/oscuro que verifica que el stub deCardElementrealmente recibe el color correcto según el tema (el stub ahora expone eloptions.style.base.color/iconColorque recibió vía atributosdata-*, así que esto es una aserción real, no solo "no falla").PaymentsPage.tsxno tiene ningún archivo de pruebas (hueco preexistente) - diferido a la pasada final de pruebas. -
StripeCheckout.tsxincluye 14console.logde depuración sin proteger en el formulario de pago de producción, incluyendo un volcado completo de las variables GraphQL de compra ("Sending variables to GraphQL", un bloque "=== Purchase Debug Info ===" conpackageId/paymentMethodId/customCoins) y un log de validación que se dispara en cada render dentro de la expresióndisableddel botón de envío. Impacto: Medio — consola de producción ruidosa y una filtración menor del id del método de pago hacia las devtools en una superficie de pago. Corregido. Se eliminaron todos: los 3 logs del efecto de auto-selección de método de pago, los 7 logs del bloque "Purchase Debug Info" más el volcado separado "Sending variables to GraphQL" (8 en total), y el log "Button disabled validation" por render del botón de envío - este último estaba envuelto en una IIFE únicamente para tener un lugar donde poner elconsole.logantes dereturnar el booleano real, así que la propdisableddel botón se simplificó de vuelta a una expresión simple una vez eliminado el log, en lugar de dejar una IIFE innecesaria envolviendo un solo booleano. -
ProfilePage.tsxyPublicProfilePage.tsxfijan cadenas de UI fuera det()— en idiomas opuestos entre sí. El menú de configuración, el selector de tema, y las etiquetas deFollowListModaldeProfilePage.tsx('Privacy','Profile visitors','Remove','Unfollow','Load more','No users to show', etc.) están en inglés fijo;PublicProfilePage.tsxfija las mismas props deFollowListModalen español fijo ("Eliminar","Cargar más","No hay usuarios para mostrar"). Ninguno de los dos pasa nunca port(), así que ninguna de estas cadenas existe enen.json/es.json(los dos archivos de idioma por lo demás coinciden perfectamente 1 a 1, 2.628/2.628 claves). Resultado: un usuario en idioma español que ve su propio perfil ve texto de menú en inglés; un usuario en idioma inglés que ve el perfil de otra persona ve texto de lista de seguidores en español. Impacto: Medio — un bug visible que rompe el idioma en dos de las páginas más visitadas, y una instancia directa de que la regla de este proyecto de "enviar cadenas en ambos idiomas" se saltó porque las cadenas nunca se hicieron traducibles en primer lugar. Corregido en la raíz, no solo en los dos puntos de llamada.FollowListModal.tsxen sí era la fuente real de la división de "idiomas opuestos": los valores predeterminados de sus propias propsremoveLabel/unfollowLabel/emptyLabeleran español fijo ('Eliminar','Dejar de seguir','No hay usuarios para mostrar') aunque el componente ya importauseTranslationy lo usa para otras cadenas en el mismo archivo - así que cualquier invocador que olvidara sobrescribirlas (o uno futuro que nunca supiera que debía hacerlo) obtendría español en silencio sin importar el idioma real de la app. Se cambiaron las props a verdaderamente opcionales y se les dieron valores de reserva reales respaldados port()(reutilizando claves existentes:common.remove,profile.unfollow,profile.followers/profile.followingpara el título) calculados dentro del componente, no como literales de parámetro por defecto de JS. También se encontró y eliminó una prop completamente muerta mientras se estaba ahí:loadMoreLabelse pasaba por ambos invocadores y por las props del componente pero nunca se renderizaba en ningún lugar - el mecanismo real de "cargar más" es undivcentinela deIntersectionObserver, no un botón cliqueable con texto. Luego se corrigieron ambos puntos de llamada para que dejaran de fijar un solo idioma: los ítems del menú de configuración, las opciones del selector de tema, y las props deFollowListModaldeProfilePage.tsxahora pasan todos port()(reutilizando en su mayoría claves ya existentes usadas en otras partes de la app -navigation.notifications/.coins/.payments/.settings,settings.privacy/.profile_viewers,theme.system_default/.light/.dark,top_fans.title); las props deFollowListModaldePublicProfilePage.tsxhacen lo mismo en lugar de fijar español. Nuevas claves añadidas aen.json/es.json:profile.saved_posts,profile.no_users_to_show. Nueva prueba:FollowListModal.test.tsx(verifica que el valor de reserva realmente traducido se renderiza cuando no se pasa una prop de sobrescritura - no el español fijo anterior - y que las sobrescrituras explícitas siguen funcionando).ProfilePage.tsx/PublicProfilePage.tsxaún no tienen sus propios archivos de pruebas (hueco preexistente) - diferido a la pasada final de pruebas. -
_freshcheck9_Security.tsxes un duplicado obsoleto y completamente huérfano deSecuritySettingsPage.tsx. 606 líneas, sin ninguna referencia en ningún lugar (confirmado por grep), y una instantánea más antigua a la que le falta la UI de selección de método de 2FA por SMS/correo que sí tiene la página real. Archivo:apps/frontend-nextjs/src/page-components/settings/_freshcheck9_Security.tsx. Impacto: Bajo — sin efecto en tiempo de ejecución, solo desorden de código muerto que vale la pena eliminar. Corregido. Se reconfirmaron cero referencias (grep) y el tamaño exacto de 606 líneas antes de eliminarlo;tsc --noEmitlimpio después. - El
logoutdeAuthContextestá tipado como síncrono (() => void) pero en realidad esasync, esperando una mutation yclearStore(). Cada punto de llamada actual haceawait logout()así que funciona hoy, pero la interfaz no promete unPromise, así que un futuro invocador que olvide elawaitno recibe ninguna advertencia del compilador aunque saltárselo generaría una condición de carrera entre la redirección y la revocación de sesión del lado del servidor. Archivo:contexts/AuthContext.tsx. Impacto: Bajo. Corregido - y el mismo error de tipado existía en dos hermanos declarados justo al lado.logoutAccount(userId: string) => voidylogoutAll() => voidtienen exactamente el mismo bug: ambas implementaciones sonasync(revocan una sesión del lado del servidor antes de limpiar el almacenamiento local), pero la interfaz no prometía que ninguna devolviera algo esperable. Las tres se corrigieron a=> Promise<void>(login/switchAccount, revisados junto a ellas, son genuinamente síncronas y se dejaron sin cambios).tsc --noEmitquedó limpio después sin necesitar cambios en ningún punto de llamada, confirmando la propia afirmación del roadmap de que cada invocador real ya las espera - solo el tipo mentía. No se añadió ninguna prueba nueva: es una corrección solo de tipos sin cambio de comportamiento, yAuthContext.test.tsxya ejercelogoutAccount/logoutAllconawaiten los cuerpos de sus propias pruebas async.
Features de cara al usuario que hoy no existen en absoluto
No son bugs, no están a medio construir — son capacidades comunes en plataformas comparables que no tienen ningún modelo de backend, ningún campo de esquema, ni ninguna UI en ningún lugar de este código. Confirmadas como ausentes buscando sus nombres obvios en graphql/types, database/models, y los dos árboles src de frontend, sin ningún resultado.
- Sistema de tickets de soporte. La única vía de "contactar soporte" hoy es el formulario genérico de feedback (
submitFeedback); no hay ningún modelo dedicado de ticket de soporte, ninguna capacidad de hilo/respuesta, ningún seguimiento de estado de ticket distinto del panel de triage de feedback. Impacto: Medio.- Corregido: Se construyó un sistema completo de tickets de soporte, distinto de
UserFeedback, con capacidad real de hilo/respuesta. Backend: tablassupport_ticket/support_ticket_message+ modelos Sequelize,support-ticket.manager.js(crear/responder/cerrar para usuarios; listar/responder/actualizar-estado para administradores, protegido por un nuevo permiso de administradorMANAGE_SUPPORT_TICKETS), tipos/resolvers de GraphQL reflejados tanto enschema.web.graphqlscomo enschema.admin.graphqls, además de archivos de operaciones dedicados enpackages/graphql/operations/Web/Support/**(para que un futuro cliente iOS/Android los reciba gratis) ypackages/graphql/operations/Admin/Support/**. Frontend web: nueva página/settings/support(crear ticket, ver hilo, responder, cerrar), accesible desde el menú de configuración. Frontend admin: nueva página de triage/support(filtros de estado/categoría, vista de hilo, respuesta, actualización de estado), protegida porMANAGE_SUPPORT_TICKETStanto en la navegación como en la ruta misma, siguiendo el patrón ya existente del panel de feedback. Se agregó i18n en ambos idiomas enfrontend-nextjsyfrontend-admin. Se escribieron pruebas (aún no ejecutadas — pospuestas a la pasada final de pruebas) para el manager, los resolvers y ambas superficies de frontend.
- Corregido: Se construyó un sistema completo de tickets de soporte, distinto de
- Borradores de publicaciones. No hay forma de empezar a redactar una publicación y guardarla sin publicar para después — ni del lado del cliente, ni del servidor. La creación de publicaciones es solo publicar-inmediatamente. Impacto: Medio — una expectativa muy estándar de creación de contenido.
- Corregido: El modelo ya tenía los campos
isPublished/scheduledAt(de la programación de publicaciones), peroPostCreateInput.isPublishedera un no-op muerto —post.validator.jsverificaba/escribía el campo mal escritoinput.is_publishedmientras que el esquema y todos los llamadores usanisPublisheden camelCase, así que un cliente nunca podía realmente crear una publicación sin publicar. Se corrigió el error de mayúsculas/minúsculas (tanto envalidateCreateInputcomo envalidateUpdateInput), y se relajó la regla de "texto o medios requeridos" específicamente para un borrador (isPublished:false, sinscheduledAt) para poder guardar un borrador vacío. Se agregaron la querymyDraftsy la mutaciónpublishDraft(post.manager.js#getDrafts/#publishDraft, siguiendo el mismo patrón que las publicaciones programadas) — editar el contenido de un borrador y eliminarlo reutilizan las mutaciones existentesupdatePost/deletePost, que ya funcionaban con cualquier publicación sin importar su estado de publicación. De paso, se encontró y corrigió un error real de privacidad que esta función habría heredado de otro modo:getPost()nunca verificabaisPublished, así que cualquier publicación sin publicar (un borrador, o una publicación aún programada) con la visibilidad por defectopublicera completamente legible por ID por cualquier usuario autenticado, no solo su dueño — ahora las publicaciones sin publicar son exclusivas de su dueño sin importar lavisibility. Frontend:CreatePostModalobtuvo un botón "Guardar borrador" junto a Compartir/Programar (omite los requisitos de texto/medios/precio, y nunca dispara el crossposting ni el panel de impulso/promoción), y una nueva página/settings/drafts(listar, publicar, editar, eliminar) que reutiliza el patrón de UI de la página de publicaciones programadas.
- Corregido: El modelo ya tenía los campos
- Encuestas en publicaciones/historias. Los tipos
Poll/PollOptionexisten, pero solo dentro demessage.type.js(encuestas de chat) — no hay ningún sticker/adjunto de encuesta para publicaciones o historias, una función de engagement común en plataformas comparables. Impacto: Bajo-Medio.- Corregido: Se reutilizaron los tipos GraphQL
Poll/PollOption/PollInputya existentes de las encuestas de chat (se agregó un campoexpiresAtopcional aPoll, inofensivo para las encuestas de chat que nunca expiran) en lugar de construir tipos paralelos. Backend: la definición de una encuesta (pregunta + 2-10 opciones) vive como JSONB en la propia fila dePost(post.poll) - funciona tanto para publicaciones como para historias sin ningún trabajo de esquema adicional, ya que una historia es solo una fila dePostcontype:'story'. Los votos se registran en una nueva tablapost_poll_vote, reflejando la tablapoll_votede las encuestas de chat (access-service dedicadopost-poll.access-service.js, un voto por usuario, volver a votar sobrescribe el anterior). Nueva mutaciónvotePostPoll(siguiendo el patrón demyDrafts) y un resolver de campoPost.pollque reflejaMessage.poll. TantoPostCreateInputcomoStoryCreateInputobtuvieron un campopoll; una encuesta cuenta como contenido de la publicación/historia por sí sola (se relajó la regla de "texto o medios requeridos" en el validador cuando hay una encuesta presente, el mismo trato que los medios). Las encuestas son inmutables una vez creadas (sin ruta de edición - la gente ya podría haber votado). Al construir esto, se encontró y corrigió un error real de privacidad engetPost(): nunca verificabaisPublished, así que cualquier publicación sin publicar (un borrador, o una publicación aún programada) con la visibilidad por defectopublicera completamente legible por ID por cualquier usuario autenticado, no solo su dueño - corregido junto con esta función ya que afecta directamente al contenido de borradores/publicaciones programadas con una encuesta. Frontend: un componente reutilizablePostPoll.tsx(barra de porcentaje, marca de verificación, bloqueado una vez votado, reflejando la UI de la encuesta de chat ya existente enMessageBubble.tsx) conectado enPostCard.tsx(feed),PostModal.tsx(detalle/permalink, móvil + escritorio), yStoryViewer.tsx(sticker de historia).CreatePostModal.tsxobtuvo un panel de composición de encuestas (pregunta + hasta 4 opciones) junto a sus paneles existentes de precio/programación - una encuesta funciona para una publicación nueva pero no se ofrece al editar (por la inmutabilidad).CreateStoryModal.tsxobtuvo la misma UI de encuestas y ya no requiere una foto/video cuando se configura una encuesta.
- Corregido: Se reutilizaron los tipos GraphQL
- Código QR para compartir perfil. No hay ningún campo
qrCodeni generador en ningún lugar para el perfil propio de un usuario — común para promocionar una cuenta fuera de línea o en enlaces de biografía en otros lugares. Impacto: Bajo.- Corregido: Esta afirmación estaba desactualizada — la función ya existe y funciona de extremo a extremo, simplemente no estaba reflejada en este roadmap.
components/ProfileQRCode.tsxgenera un código QR real y estilizado completamente del lado del cliente (el paquete npmqr-code-styling, importado dinámicamente para que nunca se ejecute durante SSR) que codifica el enlace compartible del perfil, con el logo de Closegram incrustado en el centro y la corrección de errores forzada a'H'para que el logo no rompa el escaneo.components/ShareProfileModal.tsxlo envuelve con acciones de Compartir/Copiar enlace/Descargar más un enlace a la página de enlaces en biografía del perfil, y ya está conectado tanto enProfilePage.tsx(perfil propio) como enPublicProfilePage.tsx(al ver el de otra persona) mediante sus botones "Compartir perfil". No se necesita ninguna intervención del backend - el QR simplemente codifica${window.location.origin}/${username}, calculado al instante, así que tampoco hay nada que compartir aquí con iOS/Android (un generador de QR nativo es la elección natural en esas plataformas). Inicialmente se empezó a construir un campo GraphQL redundante del lado del servidorUser.qrCodeDataUrl(reutilizando el paquete npmqrcodeya usado por la configuración de 2FA) antes de descubrir la implementación de frontend ya funcional - se revirtió ese trabajo de backend ya que habría sido código muerto que ningún cliente necesitaba. Se agregó lo único que realmente faltaba: un archivo de pruebas paraShareProfileModal.tsx(ShareProfileModal.test.tsx) que cubre la acción de copiar enlace, la navegación a la página de enlaces en biografía, y el comportamiento de cierre al hacer clic en el fondo.
- Corregido: Esta afirmación estaba desactualizada — la función ya existe y funciona de extremo a extremo, simplemente no estaba reflejada en este roadmap.
- Versionado de los Términos de Servicio / re-aceptación forzada.
User.is_terms_conditions_acceptedes un booleano simple que se fija una sola vez al registrarse — no tiene ningún número de versión asociado, así que no hay ningún mecanismo para forzar a los usuarios existentes a re-aceptar términos actualizados cuando cambian. Impacto: Medio-Alto para una plataforma que vende contenido íntimo/adulto pago — este es un hueco real de exposición legal, no solo algo deseable.- Corregido: El hueco era en realidad peor de lo descrito -
isTermsConditionsAcceptednunca se fijaba ni se verificaba en ningún lugar del código (ni siquiera una vez al registrarse); simplemente se quedaba en su valor por defectofalsepara siempre, código muerto. Se construyó un mecanismo real y versionado:constants/environment.js#CURRENT_TERMS_VERSION(configurable por variable de entorno, por defecto'1.0') es la única fuente de verdad de "qué versión está vigente ahora mismo" - se sube el número y toda cuenta cuya versión guardada no coincida, incluyendo toda cuenta preexistente (versión guardadanull), necesita re-aceptar.User.termsAcceptedVersion/termsAcceptedAtson la caché rápida de "estado actual"; una nueva tablauser_terms_acceptance, de solo inserción, es el rastro de auditoría legal (quién aceptó qué versión, cuándo, desde qué IP) - necesario para una defensa legal real en una plataforma que vende contenido adulto pago. Nuevoterms-acceptance.manager.js(getTermsStatus/acceptTerms/acceptCurrentVersionAtSignup), una querytermsStatus+ mutaciónacceptTerms, y camposUser.termsAcceptedVersion/termsAcceptedAt(exclusivos del propio usuario, con el mismo control de acceso que otros campos sensibles de User). El registro ahora acepta automáticamente la versión actual (el formulario de registro ya muestra el aviso implícito "al continuar, aceptas nuestros Términos..."), así que una cuenta recién creada empieza al día en lugar de toparse de inmediato con su propia puerta de re-aceptación. Frontend:TermsGate.tsx, envuelto alrededor de cada pantalla autenticada porProtectedRoute.tsx(la misma cobertura que la puerta de onboarding ya existente) - mientrasneedsAcceptancesea verdadero, muestra un modal de pantalla completa que no se puede cerrar sin aceptar. De paso, enauthentication.manager.js, también se encontró y eliminó un error real y no relacionado directamente adyacente a este trabajo: un bloque del programa de referidos copiado por error enlogin()(en vez de solo enregister()) que referenciaba una variablereferralCodeno declarada, lanzando unReferenceErroren cada inicio de sesión - silenciado por su propio try/catch, así que nunca se manifestaba, pero tampoco hacía nada.
- Corregido: El hueco era en realidad peor de lo descrito -
- Flujo de retiro de contenido por DMCA/copyright. Solo existe la moderación de contenido genérica (reportes de usuarios → revisión de admin); no hay ningún flujo legal dedicado de retiro con un proceso formal de aviso y contra-notificación, que es materialmente distinto de la moderación de contenido rutinaria para cualquier plataforma que aloja medios generados por usuarios. Impacto: Medio-Alto — exposición legal/de cumplimiento para una plataforma de contenido generado por usuarios.
- Corregido: Se construyó un flujo real de aviso y contra-notificación conforme a 17 U.S.C. 512, deliberadamente separado del flujo rutinario "esto viola las normas" de
content-report.manager.jsen lugar de sobrecargarlo. Backend: nuevas tablasdmca_takedown_request(nombre/correo del denunciante, descripción de la obra, las dos declaraciones juradas que exige 512(c)(3), una firma escrita, ciclo de vida de estado) ydmca_counter_notice(la declaración jurada de la parte acusada, el consentimiento de jurisdicción según 512(g)(3)(D), firma), con un rastro de auditoría completo - nada se sobrescribe jamás, cada revisión queda registrada.dmca.manager.jsresuelve al propietario del contenido acusado del lado del servidor a partir de la publicación/comentario reportado (nunca confía en un id de propietario proporcionado por el cliente, lo cual permitiría a un denunciante desviar un retiro hacia la cuenta equivocada), y reutiliza los primitivos ya existentes y funcionalescontent-moderation.manager.js#removeContent/#restoreContentpara el retiro/restauración real - sin ningún mecanismo paralelo de ocultamiento de contenido. Cualquiera puede presentar un aviso (no se requiere cuenta, siguiendo la práctica real de DMCA); una contranotificación solo puede presentarla el propietario real del contenido, y solo mientras el contenido esté efectivamente retirado como resultado de ese aviso específico. Un nuevo permiso de administradorMANAGE_DMCA_REQUESTSprotege la cola de revisión. Frontend: una opción "Reportar infracción de derechos de autor" en el menú de opciones de la publicación (DmcaTakedownModal.tsx, deliberadamente más riguroso que el flujo genérico ya existente de "Reportar publicación" - recopila el nombre legal/correo y ambas declaraciones juradas), una nueva página/settings/dmca(avisos presentados por ti, avisos presentados contra tu contenido con un formulario de contranotificación en línea), y una cola de revisión/dmcaen el panel de administración (aprobar/rechazar un retiro, aceptar/rechazar una contranotificación, vista completa del detalle legal). Nuevos archivos de operaciones.graphqlenpackages/graphql/operations/Web/Dmca/**yAdmin/Dmca/**para que un futuro cliente iOS/Android reciba el mismo contrato.
- Corregido: Se construyó un flujo real de aviso y contra-notificación conforme a 17 U.S.C. 512, deliberadamente separado del flujo rutinario "esto viola las normas" de
Panel de administración — herramientas faltantes
Verificado mediante una auditoría completa de apps/frontend-admin (todas las rutas, la navegación, y el catálogo de permisos) cruzada con el esquema de administración del backend — cada elemento de abajo se confirmó como faltante revisando directamente los tipos/resolvers reales de GraphQL, no asumido. Varios casi-aciertos explícitamente no están listados porque ya existen: ban/suspensión masiva de usuarios, eliminación masiva de contenido (adminBulkRemoveContent), revisión masiva de reportes (adminBulkReviewReports), actualización masiva de permisos de admin (BulkPermissionsModal), alternado de permisos por admin (PermissionsModal), una exportación de analíticas genérica bajo demanda (adminExportAnalytics, csv/json), lista blanca de IP de administradores para login, y un feed de actividad pequeño y sin filtros en el dashboard.
- La transmisión/anuncio a todos los usuarios es código muerto, no solo UI faltante.
admin-managers/index.js#sendBroadcastMessagedelega aadminNotificationManager.sendBroadcastMessage(...), peroAdminNotificationManagersolo define realmentebroadcastToAdmins(notifica a otros administradores, no a usuarios finales) — el método delegado no existe, así que esto lanzaría un error si algo alguna vez lo llamara. Ningún tipo/resolver de GraphQL lo expone, y no hay ninguna página de frontend. Impacto: Alto — soporte/operaciones no tiene forma de enviar un aviso de interrupción o actualización de política a los usuarios o a un segmento. Corregido. Se implementó el método realsendBroadcastMessage(message, adminId, options, context)enAdminNotificationManager, así que la fachada ya existente enadmin-managers/index.jsahora realmente funciona en lugar de lanzar un error. La entrega reutiliza el modeloNotificationpor usuario ya existente en lugar de inventar un mecanismo paralelo -notificationType:'system'ya existía sin usar en el enumNotificationType, y la rama de renderizado por defecto deNotificationsPage.tsxya muestra el texto demessagecorrectamente para una notificación sin actor, así que no se necesitó ningún cambio en el frontend de consumo. Una nueva tablaadmin_broadcastes el rastro de auditoría (quién envió qué, a qué segmento, a cuántos se alcanzó) - primero se crea una fila fresca deAdminBroadcast, luego el mensaje se distribuye como una fila deNotificationpor cada usuario objetivo mediante un nuevobulkCreate. La segmentación admiteall(todos los usuarios activos),verified(solo usuarios verificados), o una listacustomde ids de usuario (user.access-service.js#getActiveUserIds, limitado a 100 mil como válvula de seguridad). Un nuevo permiso de administradorMANAGE_BROADCASTSprotege tanto la acción de envío como la consulta de historial. Frontend de administración: nueva página/broadcasts(formulario de composición con una confirmación de dos pasos "¿estás seguro?" antes de un envío masivo irreversible, más una tabla de historial paginada). Nuevos archivos de operaciones.graphqlenpackages/graphql/operations/Admin/Broadcasts/**. - No hay una página de registro de auditoría de administración dedicada y filtrable. El backend ya soporta esto bien (
adminGetActivityLog(adminId, action, startDate, endDate, limit, offset), además deexportAuditTrail/getActivityByType/getSystemActivity), pero el frontend solo renderiza un widget fijo de "últimas 20 entradas" en el dashboard — sin filtro por admin, acción, o rango de fechas, sin paginación, sin UI de exportación, a pesar de que el resolver acepta todo eso. Impacto: Medio-Alto — exactamente lo que un líder de operaciones necesita al auditar las acciones de otro administrador ("quién baneó a este usuario / quién cambió esa configuración de payout"). Corregido.adminGetActivityLogno tenía ningún permiso dedicado (cualquier administrador autenticado podía llamarlo); se añadió un nuevo permisoVIEW_AUDIT_LOGal catálogo y se protegió el resolver con él, junto con el alcance existente de propio-vs-super_admin. Nueva página/audit-logen el admin: filtros por administrador (menú desplegable, solo super_admin, poblado desdeadminUsers), acción en texto libre (con debounce), y rango de fechas inicio/fin, todo conectado a los argumentos ya existentes del resolver — además de paginación real usando eltotaldel resolver, un modal de detalles con el JSON crudodetailsde cada entrada, y una exportación CSV del lado del cliente de la página actual. Nueva operación.graphqlAdminGetActivityLogFullInline(la consulta inline existente del dashboard solo seleccionaba 20 filas fijas sin filtros/campos de paginación, así que se dejó tal cual para el widget y se añadió una consulta completa nueva para la página). - No hay gestión de sesión/dispositivo de administrador para OTROS administradores. Cada query/mutation de sesión (
adminActiveSessions,adminRevokeSession,adminRevokeAllSessions) está fijada al propioadmin.adminIdde quien llama desde el contexto JWT — no existe ningún argumentotargetAdminIden ningún lugar. Si se sospecha que una cuenta de administrador está comprometida, la única forma de matar sus sesiones hoy es eliminar por completo la cuenta (deleteAdmin). Impacto: Alto. Corregido. Se añadió un argumento opcionaltargetAdminIda las tres, con valor por defecto el propio llamante (sin cambio de comportamiento) y una protección de solo-super_admin para actuar sobre cualquier otro — el mismo patrón propio-vs-super_admin ya usado poradminGetActivityLog/adminIPWhitelist. De paso, se corrigió un bug real preexistente:adminRevokeSessionnunca verificaba que una sesión realmente perteneciera a su administrador objetivo, así que cualquier administrador autenticado que obtuviera elsessionIdde otro administrador ya podía revocarlo — el resolver ahora busca la sesión primero y rechaza si no coincide. Nueva tarjeta de "Sesiones activas" en la página de detalle de cada administrador (/admins/[id], ya protegida solo para super_admin) que lista dispositivo/IP/última actividad con revocación por sesión y un "cerrar sesión en todos lados" con confirmación, reutilizando exactamente las mismas queries/mutations (y el mismo patrón de UI) que la sección "Mis sesiones" ya existente en la página de cuenta propia del administrador. - No hay feature flags / interruptores de emergencia generales.
system-settings.manager.jsya definegetFeatureFlags,setFeatureFlag,enableMaintenanceMode,disableMaintenanceMode,isMaintenanceMode— nada de esto está conectado a ningún tipo/resolver de GraphQL ni página de frontend. Los únicos interruptores reales que existen son estrechos:payoutsEnabledGloballyyisActivepor paquete de monedas. Impacto: Alto — una válvula de seguridad estándar de respuesta a incidentes (p. ej. deshabilitar compras de monedas o transmisiones en vivo en toda la plataforma sin un despliegue) que esta plataforma no tiene. Corregido — y resultó que esos métodos no solo estaban desconectados, sino que no funcionaban en absoluto: llamaban agetSetting/setSetting, que consultan una forma genérica dekey/valueque no existe en el modelo realSystemSetting(la misma clase de código muerto que teníapayoutsEnabledGloballyantes de obtener su propia columna tipada). Se reconstruyerongetFeatureFlags/setFeatureFlag/isFeatureEnabled/enableFeature/disableFeature/isMaintenanceMode/enableMaintenanceMode/disableMaintenanceModesobre 5 columnas tipadas nuevas enSystemSetting(maintenanceMode,maintenanceMessage,coinPurchasesEnabled,liveStreamingEnabled,newRegistrationsEnabled), el mismo patrón que ya usabapayoutsEnabledGlobally. Un nuevo permiso de administradorMANAGE_SYSTEM_SETTINGSprotege una nueva superficie GraphQLadminGetFeatureFlags/adminSetFeatureFlag/adminSetMaintenanceMode. Cada bandera se hace cumplir donde importa, no solo se muestra:coin-purchase.manager.js(los 4 puntos de entrada de compra),live-stream.manager.js#createLiveStream, yauthentication.manager.js#registerahora verifican su bandera antes de hacer cualquier trabajo. El modo de mantenimiento es la válvula de seguridad de toda la plataforma específicamente: un nuevo middleware de Express delante de/web/graphql(solo el endpoint de consumo —/admin/graphqlsigue siendo accesible para que el personal pueda desactivarlo de nuevo) devuelve un 503 con el mensaje configurado mientras está activo, y falla de forma abierta si la propia consulta de configuración falla, para que un fallo de la base de datos nunca pueda tumbar la API. Nueva página de administración/system/feature-flagscon interruptores de aplicación instantánea (cada interruptor llama a su mutación de inmediato, acorde al caso de uso "activarlo durante un incidente") y un paso de confirmación antes de activar el modo de mantenimiento específicamente, ya que bloquea toda la plataforma. - No hay gestión del catálogo de roles/permisos de administrador. El catálogo de 13 permisos está fijado en
admin-user.manager.js#getAdminPermissions, y los roles válidos están fijados a['moderator', 'admin', 'super_admin']. La UI de administración solo puede alternar las claves fijas existentes por administrador — no hay forma de definir un nuevo permiso, crear un rol/preset personalizado, o gestionar categorías. Impacto: Medio — importará una vez que el equipo quiera roles más estrechos (p. ej. "auditor solo de pagos"). Corregido — deliberadamente no dejando que los administradores inventen nuevas cadenas de permiso: cada control de acceso real en este código eshasPermission(adminId, 'CADENA_LITERAL')incrustado en ~75 puntos de llamada revisados en el código a lo largo de cada resolver de admin, así que una clave de permiso definida en la base de datos se vería real (activable, listada) sin hacer cumplir nada en ningún lado — una falsa sensación de seguridad de manual. En su lugar se construyó exactamente lo que pide el propio ejemplo del roadmap: preajustes de rol definidos por el administrador — conjuntos con nombre y reutilizables de las claves del catálogo existente (nueva tabla/manager/superficie de GraphQLadmin_role_preset, solo super_admin, del mismo nivel queadminActivate/adminDelete/adminUpdateRoleAndPermissions), de forma que "auditor solo de pagos" ={MANAGE_PAYOUTS: true, VIEW_ANALYTICS: true}se puede crear una vez y aplicar a cualquier cuenta de administrador en una sola acción desde un nuevo menú desplegable "Aplicar un preajuste" dentro delPermissionsModalpor administrador ya existente. Cada escritura de preajuste valida sus claves contra el catálogo real (admin-user.manager.js#getPermissionKeys(), extraído degetAdminPermissionspara que ambos compartan una única fuente de verdad) y rechaza cualquier clave desconocida. Nueva página de gestión/admins/role-presets. De paso, se corrigió un bug real que este mismo catálogo había acumulado durante la sesión: 7 claves de permiso agregadas por ítems anteriores del roadmap (MANAGE_APP_VERSIONS,MANAGE_FEEDBACK,MANAGE_SUPPORT_TICKETS,MANAGE_DMCA_REQUESTS,MANAGE_BROADCASTS,VIEW_AUDIT_LOG,MANAGE_SYSTEM_SETTINGS) y la categoríasystemnunca recibieron etiquetas enen.json/es.json, así que activar cualquiera de ellas enPermissionsModalmostraba una clave sin traducir comopermissionLabels.VIEW_AUDIT_LOGen lugar de una etiqueta real — ahora está completo. - No hay exportación de datos contextual desde las vistas de lista filtradas de administración. Soporte/finanzas/legal necesitan "exportar los usuarios que acabo de filtrar" o "exportar esta lista de payouts pendientes", no un generador de reportes desconectado. Usuarios, Pagos/Cashouts, y Contenido Marcado no tienen ninguna funcionalidad de exportación ligada a sus filtros/selección reales — el único exportador real (
adminExportAnalytics) es una herramienta independiente sin relación con el estado en vivo de ninguna tabla. Impacto: Medio. Corregido. Deliberadamente no replicando la maquinaria de archivo-servidor/URL-de-descarga deadminExportAnalyticspara 3 tipos de lista más (desproporcionado para "reformatear estas filas como CSV", y ninguna de las 3 queries siquiera devuelve un total de filas necesario para juzgar la completitud de la exportación como sí hacen esos reportes enlatados). En su lugar, cada una de las 3 páginas obtuvo un botón "Exportar CSV" que vuelve a ejecutar su propia query paginada existente con los filtros actuales intactos, con un límite alto de filas, y construye el CSV del lado del cliente (nuevo helper compartidolib/csv.ts, promovido de una copia que ya vivía en línea en la página de audit-log desde el ítem #59 del roadmap). Ninguno de los 3 resolvers subyacentes (adminGetUsers/adminGetCashouts/adminGetFlaggedContent) limitabalimitantes — un cliente ya podía pedir una respuesta sin límite antes de este cambio — así que se agregó un límite del lado del servidorMAX_EXPORT_LIMIT = 5000(replicando el patrón ya existente enlink-tracking.manager.js/search-history.manager.js) a los tres sin importar lo que el cliente envíe; el botón de exportación muestra un aviso de "mostrando las primeras 5,000 — reduce tus filtros" si se alcanza el límite, en lugar de truncar en silencio. La exportación usa el permiso de visualización ya existente de cada página (VIEW_USERS/MANAGE_PAYOUTS/MODERATE_CONTENT), noEXPORT_DATA—EXPORT_DATAestá clasificado como un permiso de analítica/insights, y proteger la exportación con él por separado permitiría que un administrador vea una tabla filtrada pero no pueda exportar exactamente lo que está viendo, contradiciendo el propósito. De paso, se agregó el campouser.emailfaltante a la query de cashouts, ya que el correo existe enCoinCashout.userpero la vista de cashouts de administración nunca lo seleccionaba. - No hay búsqueda global de administración entre usuarios/publicaciones/transacciones/reportes. La paleta de comandos (Cmd+K) parece que debería hacer esto pero solo filtra la lista estática de elementos de navegación — no tiene ningún conocimiento de datos de entidades en vivo. La búsqueda está completamente aislada por página hoy. Impacto: Medio. Corregido. Se mantuvo la
CommandPalettede Cmd+K existente como único punto de entrada en lugar de construir una página de búsqueda separada — al escribir 2 o más caracteres ahora también se dispara, con debounce (300ms), una queryadminGlobalSearch(query)junto al filtro estático de elementos de navegación (sin cambios), renderizando secciones agrupadas de Usuarios/Cashouts/Publicaciones/Reportes debajo. El nuevoGlobalSearchManagerdistribuye la query a una consulta ligera por categoría, cada una limitada a 5 resultados y cada una protegida de forma independiente con el permiso existente de esa categoría (VIEW_USERS,MANAGE_PAYOUTS,MODERATE_CONTENTdos veces, para publicaciones y reportes) — a quien le falte un permiso simplemente recibe un arreglo vacío para esa categoría en lugar de que falle toda la búsqueda, siguiendo el mismo patrón con el que ya se ocultan condicionalmente los elementos de navegación en otras partes. La búsqueda de publicaciones reutiliza el índice GINto_tsvector('english', text)que la tablaPostya tenía (nuevopostAccessService.searchByText(),plainto_tsquerymediante un parámetro seguroreplacements, nunca interpolado en el string) en lugar de agregar un índice nuevo. Los resultados de usuarios y cashouts son clicables hacia sus páginas de detalle ya existentes (/users/{id},/payments/users/{userId}); los resultados de publicaciones y reportes se muestran solo como información, ya que esta aplicación realmente no tiene ninguna ruta de detalle de administración por publicación o por reporte a la cual enlazar hoy (adminGetReportsen particular resultó no tener ninguna página de frontend en absoluto) — un enlace inventado sería peor que ninguno. - No hay búsqueda de texto completo de contenido del lado de administración.
adminGetFlaggedContent(la única query de navegación de contenido) tomacontentType, status, limit, offset, orderBy— sin parámetro de texto libre, y no existe ningún resolver de búsqueda separado. Confianza y seguridad solo puede navegar lo que ya ha sido reportado, no buscar proactivamente en el texto de publicaciones una palabra clave/insulto/enlace de estafa. Impacto: Medio. Corregido. Nueva queryadminSearchContent(query, limit, offset), protegida con el mismo permisoMODERATE_CONTENTqueadminGetFlaggedContent, respaldada por un hermano paginado de la búsqueda de texto completo que este mismo ítem de la sección del roadmap (#64, búsqueda global) ya agregó (postAccessService.searchByTextPaginated(),Post.findAndCountAllsobre el índice GINto_tsvector('english', text)ya existente,distinct: truepara un conteo preciso junto con el include deuser). Limitado deliberadamente a publicaciones:Post.textes la única columna de contenido con un índice de texto completo hoy, y agregar uno equivalente para comentarios/mensajes es un esfuerzo separado y mayor al que pide este ítem — un filtrocontentTypehabría implicado una cobertura más amplia de la que realmente existe, así que se omitió en lugar de enviarse como un filtro que en silencio no hace nada para nada que no sea POST. Nuevo panel colapsable "Buscar contenido" en la página/moderation/flaggedya existente (la misma página donde está el panel "Contenido en tendencia", no una ruta separada), con entrada de texto libre con debounce (300ms), cada resultado renderizado con la misma miniatura/texto de vista previa usada en el resto de esta página y reutilizando exactamente los mismos modales y mutaciones de Marcar/Eliminar que ya usa la tabla principal de contenido marcado — un moderador puede actuar sobre una publicación encontrada proactivamente exactamente igual que sobre una reportada, sin necesidad de una plomería de acciones separada. - No hay reportes programados/automatizados (p. ej. un resumen semanal de ingresos por correo). Todos los reportes son bajo demanda únicamente (
adminExportAnalytics); el único cron job relevante (analytics-snapshot.service.js) solo escribe filas internas de almacén de datos, nunca envía correo. Impacto: Bajo-Medio. Corregido. Nueva página de administración "Suscripciones de reportes" de autoservicio (protegida conEXPORT_DATA, el mismo permiso que ya verificaadminExportAnalytics- un resumen recurrente no es más que una exportación programada): un administrador elige uno de los 5 tipos de reporte existentes (USERS/CONTENT/ENGAGEMENT/REVENUE/MODERATION, el mismo vocabulario que ya usaadminExportAnalytics) y una cadencia (DAILY/WEEKLY/MONTHLY), opcionalmente con correos de destinatarios adicionales (limitado a 5, validados) - si se deja en blanco, se envía por defecto alAdminUser.emailpropio del administrador. Nueva tablaadmin_report_subscriptioncon CRUD completo (adminGetReportSubscriptions/adminCreateReportSubscription/adminUpdateReportSubscription/adminDeleteReportSubscription), limitado a las propias suscripciones de quien llama (sin anulación de super_admin - a diferencia de sesiones/permisos, el resumen personal de otro administrador no tiene ninguna razón legítima para que alguien más lo toque). Nuevo cron jobservices/report-digest.service.js(siguiendo exactamente el mismo patrónnode-cron+start()ya usado poranalytics-snapshot.service.js/admin-session-cleanup.service.js) corre una vez al día a las 08:00, calcula qué frecuencias corresponden hoy (DAILY siempre, WEEKLY solo los lunes, MONTHLY solo el día 1), y delega enreport-digest.manager.js, que reutiliza exactamente los mismos métodos deadmin-dashboard.manager.jsqueadminExportAnalyticsya usa - resumidos a un puñado de cifras principales (p. ej. ingresos totales + tasa de crecimiento) en lugar de un volcado crudo por día, ya que un correo de resumen necesita verse de un vistazo, no un CSV. El nuevoemailService.sendReportDigest()sigue el mismo patrón bilingüe de plantilla que el resto de correos transaccionales de la app (sendPurchaseReceipt/sendCreatorEarningsPaid). Que una suscripción falle al enviarse (consulta de analítica fallida, dirección inválida) se registra y se omite en lugar de abortar toda la ejecución. De paso, se corrigió un bug real preexistente: la clave de errorvalidation.invalid_report_typedeadminExportAnalyticsnunca se había agregado aen.json/es.json, así que ese error mostraba en silencio la clave cruda en lugar de un mensaje real antes de esta corrección. - La nueva función Family Center (supervisión de tiempo de pantalla padre/hijo) no tiene ninguna superficie de administración. Las queries/mutations de
graphql/types/family-supervision.type.js(mySupervisionAsParent/mySupervisionAsChild/childUsageStats/requestFamilySupervision/respondToFamilySupervision/endFamilySupervision/setChildTimeLimit) son todas solo para usuarios autenticados — sin equivalentes con prefijo admin, sin resolver de admin, sin método de manager para listar/inspeccionar/forzar el fin de un vínculo de supervisión. Un grep de "family" enapps/frontend-admin/srcno arroja nada relacionado. Esta es una función nueva (modelo/migración/manager/resolver todos agregados en los últimos 30 commits) que involucra cuentas de menores, sin ninguna vía de administración para investigar una disputa o desvincular por una necesidad legal/de seguridad. Impacto: Alto. Corregido. Nueva queryadminGetFamilySupervisions(status, limit, offset)que lista todos los vínculos de supervisión de toda la plataforma (no limitados a quien llama, a diferencia de losfindByParent/findByChildya existentes), y una nueva mutaciónadminForceEndFamilySupervision(linkId, reason)que puede finalizar un vínculo sin importar su estado o si quien llama es parte de él - a diferencia delendFamilySupervisionde cara al usuario, que sí lo exige. Ambos reutilizan el mismo tipo/tabla/access-serviceFamilySupervisionya existente en lugar de construir un modelo paralelo. Nuevo permiso dedicadoMANAGE_FAMILY_SUPERVISION(no una clave más amplia ya existente comoVIEW_USERS, ya que esta es específicamente una superficie de seguridad de menores) protege ambos. Forzar el fin de un vínculoactivenotifica tanto al padre/madre como al hijo/a con el motivo indicado por el administrador (un vínculopendingo yadeclined/endedno tiene nada que notificar a nadie, y forzar el fin de un vínculo ya finalizado/rechazado es una operación sin efecto, igual que el propio método de cara al usuario);endedByUserIdse deja ennullya que esa columna es una FK haciaUsery ningún usuario (padre/madre o hijo/a) fue realmente quien lo finalizó. Nueva página de administración/family-center: una lista filtrable (por estado) que muestra los nombres de usuario/correos de ambas partes y una acción de forzar el fin con un modal de confirmación de motivo obligatorio, reutilizando el mismo componenteReasonModalque ya usa cualquier otro flujo de acción de moderación. - No hay control de administración para congelar/descongelar la cuenta de payout de un usuario, a pesar de que la mutation ya existe.
adminSetPayoutAccountDisabled(userId, disabled, reason)(graphql/types/admin/payout-admin.type.js, resuelto enadmin/payout-admin.resolver.js, respaldado porPayoutAccount.adminDisabled) no tiene ninguna referencia en ningún lugar deapps/frontend-admin/src. La única página de pagos por usuario (app/payments/users/[userId]/page.tsx) muestra 8StatCards de ingresos de solo lectura y nada sobre el estado de la cuenta de payout. Un administrador que revisa un cashout sospechoso no tiene forma de congelar realmente la cuenta de payout de ese usuario. Impacto: Alto. Corregido. La mutación ni siquiera tenía forma de leer primero el estado actual - no existía ninguna queryadminGetPayoutAccount(userId), así que un control de frontend no habría tenido nada que mostrar antes del primer clic. Se agregó esa query (con la misma protecciónMANAGE_PAYOUTS, respaldada por eluserPayoutAccountAccessService.findByUser()ya existente - sin necesidad de una nueva capa de datos) junto con una nueva tarjeta "Cuenta de pagos" en la página de pagos por usuario: proveedor/estado/pagos habilitados/últimos 4 dígitos del banco de un vistazo, una insignia Activa/Congelada, y un botón Congelar/Descongelar detrás de un modal de confirmación con motivo obligatorio (el mismoReasonModalque ya usa cualquier otra acción de moderación). Un usuario sin ninguna cuenta de pagos (el caso común) recibe un mensaje explícito de "no ha configurado una" en lugar de una sección vacía. De paso, se separó el contenido en línea depayments/users/[userId]/page.tsxen su propioUserRevenueContent.tsx(siguiendo la misma convención que ya sigue cualquier otra página de administración) para que la nueva tarjeta sea comprobable con pruebas unitarias ypage.tsxse mantenga como un envoltorio delgado - una lección aprendida de la forma difícil antes en este mismo esfuerzo (roadmap #63) cuando una exportación con nombre directamente dentro de unpage.tsxrompió la generación de tipos de rutas de Next.js. - No hay ninguna herramienta de administración para buscar, inspeccionar, o reembolsar una transacción de compra de monedas cruda (Stripe/PayPal/IAP).
moderation/refunds/page.tsxestá explícitamente limitada aadminGetPostPurchases/adminRefundPostPurchase— solo desbloqueos de publicaciones exclusivas. No existe ninguna queryadminGetPurchases/adminGetCoinPurchases/adminGetTransactionsen el esquema de administración, a pesar de quecoin-purchase.manager.jstiene una superficie completa derefundPurchase/approveRefund/rejectRefund. Commits recientes agregaron un proveedor de compras de PayPal completamente nuevo (services/paypal/*, una migración dePaymentCustomerpor proveedor) sin ninguna visibilidad de administración que lo acompañe sobre la mezcla de proveedores o la búsqueda por transacción para una disputa/contracargo de una simple recarga de monedas. Impacto: Alto. Corregido - y de paso se encontró un agujero de seguridad real y en producción.approveRefund/rejectRefundresultaron ser solo comentarios de docblock de la clase, no métodos reales -refundPurchase(purchaseId, context)es el único camino de reembolso que realmente existe, y es solo una reversión del libro contable de monedas (nunca llamaba a Stripe/PayPal para emitir un reembolso real del lado del procesador - ninguno de los dos servicios expone todavía una llamada de reembolso, así que un administrador todavía tiene que hacer esa parte con el proveedor a mano). Mucho más grave: la mutación de esquema de clienterefundCoinPurchase(purchaseId)a la que este método estaba conectado no tenía ninguna verificación de autorización de ningún tipo - ni siquieraif (!user)- a diferencia de cualquier otra mutation hermana en ese mismo archivo de resolver. Cualquier persona que llamara, autenticada o no, podía reembolsar cualquier compra de monedas completada de cualquier usuario por id, revirtiendo en silencio su saldo de monedas; la prueba de integración que la ejercitaba incluso lo demostraba (un token de usuario común, éxito). Esa mutation ha sido eliminada del esquema por completo en lugar de parcheada, ya que nada enapps/frontend-nextjs/apps/frontend-admin/packages/graphql/operationsla llamaba jamás. Su reemplazo,adminRefundCoinPurchase(purchaseId, reason!), es exclusivo de administradores detrás de un nuevo permiso dedicadoMANAGE_TRANSACTIONS(los ya existentesMANAGE_PAYOUTS/MODERATE_CONTENTson ambos un ajuste semánticamente incorrecto - uno trata de pagar a creadores, el otro de contenido, ninguno de reembolsos de compras) y grabareason+ el id del administrador que actúa en elmetadataJSONB de la compra como rastro de auditoría (no existe ninguna columna dedicada de motivo de reembolso enCoinPurchase). El nuevoadminGetCoinPurchases(status, platform, userId, limit, offset)lista cada compra de toda la plataforma con suPaymentTransactionincluida (así que la mezcla de proveedores - stripe/paypal - y el id de cargo externo son visibles por fila, no solo la columna internaplatformweb/ios/android) - exactamente la brecha de visibilidad de "mezcla de proveedores" que señalaba el roadmap. Frontend: se agregó una tercera pestaña "Compras de monedas" alRefundsContent.tsxya con pestañas existente (ya construido para extenderse así desde las pestañas de publicaciones/mensajes) - una columna Proveedor reemplaza a "Vendedor" (no hay vendedor en una recarga de la plataforma) más una columna extra de "Monto pagado" en dinero real, y la acción de reembolso solo aparece para comprascompleted(a diferencia de las compras de publicaciones/mensajes, una compra de monedas puede estarpending/failed, y el manager rechazaría un intento de reembolso sobre cualquiera de las dos). - No hay visibilidad de administración sobre las sesiones/dispositivos activos de un usuario regular para investigar fraude o robo de cuenta (distinto del hueco ya listado sobre administradores gestionando las sesiones de otros administradores — esto es sobre investigar la cuenta de un usuario).
UserModerationInfo(el tipo que respaldaadminGetUserDetails) no tiene ningún campo de sesión/dispositivo/IP, ysessions.type.jssolo expone datos de sesión al propio dueño de la sesión. Impacto: Medio. Corregido. Nueva queryadminGetUserSessions(userId)(VIEW_USERS, la misma protección deadminGetUserDetails) más las mutationsadminRevokeUserSession(sessionId, userId, reason)/adminRevokeAllUserSessions(userId, reason)(SUSPEND_USERS- revocar es una acción correctiva, un nivel por encima de una simple lectura). Las tres reutilizansessions-devices.manager.jstal cual ya existía - cada método ahí ya tomaba un argumentouserIdexplícito en lugar de fijar a quien llama, así que no se necesitaron nuevos métodos de manager, solo alcanzabilidad en el esquema de administración y una verificación de permiso;revokeSession/terminateAllSessionsganaron un parámetroreasonopcional (con el mismo valor por defecto que el flujo de autoservicio) para que una revocación iniciada por un administrador grabe un motivo real enrevoke_reasonen lugar del genérico'user_logout'. De paso, se corrigió un bug real en el mismo método que se reutiliza: la transformación de filas degetActiveSessions()guardaba el sistema operativo bajo la claveplatform- un campo al queUserSession.osnunca resuelve, ya que el esquema no declaraplatformen absoluto - y nunca establecíauserId/expiresAt(en silencio null ya que ningún llamador existente los había seleccionado todavía, aunqueuserIdes no-nulo en el esquema). Este bug ya era visible para el usuario: Configuración → Seguridad → Sesiones activas ha estado mostrando un sistema operativo en blanco en la línea de navegador/SO de cada sesión. Frontend: una nueva sección colapsable "Sesiones" en la página de detalle de administración por usuario (carga diferida al expandir, siguiendo el mismo patrón de divulgación de Tendencias/Buscar contenido ya usado en otras partes del panel de administración) lista dispositivo/SO/navegador/IP/ubicación/última actividad con una revocación por sesión y una acción masiva "Cerrar sesión en todos los dispositivos" (que solo aparece cuando hay más de una sesión) - reutilizando las cadenas de i18nsecurity.*(sessionsTitle,forceLogout,actionTitles.force_logout, etc.) que llevaban sin usarse en ambos archivos de idioma, aparentemente preescritas justo para esta función. - No hay control de administración sobre el umbral de detección de auto-moderación NSFW, ni una vista de estadísticas para ello.
admin-managers/nsfw-detection.manager.jstiene métodos funcionalesgetThreshold()/setThreshold(threshold)/getDetectionStats(), perocontent-moderation.resolver.jsnunca importa el manager en absoluto — lee las puntuaciones persistidas directamente de la BD en su lugar. Los tres métodos no se llaman en ningún lugar fuera de su propio archivo; la sensibilidad de auto-marcado es efectivamente fija y no ajustable desde el panel de administración. Impacto: Medio. Corregido - y esos tres métodos resultaron estar mucho más rotos que simplemente desconectados.getThreshold()/setThreshold()leían/escribían una propiedad de instancia en memoria (defaultThreshold = 0.7) sin ninguna persistencia - se perdía en cada reinicio, y de todas formas era irrelevante, ya que la decisión REAL de auto-marcado (autoModerate(), llamada desde el camino de detección realmente funcionalanalyzePostMedia()) nunca la leía: tenía sus propias tres bandas de puntuación fijas (0.9/0.7/0.5) completamente independientes del umbral "configurable". ConectarsetThreshold()tal cual habría sido puramente cosmético.getDetectionStats()llamaba a un métodogetAll()que no existe ni enPostNsfwScoreAccessServiceni enNsfwCommentScoreAccessService- unTypeErrorgarantizado en cada llamada, nunca ejercitado por ninguna prueba - y su propia lógica de reduce leía un camponsfw_scoreque ningún modelo declara (la columna real esconfidence). Corrección real: el umbral ahora vive en una nueva columnansfw_detection_thresholdenSystemSetting(la misma fila singleton y access-service que ya usa el trabajo de feature flags/interruptores de emergencia), y los tres niveles deautoModerate()ahora escalan a partir de UN solo valor definido por el administrador - el umbral es el límite de "ocultar directamente", con los niveles de advertencia/marcado situados 0.2/0.4 por debajo (limitados a ≥0), preservando el espaciado original en el valor por defecto de 0.7 mientras hace que el punto de anclaje sea genuinamente ajustable.getDetectionStats()ahora llama a un nuevogetStats()en cada access service que calcula agregados reales a nivel de BD (COUNT/AVGvíaSequelize.fn) sobre las columnas realesisNsfw/confidence, en lugar de traer miles de filas a memoria a través de un método que nunca existió. NuevosadminGetNsfwThreshold/adminSetNsfwThreshold/adminGetNsfwDetectionStats(MODERATE_CONTENT- el mismo permiso que protege cualquier otra superficie real de moderación de contenido, noMANAGE_SYSTEM_SETTINGS, ya que esto ajusta el comportamiento de moderación, no una bandera a nivel de sistema). Nueva página de administración/moderation/nsfw-detection: un campo de umbral con validación en línea, y una vista de estadísticas desglosada por publicaciones/comentarios más un porcentaje general marcado. - Un puñado de queries de administración muertas/duplicadas, vale la pena una pasada de limpieza:
adminGetPayoutProfileyadminGetContentModerationHistoryno se usan — las páginas que parecen necesitarlas obtienen los mismos datos vía campos anidados (CoinCashout.payoutProfile,ContentDetails.moderationHistory) en su lugar;adminGetModerationStatsduplica aadminGetContentModerationStats/adminGetModerationQueueStats, que sí usa el dashboard. Impacto: Bajo. Corregido. Se confirmó que las tres afirmaciones seguían siendo ciertas (cero referencias enapps/frontend-admin/srcopackages/graphql/operations) antes de tocar nada. Se eliminaron los campos de queryadminGetPayoutProfileyadminGetContentModerationHistorydepayout-admin.type.js/content-moderation.type.jsy sus resolvers, y también se eliminóadminGetModerationStats(su handler llamaba al mismocontentModerationManager.getContentStats()que ya usa eladminGetContentModerationStatsque sí se conserva, así que el método del manager subyacente se queda — solo se va la superficie de query duplicada sin argumentos).payoutAdminManager.getPayoutProfile()tenía exactamente un solo llamador (el resolver recién eliminado), así que se borró por completo en lugar de dejarlo como código muerto huérfano, junto con su import ahora sin uso deuserPayoutProfileAccessService;contentModerationManager.getModerationHistory()ygetContentStats()se quedan, ya que ambos siguen siendo llamados desde otros lugares (content-report.manager.js, la fachada deadmin-managers, y el resolver de campoadminGetContentModerationStats/ContentDetails.moderationHistoryque sobrevive). Se reflejaron las tres eliminaciones enschema.admin.graphqls;PayoutProfileyContentModerationStatspermanecen en el esquema ya que ambos tipos siguen siendo devueltos por otros campos que sí se usan. Ningún archivo.graphqlde operaciones hacía referencia a las tres queries muertas, así que no había nada que eliminar ahí. Se reconstruyeronapollo-admin/apollo-websin errores.
Huecos de documentación
Encontrados al cruzar apps/docs contra el estado actual del código que describe.
- Cada enlace de "ver código fuente" y "Editar esta página" en todo el sitio de documentación apunta a la rama
dev, que está 513 commits y ~19 días detrás demain(editUrldedocusaurus.config.js). Concretamente,apps/backend/graphql/resolvers/webauthn.resolver.jsyadmin-passkey.service.jsno existen endeven absoluto, y elcoin-purchase.manager.jsdedevno tienepurchaseCoinsWithSavedPaypal. Cada enlace de clic-a-código-fuente en los docs de features apunta a una instantánea materialmente desactualizada. Impacto: Medio. Corregido.devya se puso al día conmain(el desfase concreto que medía este ítem ya no se reproduce - tantowebauthn.resolver.jscomoadmin-passkey.service.jsahora también existen enmain), pero volver a apuntareditUrladevespecíficamente solo habría tapado el problema de fondo:main, nodev, es la rama real de destino de PRs/estable de este repo, así que es contra la que un enlace de "Editar esta página" debería abrir un PR - un enlace fijado permanentemente a la rama que en ese momento vaya más adelantada simplemente volverá a desactualizarse la próxima vez quedevdiverja. Se cambió eleditUrldedocusaurus.config.jsdeedit/dev/apps/docs/docs/aedit/main/apps/docs/docs/. Fuente única de verdad (sineditUrlduplicado en otro lugar deapps/docs, confirmado vía grep); las URLs dedevincrustadas en el directoriobuild/son un artefacto de compilación local desactualizado (ignorado por git, se regenera en el próximodocusaurus build), no un problema de archivo fuente. -
technical/ios.mdcontradice directamente afeatures/authentication.mdy a esta misma hoja de ruta sobre qué tan completa está la autenticación en iOS. La tabla de funciones detechnical/ios.mdafirmaAuth | ✅ 100% | Phone OTP, Apple Sign In, Google Sign In, mientras quefeatures/authentication.mdy la sección de paridad iOS/Android de abajo afirman correctamente ambas que el login con OTP por teléfono todavía no está conectado en iOS.technical/ios.mdparece haberse quedado fuera de la pasada de reconciliación de documentación que atravesó el resto deapps/docs. Impacto: Medio — activamente engañoso en un documento que un lector trataría como fuente de verdad sobre el estado. Corregido - y resultó que erafeatures/authentication.md(y la propia sección de paridad de esta hoja de ruta) la que lo tenía al revés, notechnical/ios.md. Se leyó el código fuente real de iOS antes de asumir que cualquiera de los dos documentos tenía razón: el OTP por teléfono está completamente conectado de punta a punta enapps/ios(el botón de Teléfono deWelcomeView→ modo teléfono deLoginView→RequestPhoneOtpUseCase→VerifyCodeView→loginWithPhone, todo el camino a través deAuthRepository/AuthServicehasta las mutaciones GraphQL reales, sin ningún stub o TODO en ese camino) - y lo mismo con el registro de token de dispositivo para push (PushNotificationManager→DeviceTokenService.registerDeviceToken()→ la mutación real).apps/docs/docs/technical/features/authentication.md(la referencia técnica detallada, un nivel más profundo que los dos documentos que discrepaban) ya tenía esto correcto, marcando ambas cosas comoiOS only/conectadas - simplemente no se había contrastado contra elfeatures/authentication.mdde nivel superior más simple, que era el que en realidad necesitaba corrección. Se corrigieron las dos afirmaciones falsas de "apps/iostodavía no implementó/conectó esto" enfeatures/authentication.md(OTP por teléfono, registro de token de dispositivo) y se eliminó la línea ahora falsa de la sección de paridad de abajo.technical/ios.mdsí tenía un problema real y distinto, eso sí: su propia sección de "TODOs conocidos" listaba "flujo de recuperación de contraseña" como una brecha crítica de Auth, peroForgotPasswordView.swiftes real y alcanzable desdeLoginView/PasswordView- no es una brecha en absoluto. Se corrigió esa lista a las dos brechas de Auth que sí se verificaron como reales: renovación de token (AuthService.refreshToken()es un stub TODO literal) y eliminación de cuenta (elAuthRepository.deleteAccount()conectado al backend existe, pero nada en la UI lo dispara - el camino de eliminación del lado de Settings pasa por unProfileRepository.deleteAccount()distinto y stubbed que simplemente lanzaProfileError.featureNotSupported), y se ajustó la fila de Auth de 100% a 95% para que coincida. No se modificó ningún código fuente deapps/ios(fuera de alcance segúnCLAUDE.md) - esto fue exclusivamente una corrección de precisión documental. -
features/recent-updates.mdes un único changelog sin fecha de "este ciclo" que se reescribe por completo en vez de agregarse incrementalmente, sin anclas de fecha calendario y sin ninguna otra página de changelog en toda la documentación. Una vez que llegue el próximo ciclo y lo sobrescriba, no queda ningún registro duradero de qué se lanzó y cuándo. Impacto: Bajo-Medio. Corregido. Se convirtió la página a un formato fechado y de solo-anexado: una nueva introducción declara la convención explícitamente (cada ciclo obtiene su propia sección## AAAA-MM-DD — Título, la más reciente primero; un ciclo nuevo se agrega como una nueva sección arriba de la anterior, que nunca se reescribe ni se elimina), y el contenido del ciclo existente se envolvió en## 2026-07-21 — Reconciliación completa de la documentación de funciones contra el código(fecha derivada de las marcas de tiempo de migración ya incrustadas en su contenido -20260721*/20260722*- y confirmada contra la fecha del commit de creación del archivo), con cada encabezado interior degradado un nivel (##→###,###→####) para que anide correctamente bajo la nueva sección fechada. Los dos enlaces de ancla internos a la sección de Despliegue siguen resolviendo correctamente (el texto/slug del encabezado no cambió, solo se degradó). - La documentación nunca menciona el comando raíz
npm run test:allni su requisito deTZ=UTC, a pesar de que CLAUDE.md ypayments-subscriptions.unit.test.jsmarcan ambos que las pruebas de backend asumen una zona horaria de contenedor en UTC.technical/testing.mdsolo documentanpm testpor workspace. Alguien que siga solo la documentación pública (no CLAUDE.md) podría encontrar fallos de prueba locales espurios sin ninguna explicación. Impacto: Medio. Corregido. Se agregó una nueva sección "Comandos a nivel de raíz" atechnical/testing.md(justo después de la tabla de resumen por paquete, antes de los análisis detallados paquete por paquete) que documenta los cinco scripts de prueba raíz delpackage.json(test,test:frontend,test:backend,test:all,test:log) verificados contra sus definiciones reales, más una subsección dedicada "El requisito deTZ=UTC" que explica el porqué (pruebas con temporizadores falsos que dependen de límites de fecha, asumiendo un contenedor en UTC, segúnpayments-subscriptions.unit.test.js) y señala el Stop hook (run-tests-on-stop.sh) como el ejemplo existente de cómo hacerlo correctamente. -
WEBAUTHN_RP_ID/WEBAUTHN_ORIGIN/WEBAUTHN_RP_NAMEse mencionan en dos docs de features como cubiertos en "configuración de entorno", pero ninguno de los docs de primeros pasos los documenta realmente, a pesar de que las passkeys están completamente lanzadas tanto del lado de usuario como de administrador, yadmin/features/admin-accounts.mdadvierte explícitamente que unWEBAUTHN_ORIGINmal configurado lanzaUnexpected registration response origin. Impacto: Medio — un modo de fallo real y documentado sin instrucciones de configuración correspondientes en ningún lugar. Corregido. Se agregó una nueva sección "Passkeys (WebAuthn)" agetting-started/environment-setup.md(justo después de JWT, siguiendo la agrupación por categoríaauthdeenv-catalog.js) que documenta las tres variables, sus valores por defecto (localhost/FRONTEND_URL/APP_NAME), la relación compartida-no-separada con admin, y ambos modos de fallo documentados - basado en el bloque de comentarios preciso que ya existía enapps/backend/.env.example(al que esta nueva sección ahora remite para un ejemplo real de producción) y enlazado de forma cruzada con el detalle específico de admin enadmin-accounts.md. Se redirigieron los enlaces "configuración de entorno" ahora colgantes de los dos docs de features (technical/features/authentication.md) hacia el ancla de la nueva sección en vez del documento sin ancla. - El sitio de documentación todavía usa el favicon/logo de stock de Docusaurus y las ilustraciones de relleno
undraw_docusaurus_*sin usar, a pesar de que el título del sitio dice correctamente "Closegram Docs." Impacto: Bajo. Corregido. Se reemplazóstatic/img/logo.svg(el logo de la barra de navegación, que era literalmente la mascota dinosaurio de stock de Docusaurus) por una marca SVG creada a mano - una insignia de cuadrado redondeado con el gradiente de marca real de la app (morado--ifm-color-primary,#8b4ef0→#6d28d9, igual que ensrc/css/custom.css) con un monograma "C" en blanco. Se regeneraronfavicon.icoydocusaurus-social-card.jpga partir de esa misma marca usandosharp(ya presente en losnode_modulescompartidos del monorepo, usado por el pipeline de imágenes del backend) - el favicon es un contenedor PNG-en-ICO moderno (soportado por todos los navegadores principales), y la tarjeta social es un JPEG de 1200×675 con la marca más un logotipo "Closegram Docs" sobre el gradiente de marca, reemplazando las versiones de stock de Docusaurus de ambos. Se eliminaron por completo los tres archivosundraw_docusaurus_*.svgsin usar ydocusaurus.png(se confirmó que no tenían ninguna referencia en ningún lugar fuera de esta entrada de la hoja de ruta -HomepageFeatures/index.jsya renderiza tarjetas solo de texto sin ninguna prop de imagen, así que eran puro peso muerto, ni siquiera estaban conectados a nada). Verificado con unnpm run build -w docscompleto (ambos localesen/es) - sin errores nuevos ni advertencias de enlaces rotos.
Paridad iOS/Android (backend listo, cliente nativo sin conectar — ver los docs de features enlazados para el detalle, no repetido aquí)
Estos ya tienen su propio checklist con evidencia por elemento; listados aquí solo para que esta lista sea un panorama completo en un solo lugar:
- Responder/rechazar una llamada entrante, historial de llamadas, e interfaz de espectador/solicitud de palabra en llamadas grupales — solo web; ya completamente conectado en iOS. Ver Llamadas.
- Flujo de compra nativa en la app (StoreKit 2 / Play Billing →
redeem*CoinPurchase) — backend + GraphQL listos. Ver Monedas.
Pendiente de una decisión de producto/negocio (no iniciado — necesita una decisión antes que tiempo de ingeniería)
- Verificación de identidad automatizada/de terceros. Hoy es 100% revisión manual de administrador. Necesita una decisión de proveedor (Persona, Onfido, Stripe Identity, o similar) antes de poder empezar la implementación — implicaciones de costo y cumplimiento, no solo código.
- Generación de documentos fiscales para payouts (formularios estilo 1099 para creadores). Necesita una decisión sobre qué jurisdicciones/umbrales soportar y si construirlo internamente o integrar un proveedor (p. ej. Stripe Tax, Track1099).
- Comisiones recurrentes / leaderboards de referidos. Hoy los referidos solo pagan un bono único al referidor. Un modelo de comisión recurrente necesita una fórmula/tope de pago decidido antes de poder construirse.
Capítulos futuros más grandes (explícitamente fuera de alcance hasta que tengan su propio esfuerzo dedicado)
- Conexión de la app nativa
apps/ios. Hay una cantidad grande y creciente de contrato de backend esperando sin usar: Family Center, Control de versiones de la app, 2FA por SMS/correo, tokens de dispositivo, OTP por teléfono, ubicación en vivo, las funciones de llamadas de arriba, compras nativas, y más — todo ya expuesto a través depackages/graphql/operations/Web/**, así que la próxima ejecución de codegen deapollo-swiftlo recoge automáticamente. SegúnCLAUDE.md,apps/iosen sí no debe tocarse hasta que esto se convierta en su propia tarea dedicada. - App de Android. Todavía no existe como cliente en absoluto — no hay
apps/android, ni paquete de codegen para Android. El mismo contrato compartidopackages/graphql/operations/Web/**que respalda aapollo-web/apollo-swiftnecesitaría un objetivo de codegen para Android (p. ej. Apollo Kotlin) agregado cuando esto comience.