Profile Viewers — Technical Reference
Updated — the missing write-side wiring described below has been fixed: PublicProfilePage.tsx now calls recordProfileVisit on load. The feature is fully functional end to end as of this pass.
Where this lives
Backend
apps/backend/graphql/types/user-profile-view.type.js— schema formyProfileViewers,myProfileViewerCount,recordProfileVisit,clearMyProfileViewersapps/backend/graphql/resolvers/user-profile-view.resolver.js— all four resolvers, all require authapps/backend/managers/user-managers/profile-view.manager.js—recordVisit,getMyViewers,getViewerCount,clearMyViewersapps/backend/data-access-services/user/user-profile-view.access-service.js— database access, including a 60-second debounce window (findRecent/DEBOUNCE_WINDOW_MS) to avoid double-counting rapid repeat visits- Dedicated table, not piggybacked on anything:
user_profile_view(modelapps/backend/database/models/UserProfileView.js, migration20260717010000-create-user-profile-view.js) — columnsviewer_id,viewed_user_id,viewed_at,created_at, with a composite index on(viewed_user_id, viewed_at DESC).
Frontend
apps/frontend-nextjs/src/page-components/settings/ProfileViewersPage.tsx— queriesmyProfileViewers(limit: 50, offset: 0)andmyProfileViewerCounton mount (network-only), renders the list + count, and a Clear button callingclearMyProfileViewers.limit: 50is hardcoded — no pagination UI despite the resolver acceptingoffset.- Route:
apps/frontend-nextjs/src/app/settings/profile-viewers/page.tsx - Entry points:
SettingsPage.tsx("Profile visitors" settings item) andProfilePage.tsx(a menu item on your own profile), both linking to/settings/profile-viewers. recordProfileVisitis now called fromPublicProfilePage.tsx(fixed this pass) — auseEffectfires once per page load, reusing the page's existingisOwnProfilecheck to skip self-views, and only fires for authenticated visitors. The mutation swallows its own errors client-side (.catch(() => {})) so a failed call never breaks the profile page. The backend's own no-op-on-self-view and 60-second debounce still apply as a second layer of protection.
Technical implementation checklist
-
myProfileViewers— real query, dedupes to one row per distinct viewer (most recent visit) in application code (profile-view.manager.js) -
myProfileViewerCount— real query -
clearMyProfileViewers— deletes all rows for the caller -
recordProfileVisit(backend) — fully implemented: requires auth, no-ops on self-view, 60s debounce, checks thehide_profile_visitsflag (see below), which is real and wired. Correction: the argument is namedtargetUserId, notviewedUserIdas an earlier version of this doc assumed — verify against the type file rather than this doc if in doubt. -
recordProfileVisit(frontend call site) — fixed this pass.PublicProfilePage.tsxnow calls it in auseEffecton load, guarded to skip self-views and logged-out visitors. -
hide_profile_visitsprivacy flag — real and wired end to end: defaults tofalseinprivacy-settings.manager.js's default settings object, included invalidatePrivacySettings's boolean allow-list, exposed ashideProfileVisitson thePrivacySettingsGraphQL type and its input, and has a toggle inAccountPrivacyPage.tsx. Enforced inprofile-view.manager.js's gate check — a viewer with it enabled records no visit for any profile they browse. - No premium/coin gating — free for all authenticated users.
- No retention/expiry job — history is unlimited until the user calls
clearMyProfileViewers. - No iOS implementation exists (confirmed by grep across
apps/ios).
GraphQL API
query MyProfileViewers($limit: Int, $offset: Int) {
myProfileViewers(limit: $limit, offset: $offset) {
viewer { id username profilePicture isVerified }
viewedAt
}
}
query MyProfileViewerCount { myProfileViewerCount }
# Now called from PublicProfilePage.tsx on load (fixed this pass)
mutation RecordProfileVisit($targetUserId: ID!) {
recordProfileVisit(targetUserId: $targetUserId) # returns Boolean!, not an object
}
mutation ClearMyProfileViewers { clearMyProfileViewers } # also returns Boolean!, not an object