Skip to main content

Roadmap — Features Left to Implement

A backlog of genuinely unfinished or missing work, compiled by reading the current code directly (not by trusting older docs — several previously-documented "gaps" turned out to already be fixed by the time this list was written, e.g. storyViewers, getCurrentSession, terminateAllSessions, canStory enforcement, the RecommendationsSection discovery tabs, and CollaboratorsModal's invite/remove UI). Check items off as they land, and re-verify against the code before assuming an item is still open — this list will drift out of date the same way the others did.

Each item lists its real-world impact and the evidence for why it's still incomplete.


Security & abuse prevention

  • No brute-force protection on regular user login. Admin login has a real failed-attempt lockout (MAX_FAILED_LOGIN_ATTEMPTS, admin-user.manager.js), but the client-facing login mutation (authentication.manager.js) has none at all — no lockout, no rate limit, no CAPTCHA fallback. Impact: High — an attacker can attempt unlimited password guesses against any account. Fixed: login() now mirrors the admin lockout (new User.failedLoginAttempts/lockedUntil columns + migration, per-user in-memory rate check via rate-limiter.service.js, translated lockout/remaining-attempts messages in en/es — which also fixed the admin flow's own missing translation keys for the same messages).
  • No IP-based rate limiting on auth-sensitive endpoints. The GraphQL @rateLimit directive is already wired into the schema (api/server.js) and works (Redis-backed in production, in-memory fallback in dev), but it's applied to exactly two fields in the entire backend (recordAdImpression/recordAdClick, ad-revenue.type.js). login, requestPhoneOtp (costs real SMS money per call), forgotPassword, register, verifyLoginTwoFactor, and adminLogin all have zero rate limiting. Impact: High for OTP (direct cost per abuse call) and login (credential stuffing); Medium for registration/password-reset (spam/enumeration). Fixed: @rateLimit added to login, register, requestPasswordReset, verifyLoginTwoFactor, requestPhoneOtp, loginWithPhone, adminLogin, adminVerify2FA. Also fixed a real bug found while touching this: the shared keyGenerator in api/server.js had a block body with no return, so every existing @rateLimit field (including the two above) was silently keying on undefined instead of per-IP.
  • No global request-rate middleware. express-rate-limit is an installed dependency but isn't mounted anywhere in api/server.js — there's no blanket per-IP cap on /graphql or /admin/graphql traffic at all, only the two @rateLimit-decorated fields above. Fixed: mounted on both /web/graphql and /admin/graphql (separate limiter instances), reusing the pre-existing RATE_LIMITS.API_GENERAL threshold from constants/admin-security.js.
  • Rate-limit / abuse-threshold values aren't configurable from the admin UI. RATE_LIMITS is a plain hardcoded object in constants/admin-security.js, consumed directly by rate-limiter.service.js — changing a threshold during an active abuse incident means a code deploy, not an admin-panel edit. No query/mutation or frontend page exists for it. Impact: Low-Medium. Fixed: rate-limiter.service.js now layers admin overrides (persisted via the existing-but-unwired system-settings.manager.js JSON-setting mechanism) on top of the hardcoded defaults, applied immediately in-memory with no restart needed; new super-admin-only adminGetRateLimits/adminUpdateRateLimit/adminResetRateLimit schema + a new /system/rate-limits admin page (RateLimitsContent.tsx) to edit them.
  • No IP/device blocklist for end users (ban-evasion prevention). A banned user can simply re-register from the same IP/device today — there's nothing to stop it. The only IP-list feature in the codebase (admin-ip-whitelist.manager.js, the /ip-whitelist admin page) is scoped entirely to allow-listing IPs for admin login access, not blocking end-user accounts; the User model has no lastLoginIp/deviceFingerprint-style field at all, and there's no ban-by-IP/device mutation anywhere. Impact: High — this is one of the most common trust & safety needs for a creator/social platform and the platform has nothing for it. Fixed: new User.lastLoginIp/lastLoginDeviceId columns (populated on registration and login across every account-creation path: email/password, Firebase, Apple, phone OTP) plus a new blocked_identifier table + ban-evasion.manager.js, checked in register()/login() before any other work. New BAN_USERS-gated adminBlockIdentifier/adminUnblockIdentifier/adminGetBlockedIdentifiers schema, a new /moderation/blocklist admin page, and the last-known IP/device now show on adminGetUserDetails with an inline "Block IP"/"Block device" action right on the user detail page. Known gap, disclosed honestly: device-level blocking only takes effect if the client sends an (optional) deviceId on UserRegistrationInputapps/frontend-nextjs's signup form doesn't generate/send one yet, so today device blocking is backend-ready but not yet exercised end-to-end from web; IP blocking has no such gap since the IP is server-derived, not client-supplied, so it's fully effective today.
  • grantVerification/removeVerification perform no real admin authorization check. graphql/resolvers/verification.resolver.js guards both mutations with if (!context.user) only (not context.admin), then passes the caller's own regular-user id through as the acting admin. The manager's internal isAdmin() gate (managers/user-managers/verification-badges.manager.js) checks user.accountType === 'admin' || user.is_admin === true, but nothing in the codebase ever sets accountType to 'admin' and is_admin isn't a real column — so today the mutations are self-defeating (nobody can pass), but the resolver itself has zero real authz, and a sibling mutation's own comment confirms the reject path had this exact hole fixed already. Impact: High — a latent privilege-check bug that reopens to any authenticated user the moment the internal gate is ever fixed without also fixing the resolver. Fixed: retired both mutations from the schema/resolver entirely rather than patching the check in place — this route can never carry a real admin context (context.admin is only ever populated on /admin/graphql, see graphql/context/index.js), and the properly-gated equivalent (adminVerifyUser/adminRemoveVerification) already exists and is what the admin panel actually calls.
  • Bulk follow/unfollow/block have a per-call size cap but no daily/hourly abuse-rate enforcement. managers/user-managers/bulk-operations.manager.js#getBulkLimits computes dailyFollowLimit/hourlyFollowLimit/dailyBlockLimit etc., but the usage-tracking check is commented out (// TODO: Check current usage against limits). bulkFollowUsers/bulkUnfollowUsers/bulkBlockUsers only cap a single call's batch size (default 50) — nothing stops calling them hundreds of times per hour. Impact: High — real mass-follow/mass-block spam vector, distinct from the login/OTP rate-limiting gaps above. Fixed: all three now check real usage (summed from bulk_operation rows) against the limit before doing any work; getBulkOperationLimits also now returns real currentUsage/remaining instead of the commented-out stub. Also fixed two adjacent bugs: bulkUnfollowUsers/bulkBlockUsers never even verified the acting user existed, and batch_size_exceeded had no translation (silently rendered the raw key).
  • Admin platform-wallet transfers (transferToUser/takeFromUser/topUp in managers/coin-managers/platform-wallet.manager.js) don't record which admin acted. Each accepts an adminId parameter but never persists it — no adminId/performedBy column on CoinTransaction, no audit-log entry. A super-admin can move coins into or out of any user's balance with zero record of who did it. Impact: Medium-High — financially sensitive and not covered by any other admin action-logging system. Fixed: transferToUser/takeFromUser now stamp the acting admin onto CoinTransaction.relatedId; all three (including topUp, which has no per-user ledger row to attach to) also log via analyticsService.trackAdminAction — the same mechanism that backs the admin activity log.

Backend logic gaps (real stubs, not just missing UI)

  • The whole account-restriction system has no persistence layer. restrictUser/unrestrictUser/addRestriction/removeRestriction/getUserRestrictions/getRestrictionStats/cleanupExpiredRestrictions all have // TODO: store/deactivate/get restriction details in database and either no-op or return a fabricated mockRestrictions/zeroed stats object; checkRateLimit uses a hardcoded mockCurrentUsage = { hourly: 5, daily: 25 } regardless of real usage. getRestrictedUsers (admin) always returns [] as a result. File: managers/user-managers/restrictions-limits.manager.js. Impact: High — the entire "restrict user" moderation/soft-ban tool records nothing, sends no notifications, and rate-limit checks are fake. Fixed: new account_restriction table (named to avoid colliding with the unrelated, already-real UserRestriction model — the user-to-user "Restrict" social feature) backs every method for real; checkRateLimit now counts real rows from the post/comment/message/follow/like tables instead of a hardcoded mock. Also fixed several bugs uncovered while wiring this up: the internal admin gate checked User.accountType==='admin' (same dead pattern as the retired grantVerification/removeVerification, replaced with a real AdminUser lookup); getUserRestrictions's return shape could never have serialized against its own [UserRestriction!]! schema type; getRestrictedUsers was permanently wired to the wrong manager method (stats object, not a user list) and its resolver's user.isAdmin gate could never be true on this route — replaced with proper admin-prefixed queries/mutations (adminGetRestrictedUsers, adminGetRestrictionStats, adminRestrictUser, adminUnrestrictUser, adminAddUserRestriction, adminRemoveUserRestriction) gated by a real BAN_USERS permission check; and two silent action-name mismatches (post vs posting_disabled, post vs posts_per_hour) that a || 10/|| 50 fallback had been masking. No new admin-panel UI was built for the new mutations in this pass — an admin can call them today, but there's no /moderation page wired to them yet.
  • Mentions/Tags: "who mentioned me", "who I mentioned", and tap-to-tag approval never touch real data. getUsersMentionedBy/getUsersIMentioned/getTaggedInPosts (mentions-tags.manager.js) always return mock empty arrays regardless of input — the real query against PostMention/MessageMention (commented out in the code) was never written; approveTag/rejectTag/getTagPermissions/updateTagPermissions never persist (log to console only). The frontend (Settings → Mention history, MentionHistoryPage.tsx) is fully wired and calls these correctly; it just always renders empty. Impact: Medium-High — a real, visible, always-broken feature, plus a pending-tag-approval flow that would silently no-op if built. Fixed: getUsersMentionedBy/getUsersIMentioned now run real queries against both PostMention (post captions/tap-to-tags) and MessageMention (chat @mentions - new data-access-services/message/message-mention.access-service.js, that model existed but had no read-side access service yet), merged and deduplicated to one entry per mentioner/mentioned user, newest first - matching the [User!]! schema shape the resolver already expected but the old mock never produced. getTaggedInPosts now runs a real PostMention query shaped to the schema's TaggedPostsResponse/TaggedPost types. Investigating approveTag/rejectTag turned up that they were never actually reachable at all (no schema field declares them - graphql/types/user-features.type.js only ever declared updateTagPermissions) and are fully superseded by an already-real, separately-named approvePostTag/rejectPostTag flow (post.manager.js, graphql/types/tag-review.type.js) built for the exact same tag-review feature - so instead of resurrecting dead duplicate stubs, they (and the equally-dead, duplicate mentions-tags.manager.js#canTagUser - a different, unreachable copy of the real canTagUser already fixed in content-tag.manager.js, see the block/privacy-checks item above) were removed, along with their unreachable facade wrappers in managers/user-managers/index.js. getTagPermissions/updateTagPermissions now read/write through to the same real, already-persisted privacy settings (who_can_tag/require_approval_for_tags in User.settings.privacy, via privacy-settings.manager.js) that every actual tag/mention-creation code path already enforces, instead of a parallel object that was never saved anywhere; also fixed updateTagPermissions's resolver, which used to swallow a manager error into a {success:false, message} object even though the schema's TagPermissions! return type has no such shape (now throws, like every other mutation in the file). Along the way, fixed the same argument-shift bug found repeatedly elsewhere this pass on getMentionTagStats: the resolver ignored the schema's timeframe argument entirely and called the manager with 2 positional args where the real signature needs 3; getMentionTagStats itself is now backed by real counts from both mention tables (previously hardcoded all-zeros), with topMentioners/topTaggers left an honest empty array since ranking them needs a group-by aggregate query that doesn't exist anywhere in this codebase and this field has no frontend caller today to justify building one speculatively. No new frontend UI was needed: MentionHistoryPage.tsx already consumes usersMentionedBy/usersIMentioned directly (confirmed real per the roadmap's own note above) and now renders real data instead of always-empty lists; taggedInPosts/tagPermissions/updateTagPermissions/getMentionTagStats remain without a frontend consumer, but investigation found this is by design, not a gap - TagsScreenPage.tsx (Tagged/Pending tabs) and TagsAndMentionsPage.tsx (tag/mention privacy controls) already fully implement the equivalent user-facing features through a different, already-correct pair of GraphQL fields (taggedPosts/pendingTagReviews+approvePostTag/rejectPostTag, and privacySettings+updatePrivacySettings/updateExtendedPrivacySettings, respectively) - fixing these fields still matters for a future iOS/Android client or any other caller that reaches them directly, per this project's schema-completeness rule. Added new .graphql operation files for all six fixed queries/mutations (Web/Profile/{UsersMentionedByInline,UsersIMentioned,TagPermissionsInline,UpdateTagPermissionsInline,TaggedInPostsInline,GetMentionTagStatsInline}.graphql, two of which already existed) and rebuilt apollo-web.
  • Mutual-connections count is hardcoded to 0. advanced-social.manager.js#getMutualConnectionsCount is a one-line // TODO: Implement efficient mutual connections counting … return 0, feeding into networkMetrics.mutualConnectionsCount and the contact-suggestions mapping (mutualConnections in ContactSuggestion is separately hardcoded to 0 too, in contact-import.manager.js). Impact: Medium — a visible, always-zero number wherever mutual connections are shown. Fixed: getMutualConnectionsCount now uses a new user-follow.access-service.js#countReciprocalFollows (real reciprocal-follow count); the ContactSuggestion.mutualConnections hardcode had actually moved to graphql/resolvers/user-contact-import.resolver.js by the time this was fixed (not contact-import.manager.js anymore - the roadmap note was stale) and now uses the already-existing, already-real getMutualFollowing(viewerId, targetId). Also found and fixed two more of the same hardcoded-0 spots while in the area, both in search-discovery.manager.js (getPopularUserSuggestions, getNewUserSuggestions) - fixed by reusing that file's own _computeSearchRelations batched query, the same one searchUsers already relies on. A third suspected spot (search-discovery.manager.js:1147, referenced by a comment as still-broken) turned out to already be correct: searchUsers overwrites that hardcoded default with the real computed value immediately after, so it only stale-tested for future readers, not an actual live bug.
  • Location privacy tiers don't work. user-location.manager.js#canSeeLocation has three // TODO comments and unconditionally return true after the self-check; setLocationPrivacy writes a privacy_level value to a column that doesn't exist on the UserLocation model, so Sequelize silently drops it — a no-op. The only real, enforced location-privacy control today is the plain boolean isPublic. Impact: Medium. Fixed: new UserLocation.privacyLevel column (public/friends/private, migration defaults existing rows to public to preserve current real-world behavior since the old check always returned true); canSeeLocation now actually branches on it (private → false, friends → delegates to the real close-friends.manager.js#isCloseFriend, public/default → true); setLocationPrivacy fixed to write the camelCase privacyLevel attribute instead of the silently-dropped snake_case one. New canSeeLocation(userId) query and setLocationPrivacy(privacyLevel) mutation exposed in the schema (previously neither existed on the client-facing type at all). Shipped end-to-end: apps/frontend-nextjs's existing Settings → Location sharing page (LocationSharingPage.tsx) now has a real public/friends/private control wired to the new mutation, with i18n strings in both en.json/es.json.
  • getDataPortabilityInfo returns the wrong shape entirely, and CSV/XML data-export output is placeholder-quality even where reachable. getDataPortabilityInfo forwards to getDataExportStatus, which has none of the canExport/exportFormats/retentionPeriod/lastExport keys the schema declares non-null, so querying it throws. Separately, DataExportRequestInput has no format field at all — there's no way to request CSV/XML through GraphQL — and even the manager's own convertToCSV/convertToXML (data-export.manager.js) only emit a 2-3 line count summary, not the actual exported data, unlike the fully-real JSON path. Impact: Low-Medium. Fixed: getDataPortabilityInfo is now a real method on data-export.manager.js returning the actual DataPortabilityInfo shape (canExport false while an export is pending/processing, exportFormats: ['json','csv','xml'], a 30-day retentionPeriod matching the existing expiresAt window, lastExport from the newest ready row via a new findLatestCompletedByUserId access-service method). DataExportRequestInput.format (and a matching DataExportStatus.format) added to the schema (backend + both schema.web.graphqls/schema.admin.graphqls mirrors) and wired through requestDataExport, with real validation (validation.invalid_export_format) rejecting anything outside json/csv/xml. convertToCSV/convertToXML now emit the real per-row data (profile/posts/comments/messages/followers/following) with proper escaping, instead of a 2-3 line count summary. Shipped end-to-end: the already-existing Settings → Download your information page (DownloadDataPage.tsx) now has a real format picker wired into the request mutation and displays the live portability info, with i18n strings in both en.json/es.json.
  • A dead, broken security-notification toggle pair sits next to a working one. enableSecurityNotifications/disableSecurityNotifications/getSecurityNotificationSettings never persist (TODO: Update user security notification settings, always returns defaults) while a separate, real implementation (getSecurityNotificationPrefs/updateSecurityNotificationPrefs) exists in the same file and is what's actually wired to the UI. Both pairs are exposed via GraphQL. File: managers/user-managers/security-alerts.manager.js. Impact: Medium — calling the dead pair returns success: true but silently does nothing. Fixed: retired enableSecurityNotifications/disableSecurityNotifications/getSecurityNotificationSettings entirely (manager methods, GraphQL mutations, schema mirror) rather than patching them - same precedent as the earlier grantVerification/removeVerification retirement, since a properly-working, UI-wired equivalent already existed. Found a more serious bug while tracing the dead pair's only real caller: the actual alert-dispatch code (sendSecurityNotification, used by every createSecurityAlert call) was gating on the dead stub's hardcoded fake settings, not the real getSecurityNotificationPrefs - meaning toggling notification types off in the UI never actually suppressed anything, for any user, ever. Fixed by rewiring sendSecurityNotification to check the real per-type preference (via a new ALERT_TYPE_TO_NOTIFICATION_PREF map) before sending; alert types with no matching preference (phone_change, API-key events) have no toggle to gate on and are always sent, and SMS delivery no longer depends on the old stub's always-false sms flag (which meant no security SMS had ever been sent) - it's now sent whenever the user has a verified phone.
  • getDeviceStats fabricates its response. Returns hardcoded numbers (totalDevices: 3, activeDevices: 2, fake location strings) regardless of the real user's devices — not just "no UI" (see below), the backend itself never queries real device data. File: managers/user-managers/sessions-devices.manager.js. Impact: Medium — any security-page "your devices" widget built against this would show fake data on day one. Fixed: now derives every number from the user's real user_session history via a new UserSessionAccessService.getAllForStats. Devices are grouped by the deviceType/deviceName/browser/os tuple (there's no dedicated device-id column); totalDevices/activeDevices/deviceTypes/platforms/browsers come from that grouping, locations from real session geolocation, and suspiciousActivity.newDevices/newLocations from comparing the requested window against the user's session history before it - a device/location with no prior session is "new". failedLogins is honestly reported as 0 rather than a fabricated number, since (as already documented on getLoginHistory) no login-attempt log exists yet - only successful logins ever create a session row. Also fixed the resolver (user-sessions.resolver.js): it was silently discarding the schema's own timeframe argument and passing context into the manager's options parameter position, so the manager's real context was always {} - same class of argument-shift bug documented for userStats below. No frontend page consumes this field yet (getDeviceStats(timeframe: String): JSON! has zero callers in frontend-nextjs/frontend-admin), so no UI was built in this pass - this item was specifically about the backend fabrication.
  • reviewVerificationRequest (a schema-exposed mutation) is a hardcoded stub that operates on the wrong user. Never looks up the real request; uses mockRequest = { userId: 'user_123', ... }, so approving/rejecting any real requestId actually operates on a nonexistent user_123. A working alternate exists for the real admin panel (adminVerifyUser/adminRejectVerificationRequest), so this specific mutation is redundant-but-broken rather than the only path. File: managers/user-managers/verification-badges.manager.js. Impact: Medium (schema-exposed, would silently misbehave for any caller). Fixed: retired entirely (schema, resolver, manager method) rather than patched - it turned out to have the exact same root cause as the already-retired grantVerification/removeVerification: gated only by context.user.isAdmin, which can never be true on this web-only route (context.admin is only ever populated on /admin/graphql), so it was doubly broken (unreachable and operating on a mock user even if it were reachable). adminVerifyUser/adminRejectVerificationRequest already cover the real, working admin-review path. While investigating this, found and fixed a second, more actionable bug in the same file: verificationRequest(requestId) - the query a regular user needs to check the status of their own submitted request - was also permanently broken, since its backing getVerificationRequestById was a hardcoded-null stub. Unlike the mutation, this one had no working replacement and no separate request table exists (a "request" is just the pending state on the User row itself, same as the already-working getVerificationRequest(userId) uses) - fixed by looking up the user directly, since requestId and userId are the same id in this scheme.
  • reportConversation never persists the report. Builds a report object with // TODO: Create report in content_reports table / Save to database and notify moderators and just returns it — moderators never see it. This is a live, schema-exposed trust & safety mutation that appears to succeed but does nothing. File: managers/message-managers/conversation.manager.js. Impact: High. Fixed: now goes through the same real content_report pipeline post/comment/message/user reports already use (managers/admin-managers/content-report.manager.js#createReportContentReportAccessService) — real persistence, the existing dedup check ("you already reported this"), and the existing urgent-report admin alert, all for free. Two schema extensions were needed since content_report.reported_user_id/content_type are real, non-null, enum-backed columns that didn't previously account for conversations: the ContentType enum and ReportsByType admin-stats type both gained a conversation value/field (mirrored in schema.web.graphqls/schema.admin.graphqls and content-report.validator.js's allow-list), so a persisted conversation report can actually be listed/filtered/counted through the existing admin reports UI instead of just sitting in the table unseen. Since reported_user_id is singular but a conversation can have multiple participants, the report targets the other participant for a direct (1:1) conversation, or the group's creator otherwise. The conversation-specific 'inappropriate' reason (part of this mutation's existing input contract) has no equivalent in the shared ReportReason enum - rather than growing that enum (and every ReportsByReason/codegen consumer of it) for one content type's synonym, it's mapped onto the existing 'other' bucket when persisting, while the mutation's own response still echoes back the caller's original wording.
  • refundMessagePurchase has no ownership/admin check. Any authenticated user can pass an arbitrary purchaseId and the manager refunds coins from the seller back to the buyer with zero verification the caller is the buyer, seller, or an admin (// TODO: Add admin check is literally in the resolver). Currently unreachable from either frontend (see below), but it's a live, callable, financially-sensitive mutation today. File: graphql/resolvers/message-purchase.resolver.js, managers/message-managers/message-purchase.manager.js. Impact: High. Fixed: the "admin check" the TODO wanted could never actually work on this route - context.admin is only ever populated on /admin/graphql, and this mutation lives on the client schema - the exact same bug already fixed once for exclusive-post purchases (refundPostPurchase/adminRefundPostPurchase), so the identical two-path remedy was applied here: refundMessagePurchase is now scoped to buyer self-service with a 24h refund window (message_purchase.not_owner/refund_window_expired), and a new adminRefundMessagePurchase/adminGetMessagePurchases pair on the admin schema (gated by a real MODERATE_CONTENT permission check) gives moderators an unbounded path plus a listing to act on. Also fixed a real, separate bug found while touching this file: every message_purchase.* error key referenced by this manager (not_found, already_refunded, message_not_paid, cannot_purchase_own_message, invalid_amount, already_purchased, access_denied) had silently been falling back to the raw key string for lack of any errors.message_purchase translations existing at all - added real EN/ES text for the full set. Shipped end-to-end: the admin panel's existing /moderation/refunds page (built for post-purchase refunds) now has a Post/Message purchases tab, backed by a new MessagePurchase.seller field added for buyer/seller display parity with PostPurchase.
  • userStats/getUserStats never returns the viewer-relative fields the schema declares. UserStats.isFollowing/isFollower/isCloseFriend/isBlocked/isBlockedBy/hasPendingRequest are always null: the resolver calls the manager with (userId, viewerId, context) but the delegating wrapper only accepts (userId, context), so viewerId overwrites context and is silently dropped — meaning the real request context (locale, etc.) is also lost for this call's error messages. File: managers/user-managers/index.js (wrapper) / managers/user-managers/statistics.manager.js (never sets the fields regardless). Impact: Medium-High — any profile "stats" view relying on follow/block/close-friend state from this query is always wrong. Fixed: the wrapper now accepts (userId, viewerId, context) matching the resolver's real call, and getUserStats reuses the already-correct getRelationshipStatus(viewerId, userId, context) (the same method backing RelationshipStatus) to populate all six fields for real, instead of leaving them unset. Semantics: isFollowing/isFollower are relative to the viewer ("does the viewer follow this user" / "does this user follow the viewer back"), and the fields stay null (their real nullable-in-the-schema meaning of "not applicable") for an anonymous request or when viewing your own stats, rather than fabricating false. The one other internal caller of this manager method (getAccountAnalytics, self-analytics with no separate viewer) was updated to pass null explicitly so it keeps getting the correct not-applicable result instead of accidentally treating its own context object as a truthy viewer id.
  • canTagUser/canMentionUser skip block and privacy checks entirely. Both gate the real tag/mention-creation flow but only check "user exists / account active" — the "check if tagger is blocked" and "check privacy settings" steps are // TODO and never run. Files: managers/post-managers/content-tag.manager.js, managers/post-managers/post-mention.manager.js. Impact: Medium — a user can block someone and that person can still tag/mention them in posts. Fixed: turned out both TODOs were asking for something that already existed and was already correct - privacy-settings.manager.js#canPerformAction has real, working 'tag_user'/'mention_user' cases (block check + the who_can_tag/who_can_mention privacy setting) already used elsewhere (e.g. group-invite privacy in conversation.manager.js), it just was never wired into these two stubs. Both now delegate to it directly - no new logic needed, just connecting an existing, already-tested primitive.
  • Account deletion never cascades to related data. deleteAccount/deleteAccountImmediately anonymize the User row but leave posts, comments, messages, media, and follow relationships fully intact and attributable (// TODO: Delete or anonymize related data). File: managers/user-managers/account-management.manager.js. Impact: High — a real GDPR/compliance gap, not just a nice-to-have. Fixed: both methods now call a new _cascadeDeleteUserData(userId) right after anonymizing the User row. Posts, comments and messages (all paranoid: true models) are soft-deleted - deleted_at is set, the row itself is kept, mirroring the same "anonymize/hide rather than hard-erase" approach already used for the User row (kept for referential integrity) - via three new bulk access-service methods (post.access-service.js#deleteAllByUser, message.access-service.js#deleteAllBySender, and the already-existing-but-unused comment.access-service.js#deleteByUser). Pure relationship/metadata rows with no standalone audit value - likes/reactions, follow relationships (in both directions), conversation membership, saved posts, and notifications - are hard-deleted via new/extended bulk methods on their respective access services. The whole cascade runs inside one sequelize.transaction() so a mid-cascade failure rolls back cleanly instead of leaving some tables cleaned up and others not (this is a separate transaction from the User-row anonymization update itself, since user.access-service.js#update is used far too widely to safely retrofit transaction support into it for this one caller).
  • Account deactivation doesn't hide content, and legacy-contact actions don't notify or audit-log. Deactivating an account leaves its posts/comments fully visible (// TODO: Hide user content without deleting); addLegacyContact/removeLegacyContact never send the required notification or log the admin action. Files: managers/user-managers/account-management.manager.js, managers/user-managers/memorialized-accounts.manager.js. Impact: Medium. Fixed: content visibility is now checked live off User.accountStatus at query time - the feed's canView() gate, getTrendingPosts()/getByType()'s (both already had an isPrivate: false creator join) and getByHashtag()'s (previously had no creator-visibility filter at all) creator joins all now also exclude accountStatus: 'deactivated', and profile.manager.js#getProfile now rejects viewing another user's deactivated profile (owners can still view their own). Since this is all query-time, reactivateAccount's matching // TODO: Restore user content visibility needed no code at all - flipping accountStatus back off 'deactivated' already makes everything visible again, so that TODO became an explanatory comment instead of new logic. addLegacyContact/removeLegacyContact now send a real notification to the legacy contact (best-effort, matching the codebase's established notificationAccessService.create shape) and log via analyticsService.trackAdminAction through a new _auditAction helper (same pattern as platform-wallet.manager.js/restrictions-limits.manager.js). While wiring this up, found the entire memorialized.* error/success translation namespace was missing from both en.json/es.json - every message in memorialized-accounts.manager.js (memorialization requests, review, legacy contacts) had been silently falling back to raw key strings; added the full set of real EN/ES text.
  • Quiet-hours notification suppression uses server-local time, not the user's own timezone. File: managers/user-managers/notification-settings.manager.js#isInQuietHours. Impact: Low-Medium. Fixed: isInQuietHours now takes the user's User.timezone (an IANA string, e.g. America/New_York - a column that already existed but was never read by this check) and evaluates the quiet-hours window in it via a new getCurrentTimeInTimezone helper (Intl.DateTimeFormat with a timeZone option - no new dependency needed), falling back to server-local time only when the user has no timezone set or it's an unrecognized string. shouldReceiveNotification (the one real caller) now fetches the user row and passes user.timezone through.
  • Search stats / search-result reporting are silent no-ops. getSearchStats always returns { totalSearches: 0, recentSearches: [] }; reportSearchResult always returns { success: true } without persisting anything. Both are exposed in GraphQL but have zero frontend callers today. Impact: Low. Fixed: getSearchStats turned out to have a real, already-working data source sitting right next to it - the "Recent searches" feature (search-history.manager.js, backed by the real user_search_history table) already records every search via recordSearch, it just never fed getSearchStats. Now returns a real totalSearches count (new UserSearchHistoryAccessService#countSince, windowed by the query's timeframe argument, default 30 days) and real recentSearches. Also fixed the same argument-shift bug found repeatedly elsewhere this pass: the resolver called the manager as (userId, {timeframe}, context) but the delegating wrapper only took (userId, context). reportSearchResult now persists through the same real content_report pipeline reportConversation uses (a search result IS a user, so contentType: 'user'/contentId: resultId - dedup check and urgent-report admin alert included, real reason validation replacing the previous no-op that accepted anything).
  • POST /upload silently swaps in a random public stock photo when S3 fails, and still returns HTTP 200. api/server.js's upload handler: on any S3 error (bad credentials, network blip, or S3 disabled) the catch block responds with { url: 'https://picsum.photos/400/400?random=...' } — an unrelated random photo from an external service — instead of an error, including on the messages/user-... media-attachment path. A client has no way to tell a real upload from a silent substitution. Impact: High — silent data-integrity issue on a path real clients depend on for message/media attachments. Fixed: the branching logic was extracted into a small, pure, Express-free module (api/upload-response.util.js - server.js itself starts Apollo/DB connections on require and can't be unit-tested directly) with three real outcomes instead of one fake one: a genuine successful upload returns the real S3 URL; storage being intentionally disabled (DISABLE_S3, dev/test) now returns the storage service's own already-existing deterministic mock URL instead of calling out to an external stock-photo API on every single upload; and any real failure (bad credentials, network blip, or storage returning an unexpected shape while enabled) now returns a real 502 with the actual error message, never a substituted photo with a false 200. Verified the frontend needed zero changes: ChatView.tsx's two upload call sites already did if (!res.ok) throw new Error('Upload failed') - they were built to handle a real failure correctly, they just never received one before now.
  • payment-customer.manager.js has several fabricated financial methods, and deleteCustomer never deletes the customer from Stripe. getCustomerStats always returns zeros (// TODO: Get actual stats from payment provider and database), getBalance just echoes the local DB field (// TODO: Get actual balance from payment provider), syncWithProvider is a no-op, and deleteCustomer only deletes the local row (// TODO: Delete customer from payment provider (Stripe)), leaving an orphaned Stripe customer forever. Not yet exposed via any GraphQL resolver, but the manager is live internal infrastructure (used by payment-method.manager.js/coin-purchase.manager.js via getOrCreateCustomer). Impact: Medium — will silently misreport the moment any of these methods get wired to a resolver. Fixed: deleteCustomer now really deletes the Stripe customer first (services/stripe#customersStripe.deleteCustomer) before removing the local row, treats Stripe's resource_missing error code as "already gone" (a benign success, not a failure), and aborts without touching the local row when Stripe fails for any other reason - no more orphaned Stripe customers, and no more orphaned local rows either. syncWithProvider now really fetches the live Stripe customer (customersStripe.getCustomer) and reconciles the local email if it diverged, degrading gracefully (returns the local record unchanged) if Stripe is unreachable. getCustomerStats now computes real numbers from this app's own transaction ledger via PaymentTransactionAccessService (extended with a new getStats(userId) aggregate query - total/completed/failed transaction counts, completed amount, first/last completed dates) and real payment-method counts via PaymentMethodAccessService#getByUser; active_subscriptions is honestly left at 0 since no reachable "active subscriptions per payment customer" data source exists yet (creator subscriptions are tracked per subscriber/creator pair, not per PaymentCustomer). getBalance was reviewed and left as-is: echoing the local DB field is correct today, since this app-issued credit balance has no Stripe-side equivalent to sync from. Added the missing errors.payment_customer.* (not_found/create_failed/provider_delete_failed) and errors.validation.{customer_id_required,provider_customer_id_required} translation keys to both en.json/es.json - every error path in this manager had been silently falling back to raw key strings. No frontend UI built in this pass: this manager still has zero GraphQL resolver exposure, exactly as before - the fix is scoped to making its existing internal callers (payment-method.manager.js, coin-purchase.manager.js) trustworthy. Also updated tests/unit-test/payments-subscriptions.unit.test.js (this repo's deliberately-real-manager-exercising integration-style suite, which had pinned the old broken behavior on purpose per its own header comment) to assert the new, correct behavior instead of the old bugs.
  • Several recently-added managers throw hardcoded, untranslated error strings instead of using the backend's i18n service — mixing languages depending on the manager. note.manager.js, story-highlight.manager.js, and subscription-bundle.manager.js all throw new Error('...') with plain hardcoded Spanish text (no matching keys exist in translations/en.json); message-translation.manager.js does the reverse with hardcoded English. Every other manager in the codebase goes through t.error('key', context) against translations/{en,es}.json. Net effect: an English-locale user hits raw Spanish error text from Notes/Story Highlights/Subscription Bundles, and vice versa for message translation errors. Impact: Medium — a real i18n regression on newly-shipped features, the exact class of gap this project's CLAUDE.md calls out as "not done." Fixed: all four managers now go through t.error('key', context) like every other manager. Added new errors.note.* (empty/too_long), errors.story_highlight.* (title_required/title_too_long/not_found/not_owner), and errors.subscription_bundle.* (name_required/not_found/not_owner/has_active_buyers/not_available/cannot_purchase_own) namespaces to both en.json/es.json; message-translation.manager.js#setSetting's "not a participant" check now reuses the already-existing errors.conversation.not_participant key (same English text, now properly translatable) instead of a hardcoded literal. Threading context through required adding a context parameter to several manager methods that didn't previously accept one (note.manager.js#createNote, story-highlight.manager.js#createHighlight/updateHighlight/deleteHighlight, subscription-bundle.manager.js#createBundle/updateBundle/deleteBundle, message-translation.manager.js#setSetting) and updating their resolvers (note.resolver.js, story-highlight.resolver.js, subscription-bundle.resolver.js, message-translation.resolver.js) to pass context through - deleteHighlight/deleteBundle were already being called with a context argument from their resolvers that the manager methods silently dropped (one more instance of this pass's recurring argument-shift bug class). Updated the two-digit-character limit messages ({max} in note/highlight titles) to use the same t.error(key, context).replace('{max}', N) interpolation convention already established by validators/password-policy.validator.js. Updated existing unit test assertions in untested-managers.unit.test.js, payments-subscriptions.unit.test.js, message-translation-manager.unit.test.js, and resolvers/message-translation.resolver.test.js to match - this codebase's global Jest translation.service mock (tests/setup.js) returns the raw key unchanged, so every other manager's unit tests already assert on the dotted key itself (e.g. 'story_highlight.not_owner') rather than translated prose, and these tests now follow the same convention.
  • Minor backend items worth a pass together: deleteFeedback (user-feedback.manager.js) is documented as "owner or admin" but has no actual admin bypass (// TODO: Check if user is admin) — only the owner can delete their own feedback today; payment-method.manager.js's class doc comment advertises detectFraudulentCard/detectSuspiciousActivity/getMostUsedMethod/getFailureRate/getUsageHistory/bulkDeleteMethods/exportPaymentMethods, none of which actually exist on the class. Impact: Low individually. Fixed: deleteFeedback now has a real admin bypass, reusing the exact permission primitive graphql/resolvers/admin/user-feedback-admin.resolver.js already uses for every other feedback-triage mutation - adminUserManager.hasPermission(admin.adminId, 'MANAGE_FEEDBACK') against context.admin - instead of the legacy user.accountType === 'admin' style check used (and itself flagged "TODO: Implement proper admin role checking") elsewhere in this codebase. deleteFeedback had zero GraphQL exposure before this pass (like payment-customer.manager.js before the fix above), so the bypass was unreachable and untestable end-to-end; wired it up with a new owner-only deleteMyFeedback mutation (mirroring the existing upvoteFeedback/downvoteFeedback shape) and a new adminDeleteFeedback mutation (mirroring the existing adminUpdateFeedbackStatus/adminRespondToFeedback shape, gated by the same requireFeedbackPermission helper), both mirrored in schema.web.graphqls/schema.admin.graphqls plus new .graphql operation files under packages/graphql/operations/{Web,Admin} and rebuilt apollo-web/apollo-admin. Extended the existing admin Feedback triage page (apps/frontend-admin/src/app/feedback/FeedbackContent.tsx) with a delete action + confirmation modal, matching the delete-with-confirm pattern already used on the moderation blocklist page. While touching this manager, found the entire user_feedback.* error-translation namespace (plus validation.feedback_id_required/admin_id_required/query_required) was missing from both en.json/es.json - every error path in user-feedback.manager.js had been silently falling back to raw key strings; added the full set. Separately, payment-method.manager.js's class doc comment was trimmed down to the ~15 methods that actually exist on the class, removing the ~25 fictional ones (detectFraudulentCard, bulkDeleteMethods, etc.) that would mislead a future reader into thinking they could call them - no new methods were built, since none of those capabilities were requested or needed by any real caller today.

Backend-ready, missing frontend UI

A cross-reference of every Query/Mutation field in the shared web schema against actual usage in apps/frontend-nextjs/src turned up a large set of features with a complete, real backend and zero UI. Grouped by area:

  • 1:1 voice calling is one-directional — no incoming-call UI at all. AnswerCall/DeclineCall/JoinCall/LeaveCall and the CallIncoming subscription all have ready operation docs, and ChatView.tsx even imports CallIncomingDocument — but never actually subscribes to it. Only the caller side (StartCall/EndCall) works; a recipient gets no ring/accept/decline UI. Impact: High. Fixed: investigating this turned up a second, bigger bug behind it - VoiceCallModal.tsx (the "working" caller-side UI) never actually joined the LiveKit room the backend created; it opened a raw RTCPeerConnection with STUN servers only, logged the real token/wsUrl/roomName LiveKit needs, and never used them, so no real audio was ever exchanged even on an outgoing call. Rewrote it to use livekit-client (already a dependency, already proven working for live streams in LiveRoomPage.tsx) for both directions: connect to the room, publish a local audio track, subscribe to the remote track, real mute via localParticipant.setMicrophoneEnabled, and real connection-quality reporting via RoomEvent.ConnectionQualityChanged (previously a hardcoded "Excellent"/Spanish-only label with no t() at all). Added a new app-wide IncomingCallContext.tsx (mounted once in Providers.tsx, same pattern as the existing NotificationsRealtimeContext.tsx) that subscribes to callIncoming for the current user regardless of which page they're on, shows a ringing banner with the caller's name/avatar, and wires AnswerCall/DeclineCall - answering opens the same (now-real) VoiceCallModal. ChatView.tsx's unused CallIncomingDocument import was removed now that the subscription lives in the global provider instead. New calls.* i18n namespace added to both en.json/es.json (the old modal had several hardcoded Spanish-only strings with no translation at all, regardless of locale). New tests: VoiceCallModal.test.tsx (LiveKit connect/publish, mic-permission failure, mute, connection-quality mapping, end-call) and IncomingCallContext.test.tsx (ringing banner, name fallback, answer/decline branching), both with livekit-client mocked. JoinCall/LeaveCall (group-call join/leave) remain unwired to any UI - group calling has no entry point anywhere in the frontend today, which is really the separate "conversation management" gap tracked below, not part of this 1:1-calling fix.
  • Live audio-room "speaker request" system (Clubhouse-style) has zero UI. requestToSpeak/approveSpeakerRequest/denySpeakerRequest/cancelSpeakerRequest/promoteToSpeaker/demoteToViewer/joinAsViewer/pendingSpeakerRequests/mySpeakerRequests are all backend-complete (call-managers/speaker-request.manager.js) with ready operation docs; no component references any of them. Impact: Medium-High. Partially addressed - real backend bugs found and fixed, UI deliberately not built this pass (see below). Investigating turned up that this backend wasn't actually "complete" as claimed - three real bugs meant the feature couldn't have worked even with a UI in front of it: (1) requestToSpeak/approveSpeakerRequest/denySpeakerRequest/getPendingRequestsForCall all gated on participant.status === 'JOINED' to mean "is a speaker", but call.manager.js#joinAsViewer also sets status: 'JOINED' on a plain viewer row (the real speaker/viewer distinction is role: CALLER/RECEIVER vs PARTICIPANT, per getCallParticipants's own definition in the same file) - so every real viewer's own requestToSpeak call would immediately fail with "You are already a speaker", and any participant (including the requester themselves) could approve/deny/view any request, not just the host/speakers. (2) approveSpeakerRequest called callManager.promoteToSpeaker(request.callId, request.userId, context) - an argument-shift bug (the real signature is (callId, userId, promotedBy, context)) - so the promotion's own internal call.callerId !== promotedBy check compared the real caller id against a context object and always threw, silently swallowed by a catch block: an "approved" speaker request never actually promoted anyone in the real LiveKit room. (3) promoteToSpeaker/demoteToViewer only ever allowed the original call creator, contradicting approveSpeakerRequest's own stated rule ("Only the call host or speakers can approve requests") - fixed to allow an existing speaker (role CALLER/RECEIVER) too, consistently with the rest of the authorization model. All three fixed in call.manager.js/speaker-request.manager.js, with new tests covering the real role-based authorization and the promotion argument fix. UI was NOT built in this pass: requestToSpeak is only ever reachable for someone who joined a call via joinAsViewer (the only path that creates a role: 'PARTICIPANT' row) - and joinAsViewer itself has zero callers anywhere in this codebase, because there is no "audio room" concept in the frontend at all yet (no room creation/discovery flow, no way to join an ongoing call you weren't one of the original startCall participants of - ChatView.tsx's only call entry point is a 1:1 receiverId call). Shipping a "Raise hand" button today would always throw "You are already a speaker" for every real user, since nobody can reach viewer status - it would be UI wired to a real mutation that's nonetheless unreachable end-to-end, the same class of problem this pass keeps finding and refusing to paper over. Building the actual prerequisite (room creation/discovery/join-as-viewer UI) is a materially larger, separate feature, closer in scope to the "Consumer features that don't exist today" section than to wiring already-reachable backend fields.
  • Paid/locked DM messages: the "unlock" side works, the "lock" side doesn't exist in the composer. purchaseMessage is wired in ChatView.tsx, but the message composer hardcodes every outgoing message to isLocked: false, unlockPrice: null (useChatMessages.ts) — so no message can ever actually become purchasable. LockMessage, GetMyMessagePurchases, HasMessageAccess, GetMyCreatorEarnings are all defined and unused. Impact: High — an entire paid-DM monetization feature is unreachable for creators. Investigated - this item was stale by the time it was written (the roadmap's own intro warns some entries turn out to already be fixed). The isLocked/unlockPrice fields it names are dead, never-backed Message GraphQL fields (no matching column exists - the real model columns are isPaid/coinPrice, exposed as Message.isPaid/Message.price) - the useChatMessages.ts code cited is just an Apollo cache-shape filler for fields a messageAdded subscription payload doesn't select, not the actual send path, and it already defaults the real fields (isPaid: false, price: null) correctly too, overridden by the real subscription payload via a later spread. The real "lock a message" UI already exists and is fully wired: ChatView.tsx/MessageInputArea.tsx's "Set price" flow (showPaidMediaModal, pendingPaidPrice, four separate entry points across the attach/quick-actions menus) sends isPaid/price directly on sendMessage's MessageCreateInput, validated and persisted server-side (message.resolver.js), and gated on read via Message.mediaUrls's field resolver calling messagePurchaseManager.verifyAccess - unrelated to the separate LockMessage mutation the roadmap flagged as unused (that one exists to lock an already-sent message after the fact, a different, legitimately-still-unused use case). While verifying this, found a real, separate bug in that unused lockMessage path worth fixing anyway since it's a live security hole: its own resolver doc comment claims "Only message sender can lock messages," but nothing enforced it - any authenticated user could call lockMessage on any message, including one they didn't send, retroactively making someone else's message paid behind an arbitrary price. Fixed by threading the caller's id through and checking message.senderId, reusing the existing errors.message.not_sender key (no new translation needed). myMessagePurchases/hasMessageAccess/myCreatorEarnings remain unused by any frontend page - tracked together with the broader financial-dashboard gap below rather than duplicated here.
  • Comment deletion is entirely absent from the web app. deleteComment(commentId, actAsUserId): Boolean! has a real resolver/manager but zero references anywhere in apps/frontend-nextjs, apps/frontend-admin, or the operations packages — users apparently cannot delete their own comments from the web UI. Impact: High for a social app. Fixed: added a "Delete" action next to "Reply" on both top-level comments and replies in PostModal.tsx (the shared comment list used by the post modal, comments-only view, and every entry point that renders it), visible when the caller is either the comment's own author or the post's owner - mirroring the real authorization already enforced server-side in post-comment.manager.js#deleteComment. Confirming taps through a shared Modal confirm dialog (reused from @/components/ui/Modal, the same primitive PostOptionsMenu.tsx already uses for post deletion) before calling the new DeleteCommentInline mutation and refetching the comment list. New .graphql operation file added (Web/Posts/DeleteCommentInline.graphql) and apollo-web rebuilt. New post.delete_comment* i18n keys added to both en.json/es.json.
  • Conversation management is largely unbuilt in the chat UI: leaveConversation, archiveConversation/unarchiveConversation, pinConversation/unpinConversation, unblockConversation, clearConversationHistory, updateParticipantRole, transferAdmin, plus scheduleMessage/cancelScheduledMessage/editScheduledMessage (scheduled/self-destructing messages) — all have real operation docs, none referenced in any component. Impact: Medium — archive/pin/leave/roles/scheduling are common chat-app expectations, backend-ready and absent. Fixed: all nine wired into ConversationDetailsPanel.tsx (the shared chat details/settings sidebar), reusing data already fetched by the existing GetConversation query (isPinned/isArchived/isBlocked were already selected on ConversationParticipant for the mute toggle - just never read for anything else). Added: Pin/Archive toggles next to the existing Mute toggle; Block now flips to a real Unblock action once me.isBlocked; a new "Clear chat history" destructive action with its own confirm dialog; a "Leave group" action (group conversations only, hidden for the last remaining admin - mirroring the server's own leaveConversation guard); per-member "Make admin"/"Remove admin" buttons next to the existing remove-member control (visible to the same canManageMembers group admins/creator); a creator-only "Transfer ownership" action per admin, with its own confirm; and a self-contained "Scheduled messages" panel (compose text + <input type="datetime-local">, list with per-item Edit/Cancel) built around the already-existing GetScheduledMessages/ScheduleMessage/CancelScheduledMessage/EditScheduledMessage operations, deliberately kept independent of the main composer's send flow rather than threading a new mode through MessageInputArea.tsx's already-large prop surface. New messages.*/members.* i18n keys added to both en.json/es.json.
  • No live username/email-availability check at signup, despite isUsernameAvailable/isEmailAvailable being fully implemented — OnboardingPage.tsx has no real-time validation call, so a taken username is presumably only discovered after submit. Same for the broader validateUserData/validateProfileContent/validateFieldRealtime real-time validation queries — none are called from any profile-edit form. Impact: Medium (signup/profile-edit UX polish, backend-ready). Fixed — and the roadmap's own claim was wrong about where the gap was. The actual signup form is Login.tsx (OnboardingPage.tsx is a separate post-signup wizard with no username/email fields), and it already had a fully-built debounced live-validation UI wired to validateUsername/validateEmail (via ValidateUsernameInline/ValidateEmailInline) — suggestion chips and all. It just silently threw on every call: the GraphQL schema declares ValidationResult { valid: Boolean!, message, suggestions }, but managers/user-managers/index.js's validateUsername/validateEmail/validateFieldRealtime returned the other shape ({available, message} from isUsernameAvailable/isEmailAvailable) directly — the non-null valid field was never present, so GraphQL errored on every request and the already-built UI never worked. Fixed by adding proper validateUsername/validateEmail methods to validation.manager.js that map {available} → {valid}, with the facade in index.js reduced to thin delegation (matching this codebase's manager/facade convention). Also wired the broader validateProfileContent real-time query — confirmed genuinely unused, as claimed — into EditProfilePage.tsx's bio field, and added the same debounced validateUsername live-check (with suggestion chips, mirroring Login.tsx) to that page's username field, since it previously only surfaced a taken-username error after a failed save. New operation docs: ValidateFieldRealtimeInline.graphql, ValidateProfileContentInline.graphql. New tests in validation-manager.unit.test.js covering the {available}→{valid} mapping for both fields.
  • No coin-wallet / tips / purchase-history dashboard. myTransactionStats, myPurchaseHistory, coinPurchaseStats, mySentTips, contentTips, contentTipTotal, recentTips are all backend-ready with no operation document anywhere. Tip sending works (TipModal.tsx); there's no "my sent/received tips" or "purchase history" view. Impact: Medium. Fixed — several of the named fields turned out to already have real views, the actual gaps were narrower. CoinTransactionsPage.tsx (a real "purchase history" view, at /coins/transactions) and TipsPage.tsx (a real "received tips" view with stats + top supporters, at /settings/tips) both already existed — myPurchaseHistory itself is a byte-for-byte duplicate of the already-used myCoinPurchases (same manager call, same args), so it needed no new UI. The real gaps: mySentTips had no consumer anywhere, so added a Received/Sent tab switcher to TipsPage.tsx — the Sent tab lists tips this user sent to creators, with its own totals pulled from myTipStats's (previously unselected) totalSent/totalSentAmount fields. myTransactionStats and coinPurchaseStats had no consumer anywhere, so added a wallet-stats header to CoinTransactionsPage.tsx (current balance, total coins purchased, total spent, total transactions). contentTips/contentTipTotal and recentTips were deliberately left unbuilt: the first two are per-content tip breakdowns (e.g. "who tipped this specific chat message"), which don't fit a personal wallet dashboard's shape and have no existing per-message UI surface to attach a total to without a materially separate change to the message-bubble renderer; recentTips takes no user/auth argument at all (a platform-wide, unauthenticated recent-tips feed) with no comparable public activity-feed page anywhere in the app to host it. New operation docs: MySentTipsInline.graphql, MyTransactionStatsInline.graphql, CoinPurchaseStatsInline.graphql; extended MyTipStatsInline.graphql with the sent-side fields. New tests: TipsPage.test.tsx, CoinTransactionsPage.test.tsx.
  • A full "security dashboard" has no frontend consumer in either app: securityStats, sessionDetails, getSecurityAlertsStats, terminateSession/terminateOtherSessions (distinct from the already-wired revokeSession/revokeAllOtherSessions) are unused. Impact: Medium. Fixed — and the security dashboard itself was stale: it already exists. SecurityAlertsPage.tsx (score card, alerts list, persisted event log) and SessionsSettingsPage.tsx (active sessions, revoke/revoke-all-others, expandable per-session detail, login history) are both real, already-built pages - this bullet's own premise ("no frontend consumer") was wrong for the dashboard as a whole, only true for the four specific fields it named. Of those: securityStats (real counts from the persisted security_event audit trail - total logins, failed attempts, suspicious activity, blocked attempts, devices seen) had no consumer, so added an "Account activity" stat grid to SecurityAlertsPage.tsx. getSecurityAlertsStats (a by-severity breakdown of this user's alerts) had no consumer, so added high/critical count chips next to the existing unread-count badge on that same page. sessionDetails and terminateSession/terminateOtherSessions were deliberately left unbuilt: sessionDetails(sessionId) returns the exact same UserSession shape activeSessions already does, and SessionsSettingsPage.tsx's existing expandable "View details" row already shows IP/first-seen/expires straight from the list it already fetched - a second query for data already in hand would add a network round-trip with no new information. terminateSession/terminateOtherSessions are, at the resolver level, literal aliases that call the exact same manager methods as the already-wired revokeSession/revokeAllOtherSessions (user-sessions.resolver.js) - true duplicate mutations, not a missing capability. New operation docs: SecurityStatsInline.graphql, SecurityAlertsStatsInline.graphql. New tests: SecurityAlertsPage.test.tsx.
  • Live-stream analytics/moderation actions are dormant: liveStreamStats, liveStreamViewers, banLiveViewer/unbanLiveViewer, cancelLiveStream/deleteLiveStreamLiveRoomPage has substantial live functionality already, these specific ones just aren't called. Impact: Medium. Fixed. All six wired into LiveRoomPage.tsx. The owner-only viewer-count badge (top-right) is now a button that opens a new "Viewers & stats" panel: a liveStreamStats summary grid (peak viewers, total views, reactions, comments, shares, avg. watch time) plus a liveStreamViewers list, each row with a ban/unban control. Per-comment moderation gained a banLiveViewer button next to the existing Reward action (hover-reveal, owner-only, hidden for the owner's own comments) - unbanLiveViewer is reachable from the same viewer-list row once a ban has been issued this session (the schema has no "is this user currently banned" query to source that state from on a fresh page load, so the toggle is seeded from the action just taken, not fabricated as a persistent status). cancelLiveStream (for a still-scheduled stream) got a new "Cancel" button next to "Start broadcasting" in the pre-live overlay. deleteLiveStream got a new "Delete this stream" button in the post-end overlay, owner-only. New operation docs: LiveStreamViewersInline.graphql, LiveStreamStatsInline.graphql, BanLiveViewerInline.graphql, UnbanLiveViewerInline.graphql, CancelLiveStreamInline.graphql, DeleteLiveStreamInline.graphql. Added two small icons (Ban, BarChart3) to the project's lucide-derived icon set, which didn't have either. No test file exists yet for LiveRoomPage.tsx (1000+ lines, heavy livekit-client/MediaRecorder/subscription mocking already needed just to render it, same class of setup VoiceCallModal.test.tsx required) - writing one is deferred to the final testing pass along with the other untested files this session touched (PostModal.tsx, ConversationDetailsPanel.tsx), consistent with how those were handled.
  • Live location sharing has no "update" or "stop" UI. updateLiveLocation/stopLiveLocation are fully implemented (message.resolver.js/messageManager) — static one-shot share and starting a live share both work, but there's no caller for updating or explicitly stopping an in-progress live share. Impact: Medium. Fixed — and "static share works" was wrong too, a more serious bug than the missing update/stop UI this item named. shareLocation(input: LocationInput!): Message! never took a conversationId argument at all - ChatView.tsx's existing "Share location" action called it with only {latitude, longitude}, and the resolver passed that straight to the manager, which requires conversationId to create the message (conversation_id is a non-null column). Every "Share location" click was throwing server-side, not succeeding as claimed. Fixed by adding conversationId: ID! to the mutation signature (schema + resolver + the ShareLocation operation doc) and threading it from the already-known conversation id in ChatView.tsx. While fixing this, found and fixed two related authorization gaps in the same code path: shareLocation never checked the sender was actually a participant of the target conversation (any authenticated user could inject a location message into an arbitrary conversation they don't belong to), and updateLiveLocation never checked the caller owned the message being updated (any authenticated user could redirect someone else's active live-location share to fake coordinates) - both fixed with the same participant/ownership checks this codebase uses elsewhere. With shareLocation actually reachable, built the "live" half: a new "Share live location" composer action (locationType: 'live') that starts a navigator.geolocation.watchPosition loop calling updateLiveLocation on each position change, and a "Stop sharing" button on the sender's own active live-location message bubble calling stopLiveLocation. New tests in message-manager.unit.test.js (shareLocation's participant check, static/live expiry, updateLiveLocation's ownership check + expiry) and resolvers/message.resolver.test.js (arg passthrough for both mutations); MessageBubble.test.tsx extended with the new Stop-sharing button (shown only to the sender, only while live and unexpired).
  • Smaller unused-but-ready items: usersWhoReacted/messageThread/pollResults (poll voting works, viewing results/reactions doesn't); deleteNotification/deleteAllNotifications (mark-as-read works, delete doesn't — confirmed NotificationsPage.tsx has no delete/dismiss/"clear all" affordance anywhere despite both mutations being fully implemented); mutualFollowers/mutualFollowing queries; bulkFollowUsers/bulkMuteUsers; myReports/contentReports (submit works, viewing your own report history doesn't); verificationBadgeInfo; productReviewSummary/deleteProductReview; updatePostPromotion/promotionById. Impact: Low individually, worth a pass together. Mostly fixed - real UI wired for the genuinely-missing pieces, several turned out to already be redundant. deleteNotification/deleteAllNotifications: NotificationsPage.tsx got a "Clear all" header button (with a confirm dialog) and a hover-reveal per-row delete button. mutualFollowers: PublicProfilePage.tsx gained a "Followed by alice, bob and others" line under the bio (mobile + desktop layouts) - only names actually returned are shown, no fabricated "+N others" count since the field has no total. bulkFollowUsers/bulkMuteUsers: the already-existing BulkFollowerActionsPage.tsx (bulk unfollow/block/remove-followers/process-requests) gained "Follow back" on the followers tab and "Mute" on the following tab - the exact same bulk-select infrastructure, just two more actions. usersWhoReacted: each reaction pill in MessageBubble.tsx now lazy-fetches and shows who reacted as a hover tooltip, without changing the pill's existing click-to-toggle-my-own-reaction behavior. myReports: new /settings/my-reports page (added to the settings nav under "Your reports") listing the caller's submitted content reports with reason, content type, status, and review notes once resolved. verificationBadgeInfo: the verified checkmark on PublicProfilePage.tsx (mobile, desktop-compact, desktop-header instances) is now wrapped in a button that lazy-fetches badge info on hover/click and shows it as a tooltip. deleteProductReview: ShopManagePage.tsx's order rows previously just hid "Leave a review" once reviewed with no way to see or remove what was submitted - now shows the review (stars + comment) with a delete button. updatePostPromotion: MyPromotionsPage.tsx campaign names are now inline-editable (pencil icon → rename) - the only field UpdatePostPromotionInput actually exposes besides the unstructured targetAudience JSON blob, which was left alone rather than building a UI for an untyped field. Three fields turned out to be genuine duplicates of data already delivered elsewhere, needing no new UI (same "stale claim" pattern as several earlier items this pass): pollResults duplicates Message.poll (already selected on every message and already the source of the vote percentages/counts rendered inline in MessageBubble.tsx); productReviewSummary duplicates Product.averageRating/Product.reviewCount (already used in PurchaseProductModal.tsx); promotionById duplicates myPromotions + promotionStats (MyPromotionsPage.tsx's existing list + expand-for-stats flow already returns the same data). Three were deliberately left unbuilt: mutualFollowing has no comparably standard UI slot the way mutualFollowers' "Followed by" convention does; messageThread (a full reply-thread view) is a materially larger feature than the rest of this batch; contentReports (viewing all reports on a piece of content, not just your own) would expose who-else-reported-what across users, a moderation-only concern that doesn't belong in a regular user-facing surface. New operation docs: MutualFollowersForProfile.graphql, BulkFollowUsersAction.graphql, BulkMuteUsersAction.graphql, MyReports.graphql, VerificationBadgeInfoInline.graphql, MyProductReviewInline.graphql, DeleteProductReviewInline.graphql, UpdatePostPromotionInline.graphql. New icons added to the project's lucide-derived set (Flag). New tests: NotificationsPage.test.tsx, MyReportsPage.test.tsx, MessageBubble.test.tsx extended with reaction-tooltip coverage; BulkFollowerActionsPage.tsx/ShopManagePage.tsx/MyPromotionsPage.tsx/PublicProfilePage.tsx test coverage deferred to the final testing pass given their size (all pre-existing, large files with no prior test file).

Frontend-only gaps (dead or permanently-disabled UI)

  • CoinsModal.tsx is fake demo code, not a real purchase flow. Hardcoded package list, reads a balance field that doesn't exist on the real user type, and its "purchase" handler is await new Promise(resolve => setTimeout(resolve, 1500)) followed by alert('Purchase successful! (Demo)') — zero GraphQL calls. Only referenced from a Storybook story today, not any real page (the real flow is GetCoinsModal.tsx/GetCoinsPage.tsx/CoinExpressCheckout.tsx). Impact: Low, but worth deleting so nobody accidentally wires it up thinking it's real. Fixed. Confirmed via grep that its only real reference was the barrel coins/index.ts (which existed solely to re-export it) and its own Storybook story - deleted both CoinsModal.tsx and coins/index.ts, and removed the Wallet story from CoinsModals.stories.tsx (the GetCoins/Insufficient stories for the real modals are untouched).
  • A "Nicknames" button in conversation details is permanently disabled with no backing feature at all (no schema field, no manager method for per-conversation nicknames) — just disabled title="Coming soon". File: ConversationDetailsPanel.tsx. Impact: Low, but it's a visible, discoverable dead end for users. Fixed - built the real, minimal version of the feature rather than just removing the dead button. Added a self-set, per-conversation display nickname: a new nullable nickname column on conversation_participant (migration + model), a nickname: String field on the ConversationParticipant GraphQL type, and a new updateMyNickname(conversationId, nickname) mutation (null/empty clears it back to the real name) - mirroring the existing self-scoped participant actions (pinConversation/muteConversation) for both the manager's participant-membership check and its authorization shape. Chose a self-set "shown to everyone in this chat" nickname (like a Discord server nickname) over a per-viewer "the name I call this other person" nickname, since the latter needs a materially different data model (a three-way viewer/target/conversation table, not a column on the existing one-row-per-participant table) for a Low-impact item. The button now opens a real editor (prefilled with the current value) instead of being permanently disabled, and the row itself shows the active nickname inline once set. New operation docs: added UpdateMyNickname to ConversationParticipants.graphql, added nickname to GetConversationParticipants's and GetConversation's participant selections. New tests: conversation-manager.unit.test.js (participant check, length validation, trim, clear-via-null, clear-via-blank) and resolvers/conversation.resolver.test.js (auth guard + delegation) - ConversationDetailsPanel.tsx itself still has no test file (pre-existing gap, deferred to the final testing pass along with the other large untested files touched this session).
  • The Stripe CardElement renders white-on-white and is unreadable in light mode, in both checkout flows that use it. StripeCheckout.tsx (coin purchase) and PaymentMethodsPage.tsx's AddCardForm (add a saved card) both hardcode color: '#ffffff'/iconColor: '#ffffff' for the card input, while the surrounding container is bg-white in light mode. ThemeContext supports a real resolved light theme (including via OS prefers-color-scheme), so any light-mode user typing a card number sees invisible text while entering payment details. Impact: High — blocks/obscures the coin-purchase and add-card flows, two core monetization paths, for any light-mode user. Fixed - and found a third instance the roadmap didn't name. A grep for every CardElement usage turned up PaymentsPage.tsx's AddCardForm (the /payments page's own "add a card" flow, distinct from the settings one) with the exact same hardcoded #ffffff. All three now read useTheme() and pick #111827 (readable dark gray) in light mode vs #ffffff in dark mode, for both color and iconColor. Also removed a stray console.log('CardElement onChange: ...') left in StripeCheckout.tsx while touching that block (the broader console.log cleanup is tracked separately below). New tests: StripeCheckout.test.tsx and PaymentMethodsPage.test.tsx both extended with a light/dark pair asserting the CardElement stub actually receives the theme-correct color (the stub now exposes the options.style.base.color/iconColor it was given via data-* attributes so this is a real assertion, not just "it doesn't crash"). PaymentsPage.tsx has no test file at all (pre-existing gap) - deferred to the final testing pass.
  • StripeCheckout.tsx ships 14 unguarded debug console.logs in the production checkout form, including a full dump of GraphQL purchase variables ("Sending variables to GraphQL", a "=== Purchase Debug Info ===" block with packageId/paymentMethodId/customCoins) and a validation log that re-fires on every render inside the submit button's disabled expression. Impact: Medium — noisy production console and minor payment-method-id leakage into devtools on a payment surface. Fixed. All removed: the auto-select-payment-method effect's 3 logs, the "Purchase Debug Info" block's 7 logs plus the separate "Sending variables to GraphQL" dump (8 total), and the submit button's per-render "Button disabled validation" log - the last of these was wrapped in an IIFE purely to have a place to put the console.log before returning the actual boolean, so the button's disabled prop was simplified back to a plain expression once the log was gone, rather than leaving a needless IIFE wrapping a single boolean.
  • ProfilePage.tsx and PublicProfilePage.tsx hardcode UI strings outside t() — in opposite languages from each other. ProfilePage.tsx's settings menu, theme picker, and FollowListModal labels ('Privacy', 'Profile visitors', 'Remove', 'Unfollow', 'Load more', 'No users to show', etc.) are plain hardcoded English; PublicProfilePage.tsx hardcodes the same FollowListModal props in hardcoded Spanish ("Eliminar", "Cargar más", "No hay usuarios para mostrar"). Neither ever passes through t(), so none of these strings exist in en.json/es.json at all (the two locale files are otherwise a perfect 1:1 key match, 2,628/2,628). Net effect: a Spanish-locale user viewing their own profile sees English menu text; an English-locale user viewing someone else's profile sees Spanish follow-list text. Impact: Medium — a visible, locale-breaking bug on two of the most-visited pages, and a direct instance of this project's "ship strings in both locales" rule being skipped because the strings were never made translatable in the first place. Fixed at the root, not just at the two call sites. FollowListModal.tsx itself was the real source of the "opposite language" split: its own removeLabel/unfollowLabel/emptyLabel prop defaults were hardcoded Spanish ('Eliminar', 'Dejar de seguir', 'No hay usuarios para mostrar') even though the component already imports useTranslation and uses it for other strings in the same file - so any caller that forgot to override them (or a future caller that never knew to) would silently get Spanish regardless of the app's locale. Changed the props to genuinely optional and gave them real t()-backed fallbacks (reusing existing keys: common.remove, profile.unfollow, profile.followers/profile.following for the title) computed inside the component, not as JS default-parameter literals. Also found and removed a fully dead prop while in there: loadMoreLabel was threaded through both callers and the component's props but never actually rendered anywhere - the real "load more" mechanism is an IntersectionObserver sentinel div, not a clickable button with text. Then fixed both call sites to stop hardcoding a single language: ProfilePage.tsx's settings-menu items, theme-picker options, and FollowListModal props now all go through t() (mostly reusing existing keys already used elsewhere in the app - navigation.notifications/.coins/.payments/.settings, settings.privacy/.profile_viewers, theme.system_default/.light/.dark, top_fans.title); PublicProfilePage.tsx's FollowListModal props do the same instead of hardcoding Spanish. New keys added to both en.json/es.json: profile.saved_posts, profile.no_users_to_show. New test: FollowListModal.test.tsx (asserts the real translated fallback renders when no override prop is passed - not the old hardcoded Spanish - and that explicit overrides still work). ProfilePage.tsx/PublicProfilePage.tsx still have no test files of their own (pre-existing gap) - deferred to the final testing pass.
  • _freshcheck9_Security.tsx is a stale, fully orphaned duplicate of SecuritySettingsPage.tsx. 606 lines, unreferenced anywhere (confirmed by grep), and an older snapshot missing the SMS/Email 2FA method-selection UI the real page has. File: apps/frontend-nextjs/src/page-components/settings/_freshcheck9_Security.tsx. Impact: Low — no runtime effect, just dead-code clutter worth deleting. Fixed. Re-confirmed zero references (grep) and the exact 606-line size before deleting; tsc --noEmit clean afterward.
  • AuthContext's logout is typed as synchronous (() => void) but is actually async, awaiting a mutation and clearStore(). Every current call site happens to await logout() so it works today, but the interface doesn't promise a Promise, so a future caller that forgets await gets no compiler warning even though skipping it would race the redirect against the server-side session revoke. File: contexts/AuthContext.tsx. Impact: Low. Fixed - and the same mistyping existed on two siblings declared right next to it. logoutAccount(userId: string) => void and logoutAll() => void have the exact same bug: both implementations are async (revoking a session server-side before clearing local storage), but the interface promised neither returned anything awaitable. All three corrected to => Promise<void> (login/switchAccount, checked alongside them, are genuinely synchronous and left alone). tsc --noEmit was clean afterward with no call-site changes needed, confirming the roadmap's own claim that every real caller already awaits these - only the type was lying. No new test added: this is a type-only correction with no behavior change, and the existing AuthContext.test.tsx already exercises logoutAccount/logoutAll with await in its own async test bodies.

Consumer features that don't exist at all today

Not bugs, not half-built — these are capabilities common on comparable platforms that have no backend model, no schema field, and no UI anywhere in this codebase. Confirmed absent by grepping for their obvious names across graphql/types, database/models, and both frontend src trees with zero matches.

  • Support ticket system. The only "contact support" path today is the generic feedback form (submitFeedback); there's no dedicated support-ticket model, no thread/reply capability, no ticket status tracking distinct from the feedback triage panel. Impact: Medium.
    • Fixed: Built a full support-ticket system, distinct from UserFeedback, with real thread/reply capability. Backend: support_ticket/support_ticket_message tables + Sequelize models, support-ticket.manager.js (create/reply/close for users; list/reply/update-status for admins, gated by a new MANAGE_SUPPORT_TICKETS admin permission), GraphQL types/resolvers mirrored into both schema.web.graphqls and schema.admin.graphqls, plus dedicated operation files under packages/graphql/operations/Web/Support/** (so a future iOS/Android client gets them for free) and packages/graphql/operations/Admin/Support/**. Web frontend: new /settings/support page (create ticket, view thread, reply, close), reachable from the settings menu. Admin frontend: new /support triage page (status/category filters, thread view, reply, status update), gated behind MANAGE_SUPPORT_TICKETS in both the nav and the route itself, mirroring the existing feedback-panel pattern. i18n added to both locales in frontend-nextjs and frontend-admin. Tests written (not yet run — deferred to the final test pass) for the manager, resolvers, and both frontend surfaces.
  • Draft posts. No way to start composing a post and save it unpublished for later — not client-side, not server-side. Post creation is publish-immediately only. Impact: Medium — a very standard content-creation expectation.
    • Fixed: The model already had isPublished/scheduledAt fields (from post scheduling), but PostCreateInput.isPublished was a dead no-op - post.validator.js checked/wrote the wrong-cased input.is_published while the schema and every caller use camelCase isPublished, so a client could never actually create an unpublished post. Fixed the casing bug (in both validateCreateInput and validateUpdateInput), and relaxed the "text or media required" rule specifically for a draft (isPublished:false, no scheduledAt) so an empty draft can be saved. Added myDrafts query + publishDraft mutation (post.manager.js#getDrafts/#publishDraft, mirroring the existing scheduled-posts pattern) - editing a draft's content and deleting it reuse the existing updatePost/deletePost mutations, which already worked for any post regardless of publish state. Along the way, found and fixed a real privacy bug this feature would otherwise have inherited: getPost() never checked isPublished at all, so any unpublished post (a draft, or a still-scheduled post) with the default visibility:'public' was fully readable by ID by any authenticated user, not just its owner - now unpublished posts are owner-only regardless of visibility. Frontend: CreatePostModal gained a "Guardar borrador" button next to Share/Schedule (skips the text/media/pricing requirements, never fires crossposting or the promotion-boost panel), and a new /settings/drafts page (list, publish, edit, delete) reusing the scheduled-posts page's UI pattern.
  • Polls on posts/stories. Poll/PollOption types exist, but only inside message.type.js (chat polls) — there's no poll sticker/attachment for posts or stories, a common engagement feature on comparable platforms. Impact: Low-Medium.
    • Fixed: Reused the existing Poll/PollOption/PollInput GraphQL types from chat polls (added a nullable expiresAt field to Poll, harmless for chat polls which never expire) instead of building parallel types. Backend: a poll's definition (question + 2-10 options) lives as JSONB on the Post row itself (post.poll) - works for both posts and stories with zero extra schema work, since a story is just a Post row with type:'story'. Votes are tracked in a new post_poll_vote table, mirroring the chat poll's poll_vote table (dedicated access-service post-poll.access-service.js, one vote per user, re-voting overwrites). New myDrafts-style votePostPoll mutation and a Post.poll field resolver mirroring Message.poll. PostCreateInput/StoryCreateInput both gained a poll field; a poll counts as post/story content on its own (relaxed the "text or media required" rule in the validator when a poll is present, same treatment as media). Polls are immutable once created (no edit path - people may already have voted). While building this, found and fixed a real privacy bug in getPost(): it never checked isPublished at all, so any unpublished post (a draft, or a still-scheduled post) with the default visibility:'public' was fully readable by ID by any authenticated user, not just its owner - closed alongside this feature since it directly affects draft/scheduled-post-with-a-poll content. Frontend: a reusable PostPoll.tsx component (bar-fill percentage, checkmark, locked-once-voted, mirroring the existing chat poll's UI in MessageBubble.tsx) wired into PostCard.tsx (feed), PostModal.tsx (permalink/detail, mobile + desktop), and StoryViewer.tsx (story sticker). CreatePostModal.tsx gained a poll composer panel (question + up to 4 options) alongside its existing pricing/schedule panels - a poll works for a brand-new post but isn't offered while editing (immutability). CreateStoryModal.tsx gained the same poll UI and no longer requires a photo/video when a poll is set.
  • QR code profile sharing. No qrCode field or generator anywhere for a user's own profile — common for cross-promoting an account offline or in bio links elsewhere. Impact: Low.
    • Fixed: This claim was stale — the feature already exists and works end-to-end, it just wasn't reflected in this roadmap. components/ProfileQRCode.tsx generates a real, styled QR code entirely client-side (the qr-code-styling npm package, dynamically imported so it never runs during SSR) encoding the profile's shareable link, with the Closegram logo punched into the center and error correction forced to 'H' so the logo doesn't break scanning. components/ShareProfileModal.tsx wraps it with Share/Copy link/Download actions plus a link to the profile's link-in-bio page, and is already wired into both ProfilePage.tsx (own profile) and PublicProfilePage.tsx (viewing someone else's) via their "Share profile" buttons. No backend involvement needed - the QR just encodes ${window.location.origin}/${username}, computed instantly, so there's nothing for iOS/Android to share here either (a native QR generator is the natural choice on those platforms). Initially started building a redundant server-side User.qrCodeDataUrl GraphQL field (reusing the qrcode npm package already used by 2FA setup) before discovering the working frontend implementation - reverted that backend work since it would have been dead code no client needed. Added the one thing that actually was missing: a test file for ShareProfileModal.tsx (ShareProfileModal.test.tsx) covering the copy-link action, the link-in-bio navigation, and the backdrop-close behavior.
  • Terms of Service versioning / forced re-acceptance. User.is_terms_conditions_accepted is a plain boolean set once at signup — there's no version number tied to it, so there's no mechanism to force existing users to re-accept updated terms when they change. Impact: Medium-High for a platform selling paid adult/intimate content — this is a real legal-exposure gap, not just a nice-to-have.
    • Fixed: The gap was actually worse than described - isTermsConditionsAccepted was never set or checked anywhere in the codebase at all (not even once at signup); it just sat at its false default forever, dead weight. Built a real, versioned mechanism: constants/environment.js#CURRENT_TERMS_VERSION (env-overridable, defaults '1.0') is the single source of truth for "what version is live right now" - bump it and every account whose stored version doesn't match, including every pre-existing account (stored version is null), needs to re-accept. User.termsAcceptedVersion/termsAcceptedAt are the fast "current status" cache; a new append-only user_terms_acceptance table is the legal audit trail (who accepted what version, when, from which IP) - required for real defensibility on a platform selling paid adult content. New terms-acceptance.manager.js (getTermsStatus/acceptTerms/acceptCurrentVersionAtSignup), a termsStatus query + acceptTerms mutation, and User.termsAcceptedVersion/termsAcceptedAt fields (self-only, same gating as other sensitive User fields). Registration now auto-accepts the current version (the signup form already shows the implicit "by continuing, you agree to our Terms..." disclaimer), so a brand-new account starts up to date instead of immediately hitting its own gate. Frontend: TermsGate.tsx, wrapped around every authenticated screen by ProtectedRoute.tsx (same coverage as the existing onboarding gate) - while needsAcceptance is true it shows a full-screen modal that can't be dismissed without accepting. While in authentication.manager.js, also found and removed a real, unrelated bug directly adjacent to this work: a referral-program block copy-pasted into login() (instead of just register()) that referenced an undeclared referralCode variable, throwing a ReferenceError on every single login - silently swallowed by its own try/catch, so it never surfaced, but it never did anything either.
  • DMCA / copyright takedown workflow. Only generic content moderation (user reports → admin review) exists; there's no dedicated legal takedown flow with a formal notice-and-counter-notice process, which is materially different from routine content moderation for any platform hosting user-generated media. Impact: Medium-High — legal/compliance exposure for a UGC platform.
    • Fixed: Built a real 17 U.S.C. 512 notice-and-counter-notice workflow, deliberately separate from content-report.manager.js's routine "this violates guidelines" flow rather than overloading it. Backend: new dmca_takedown_request (complainant name/email, work description, the two sworn statements 512(c)(3) requires, a typed signature, status lifecycle) and dmca_counter_notice (the accused party's sworn statement, jurisdiction consent per 512(g)(3)(D), signature) tables, with a full audit trail - nothing is ever overwritten, every review is recorded. dmca.manager.js resolves the accused content owner server-side from the reported post/comment (never trusts a client-supplied owner id, which would let a complainant misdirect a takedown), and reuses the existing, already-working content-moderation.manager.js#removeContent/#restoreContent primitives for the actual takedown/restoration - no parallel content-hiding mechanism. Anyone can file a notice (no account required, matching real-world DMCA practice); a counter-notice can only be filed by the actual content owner, and only while the content is genuinely down as a result of that specific notice. New MANAGE_DMCA_REQUESTS admin permission gates the review queue. Frontend: a "Report copyright infringement" option in the post options menu (DmcaTakedownModal.tsx, deliberately more rigorous than the existing generic "Report post" flow - collects the legal name/email and both sworn statements), a new /settings/dmca page (notices filed by you, notices filed against your content with an inline counter-notice form), and an admin /dmca review queue (approve/reject a takedown, accept/reject a counter-notice, full legal detail view). New .graphql operation files under packages/graphql/operations/Web/Dmca/** and Admin/Dmca/** so a future iOS/Android client gets the same contract.

Admin panel — missing tooling

Verified via a full audit of apps/frontend-admin (all routes, nav, and the permission catalog) cross-checked against the backend admin schema — each item below was confirmed missing by grepping the actual GraphQL types/resolvers, not assumed. Several near-misses are explicitly not listed because they already exist: bulk user ban/suspend, bulk content removal (adminBulkRemoveContent), bulk report review (adminBulkReviewReports), bulk admin-permission updates (BulkPermissionsModal), per-admin permission toggling (PermissionsModal), a generic on-demand analytics export (adminExportAnalytics, csv/json), admin IP allowlisting for login, and a small unfiltered activity feed on the dashboard.

  • Platform-wide broadcast/announcement to users is dead code, not just missing UI. admin-managers/index.js#sendBroadcastMessage delegates to adminNotificationManager.sendBroadcastMessage(...), but AdminNotificationManager only actually defines broadcastToAdmins (notifies other admins, not end users) — the delegated method doesn't exist, so this would throw if anything ever called it. No GraphQL type/resolver exposes it, and there's no frontend page. Impact: High — support/ops has no way to push an outage notice or policy update to users or a segment. Fixed. Implemented the real sendBroadcastMessage(message, adminId, options, context) on AdminNotificationManager, so the existing facade in admin-managers/index.js now actually works instead of throwing. Delivery reuses the existing per-user Notification model rather than inventing a parallel mechanism - notificationType:'system' already existed unused in the NotificationType enum, and NotificationsPage.tsx's default rendering branch already shows message text gracefully for an actor-less notification, so no consumer-frontend changes were needed at all. A new admin_broadcast table is the audit trail (who sent what, to which segment, how many were reached) - a fresh AdminBroadcast row is created first, then the message is fanned out as one Notification row per targeted user via a new bulkCreate. Targeting supports all active users, verified users only, or a custom list of user ids (user.access-service.js#getActiveUserIds, capped at 100k as a safety valve). New MANAGE_BROADCASTS admin permission gates both the send action and the history query. Admin frontend: new /broadcasts page (compose form with a two-step "are you sure" confirm before an irreversible mass-send, plus a paginated history table). New .graphql operation files under packages/graphql/operations/Admin/Broadcasts/**.
  • No dedicated, filterable admin audit-log page. The backend already supports this well (adminGetActivityLog(adminId, action, startDate, endDate, limit, offset), plus exportAuditTrail/getActivityByType/getSystemActivity), but the frontend only renders a hardcoded "last 20 entries" widget on the dashboard — no filter by admin, action, or date range, no pagination, no export UI, despite the resolver accepting all of it. Impact: Medium-High — exactly what an ops lead needs when auditing another admin's actions ("who banned this user / who changed that payout setting"). Fixed. adminGetActivityLog had no dedicated permission check at all (any authenticated admin could call it); added a new VIEW_AUDIT_LOG permission to the catalog and gated the resolver with it, alongside the existing self-vs-super_admin scoping. New /audit-log admin page: filters by admin (dropdown, super_admin only, populated from adminUsers), free-text action (debounced), and start/end date range, all wired to the resolver's existing arguments — plus real pagination via the resolver's total, a details modal for each entry's raw details JSON, and a client-side CSV export of the current page. New .graphql operation AdminGetActivityLogFullInline (the dashboard's existing inline query only selected a hardcoded 20 rows with no filters/pagination fields, so it was left as-is for the widget and a new full query was added for the page).
  • No admin session/device management for OTHER admins. Every session query/mutation (adminActiveSessions, adminRevokeSession, adminRevokeAllSessions) is hardcoded to the caller's own admin.adminId from JWT context — there's no targetAdminId argument anywhere. If an admin account is suspected compromised, the only way to kill its sessions today is full deleteAdmin. Impact: High. Fixed. Added an optional targetAdminId argument to all three, defaulting to the caller (unchanged behavior) with a super_admin-only gate to act on anyone else — the same self-vs-super_admin pattern already used by adminGetActivityLog/adminIPWhitelist. Along the way, closed a real pre-existing bug: adminRevokeSession never verified a session actually belonged to its target admin, so any authenticated admin who obtained another admin's sessionId could already revoke it — the resolver now looks the session up first and rejects a mismatch. New "Active sessions" card on the per-admin detail page (/admins/[id], already super_admin-gated) lists device/IP/last-activity with per-session revoke and a confirm-gated "log out everywhere," reusing the exact same query/mutations (and UI pattern) as the existing "My sessions" section on the admin's own account page.
  • No general feature flags / kill switches. system-settings.manager.js already defines getFeatureFlags, setFeatureFlag, enableMaintenanceMode, disableMaintenanceMode, isMaintenanceMode — none of it is wired to any GraphQL type/resolver or frontend page. The only real toggles that exist are narrow: payoutsEnabledGlobally and per-coin-package isActive. Impact: High — a standard incident-response safety valve (e.g. disable coin purchases or live streaming platform-wide without a deploy) that this platform doesn't have. Fixed — and it turned out those methods weren't just unwired, they were non-functional: they called getSetting/setSetting, which query a generic key/value shape that doesn't exist on the real SystemSetting model (same class of dead code as payoutsEnabledGlobally was before it got its own typed column). Rebuilt getFeatureFlags/setFeatureFlag/isFeatureEnabled/enableFeature/disableFeature/isMaintenanceMode/enableMaintenanceMode/disableMaintenanceMode against 5 new typed columns on SystemSetting (maintenanceMode, maintenanceMessage, coinPurchasesEnabled, liveStreamingEnabled, newRegistrationsEnabled), the same pattern payoutsEnabledGlobally already used. New MANAGE_SYSTEM_SETTINGS admin permission gates a new adminGetFeatureFlags/adminSetFeatureFlag/adminSetMaintenanceMode GraphQL surface. Each flag is enforced where it matters, not just displayed: coin-purchase.manager.js (all 4 purchase entry points), live-stream.manager.js#createLiveStream, and authentication.manager.js#register now check their flag before doing any work. Maintenance mode is the platform-wide safety valve specifically: a new Express middleware in front of /web/graphql (the consumer endpoint only — /admin/graphql stays reachable so staff can turn it back off) returns a 503 with the configured message while it's on, and fails open if the settings lookup itself errors so a DB hiccup can never take the API down. New /system/feature-flags admin page with instant-apply toggles (each switch calls its mutation immediately, matching the "flip it during an incident" use case) and a confirm step before enabling maintenance mode specifically, since it blocks the entire platform.
  • No admin role/permission catalog management. The 13-key permission catalog is hardcoded in admin-user.manager.js#getAdminPermissions, and valid roles are hardcoded to ['moderator', 'admin', 'super_admin']. The admin UI can only toggle existing fixed keys per admin — there's no way to define a new permission, create a custom role/preset, or manage categories. Impact: Medium — will matter once the team wants narrower roles (e.g. "payments-only auditor"). Fixed — deliberately not by letting admins invent new permission strings: every real gate in this codebase is hasPermission(adminId, 'LITERAL_STRING') baked into ~75 code-reviewed call sites across every admin resolver, so a DB-defined permission key would look real (toggleable, listed) while enforcing nothing anywhere - a textbook false sense of security. Instead built what the roadmap's own example calls for: admin-defined role presets - named, reusable bundles of the existing fixed catalog keys (new admin_role_preset table/manager/GraphQL surface, super_admin only, same tier as adminActivate/adminDelete/adminUpdateRoleAndPermissions), so "payments-only auditor" = {MANAGE_PAYOUTS: true, VIEW_ANALYTICS: true} can be created once and applied to any admin account in one action from a new "Apply a preset" dropdown inside the existing per-admin PermissionsModal. Every preset write validates its keys against the real catalog (admin-user.manager.js#getPermissionKeys(), extracted from getAdminPermissions so both share one source of truth) and rejects anything unknown. New /admins/role-presets management page. Along the way, fixed a real bug this same catalog had accumulated over the session: 7 permission keys added by earlier roadmap items (MANAGE_APP_VERSIONS, MANAGE_FEEDBACK, MANAGE_SUPPORT_TICKETS, MANAGE_DMCA_REQUESTS, MANAGE_BROADCASTS, VIEW_AUDIT_LOG, MANAGE_SYSTEM_SETTINGS) and the system category were never given en.json/es.json labels, so toggling any of them in PermissionsModal rendered a raw untranslated key like permissionLabels.VIEW_AUDIT_LOG instead of a real label - now complete.
  • No contextual data export from filtered admin list views. Support/finance/legal need "export the users I just filtered" or "export this list of pending payouts," not a disconnected report generator. Users, Payments/Cashouts, and Flagged Content have zero export affordance tied to their actual filters/selection — the one real exporter (adminExportAnalytics) is a standalone tool unrelated to any table's live state. Impact: Medium. Fixed. Deliberately not by mirroring adminExportAnalytics's server-file/download-URL machinery for 3 more list types (disproportionate for "reformat these rows as CSV," and none of the 3 queries even return a row total needed to judge export completeness the way that tool's canned reports do). Instead each of the 3 pages got an "Export CSV" button that re-runs its own existing paginated query with the current filters intact, at a high row limit, and builds the CSV client-side (new shared lib/csv.ts helper, promoted from a copy that had been sitting inline in the audit-log page since roadmap #59). None of the 3 underlying resolvers (adminGetUsers/adminGetCashouts/adminGetFlaggedContent) previously clamped limit at all — a client could already request an unbounded response before this change — so a MAX_EXPORT_LIMIT = 5000 server-side clamp (mirroring the existing pattern in link-tracking.manager.js/search-history.manager.js) was added to all three regardless of what the client sends; the export button surfaces a "showing the first 5,000 — narrow your filters" notice if the cap is hit, rather than silently truncating. Export uses each page's existing view permission (VIEW_USERS/MANAGE_PAYOUTS/MODERATE_CONTENT), not EXPORT_DATA - EXPORT_DATA is scoped as an analytics/insights permission, and gating export behind it separately would let an admin see a filtered table but not export exactly what they're looking at, defeating the point. Along the way, added the missing user.email field to the cashouts query, since email is on CoinCashout.user but the admin cashouts view never selected it.
  • No global admin search across users/posts/transactions/reports. The Cmd+K command palette looks like it should do this but only filters the static nav-item list — it has no knowledge of live entity data. Search is fully siloed per page today. Impact: Medium. Fixed. Kept the existing Cmd+K CommandPalette as the single entry point rather than building a separate search page — typing 2+ characters now also fires a debounced (300ms) adminGlobalSearch(query) query alongside the unchanged static nav-item filter, rendering grouped Users/Cashouts/Posts/Reports sections underneath. New GlobalSearchManager fans the query out to one lightweight lookup per category, each capped at 5 results and each independently gated on that category's existing permission (VIEW_USERS, MANAGE_PAYOUTS, MODERATE_CONTENT twice, for posts and reports) — a caller missing a permission silently gets an empty array for that category instead of the whole search erroring, matching how nav items are already conditionally hidden elsewhere. Post search reuses the GIN to_tsvector('english', text) index the Post table already had (new postAccessService.searchByText(), plainto_tsquery via a safe replacements parameter, never string-interpolated) rather than adding a new index. User and cashout results are clickable through to their existing detail pages (/users/{id}, /payments/users/{userId}); post and report results render informational-only, since this app genuinely has no per-post or per-report admin detail route to link to today (adminGetReports in particular turned out to have no frontend page at all) — a fabricated link would be worse than none.
  • No admin-side full-text content search. adminGetFlaggedContent (the only content-browsing query) takes contentType, status, limit, offset, orderBy — no free-text parameter, and no separate search resolver exists. Trust & safety can only browse what's already been reported, not proactively search post text for a keyword/slur/scam link. Impact: Medium. Fixed. New adminSearchContent(query, limit, offset) query, gated by the same MODERATE_CONTENT permission as adminGetFlaggedContent, backed by a paginated sibling of the full-text search this same roadmap section's global-search item (#64) added (postAccessService.searchByTextPaginated(), Post.findAndCountAll over the existing to_tsvector('english', text) GIN index, distinct: true for an accurate count alongside the user include). Scoped to posts only, deliberately: Post.text is the only content column with a full-text index today, and adding an equivalent one for comments/messages is a separate, larger effort than this item asks for - a contentType filter would have implied broader coverage than actually exists, so it was left out rather than shipped as a filter that silently does nothing for anything but POST. New collapsible "Search content" panel on the existing /moderation/flagged page (same page as the "Trending content" panel it sits next to, not a separate route), debounced (300ms) free-text input, each hit rendered with the same preview thumbnail/text used elsewhere in this page and reusing the exact same Flag/Remove modals and mutations the main flagged-content table already uses - a moderator can act on a proactively-found post exactly like a reported one, no separate action plumbing needed.
  • No scheduled/automated reports (e.g. a weekly revenue digest email). All reporting is on-demand only (adminExportAnalytics); the one relevant cron job (analytics-snapshot.service.js) only writes internal warehouse rows, never sends email. Impact: Low-Medium. Fixed. New self-service "Report subscriptions" admin page (EXPORT_DATA-gated, the same permission adminExportAnalytics already checks - a recurring digest is just a scheduled export): an admin picks one of the 5 existing report types (USERS/CONTENT/ENGAGEMENT/REVENUE/MODERATION, same vocabulary adminExportAnalytics uses) and a cadence (DAILY/WEEKLY/MONTHLY), optionally with extra recipient emails (capped at 5, validated) - left blank, it defaults to the admin's own AdminUser.email. New admin_report_subscription table + full CRUD (adminGetReportSubscriptions/adminCreateReportSubscription/adminUpdateReportSubscription/adminDeleteReportSubscription), scoped to the caller's own subscriptions only (no super_admin override - unlike sessions/permissions, another admin's personal digest has no legitimate reason for anyone else to touch it). New services/report-digest.service.js cron job (following the exact node-cron + start() pattern already used by analytics-snapshot.service.js/admin-session-cleanup.service.js) runs once daily at 08:00, works out which frequencies are due today (DAILY always, WEEKLY only on Monday, MONTHLY only on the 1st), and hands off to report-digest.manager.js, which reuses the exact same admin-dashboard.manager.js methods adminExportAnalytics already calls - summarized to a handful of headline numbers (e.g. total revenue + growth rate) rather than a raw per-day dump, since a digest email needs a glance, not a CSV. New emailService.sendReportDigest() follows the same bilingual template pattern as the app's other transactional emails (sendPurchaseReceipt/sendCreatorEarningsPaid). One subscription failing to send (bad analytics query, bad address) is logged and skipped rather than aborting the whole run. Along the way, fixed a real pre-existing i18n gap: adminExportAnalytics's own validation.invalid_report_type error key had never been added to en.json/es.json, so that error was silently showing the raw key string instead of a real message before this fix.
  • The new Family Center feature (parent/child screen-time supervision) has zero admin surface. graphql/types/family-supervision.type.js's queries/mutations (mySupervisionAsParent/mySupervisionAsChild/childUsageStats/requestFamilySupervision/respondToFamilySupervision/endFamilySupervision/setChildTimeLimit) are all authenticated-user-only — no admin-prefixed equivalents, no admin resolver, no manager method to list/inspect/force-end a supervision link. A grep of apps/frontend-admin/src for "family" turns up nothing related. This is a brand-new feature (model/migration/manager/resolver all added in the last 30 commits) involving minors' accounts, with no admin path to investigate a dispute or force-unlink for a safety/legal need. Impact: High. Fixed. New adminGetFamilySupervisions(status, limit, offset) query lists every supervision link platform-wide (not scoped to one caller, unlike the existing findByParent/findByChild), and a new adminForceEndFamilySupervision(linkId, reason) mutation can end a link regardless of status or whether the caller is a party to it - unlike the user-facing endFamilySupervision, which requires exactly that. Both reuse the existing FamilySupervision type/table/access-service as-is rather than building a parallel model. New dedicated MANAGE_FAMILY_SUPERVISION permission (not an existing broader key like VIEW_USERS, since this is specifically a minors'-safety surface) gates both. Force-ending an active link notifies both the parent and the child with the admin's stated reason (a pending or already-declined/ended link has nothing to notify anyone about, and force-ending an already-ended/declined link is a no-op, matching the user-facing method's own idempotency); endedByUserId is left null since that column is a User FK and no user (parent or child) actually ended it. New /family-center admin page: a filterable (by status) list showing both parties' usernames/emails and a force-end action with a required-reason confirm modal, reusing the same ReasonModal component every other moderation action flow already uses.
  • No admin control to freeze/unfreeze a user's payout account, despite the mutation already existing. adminSetPayoutAccountDisabled(userId, disabled, reason) (graphql/types/admin/payout-admin.type.js, resolved in admin/payout-admin.resolver.js, backed by PayoutAccount.adminDisabled) has zero references anywhere in apps/frontend-admin/src. The only per-user payments page (app/payments/users/[userId]/page.tsx) shows 8 read-only revenue StatCards and nothing about payout-account status. An admin reviewing a suspicious cashout has no way to actually freeze that user's payout account. Impact: High. Fixed. The mutation had no way to even read current status first - there was no adminGetPayoutAccount(userId) query at all, so a frontend control would have had nothing to show before the first click. Added that query (same MANAGE_PAYOUTS gate, backed by the existing userPayoutAccountAccessService.findByUser() - no new data layer needed) alongside a new "Payout account" card on the per-user payments page: provider/status/payouts-enabled/bank-last-4 at a glance, an Active/Frozen badge, and a Freeze/Unfreeze button behind a required-reason confirm modal (the same ReasonModal every other moderation action already uses). A user with no payout account at all (the common case) gets an explicit "hasn't set one up" message instead of an empty section. Along the way, split payments/users/[userId]/page.tsx's inline content into its own UserRevenueContent.tsx (matching the convention every other admin page already follows) so the new card is unit-testable and page.tsx stays a thin wrapper - a lesson learned the hard way earlier in this same effort (roadmap #63) when a named export directly inside a page.tsx broke Next.js's route-type generation.
  • No admin tool to look up, inspect, or refund a raw coin-purchase transaction (Stripe/PayPal/IAP). moderation/refunds/page.tsx is explicitly scoped to adminGetPostPurchases/adminRefundPostPurchase — exclusive-post unlocks only. There is no adminGetPurchases/adminGetCoinPurchases/adminGetTransactions query anywhere on the admin schema, even though coin-purchase.manager.js has a full refundPurchase/approveRefund/rejectRefund surface. Recent commits added a whole new PayPal purchase provider (services/paypal/*, a per-provider PaymentCustomer migration) with no accompanying admin visibility into provider mix or per-transaction chargeback/dispute lookup for a plain coin top-up. Impact: High. Fixed - and found a real, live security hole along the way. approveRefund/rejectRefund turned out to be class-docblock comments only, not real methods - refundPurchase(purchaseId, context) is the only refund path that actually exists, and it's a coin-ledger reversal only (it never called Stripe/PayPal to issue a real processor-side refund - neither service exposes a refund call yet, so an admin still has to do that part with the provider by hand). Far more seriously: the client-schema refundCoinPurchase(purchaseId) mutation this method was wired to had no authorization check of any kind - not even if (!user) - unlike every sibling mutation in that same resolver file. Any caller, authenticated or not, could refund any user's completed coin purchase by id, silently reversing their coin balance; the integration test that exercised it even proved the point (a plain user token, success). That mutation has been removed from the schema entirely rather than patched, since nothing in apps/frontend-nextjs/apps/frontend-admin/packages/graphql/operations ever called it. Its replacement, adminRefundCoinPurchase(purchaseId, reason!), is admin-only behind a new dedicated MANAGE_TRANSACTIONS permission (existing MANAGE_PAYOUTS/MODERATE_CONTENT are both semantically the wrong fit - one is about paying creators, the other about content, neither about purchase refunds) and stamps reason + the acting admin id onto the purchase's metadata JSONB for an audit trail (no dedicated refund-reason column exists on CoinPurchase). New adminGetCoinPurchases(status, platform, userId, limit, offset) lists every purchase platform-wide with its joined PaymentTransaction (so provider mix - stripe/paypal - and the external charge id are visible per row, not just the internal web/ios/android platform column) - the exact "provider mix" visibility gap the roadmap called out. Frontend: added a third "Coin purchases" tab to the existing tabbed RefundsContent.tsx (already built to extend this way from the post/message tabs) - a Provider column replaces "Seller" (there's no seller for a platform top-up) plus an extra "Amount paid" real-money column, and the refund action only appears for completed purchases (unlike post/message purchases, a coin purchase can be pending/failed, and the manager would reject a refund attempt on either).
  • No admin visibility into a regular user's active sessions/devices for fraud or account-takeover investigation (distinct from the already-listed gap about admins managing other admins' sessions — this is about investigating a user's account). UserModerationInfo (the type backing adminGetUserDetails) has no session/device/IP fields at all, and sessions.type.js only exposes session data to the session's own owner. Impact: Medium. Fixed. New adminGetUserSessions(userId) query (VIEW_USERS, matching adminGetUserDetails's own gate) plus adminRevokeUserSession(sessionId, userId, reason)/adminRevokeAllUserSessions(userId, reason) mutations (SUSPEND_USERS - revoking is a corrective action, one tier up from a read). All three reuse sessions-devices.manager.js exactly as it already stood - every method there already took an explicit userId argument rather than hardcoding the caller, so no new manager methods were needed, just admin-schema reachability and a permission check; revokeSession/terminateAllSessions gained an optional reason param (defaulting to the existing self-service string) so an admin-initiated revoke stamps a real reason on revoke_reason instead of the generic 'user_logout'. Along the way, fixed a real bug in the exact method this reuses: getActiveSessions()'s row transform keyed OS as platform - a field UserSession.os never resolves to since the schema doesn't declare platform at all - and never set userId/expiresAt (silently null since no existing caller happened to select them yet, though userId is non-null on the schema). This bug was already user-facing: Settings → Security → Active Sessions has been showing a blank OS for every session's browser/OS line. Frontend: a new collapsible "Sessions" section on the per-user admin detail page (lazy-loaded on expand, matching the existing Trending/Search-content disclosure pattern elsewhere in the admin panel) lists device/OS/browser/IP/location/last-activity with a per-session revoke and a "Force logout everywhere" bulk action (shown only once there's more than one session) - reusing the security.* i18n strings (sessionsTitle, forceLogout, actionTitles.force_logout, etc.) that had been sitting unused in both locale files, apparently pre-written for exactly this feature.
  • No admin control over the NSFW auto-moderation detection threshold, and no stats view for it. admin-managers/nsfw-detection.manager.js has working getThreshold()/setThreshold(threshold)/getDetectionStats() methods, but content-moderation.resolver.js never imports the manager at all — it reads persisted scores straight from the DB instead. All three methods are called nowhere outside their own file; the auto-flagging sensitivity is effectively hardcoded and untunable from the admin panel. Impact: Medium. Fixed - and those three methods turned out to be far more broken than merely unwired. getThreshold()/setThreshold() read/wrote an in-memory instance property (defaultThreshold = 0.7) with no persistence at all - lost on every restart, and irrelevant anyway, since the REAL auto-flagging decision (autoModerate(), called from the actual working detection path analyzePostMedia()) never read it: it hardcoded its own three score bands (0.9/0.7/0.5) completely independent of the "configurable" threshold. Wiring setThreshold() up as-is would have been cosmetic. getDetectionStats() called a getAll() method that exists on neither PostNsfwScoreAccessService nor NsfwCommentScoreAccessService - a guaranteed TypeError on every call, never exercised by any test - and its own reduce logic read a nsfw_score field neither model declares (the real column is confidence). Real fix: the threshold now lives on a new nsfw_detection_threshold column on SystemSetting (same singleton row and access-service the feature-flags/kill-switches work already uses), and autoModerate()'s three tiers now scale off ONE admin-set value - the threshold is the "hide outright" cutoff, with warn/flag sitting 0.2/0.4 beneath it (clamped to ≥0), preserving the original spacing at the 0.7 default while making the anchor point genuinely adjustable. getDetectionStats() now calls a new getStats() on each access service that computes real DB-level aggregates (COUNT/AVG via Sequelize.fn) against the real isNsfw/confidence columns, instead of pulling thousands of rows into memory through a method that never existed. New adminGetNsfwThreshold/adminSetNsfwThreshold/adminGetNsfwDetectionStats (MODERATE_CONTENT - the same permission gating every other real content-moderation surface, not MANAGE_SYSTEM_SETTINGS, since this tunes moderation behavior, not a system-level flag). New /moderation/nsfw-detection admin page: a threshold input with inline validation, and a stats view broken out by posts/comments plus an overall flagged percentage.
  • A handful of dead/duplicate admin queries, worth a cleanup pass: adminGetPayoutProfile and adminGetContentModerationHistory are unused — the pages that look like they'd need them fetch the same data via nested fields (CoinCashout.payoutProfile, ContentDetails.moderationHistory) instead; adminGetModerationStats duplicates adminGetContentModerationStats/adminGetModerationQueueStats, which the dashboard actually uses. Impact: Low. Fixed. Confirmed all three claims still held (zero references anywhere in apps/frontend-admin/src or packages/graphql/operations) before touching anything. Removed the adminGetPayoutProfile and adminGetContentModerationHistory query fields from payout-admin.type.js/content-moderation.type.js and their resolvers, and removed the adminGetModerationStats field/resolver too (its handler called the same contentModerationManager.getContentStats() the still-used adminGetContentModerationStats calls, so the underlying manager method stays - only the duplicate no-arg query surface goes). payoutAdminManager.getPayoutProfile() had exactly one caller (the resolver just removed), so it was deleted outright rather than left as orphaned dead code, along with its now-unused userPayoutProfileAccessService import; contentModerationManager.getModerationHistory() and getContentStats() stay, since both are still called from elsewhere (content-report.manager.js, the admin-managers facade, and the surviving adminGetContentModerationStats/ContentDetails.moderationHistory field resolver). Mirrored all three removals into schema.admin.graphqls; PayoutProfile and ContentModerationStats stay in the schema since both types are still returned by other still-used fields. No .graphql operation files referenced any of the three dead queries, so nothing to remove there. Rebuilt apollo-admin/apollo-web clean.

Documentation gaps

Found while cross-checking apps/docs against the current state of the code it describes.

  • Every "view source" and "Edit this page" link across the docs site points at the dev branch, which is 513 commits and ~19 days behind main (docusaurus.config.js's editUrl). Concretely, apps/backend/graphql/resolvers/webauthn.resolver.js and admin-passkey.service.js don't exist on dev at all, and dev's coin-purchase.manager.js has no purchaseCoinsWithSavedPaypal. Every click-through-to-source link in the feature docs is pointing at a materially outdated snapshot. Impact: Medium. Fixed. dev has since caught back up with main (the specific staleness this item measured no longer reproduces - both webauthn.resolver.js and admin-passkey.service.js now exist on main too), but re-pointing editUrl at dev specifically would just be papering over the underlying issue: main, not dev, is this repo's actual PR-target/stable branch, so it's the one an "Edit this page" link should open a PR against - a link permanently pinned to whichever branch happens to be furthest ahead at a given moment will just go stale again the next time dev diverges. Changed docusaurus.config.js's editUrl from edit/dev/apps/docs/docs/ to edit/main/apps/docs/docs/. Single source of truth (no duplicate editUrl elsewhere in apps/docs, confirmed via grep); the build/ directory's baked-in dev URLs are a stale local build artifact (gitignored, regenerated on next docusaurus build), not a source-file issue.
  • technical/ios.md directly contradicts features/authentication.md and this same roadmap about iOS auth completeness. technical/ios.md's feature table claims Auth | ✅ 100% | Phone OTP, Apple Sign In, Google Sign In, while features/authentication.md and the iOS/Android parity section below both correctly state phone OTP login isn't wired up on iOS yet. technical/ios.md appears to have been missed by the doc-reconciliation pass the rest of apps/docs went through. Impact: Medium — actively misleading in a doc a reader would treat as a status source of truth. Fixed - and it turned out to be features/authentication.md (and this roadmap's own parity section) that had it backwards, not technical/ios.md. Read the actual iOS source before taking either doc's word for it: phone OTP is fully wired end to end on apps/ios (WelcomeView's Phone button → LoginView phone mode → RequestPhoneOtpUseCaseVerifyCodeViewloginWithPhone, all the way through AuthRepository/AuthService to the real GraphQL mutations, no stub or TODO anywhere in that path) - and so is device token registration for push (PushNotificationManagerDeviceTokenService.registerDeviceToken() → the real mutation). apps/docs/docs/technical/features/authentication.md (the detailed technical reference, one level deeper than the two docs that disagreed) already had this right, marking both iOS only/wired - it just hadn't been cross-checked against the simpler top-level features/authentication.md, which is what actually needed correcting. Fixed features/authentication.md's two false "apps/ios hasn't implemented/wired this yet" claims (phone OTP, device token registration) and removed the now-false line from the parity section below. technical/ios.md itself did have a real, separate problem though: its own "Known TODOs" section listed "password recovery flow" as a critical Auth gap, but ForgotPasswordView.swift is real and reachable from LoginView/PasswordView - not a gap at all. Corrected that list to the two Auth gaps that verified as genuinely real: token refresh (AuthService.refreshToken() is a literal TODO stub) and delete account (the backend-wired AuthRepository.deleteAccount() exists, but nothing in the UI dispatches to it - the Settings-side deletion path goes through a different, stubbed ProfileRepository.deleteAccount() that just throws ProfileError.featureNotSupported), and adjusted the Auth row from 100% to 95% to match. No apps/ios source was modified (out of scope per CLAUDE.md) - this was a documentation-accuracy fix only.
  • features/recent-updates.md is a single undated "this cycle" changelog that gets rewritten wholesale rather than appended to, with no calendar-date anchors and no other changelog page anywhere in the docs. Once the next cycle lands and overwrites it, there's no durable record of what shipped when. Impact: Low-Medium. Fixed. Converted the page to a dated, append-only format: a new intro states the convention plainly (each cycle gets its own ## YYYY-MM-DD — Title section, newest first; a new cycle is added as a new section above the previous one, which is never rewritten or deleted), and the existing cycle's content was wrapped in ## 2026-07-21 — Full-codebase feature-doc reconciliation (dated from the migration timestamps already embedded in its content - 20260721*/20260722* - and confirmed against the file's creation commit date), with every interior heading demoted one level (#####, #######) so it nests correctly under the new dated section. The two in-page anchor links to the Deployment section still resolve correctly (heading text/slug unchanged, just demoted).
  • The docs never mention the root npm run test:all command or its TZ=UTC requirement, even though CLAUDE.md and payments-subscriptions.unit.test.js both call out that backend tests assume a UTC-container timezone. technical/testing.md only documents per-workspace npm test. Someone following only the public docs (not CLAUDE.md) could hit spurious local test failures with no explanation. Impact: Medium. Fixed. Added a new "Root-level commands" section to technical/testing.md (right after the per-package overview table, before the package-by-package deep dives) documenting all five root package.json test scripts (test, test:frontend, test:backend, test:all, test:log) verified against their actual definitions, plus a dedicated "The TZ=UTC requirement" subsection explaining why (fake-timer date-boundary tests assuming a UTC container, per payments-subscriptions.unit.test.js) and pointing at the Stop hook (run-tests-on-stop.sh) as the existing example of doing this correctly.
  • WEBAUTHN_RP_ID/WEBAUTHN_ORIGIN/WEBAUTHN_RP_NAME are referenced by two feature docs as being covered in "environment setup," but neither getting-started doc actually documents them, despite passkeys being fully shipped on both the user and admin sides, and admin/features/admin-accounts.md explicitly warning that a misconfigured WEBAUTHN_ORIGIN throws Unexpected registration response origin. Impact: Medium — a real, documented failure mode with no matching setup instructions anywhere. Fixed. Added a new "Passkeys (WebAuthn)" section to getting-started/environment-setup.md (right after JWT, matching env-catalog.js's auth category grouping) documenting all three variables, their defaults (localhost/FRONTEND_URL/APP_NAME), the shared-not-separate admin relationship, and both documented failure modes - sourced from the accurate comment block that already existed in apps/backend/.env.example (which this new section now points back to for a worked prod example) and cross-linked to admin-accounts.md's admin-specific origin gotcha. Repointed both feature docs' now-dangling "environment setup" links (technical/features/authentication.md) at the new section's anchor instead of the bare doc.
  • The docs site still ships the stock Docusaurus favicon/logo and unused undraw_docusaurus_* placeholder art, despite the site title correctly reading "Closegram Docs." Impact: Low. Fixed. Replaced static/img/logo.svg (the navbar logo, was the literal stock Docusaurus dinosaur mascot) with a small hand-authored SVG mark - a rounded-square badge in the app's actual brand gradient (--ifm-color-primary purple, #8b4ef0#6d28d9, matching src/css/custom.css) with a white "C" monogram. Regenerated favicon.ico and docusaurus-social-card.jpg from that same mark via sharp (already present in the monorepo's hoisted node_modules, used by the backend's image pipeline) - the favicon is a modern PNG-in-ICO container (all major browsers support this), the social card is a 1200×675 JPEG with the mark plus a "Closegram Docs" wordmark on the brand gradient, replacing the stock Docusaurus versions of both. Deleted the three unused undraw_docusaurus_*.svg files and docusaurus.png outright (confirmed zero references anywhere outside this roadmap entry - HomepageFeatures/index.js already renders text-only cards with no image prop, so these were pure dead weight, not wired to anything to begin with). Verified with a full npm run build -w docs (both en/es locales) - no new errors or broken-link warnings.

iOS/Android parity (backend ready, native client not wired — see the linked feature docs for detail, not repeated here)

These already have their own checklists with per-item evidence; listed here just so this backlog is a complete picture in one place:

  • Answer/decline an incoming call, call history, and group-call viewer/speaker-request UI — web only; already fully wired on iOS. See Calls.
  • Native in-app purchase flow (StoreKit 2 / Play Billing → redeem*CoinPurchase) — backend + GraphQL ready. See Coins.

Deferred pending a product/business decision (not started — needs a decision first, not just engineering time)

  • Automated/third-party identity verification. Today it's 100% manual admin review. Needs a vendor decision (Persona, Onfido, Stripe Identity, or similar) before implementation can start — cost and compliance implications, not just code.
  • Tax document generation for payouts (1099-style forms for creators). Needs a decision on which jurisdictions/thresholds to support and whether to build in-house or integrate a provider (e.g. Stripe Tax, Track1099).
  • Referral running-commission / leaderboards. Today referrals only pay a one-time bonus to the referrer. A recurring-commission model needs a decided payout formula/cap before it can be built.

Larger future chapters (explicitly out of scope until their own dedicated effort starts)

  • apps/ios native app wiring. A large and growing amount of backend contract is sitting ready and unused: Family Center, App Version Control, SMS/email 2FA, device tokens, phone OTP, live location, call features above, native IAP, and more — all already exposed through packages/graphql/operations/Web/** so apollo-swift's next codegen run picks them up automatically. Per CLAUDE.md, apps/ios itself is not to be touched until this becomes its own dedicated task.
  • Android app. Doesn't exist as a client at all yet — no apps/android, no Android codegen package. The same shared packages/graphql/operations/Web/** contract that backs apollo-web/apollo-swift would need an Android codegen target (e.g. Apollo Kotlin) added when this starts.