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-facingloginmutation (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 (newUser.failedLoginAttempts/lockedUntilcolumns + migration, per-user in-memory rate check viarate-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
@rateLimitdirective 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, andadminLoginall 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:@rateLimitadded tologin,register,requestPasswordReset,verifyLoginTwoFactor,requestPhoneOtp,loginWithPhone,adminLogin,adminVerify2FA. Also fixed a real bug found while touching this: the sharedkeyGeneratorinapi/server.jshad a block body with noreturn, so every existing@rateLimitfield (including the two above) was silently keying onundefinedinstead of per-IP. - No global request-rate middleware.
express-rate-limitis an installed dependency but isn't mounted anywhere inapi/server.js— there's no blanket per-IP cap on/graphqlor/admin/graphqltraffic at all, only the two@rateLimit-decorated fields above. Fixed: mounted on both/web/graphqland/admin/graphql(separate limiter instances), reusing the pre-existingRATE_LIMITS.API_GENERALthreshold fromconstants/admin-security.js. - Rate-limit / abuse-threshold values aren't configurable from the admin UI.
RATE_LIMITSis a plain hardcoded object inconstants/admin-security.js, consumed directly byrate-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.jsnow layers admin overrides (persisted via the existing-but-unwiredsystem-settings.manager.jsJSON-setting mechanism) on top of the hardcoded defaults, applied immediately in-memory with no restart needed; new super-admin-onlyadminGetRateLimits/adminUpdateRateLimit/adminResetRateLimitschema + a new/system/rate-limitsadmin 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-whitelistadmin page) is scoped entirely to allow-listing IPs for admin login access, not blocking end-user accounts; theUsermodel has nolastLoginIp/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: newUser.lastLoginIp/lastLoginDeviceIdcolumns (populated on registration and login across every account-creation path: email/password, Firebase, Apple, phone OTP) plus a newblocked_identifiertable +ban-evasion.manager.js, checked inregister()/login()before any other work. NewBAN_USERS-gatedadminBlockIdentifier/adminUnblockIdentifier/adminGetBlockedIdentifiersschema, a new/moderation/blocklistadmin page, and the last-known IP/device now show onadminGetUserDetailswith 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)deviceIdonUserRegistrationInput—apps/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/removeVerificationperform no real admin authorization check.graphql/resolvers/verification.resolver.jsguards both mutations withif (!context.user)only (notcontext.admin), then passes the caller's own regular-user id through as the acting admin. The manager's internalisAdmin()gate (managers/user-managers/verification-badges.manager.js) checksuser.accountType === 'admin' || user.is_admin === true, but nothing in the codebase ever setsaccountTypeto'admin'andis_adminisn'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.adminis only ever populated on/admin/graphql, seegraphql/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#getBulkLimitscomputesdailyFollowLimit/hourlyFollowLimit/dailyBlockLimitetc., but the usage-tracking check is commented out (// TODO: Check current usage against limits).bulkFollowUsers/bulkUnfollowUsers/bulkBlockUsersonly 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 frombulk_operationrows) against the limit before doing any work;getBulkOperationLimitsalso now returns realcurrentUsage/remaininginstead of the commented-out stub. Also fixed two adjacent bugs:bulkUnfollowUsers/bulkBlockUsersnever even verified the acting user existed, andbatch_size_exceededhad no translation (silently rendered the raw key). - Admin platform-wallet transfers (
transferToUser/takeFromUser/topUpinmanagers/coin-managers/platform-wallet.manager.js) don't record which admin acted. Each accepts anadminIdparameter but never persists it — noadminId/performedBycolumn onCoinTransaction, 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/takeFromUsernow stamp the acting admin ontoCoinTransaction.relatedId; all three (includingtopUp, which has no per-user ledger row to attach to) also log viaanalyticsService.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/cleanupExpiredRestrictionsall have// TODO: store/deactivate/get restriction details in databaseand either no-op or return a fabricatedmockRestrictions/zeroed stats object;checkRateLimituses a hardcodedmockCurrentUsage = { 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: newaccount_restrictiontable (named to avoid colliding with the unrelated, already-realUserRestrictionmodel — the user-to-user "Restrict" social feature) backs every method for real;checkRateLimitnow 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 checkedUser.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;getRestrictedUserswas permanently wired to the wrong manager method (stats object, not a user list) and its resolver'suser.isAdmingate could never be true on this route — replaced with properadmin-prefixed queries/mutations (adminGetRestrictedUsers,adminGetRestrictionStats,adminRestrictUser,adminUnrestrictUser,adminAddUserRestriction,adminRemoveUserRestriction) gated by a realBAN_USERSpermission check; and two silent action-name mismatches (postvsposting_disabled,postvsposts_per_hour) that a|| 10/|| 50fallback 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/moderationpage 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 againstPostMention/MessageMention(commented out in the code) was never written;approveTag/rejectTag/getTagPermissions/updateTagPermissionsnever 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/getUsersIMentionednow run real queries against bothPostMention(post captions/tap-to-tags) andMessageMention(chat @mentions - newdata-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.getTaggedInPostsnow runs a realPostMentionquery shaped to the schema'sTaggedPostsResponse/TaggedPosttypes. InvestigatingapproveTag/rejectTagturned up that they were never actually reachable at all (no schema field declares them -graphql/types/user-features.type.jsonly ever declaredupdateTagPermissions) and are fully superseded by an already-real, separately-namedapprovePostTag/rejectPostTagflow (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, duplicatementions-tags.manager.js#canTagUser- a different, unreachable copy of the realcanTagUseralready fixed incontent-tag.manager.js, see the block/privacy-checks item above) were removed, along with their unreachable facade wrappers inmanagers/user-managers/index.js.getTagPermissions/updateTagPermissionsnow read/write through to the same real, already-persisted privacy settings (who_can_tag/require_approval_for_tagsinUser.settings.privacy, viaprivacy-settings.manager.js) that every actual tag/mention-creation code path already enforces, instead of a parallel object that was never saved anywhere; also fixedupdateTagPermissions's resolver, which used to swallow a manager error into a{success:false, message}object even though the schema'sTagPermissions!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 ongetMentionTagStats: the resolver ignored the schema'stimeframeargument entirely and called the manager with 2 positional args where the real signature needs 3;getMentionTagStatsitself is now backed by real counts from both mention tables (previously hardcoded all-zeros), withtopMentioners/topTaggersleft 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.tsxalready consumesusersMentionedBy/usersIMentioneddirectly (confirmed real per the roadmap's own note above) and now renders real data instead of always-empty lists;taggedInPosts/tagPermissions/updateTagPermissions/getMentionTagStatsremain without a frontend consumer, but investigation found this is by design, not a gap -TagsScreenPage.tsx(Tagged/Pending tabs) andTagsAndMentionsPage.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, andprivacySettings+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.graphqloperation files for all six fixed queries/mutations (Web/Profile/{UsersMentionedByInline,UsersIMentioned,TagPermissionsInline,UpdateTagPermissionsInline,TaggedInPostsInline,GetMentionTagStatsInline}.graphql, two of which already existed) and rebuiltapollo-web. - Mutual-connections count is hardcoded to 0.
advanced-social.manager.js#getMutualConnectionsCountis a one-line// TODO: Implement efficient mutual connections counting … return 0, feeding intonetworkMetrics.mutualConnectionsCountand the contact-suggestions mapping (mutualConnectionsinContactSuggestionis separately hardcoded to0too, incontact-import.manager.js). Impact: Medium — a visible, always-zero number wherever mutual connections are shown. Fixed:getMutualConnectionsCountnow uses a newuser-follow.access-service.js#countReciprocalFollows(real reciprocal-follow count); theContactSuggestion.mutualConnectionshardcode had actually moved tographql/resolvers/user-contact-import.resolver.jsby the time this was fixed (notcontact-import.manager.jsanymore - the roadmap note was stale) and now uses the already-existing, already-realgetMutualFollowing(viewerId, targetId). Also found and fixed two more of the same hardcoded-0 spots while in the area, both insearch-discovery.manager.js(getPopularUserSuggestions,getNewUserSuggestions) - fixed by reusing that file's own_computeSearchRelationsbatched query, the same onesearchUsersalready relies on. A third suspected spot (search-discovery.manager.js:1147, referenced by a comment as still-broken) turned out to already be correct:searchUsersoverwrites 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#canSeeLocationhas three// TODOcomments and unconditionallyreturn trueafter the self-check;setLocationPrivacywrites aprivacy_levelvalue to a column that doesn't exist on theUserLocationmodel, so Sequelize silently drops it — a no-op. The only real, enforced location-privacy control today is the plain booleanisPublic. Impact: Medium. Fixed: newUserLocation.privacyLevelcolumn (public/friends/private, migration defaults existing rows topublicto preserve current real-world behavior since the old check always returnedtrue);canSeeLocationnow actually branches on it (private→ false,friends→ delegates to the realclose-friends.manager.js#isCloseFriend,public/default → true);setLocationPrivacyfixed to write the camelCaseprivacyLevelattribute instead of the silently-dropped snake_case one. NewcanSeeLocation(userId)query andsetLocationPrivacy(privacyLevel)mutation exposed in the schema (previously neither existed on the client-facing type at all). Shipped end-to-end:apps/frontend-nextjs's existingSettings → Location sharingpage (LocationSharingPage.tsx) now has a real public/friends/private control wired to the new mutation, with i18n strings in bothen.json/es.json. -
getDataPortabilityInforeturns the wrong shape entirely, and CSV/XML data-export output is placeholder-quality even where reachable.getDataPortabilityInfoforwards togetDataExportStatus, which has none of thecanExport/exportFormats/retentionPeriod/lastExportkeys the schema declares non-null, so querying it throws. Separately,DataExportRequestInputhas noformatfield at all — there's no way to request CSV/XML through GraphQL — and even the manager's ownconvertToCSV/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:getDataPortabilityInfois now a real method ondata-export.manager.jsreturning the actualDataPortabilityInfoshape (canExportfalse while an export is pending/processing,exportFormats: ['json','csv','xml'], a 30-dayretentionPeriodmatching the existingexpiresAtwindow,lastExportfrom the newestreadyrow via a newfindLatestCompletedByUserIdaccess-service method).DataExportRequestInput.format(and a matchingDataExportStatus.format) added to the schema (backend + bothschema.web.graphqls/schema.admin.graphqlsmirrors) and wired throughrequestDataExport, with real validation (validation.invalid_export_format) rejecting anything outside json/csv/xml.convertToCSV/convertToXMLnow 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-existingSettings → Download your informationpage (DownloadDataPage.tsx) now has a real format picker wired into the request mutation and displays the live portability info, with i18n strings in bothen.json/es.json. - A dead, broken security-notification toggle pair sits next to a working one.
enableSecurityNotifications/disableSecurityNotifications/getSecurityNotificationSettingsnever 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 returnssuccess: truebut silently does nothing. Fixed: retiredenableSecurityNotifications/disableSecurityNotifications/getSecurityNotificationSettingsentirely (manager methods, GraphQL mutations, schema mirror) rather than patching them - same precedent as the earliergrantVerification/removeVerificationretirement, 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 everycreateSecurityAlertcall) was gating on the dead stub's hardcoded fake settings, not the realgetSecurityNotificationPrefs- meaning toggling notification types off in the UI never actually suppressed anything, for any user, ever. Fixed by rewiringsendSecurityNotificationto check the real per-type preference (via a newALERT_TYPE_TO_NOTIFICATION_PREFmap) 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-falsesmsflag (which meant no security SMS had ever been sent) - it's now sent whenever the user has a verified phone. -
getDeviceStatsfabricates 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 realuser_sessionhistory via a newUserSessionAccessService.getAllForStats. Devices are grouped by thedeviceType/deviceName/browser/ostuple (there's no dedicated device-id column);totalDevices/activeDevices/deviceTypes/platforms/browserscome from that grouping,locationsfrom real session geolocation, andsuspiciousActivity.newDevices/newLocationsfrom comparing the requested window against the user's session history before it - a device/location with no prior session is "new".failedLoginsis honestly reported as0rather than a fabricated number, since (as already documented ongetLoginHistory) 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 owntimeframeargument and passingcontextinto the manager'soptionsparameter position, so the manager's realcontextwas always{}- same class of argument-shift bug documented foruserStatsbelow. No frontend page consumes this field yet (getDeviceStats(timeframe: String): JSON!has zero callers infrontend-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; usesmockRequest = { userId: 'user_123', ... }, so approving/rejecting any realrequestIdactually operates on a nonexistentuser_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-retiredgrantVerification/removeVerification: gated only bycontext.user.isAdmin, which can never be true on this web-only route (context.adminis only ever populated on/admin/graphql), so it was doubly broken (unreachable and operating on a mock user even if it were reachable).adminVerifyUser/adminRejectVerificationRequestalready 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 backinggetVerificationRequestByIdwas 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 theUserrow itself, same as the already-workinggetVerificationRequest(userId)uses) - fixed by looking up the user directly, sincerequestIdanduserIdare the same id in this scheme. -
reportConversationnever persists the report. Builds a report object with// TODO: Create report in content_reports table / Save to database and notify moderatorsand 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 realcontent_reportpipeline post/comment/message/user reports already use (managers/admin-managers/content-report.manager.js#createReport→ContentReportAccessService) — 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 sincecontent_report.reported_user_id/content_typeare real, non-null, enum-backed columns that didn't previously account for conversations: theContentTypeenum andReportsByTypeadmin-stats type both gained aconversationvalue/field (mirrored inschema.web.graphqls/schema.admin.graphqlsandcontent-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. Sincereported_user_idis 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 sharedReportReasonenum - rather than growing that enum (and everyReportsByReason/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. -
refundMessagePurchasehas no ownership/admin check. Any authenticated user can pass an arbitrarypurchaseIdand 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 checkis 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.adminis 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:refundMessagePurchaseis now scoped to buyer self-service with a 24h refund window (message_purchase.not_owner/refund_window_expired), and a newadminRefundMessagePurchase/adminGetMessagePurchasespair on the admin schema (gated by a realMODERATE_CONTENTpermission check) gives moderators an unbounded path plus a listing to act on. Also fixed a real, separate bug found while touching this file: everymessage_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 anyerrors.message_purchasetranslations existing at all - added real EN/ES text for the full set. Shipped end-to-end: the admin panel's existing/moderation/refundspage (built for post-purchase refunds) now has a Post/Message purchases tab, backed by a newMessagePurchase.sellerfield added for buyer/seller display parity withPostPurchase. -
userStats/getUserStatsnever returns the viewer-relative fields the schema declares.UserStats.isFollowing/isFollower/isCloseFriend/isBlocked/isBlockedBy/hasPendingRequestare alwaysnull: the resolver calls the manager with(userId, viewerId, context)but the delegating wrapper only accepts(userId, context), soviewerIdoverwritescontextand 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, andgetUserStatsreuses the already-correctgetRelationshipStatus(viewerId, userId, context)(the same method backingRelationshipStatus) to populate all six fields for real, instead of leaving them unset. Semantics:isFollowing/isFollowerare relative to the viewer ("does the viewer follow this user" / "does this user follow the viewer back"), and the fields staynull(their real nullable-in-the-schema meaning of "not applicable") for an anonymous request or when viewing your own stats, rather than fabricatingfalse. The one other internal caller of this manager method (getAccountAnalytics, self-analytics with no separate viewer) was updated to passnullexplicitly so it keeps getting the correct not-applicable result instead of accidentally treating its owncontextobject as a truthy viewer id. -
canTagUser/canMentionUserskip 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// TODOand 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#canPerformActionhas real, working'tag_user'/'mention_user'cases (block check + thewho_can_tag/who_can_mentionprivacy setting) already used elsewhere (e.g. group-invite privacy inconversation.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/deleteAccountImmediatelyanonymize theUserrow 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 theUserrow. Posts, comments and messages (allparanoid: truemodels) are soft-deleted -deleted_atis set, the row itself is kept, mirroring the same "anonymize/hide rather than hard-erase" approach already used for theUserrow (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-unusedcomment.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 onesequelize.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 theUser-row anonymization update itself, sinceuser.access-service.js#updateis 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/removeLegacyContactnever 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 offUser.accountStatusat query time - the feed'scanView()gate,getTrendingPosts()/getByType()'s (both already had anisPrivate: falsecreator join) andgetByHashtag()'s (previously had no creator-visibility filter at all) creator joins all now also excludeaccountStatus: 'deactivated', andprofile.manager.js#getProfilenow 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 visibilityneeded no code at all - flippingaccountStatusback off'deactivated'already makes everything visible again, so that TODO became an explanatory comment instead of new logic.addLegacyContact/removeLegacyContactnow send a real notification to the legacy contact (best-effort, matching the codebase's establishednotificationAccessService.createshape) and log viaanalyticsService.trackAdminActionthrough a new_auditActionhelper (same pattern asplatform-wallet.manager.js/restrictions-limits.manager.js). While wiring this up, found the entirememorialized.*error/success translation namespace was missing from bothen.json/es.json- every message inmemorialized-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:isInQuietHoursnow takes the user'sUser.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 newgetCurrentTimeInTimezonehelper (Intl.DateTimeFormatwith atimeZoneoption - 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 passesuser.timezonethrough. - Search stats / search-result reporting are silent no-ops.
getSearchStatsalways returns{ totalSearches: 0, recentSearches: [] };reportSearchResultalways returns{ success: true }without persisting anything. Both are exposed in GraphQL but have zero frontend callers today. Impact: Low. Fixed:getSearchStatsturned 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 realuser_search_historytable) already records every search viarecordSearch, it just never fedgetSearchStats. Now returns a realtotalSearchescount (newUserSearchHistoryAccessService#countSince, windowed by the query'stimeframeargument, default 30 days) and realrecentSearches. 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).reportSearchResultnow persists through the same realcontent_reportpipelinereportConversationuses (a search result IS a user, socontentType: 'user'/contentId: resultId- dedup check and urgent-report admin alert included, real reason validation replacing the previous no-op that accepted anything). -
POST /uploadsilently 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 themessages/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.jsitself 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 real502with the actual error message, never a substituted photo with a false200. Verified the frontend needed zero changes:ChatView.tsx's two upload call sites already didif (!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.jshas several fabricated financial methods, anddeleteCustomernever deletes the customer from Stripe.getCustomerStatsalways returns zeros (// TODO: Get actual stats from payment provider and database),getBalancejust echoes the local DB field (// TODO: Get actual balance from payment provider),syncWithProvideris a no-op, anddeleteCustomeronly 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 bypayment-method.manager.js/coin-purchase.manager.jsviagetOrCreateCustomer). Impact: Medium — will silently misreport the moment any of these methods get wired to a resolver. Fixed:deleteCustomernow really deletes the Stripe customer first (services/stripe#customersStripe.deleteCustomer) before removing the local row, treats Stripe'sresource_missingerror 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.syncWithProvidernow 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.getCustomerStatsnow computes real numbers from this app's own transaction ledger viaPaymentTransactionAccessService(extended with a newgetStats(userId)aggregate query - total/completed/failed transaction counts, completed amount, first/last completed dates) and real payment-method counts viaPaymentMethodAccessService#getByUser;active_subscriptionsis honestly left at0since no reachable "active subscriptions per payment customer" data source exists yet (creator subscriptions are tracked per subscriber/creator pair, not perPaymentCustomer).getBalancewas 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 missingerrors.payment_customer.*(not_found/create_failed/provider_delete_failed) anderrors.validation.{customer_id_required,provider_customer_id_required}translation keys to bothen.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 updatedtests/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, andsubscription-bundle.manager.jsallthrow new Error('...')with plain hardcoded Spanish text (no matching keys exist intranslations/en.json);message-translation.manager.jsdoes the reverse with hardcoded English. Every other manager in the codebase goes throught.error('key', context)againsttranslations/{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 throught.error('key', context)like every other manager. Added newerrors.note.*(empty/too_long),errors.story_highlight.*(title_required/title_too_long/not_found/not_owner), anderrors.subscription_bundle.*(name_required/not_found/not_owner/has_active_buyers/not_available/cannot_purchase_own) namespaces to bothen.json/es.json;message-translation.manager.js#setSetting's "not a participant" check now reuses the already-existingerrors.conversation.not_participantkey (same English text, now properly translatable) instead of a hardcoded literal. Threadingcontextthrough required adding acontextparameter 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 passcontextthrough -deleteHighlight/deleteBundlewere already being called with acontextargument 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 samet.error(key, context).replace('{max}', N)interpolation convention already established byvalidators/password-policy.validator.js. Updated existing unit test assertions inuntested-managers.unit.test.js,payments-subscriptions.unit.test.js,message-translation-manager.unit.test.js, andresolvers/message-translation.resolver.test.jsto match - this codebase's global Jesttranslation.servicemock (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 advertisesdetectFraudulentCard/detectSuspiciousActivity/getMostUsedMethod/getFailureRate/getUsageHistory/bulkDeleteMethods/exportPaymentMethods, none of which actually exist on the class. Impact: Low individually. Fixed:deleteFeedbacknow has a real admin bypass, reusing the exact permission primitivegraphql/resolvers/admin/user-feedback-admin.resolver.jsalready uses for every other feedback-triage mutation -adminUserManager.hasPermission(admin.adminId, 'MANAGE_FEEDBACK')againstcontext.admin- instead of the legacyuser.accountType === 'admin'style check used (and itself flagged "TODO: Implement proper admin role checking") elsewhere in this codebase.deleteFeedbackhad zero GraphQL exposure before this pass (likepayment-customer.manager.jsbefore the fix above), so the bypass was unreachable and untestable end-to-end; wired it up with a new owner-onlydeleteMyFeedbackmutation (mirroring the existingupvoteFeedback/downvoteFeedbackshape) and a newadminDeleteFeedbackmutation (mirroring the existingadminUpdateFeedbackStatus/adminRespondToFeedbackshape, gated by the samerequireFeedbackPermissionhelper), both mirrored inschema.web.graphqls/schema.admin.graphqlsplus new.graphqloperation files underpackages/graphql/operations/{Web,Admin}and rebuiltapollo-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 entireuser_feedback.*error-translation namespace (plusvalidation.feedback_id_required/admin_id_required/query_required) was missing from bothen.json/es.json- every error path inuser-feedback.manager.jshad 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/LeaveCalland theCallIncomingsubscription all have ready operation docs, andChatView.tsxeven importsCallIncomingDocument— 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 rawRTCPeerConnectionwith STUN servers only, logged the realtoken/wsUrl/roomNameLiveKit needs, and never used them, so no real audio was ever exchanged even on an outgoing call. Rewrote it to uselivekit-client(already a dependency, already proven working for live streams inLiveRoomPage.tsx) for both directions: connect to the room, publish a local audio track, subscribe to the remote track, real mute vialocalParticipant.setMicrophoneEnabled, and real connection-quality reporting viaRoomEvent.ConnectionQualityChanged(previously a hardcoded "Excellent"/Spanish-only label with not()at all). Added a new app-wideIncomingCallContext.tsx(mounted once inProviders.tsx, same pattern as the existingNotificationsRealtimeContext.tsx) that subscribes tocallIncomingfor the current user regardless of which page they're on, shows a ringing banner with the caller's name/avatar, and wiresAnswerCall/DeclineCall- answering opens the same (now-real)VoiceCallModal.ChatView.tsx's unusedCallIncomingDocumentimport was removed now that the subscription lives in the global provider instead. Newcalls.*i18n namespace added to bothen.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) andIncomingCallContext.test.tsx(ringing banner, name fallback, answer/decline branching), both withlivekit-clientmocked.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/mySpeakerRequestsare 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/getPendingRequestsForCallall gated onparticipant.status === 'JOINED'to mean "is a speaker", butcall.manager.js#joinAsVieweralso setsstatus: 'JOINED'on a plain viewer row (the real speaker/viewer distinction isrole:CALLER/RECEIVERvsPARTICIPANT, pergetCallParticipants's own definition in the same file) - so every real viewer's ownrequestToSpeakcall 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)approveSpeakerRequestcalledcallManager.promoteToSpeaker(request.callId, request.userId, context)- an argument-shift bug (the real signature is(callId, userId, promotedBy, context)) - so the promotion's own internalcall.callerId !== promotedBycheck 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/demoteToVieweronly ever allowed the original call creator, contradictingapproveSpeakerRequest's own stated rule ("Only the call host or speakers can approve requests") - fixed to allow an existing speaker (roleCALLER/RECEIVER) too, consistently with the rest of the authorization model. All three fixed incall.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:requestToSpeakis only ever reachable for someone who joined a call viajoinAsViewer(the only path that creates arole: 'PARTICIPANT'row) - andjoinAsVieweritself 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 originalstartCallparticipants of -ChatView.tsx's only call entry point is a 1:1receiverIdcall). 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.
purchaseMessageis wired inChatView.tsx, but the message composer hardcodes every outgoing message toisLocked: false, unlockPrice: null(useChatMessages.ts) — so no message can ever actually become purchasable.LockMessage,GetMyMessagePurchases,HasMessageAccess,GetMyCreatorEarningsare 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). TheisLocked/unlockPricefields it names are dead, never-backedMessageGraphQL fields (no matching column exists - the real model columns areisPaid/coinPrice, exposed asMessage.isPaid/Message.price) - theuseChatMessages.tscode cited is just an Apollo cache-shape filler for fields amessageAddedsubscription 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) sendsisPaid/pricedirectly onsendMessage'sMessageCreateInput, validated and persisted server-side (message.resolver.js), and gated on read viaMessage.mediaUrls's field resolver callingmessagePurchaseManager.verifyAccess- unrelated to the separateLockMessagemutation 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 unusedlockMessagepath 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 calllockMessageon 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 checkingmessage.senderId, reusing the existingerrors.message.not_senderkey (no new translation needed).myMessagePurchases/hasMessageAccess/myCreatorEarningsremain 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 inapps/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 inPostModal.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 inpost-comment.manager.js#deleteComment. Confirming taps through a sharedModalconfirm dialog (reused from@/components/ui/Modal, the same primitivePostOptionsMenu.tsxalready uses for post deletion) before calling the newDeleteCommentInlinemutation and refetching the comment list. New.graphqloperation file added (Web/Posts/DeleteCommentInline.graphql) andapollo-webrebuilt. Newpost.delete_comment*i18n keys added to bothen.json/es.json. - Conversation management is largely unbuilt in the chat UI:
leaveConversation,archiveConversation/unarchiveConversation,pinConversation/unpinConversation,unblockConversation,clearConversationHistory,updateParticipantRole,transferAdmin, plusscheduleMessage/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 intoConversationDetailsPanel.tsx(the shared chat details/settings sidebar), reusing data already fetched by the existingGetConversationquery (isPinned/isArchived/isBlockedwere already selected onConversationParticipantfor 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 onceme.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 ownleaveConversationguard); per-member "Make admin"/"Remove admin" buttons next to the existing remove-member control (visible to the samecanManageMembersgroup 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-existingGetScheduledMessages/ScheduleMessage/CancelScheduledMessage/EditScheduledMessageoperations, deliberately kept independent of the main composer's send flow rather than threading a new mode throughMessageInputArea.tsx's already-large prop surface. Newmessages.*/members.*i18n keys added to bothen.json/es.json. - No live username/email-availability check at signup, despite
isUsernameAvailable/isEmailAvailablebeing fully implemented —OnboardingPage.tsxhas no real-time validation call, so a taken username is presumably only discovered after submit. Same for the broadervalidateUserData/validateProfileContent/validateFieldRealtimereal-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 isLogin.tsx(OnboardingPage.tsxis a separate post-signup wizard with no username/email fields), and it already had a fully-built debounced live-validation UI wired tovalidateUsername/validateEmail(viaValidateUsernameInline/ValidateEmailInline) — suggestion chips and all. It just silently threw on every call: the GraphQL schema declaresValidationResult { valid: Boolean!, message, suggestions }, butmanagers/user-managers/index.js'svalidateUsername/validateEmail/validateFieldRealtimereturned the other shape ({available, message}fromisUsernameAvailable/isEmailAvailable) directly — the non-nullvalidfield was never present, so GraphQL errored on every request and the already-built UI never worked. Fixed by adding propervalidateUsername/validateEmailmethods tovalidation.manager.jsthat map{available} → {valid}, with the facade inindex.jsreduced to thin delegation (matching this codebase's manager/facade convention). Also wired the broadervalidateProfileContentreal-time query — confirmed genuinely unused, as claimed — intoEditProfilePage.tsx's bio field, and added the same debouncedvalidateUsernamelive-check (with suggestion chips, mirroringLogin.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 invalidation-manager.unit.test.jscovering the{available}→{valid}mapping for both fields. - No coin-wallet / tips / purchase-history dashboard.
myTransactionStats,myPurchaseHistory,coinPurchaseStats,mySentTips,contentTips,contentTipTotal,recentTipsare 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) andTipsPage.tsx(a real "received tips" view with stats + top supporters, at/settings/tips) both already existed —myPurchaseHistoryitself is a byte-for-byte duplicate of the already-usedmyCoinPurchases(same manager call, same args), so it needed no new UI. The real gaps:mySentTipshad no consumer anywhere, so added a Received/Sent tab switcher toTipsPage.tsx— the Sent tab lists tips this user sent to creators, with its own totals pulled frommyTipStats's (previously unselected)totalSent/totalSentAmountfields.myTransactionStatsandcoinPurchaseStatshad no consumer anywhere, so added a wallet-stats header toCoinTransactionsPage.tsx(current balance, total coins purchased, total spent, total transactions).contentTips/contentTipTotalandrecentTipswere 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;recentTipstakes 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; extendedMyTipStatsInline.graphqlwith 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-wiredrevokeSession/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) andSessionsSettingsPage.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 persistedsecurity_eventaudit trail - total logins, failed attempts, suspicious activity, blocked attempts, devices seen) had no consumer, so added an "Account activity" stat grid toSecurityAlertsPage.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.sessionDetailsandterminateSession/terminateOtherSessionswere deliberately left unbuilt:sessionDetails(sessionId)returns the exact sameUserSessionshapeactiveSessionsalready does, andSessionsSettingsPage.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/terminateOtherSessionsare, at the resolver level, literal aliases that call the exact same manager methods as the already-wiredrevokeSession/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/deleteLiveStream—LiveRoomPagehas substantial live functionality already, these specific ones just aren't called. Impact: Medium. Fixed. All six wired intoLiveRoomPage.tsx. The owner-only viewer-count badge (top-right) is now a button that opens a new "Viewers & stats" panel: aliveStreamStatssummary grid (peak viewers, total views, reactions, comments, shares, avg. watch time) plus aliveStreamViewerslist, each row with a ban/unban control. Per-comment moderation gained abanLiveViewerbutton next to the existing Reward action (hover-reveal, owner-only, hidden for the owner's own comments) -unbanLiveVieweris 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.deleteLiveStreamgot 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 forLiveRoomPage.tsx(1000+ lines, heavylivekit-client/MediaRecorder/subscription mocking already needed just to render it, same class of setupVoiceCallModal.test.tsxrequired) - 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/stopLiveLocationare 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 aconversationIdargument 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 requiresconversationIdto create the message (conversation_idis a non-null column). Every "Share location" click was throwing server-side, not succeeding as claimed. Fixed by addingconversationId: ID!to the mutation signature (schema + resolver + theShareLocationoperation doc) and threading it from the already-known conversation id inChatView.tsx. While fixing this, found and fixed two related authorization gaps in the same code path:shareLocationnever 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), andupdateLiveLocationnever 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. WithshareLocationactually reachable, built the "live" half: a new "Share live location" composer action (locationType: 'live') that starts anavigator.geolocation.watchPositionloop callingupdateLiveLocationon each position change, and a "Stop sharing" button on the sender's own active live-location message bubble callingstopLiveLocation. New tests inmessage-manager.unit.test.js(shareLocation's participant check, static/live expiry, updateLiveLocation's ownership check + expiry) andresolvers/message.resolver.test.js(arg passthrough for both mutations);MessageBubble.test.tsxextended 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 — confirmedNotificationsPage.tsxhas no delete/dismiss/"clear all" affordance anywhere despite both mutations being fully implemented);mutualFollowers/mutualFollowingqueries;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.tsxgot a "Clear all" header button (with a confirm dialog) and a hover-reveal per-row delete button.mutualFollowers:PublicProfilePage.tsxgained 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-existingBulkFollowerActionsPage.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 inMessageBubble.tsxnow 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-reportspage (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 onPublicProfilePage.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.tsxcampaign names are now inline-editable (pencil icon → rename) - the only fieldUpdatePostPromotionInputactually exposes besides the unstructuredtargetAudienceJSON 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):pollResultsduplicatesMessage.poll(already selected on every message and already the source of the vote percentages/counts rendered inline inMessageBubble.tsx);productReviewSummaryduplicatesProduct.averageRating/Product.reviewCount(already used inPurchaseProductModal.tsx);promotionByIdduplicatesmyPromotions+promotionStats(MyPromotionsPage.tsx's existing list + expand-for-stats flow already returns the same data). Three were deliberately left unbuilt:mutualFollowinghas no comparably standard UI slot the waymutualFollowers' "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.tsxextended with reaction-tooltip coverage;BulkFollowerActionsPage.tsx/ShopManagePage.tsx/MyPromotionsPage.tsx/PublicProfilePage.tsxtest 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.tsxis fake demo code, not a real purchase flow. Hardcoded package list, reads abalancefield that doesn't exist on the real user type, and its "purchase" handler isawait new Promise(resolve => setTimeout(resolve, 1500))followed byalert('Purchase successful! (Demo)')— zero GraphQL calls. Only referenced from a Storybook story today, not any real page (the real flow isGetCoinsModal.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 barrelcoins/index.ts(which existed solely to re-export it) and its own Storybook story - deleted bothCoinsModal.tsxandcoins/index.ts, and removed theWalletstory fromCoinsModals.stories.tsx(theGetCoins/Insufficientstories 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 nullablenicknamecolumn onconversation_participant(migration + model), anickname: Stringfield on theConversationParticipantGraphQL type, and a newupdateMyNickname(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 permanentlydisabled, and the row itself shows the active nickname inline once set. New operation docs: addedUpdateMyNicknametoConversationParticipants.graphql, addednicknametoGetConversationParticipants's andGetConversation's participant selections. New tests:conversation-manager.unit.test.js(participant check, length validation, trim, clear-via-null, clear-via-blank) andresolvers/conversation.resolver.test.js(auth guard + delegation) -ConversationDetailsPanel.tsxitself 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
CardElementrenders white-on-white and is unreadable in light mode, in both checkout flows that use it.StripeCheckout.tsx(coin purchase) andPaymentMethodsPage.tsx'sAddCardForm(add a saved card) both hardcodecolor: '#ffffff'/iconColor: '#ffffff'for the card input, while the surrounding container isbg-whitein light mode.ThemeContextsupports a real resolved light theme (including via OSprefers-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 everyCardElementusage turned upPaymentsPage.tsx'sAddCardForm(the/paymentspage's own "add a card" flow, distinct from the settings one) with the exact same hardcoded#ffffff. All three now readuseTheme()and pick#111827(readable dark gray) in light mode vs#ffffffin dark mode, for bothcolorandiconColor. Also removed a strayconsole.log('CardElement onChange: ...')left inStripeCheckout.tsxwhile touching that block (the broader console.log cleanup is tracked separately below). New tests:StripeCheckout.test.tsxandPaymentMethodsPage.test.tsxboth extended with a light/dark pair asserting theCardElementstub actually receives the theme-correct color (the stub now exposes theoptions.style.base.color/iconColorit was given viadata-*attributes so this is a real assertion, not just "it doesn't crash").PaymentsPage.tsxhas no test file at all (pre-existing gap) - deferred to the final testing pass. -
StripeCheckout.tsxships 14 unguarded debugconsole.logs in the production checkout form, including a full dump of GraphQL purchase variables ("Sending variables to GraphQL", a "=== Purchase Debug Info ===" block withpackageId/paymentMethodId/customCoins) and a validation log that re-fires on every render inside the submit button'sdisabledexpression. 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 theconsole.logbeforereturning the actual boolean, so the button'sdisabledprop was simplified back to a plain expression once the log was gone, rather than leaving a needless IIFE wrapping a single boolean. -
ProfilePage.tsxandPublicProfilePage.tsxhardcode UI strings outsidet()— in opposite languages from each other.ProfilePage.tsx's settings menu, theme picker, andFollowListModallabels ('Privacy','Profile visitors','Remove','Unfollow','Load more','No users to show', etc.) are plain hardcoded English;PublicProfilePage.tsxhardcodes the sameFollowListModalprops in hardcoded Spanish ("Eliminar","Cargar más","No hay usuarios para mostrar"). Neither ever passes throught(), so none of these strings exist inen.json/es.jsonat 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.tsxitself was the real source of the "opposite language" split: its ownremoveLabel/unfollowLabel/emptyLabelprop defaults were hardcoded Spanish ('Eliminar','Dejar de seguir','No hay usuarios para mostrar') even though the component already importsuseTranslationand 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 realt()-backed fallbacks (reusing existing keys:common.remove,profile.unfollow,profile.followers/profile.followingfor the title) computed inside the component, not as JS default-parameter literals. Also found and removed a fully dead prop while in there:loadMoreLabelwas threaded through both callers and the component's props but never actually rendered anywhere - the real "load more" mechanism is anIntersectionObserversentineldiv, 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, andFollowListModalprops now all go throught()(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'sFollowListModalprops do the same instead of hardcoding Spanish. New keys added to bothen.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.tsxstill have no test files of their own (pre-existing gap) - deferred to the final testing pass. -
_freshcheck9_Security.tsxis a stale, fully orphaned duplicate ofSecuritySettingsPage.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 --noEmitclean afterward. -
AuthContext'slogoutis typed as synchronous (() => void) but is actuallyasync, awaiting a mutation andclearStore(). Every current call site happens toawait logout()so it works today, but the interface doesn't promise aPromise, so a future caller that forgetsawaitgets 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) => voidandlogoutAll() => voidhave the exact same bug: both implementations areasync(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 --noEmitwas 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 existingAuthContext.test.tsxalready exerciseslogoutAccount/logoutAllwithawaitin 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_messagetables + Sequelize models,support-ticket.manager.js(create/reply/close for users; list/reply/update-status for admins, gated by a newMANAGE_SUPPORT_TICKETSadmin permission), GraphQL types/resolvers mirrored into bothschema.web.graphqlsandschema.admin.graphqls, plus dedicated operation files underpackages/graphql/operations/Web/Support/**(so a future iOS/Android client gets them for free) andpackages/graphql/operations/Admin/Support/**. Web frontend: new/settings/supportpage (create ticket, view thread, reply, close), reachable from the settings menu. Admin frontend: new/supporttriage page (status/category filters, thread view, reply, status update), gated behindMANAGE_SUPPORT_TICKETSin both the nav and the route itself, mirroring the existing feedback-panel pattern. i18n added to both locales infrontend-nextjsandfrontend-admin. Tests written (not yet run — deferred to the final test pass) for the manager, resolvers, and both frontend surfaces.
- Fixed: Built a full support-ticket system, distinct from
- 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/scheduledAtfields (from post scheduling), butPostCreateInput.isPublishedwas a dead no-op -post.validator.jschecked/wrote the wrong-casedinput.is_publishedwhile the schema and every caller use camelCaseisPublished, so a client could never actually create an unpublished post. Fixed the casing bug (in bothvalidateCreateInputandvalidateUpdateInput), and relaxed the "text or media required" rule specifically for a draft (isPublished:false, noscheduledAt) so an empty draft can be saved. AddedmyDraftsquery +publishDraftmutation (post.manager.js#getDrafts/#publishDraft, mirroring the existing scheduled-posts pattern) - editing a draft's content and deleting it reuse the existingupdatePost/deletePostmutations, 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 checkedisPublishedat all, so any unpublished post (a draft, or a still-scheduled post) with the defaultvisibility:'public'was fully readable by ID by any authenticated user, not just its owner - now unpublished posts are owner-only regardless ofvisibility. Frontend:CreatePostModalgained 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/draftspage (list, publish, edit, delete) reusing the scheduled-posts page's UI pattern.
- Fixed: The model already had
- Polls on posts/stories.
Poll/PollOptiontypes exist, but only insidemessage.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/PollInputGraphQL types from chat polls (added a nullableexpiresAtfield toPoll, 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 thePostrow itself (post.poll) - works for both posts and stories with zero extra schema work, since a story is just aPostrow withtype:'story'. Votes are tracked in a newpost_poll_votetable, mirroring the chat poll'spoll_votetable (dedicated access-servicepost-poll.access-service.js, one vote per user, re-voting overwrites). NewmyDrafts-stylevotePostPollmutation and aPost.pollfield resolver mirroringMessage.poll.PostCreateInput/StoryCreateInputboth gained apollfield; 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 ingetPost(): it never checkedisPublishedat all, so any unpublished post (a draft, or a still-scheduled post) with the defaultvisibility:'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 reusablePostPoll.tsxcomponent (bar-fill percentage, checkmark, locked-once-voted, mirroring the existing chat poll's UI inMessageBubble.tsx) wired intoPostCard.tsx(feed),PostModal.tsx(permalink/detail, mobile + desktop), andStoryViewer.tsx(story sticker).CreatePostModal.tsxgained 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.tsxgained the same poll UI and no longer requires a photo/video when a poll is set.
- Fixed: Reused the existing
- QR code profile sharing. No
qrCodefield 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.tsxgenerates a real, styled QR code entirely client-side (theqr-code-stylingnpm 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.tsxwraps it with Share/Copy link/Download actions plus a link to the profile's link-in-bio page, and is already wired into bothProfilePage.tsx(own profile) andPublicProfilePage.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-sideUser.qrCodeDataUrlGraphQL field (reusing theqrcodenpm 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 forShareProfileModal.tsx(ShareProfileModal.test.tsx) covering the copy-link action, the link-in-bio navigation, and the backdrop-close behavior.
- Fixed: This claim was stale — the feature already exists and works end-to-end, it just wasn't reflected in this roadmap.
- Terms of Service versioning / forced re-acceptance.
User.is_terms_conditions_acceptedis 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 -
isTermsConditionsAcceptedwas never set or checked anywhere in the codebase at all (not even once at signup); it just sat at itsfalsedefault 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 isnull), needs to re-accept.User.termsAcceptedVersion/termsAcceptedAtare the fast "current status" cache; a new append-onlyuser_terms_acceptancetable is the legal audit trail (who accepted what version, when, from which IP) - required for real defensibility on a platform selling paid adult content. Newterms-acceptance.manager.js(getTermsStatus/acceptTerms/acceptCurrentVersionAtSignup), atermsStatusquery +acceptTermsmutation, andUser.termsAcceptedVersion/termsAcceptedAtfields (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 byProtectedRoute.tsx(same coverage as the existing onboarding gate) - whileneedsAcceptanceis true it shows a full-screen modal that can't be dismissed without accepting. While inauthentication.manager.js, also found and removed a real, unrelated bug directly adjacent to this work: a referral-program block copy-pasted intologin()(instead of justregister()) that referenced an undeclaredreferralCodevariable, throwing aReferenceErroron every single login - silently swallowed by its own try/catch, so it never surfaced, but it never did anything either.
- Fixed: The gap was actually worse than described -
- 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: newdmca_takedown_request(complainant name/email, work description, the two sworn statements 512(c)(3) requires, a typed signature, status lifecycle) anddmca_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.jsresolves 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-workingcontent-moderation.manager.js#removeContent/#restoreContentprimitives 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. NewMANAGE_DMCA_REQUESTSadmin 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/dmcapage (notices filed by you, notices filed against your content with an inline counter-notice form), and an admin/dmcareview queue (approve/reject a takedown, accept/reject a counter-notice, full legal detail view). New.graphqloperation files underpackages/graphql/operations/Web/Dmca/**andAdmin/Dmca/**so a future iOS/Android client gets the same contract.
- Fixed: Built a real 17 U.S.C. 512 notice-and-counter-notice workflow, deliberately separate from
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#sendBroadcastMessagedelegates toadminNotificationManager.sendBroadcastMessage(...), butAdminNotificationManageronly actually definesbroadcastToAdmins(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 realsendBroadcastMessage(message, adminId, options, context)onAdminNotificationManager, so the existing facade inadmin-managers/index.jsnow actually works instead of throwing. Delivery reuses the existing per-userNotificationmodel rather than inventing a parallel mechanism -notificationType:'system'already existed unused in theNotificationTypeenum, andNotificationsPage.tsx's default rendering branch already showsmessagetext gracefully for an actor-less notification, so no consumer-frontend changes were needed at all. A newadmin_broadcasttable is the audit trail (who sent what, to which segment, how many were reached) - a freshAdminBroadcastrow is created first, then the message is fanned out as oneNotificationrow per targeted user via a newbulkCreate. Targeting supportsallactive users,verifiedusers only, or acustomlist of user ids (user.access-service.js#getActiveUserIds, capped at 100k as a safety valve). NewMANAGE_BROADCASTSadmin permission gates both the send action and the history query. Admin frontend: new/broadcastspage (compose form with a two-step "are you sure" confirm before an irreversible mass-send, plus a paginated history table). New.graphqloperation files underpackages/graphql/operations/Admin/Broadcasts/**. - No dedicated, filterable admin audit-log page. The backend already supports this well (
adminGetActivityLog(adminId, action, startDate, endDate, limit, offset), plusexportAuditTrail/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.adminGetActivityLoghad no dedicated permission check at all (any authenticated admin could call it); added a newVIEW_AUDIT_LOGpermission to the catalog and gated the resolver with it, alongside the existing self-vs-super_admin scoping. New/audit-logadmin page: filters by admin (dropdown, super_admin only, populated fromadminUsers), free-text action (debounced), and start/end date range, all wired to the resolver's existing arguments — plus real pagination via the resolver'stotal, a details modal for each entry's rawdetailsJSON, and a client-side CSV export of the current page. New.graphqloperationAdminGetActivityLogFullInline(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 ownadmin.adminIdfrom JWT context — there's notargetAdminIdargument anywhere. If an admin account is suspected compromised, the only way to kill its sessions today is fulldeleteAdmin. Impact: High. Fixed. Added an optionaltargetAdminIdargument to all three, defaulting to the caller (unchanged behavior) with asuper_admin-only gate to act on anyone else — the same self-vs-super_admin pattern already used byadminGetActivityLog/adminIPWhitelist. Along the way, closed a real pre-existing bug:adminRevokeSessionnever verified a session actually belonged to its target admin, so any authenticated admin who obtained another admin'ssessionIdcould 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.jsalready definesgetFeatureFlags,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:payoutsEnabledGloballyand per-coin-packageisActive. 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 calledgetSetting/setSetting, which query a generickey/valueshape that doesn't exist on the realSystemSettingmodel (same class of dead code aspayoutsEnabledGloballywas before it got its own typed column). RebuiltgetFeatureFlags/setFeatureFlag/isFeatureEnabled/enableFeature/disableFeature/isMaintenanceMode/enableMaintenanceMode/disableMaintenanceModeagainst 5 new typed columns onSystemSetting(maintenanceMode,maintenanceMessage,coinPurchasesEnabled,liveStreamingEnabled,newRegistrationsEnabled), the same patternpayoutsEnabledGloballyalready used. NewMANAGE_SYSTEM_SETTINGSadmin permission gates a newadminGetFeatureFlags/adminSetFeatureFlag/adminSetMaintenanceModeGraphQL 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, andauthentication.manager.js#registernow 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/graphqlstays 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-flagsadmin 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 ishasPermission(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 (newadmin_role_presettable/manager/GraphQL surface, super_admin only, same tier asadminActivate/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-adminPermissionsModal. Every preset write validates its keys against the real catalog (admin-user.manager.js#getPermissionKeys(), extracted fromgetAdminPermissionsso both share one source of truth) and rejects anything unknown. New/admins/role-presetsmanagement 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 thesystemcategory were never givenen.json/es.jsonlabels, so toggling any of them inPermissionsModalrendered a raw untranslated key likepermissionLabels.VIEW_AUDIT_LOGinstead 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 mirroringadminExportAnalytics'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 sharedlib/csv.tshelper, 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 clampedlimitat all — a client could already request an unbounded response before this change — so aMAX_EXPORT_LIMIT = 5000server-side clamp (mirroring the existing pattern inlink-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), notEXPORT_DATA-EXPORT_DATAis 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 missinguser.emailfield to the cashouts query, since email is onCoinCashout.userbut 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
CommandPaletteas 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. NewGlobalSearchManagerfans 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_CONTENTtwice, 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 GINto_tsvector('english', text)index thePosttable already had (newpostAccessService.searchByText(),plainto_tsqueryvia a safereplacementsparameter, 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 (adminGetReportsin 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) takescontentType, 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. NewadminSearchContent(query, limit, offset)query, gated by the sameMODERATE_CONTENTpermission asadminGetFlaggedContent, backed by a paginated sibling of the full-text search this same roadmap section's global-search item (#64) added (postAccessService.searchByTextPaginated(),Post.findAndCountAllover the existingto_tsvector('english', text)GIN index,distinct: truefor an accurate count alongside theuserinclude). Scoped to posts only, deliberately:Post.textis 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 - acontentTypefilter 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/flaggedpage (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 permissionadminExportAnalyticsalready 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 vocabularyadminExportAnalyticsuses) and a cadence (DAILY/WEEKLY/MONTHLY), optionally with extra recipient emails (capped at 5, validated) - left blank, it defaults to the admin's ownAdminUser.email. Newadmin_report_subscriptiontable + 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). Newservices/report-digest.service.jscron job (following the exactnode-cron+start()pattern already used byanalytics-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 toreport-digest.manager.js, which reuses the exact sameadmin-dashboard.manager.jsmethodsadminExportAnalyticsalready 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. NewemailService.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 ownvalidation.invalid_report_typeerror key had never been added toen.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 ofapps/frontend-admin/srcfor "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. NewadminGetFamilySupervisions(status, limit, offset)query lists every supervision link platform-wide (not scoped to one caller, unlike the existingfindByParent/findByChild), and a newadminForceEndFamilySupervision(linkId, reason)mutation can end a link regardless of status or whether the caller is a party to it - unlike the user-facingendFamilySupervision, which requires exactly that. Both reuse the existingFamilySupervisiontype/table/access-service as-is rather than building a parallel model. New dedicatedMANAGE_FAMILY_SUPERVISIONpermission (not an existing broader key likeVIEW_USERS, since this is specifically a minors'-safety surface) gates both. Force-ending anactivelink notifies both the parent and the child with the admin's stated reason (apendingor already-declined/endedlink 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);endedByUserIdis leftnullsince that column is aUserFK and no user (parent or child) actually ended it. New/family-centeradmin page: a filterable (by status) list showing both parties' usernames/emails and a force-end action with a required-reason confirm modal, reusing the sameReasonModalcomponent 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 inadmin/payout-admin.resolver.js, backed byPayoutAccount.adminDisabled) has zero references anywhere inapps/frontend-admin/src. The only per-user payments page (app/payments/users/[userId]/page.tsx) shows 8 read-only revenueStatCards 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 noadminGetPayoutAccount(userId)query at all, so a frontend control would have had nothing to show before the first click. Added that query (sameMANAGE_PAYOUTSgate, backed by the existinguserPayoutAccountAccessService.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 sameReasonModalevery 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, splitpayments/users/[userId]/page.tsx's inline content into its ownUserRevenueContent.tsx(matching the convention every other admin page already follows) so the new card is unit-testable andpage.tsxstays a thin wrapper - a lesson learned the hard way earlier in this same effort (roadmap #63) when a named export directly inside apage.tsxbroke 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.tsxis explicitly scoped toadminGetPostPurchases/adminRefundPostPurchase— exclusive-post unlocks only. There is noadminGetPurchases/adminGetCoinPurchases/adminGetTransactionsquery anywhere on the admin schema, even thoughcoin-purchase.manager.jshas a fullrefundPurchase/approveRefund/rejectRefundsurface. Recent commits added a whole new PayPal purchase provider (services/paypal/*, a per-providerPaymentCustomermigration) 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/rejectRefundturned 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-schemarefundCoinPurchase(purchaseId)mutation this method was wired to had no authorization check of any kind - not evenif (!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 inapps/frontend-nextjs/apps/frontend-admin/packages/graphql/operationsever called it. Its replacement,adminRefundCoinPurchase(purchaseId, reason!), is admin-only behind a new dedicatedMANAGE_TRANSACTIONSpermission (existingMANAGE_PAYOUTS/MODERATE_CONTENTare both semantically the wrong fit - one is about paying creators, the other about content, neither about purchase refunds) and stampsreason+ the acting admin id onto the purchase'smetadataJSONB for an audit trail (no dedicated refund-reason column exists onCoinPurchase). NewadminGetCoinPurchases(status, platform, userId, limit, offset)lists every purchase platform-wide with its joinedPaymentTransaction(so provider mix - stripe/paypal - and the external charge id are visible per row, not just the internal web/ios/androidplatformcolumn) - the exact "provider mix" visibility gap the roadmap called out. Frontend: added a third "Coin purchases" tab to the existing tabbedRefundsContent.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 forcompletedpurchases (unlike post/message purchases, a coin purchase can bepending/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 backingadminGetUserDetails) has no session/device/IP fields at all, andsessions.type.jsonly exposes session data to the session's own owner. Impact: Medium. Fixed. NewadminGetUserSessions(userId)query (VIEW_USERS, matchingadminGetUserDetails's own gate) plusadminRevokeUserSession(sessionId, userId, reason)/adminRevokeAllUserSessions(userId, reason)mutations (SUSPEND_USERS- revoking is a corrective action, one tier up from a read). All three reusesessions-devices.manager.jsexactly as it already stood - every method there already took an explicituserIdargument rather than hardcoding the caller, so no new manager methods were needed, just admin-schema reachability and a permission check;revokeSession/terminateAllSessionsgained an optionalreasonparam (defaulting to the existing self-service string) so an admin-initiated revoke stamps a real reason onrevoke_reasoninstead of the generic'user_logout'. Along the way, fixed a real bug in the exact method this reuses:getActiveSessions()'s row transform keyed OS asplatform- a fieldUserSession.osnever resolves to since the schema doesn't declareplatformat all - and never setuserId/expiresAt(silently null since no existing caller happened to select them yet, thoughuserIdis 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 thesecurity.*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.jshas workinggetThreshold()/setThreshold(threshold)/getDetectionStats()methods, butcontent-moderation.resolver.jsnever 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 pathanalyzePostMedia()) never read it: it hardcoded its own three score bands (0.9/0.7/0.5) completely independent of the "configurable" threshold. WiringsetThreshold()up as-is would have been cosmetic.getDetectionStats()called agetAll()method that exists on neitherPostNsfwScoreAccessServicenorNsfwCommentScoreAccessService- a guaranteedTypeErroron every call, never exercised by any test - and its own reduce logic read ansfw_scorefield neither model declares (the real column isconfidence). Real fix: the threshold now lives on a newnsfw_detection_thresholdcolumn onSystemSetting(same singleton row and access-service the feature-flags/kill-switches work already uses), andautoModerate()'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 newgetStats()on each access service that computes real DB-level aggregates (COUNT/AVGviaSequelize.fn) against the realisNsfw/confidencecolumns, instead of pulling thousands of rows into memory through a method that never existed. NewadminGetNsfwThreshold/adminSetNsfwThreshold/adminGetNsfwDetectionStats(MODERATE_CONTENT- the same permission gating every other real content-moderation surface, notMANAGE_SYSTEM_SETTINGS, since this tunes moderation behavior, not a system-level flag). New/moderation/nsfw-detectionadmin 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:
adminGetPayoutProfileandadminGetContentModerationHistoryare unused — the pages that look like they'd need them fetch the same data via nested fields (CoinCashout.payoutProfile,ContentDetails.moderationHistory) instead;adminGetModerationStatsduplicatesadminGetContentModerationStats/adminGetModerationQueueStats, which the dashboard actually uses. Impact: Low. Fixed. Confirmed all three claims still held (zero references anywhere inapps/frontend-admin/srcorpackages/graphql/operations) before touching anything. Removed theadminGetPayoutProfileandadminGetContentModerationHistoryquery fields frompayout-admin.type.js/content-moderation.type.jsand their resolvers, and removed theadminGetModerationStatsfield/resolver too (its handler called the samecontentModerationManager.getContentStats()the still-usedadminGetContentModerationStatscalls, 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-unuseduserPayoutProfileAccessServiceimport;contentModerationManager.getModerationHistory()andgetContentStats()stay, since both are still called from elsewhere (content-report.manager.js, theadmin-managersfacade, and the survivingadminGetContentModerationStats/ContentDetails.moderationHistoryfield resolver). Mirrored all three removals intoschema.admin.graphqls;PayoutProfileandContentModerationStatsstay in the schema since both types are still returned by other still-used fields. No.graphqloperation files referenced any of the three dead queries, so nothing to remove there. Rebuiltapollo-admin/apollo-webclean.
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
devbranch, which is 513 commits and ~19 days behindmain(docusaurus.config.js'seditUrl). Concretely,apps/backend/graphql/resolvers/webauthn.resolver.jsandadmin-passkey.service.jsdon't exist ondevat all, anddev'scoin-purchase.manager.jshas nopurchaseCoinsWithSavedPaypal. Every click-through-to-source link in the feature docs is pointing at a materially outdated snapshot. Impact: Medium. Fixed.devhas since caught back up withmain(the specific staleness this item measured no longer reproduces - bothwebauthn.resolver.jsandadmin-passkey.service.jsnow exist onmaintoo), but re-pointingeditUrlatdevspecifically would just be papering over the underlying issue:main, notdev, 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 timedevdiverges. Changeddocusaurus.config.js'seditUrlfromedit/dev/apps/docs/docs/toedit/main/apps/docs/docs/. Single source of truth (no duplicateeditUrlelsewhere inapps/docs, confirmed via grep); thebuild/directory's baked-indevURLs are a stale local build artifact (gitignored, regenerated on nextdocusaurus build), not a source-file issue. -
technical/ios.mddirectly contradictsfeatures/authentication.mdand this same roadmap about iOS auth completeness.technical/ios.md's feature table claimsAuth | ✅ 100% | Phone OTP, Apple Sign In, Google Sign In, whilefeatures/authentication.mdand the iOS/Android parity section below both correctly state phone OTP login isn't wired up on iOS yet.technical/ios.mdappears to have been missed by the doc-reconciliation pass the rest ofapps/docswent through. Impact: Medium — actively misleading in a doc a reader would treat as a status source of truth. Fixed - and it turned out to befeatures/authentication.md(and this roadmap's own parity section) that had it backwards, nottechnical/ios.md. Read the actual iOS source before taking either doc's word for it: phone OTP is fully wired end to end onapps/ios(WelcomeView's Phone button →LoginViewphone mode →RequestPhoneOtpUseCase→VerifyCodeView→loginWithPhone, all the way throughAuthRepository/AuthServiceto the real GraphQL mutations, no stub or TODO anywhere in that path) - and so is device token registration for push (PushNotificationManager→DeviceTokenService.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 bothiOS only/wired - it just hadn't been cross-checked against the simpler top-levelfeatures/authentication.md, which is what actually needed correcting. Fixedfeatures/authentication.md's two false "apps/ioshasn't implemented/wired this yet" claims (phone OTP, device token registration) and removed the now-false line from the parity section below.technical/ios.mditself did have a real, separate problem though: its own "Known TODOs" section listed "password recovery flow" as a critical Auth gap, butForgotPasswordView.swiftis real and reachable fromLoginView/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-wiredAuthRepository.deleteAccount()exists, but nothing in the UI dispatches to it - the Settings-side deletion path goes through a different, stubbedProfileRepository.deleteAccount()that just throwsProfileError.featureNotSupported), and adjusted the Auth row from 100% to 95% to match. Noapps/iossource was modified (out of scope perCLAUDE.md) - this was a documentation-accuracy fix only. -
features/recent-updates.mdis 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 — Titlesection, 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:allcommand or itsTZ=UTCrequirement, even though CLAUDE.md andpayments-subscriptions.unit.test.jsboth call out that backend tests assume a UTC-container timezone.technical/testing.mdonly documents per-workspacenpm 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 totechnical/testing.md(right after the per-package overview table, before the package-by-package deep dives) documenting all five rootpackage.jsontest scripts (test,test:frontend,test:backend,test:all,test:log) verified against their actual definitions, plus a dedicated "TheTZ=UTCrequirement" subsection explaining why (fake-timer date-boundary tests assuming a UTC container, perpayments-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_NAMEare 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, andadmin/features/admin-accounts.mdexplicitly warning that a misconfiguredWEBAUTHN_ORIGINthrowsUnexpected registration response origin. Impact: Medium — a real, documented failure mode with no matching setup instructions anywhere. Fixed. Added a new "Passkeys (WebAuthn)" section togetting-started/environment-setup.md(right after JWT, matchingenv-catalog.js'sauthcategory 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 inapps/backend/.env.example(which this new section now points back to for a worked prod example) and cross-linked toadmin-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. Replacedstatic/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-primarypurple,#8b4ef0→#6d28d9, matchingsrc/css/custom.css) with a white "C" monogram. Regeneratedfavicon.icoanddocusaurus-social-card.jpgfrom that same mark viasharp(already present in the monorepo's hoistednode_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 unusedundraw_docusaurus_*.svgfiles anddocusaurus.pngoutright (confirmed zero references anywhere outside this roadmap entry -HomepageFeatures/index.jsalready renders text-only cards with no image prop, so these were pure dead weight, not wired to anything to begin with). Verified with a fullnpm run build -w docs(bothen/eslocales) - 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/iosnative 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 throughpackages/graphql/operations/Web/**soapollo-swift's next codegen run picks them up automatically. PerCLAUDE.md,apps/iositself 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 sharedpackages/graphql/operations/Web/**contract that backsapollo-web/apollo-swiftwould need an Android codegen target (e.g. Apollo Kotlin) added when this starts.