User¶
Purpose¶
The user domain owns three things about a person's account:
- The identity card — the lean
GET /users/:userIdread used by mention chips, chat headers, and link previews. - Account lifecycle — deactivation (soft delete with 30-day grace), reactivation on next login, and the eventual GDPR-grade hard delete that purges every owned row.
- The profile row (
user_profiles) — display + preferences + denormalized stats that power the Passport counts and the privacy gate.
The split with auth: auth issues credentials and mutates the identity fields on users (name, username, password, avatar, phone, email); the user domain owns the account record's lifecycle and the settings/stats row. The full aggregated profile card (moments, events, mutual friends) is a read-aggregation and lives in discovery at GET /discovery/profiles/:id, not here.
Mental model¶
GET /users/:userId
deactivate → purge → hard delete
user_profiles row
HTTP surface¶
| Method | Path | Auth | Handler → path |
|---|---|---|---|
| GET | /api/v1/users/:userId |
JWT | Handler.GetUserCard — lean card, id may be a UUID or a case-insensitive username |
| DELETE | /api/v1/auth/profile |
JWT | AuthHandler.DeleteAccount → UserService.DeactivateUser |
| PUT / DELETE | /api/v1/users/me/avatar |
JWT | Avatar finalize (owned by auth) |
GetUserCard is the only HTTP entrypoint the user domain registers; the route is mounted in backend/internal/wiring/router.go under the /users group. Account mutation (name / username / password / avatar / phone / email) is served by auth because it edits the users row. The deactivated_acct_purge cron (24h interval, backend/internal/async/scheduler.go) calls PurgeDeactivatedAccounts directly; the user_stats cron calls StatsService.Refresh.
The lean card¶
GetUserCard returns the cheap identity projection with no cross-domain reads:
{ "id": "...", "name": "...", "username": "...", "avatar_url": "...", "bio": "...", "is_friend": true }
is_friend is populated only when the caller is authenticated and not viewing themselves. The frontend consumes this through frontend/services/userService.ts (mention lookups). For the full card with moments, events, and mutuals, callers hit GET /discovery/profiles/:id.
Account lifecycle¶
| Method | Effect |
|---|---|
DeactivateUser |
Set deactivated_at = now(), revoke every session/refresh token via SessionManager.RevokeAllUserTokens |
ReactivateUser |
Clear deactivated_at (called from AuthService.finalizeLogin when the user logs back in during the grace window) |
PurgeDeactivatedAccounts |
Find users with deactivated_at <= now() - 30d and call DeleteUser on each |
DeleteUser |
Hard delete with full cascade cleanup (below) |
DeleteUser (backend/internal/services/user/user_service.go) runs the GDPR cascade:
- Revoke sessions
- Anonymise messages in group chats (
user_id = NULL); hard-delete messages, room, and participants for direct chats - Delete friendships, event participations, refresh tokens, sessions, login history, WebAuthn credentials, API keys, audit logs, check-ins, checkin-companions, moment tags, and the user's travel log
- Enqueue async deletes for the avatar and every moment media object in object storage
- Hard-delete the user row via
HardDeleteUserRow(true hard delete) - Call
EventCleaner.CleanupUserEventsso events the user hosted are dropped
Profile row¶
ProfileService (backend/internal/services/user/profile_service.go) owns user_profiles: one row per user, auto-created on first visit. It hosts the aggregate counts that power the Passport, the privacy gate, and the per-user records JSONB.
Visibility¶
passport_visibility uses the shared Visibility enum (backend/internal/models/user_profile.go):
| Value | Meaning |
|---|---|
public |
Anyone |
friends |
Self + confirmed friends see the full profile; strangers see a limited shape (user_id, visibility, joined_at) |
close_friends |
Widens the moment/tagged audiences beyond accepted friends to the owner's curated set (see friends) |
private |
Self + confirmed friends only; strangers get ErrProfileNotVisible |
Default at signup is friends. The same enum backs DefaultMomentVisibility, DefaultTaggedMomentVisibility, and per-moment visibility, which is why close_friends lives on the shared type even though the passport gate resolves the public / friends / private tiers.
GetForViewer(viewerID, targetID) reads the profile and decides:
| Viewer | passport_visibility |
Result |
|---|---|---|
| Self / friend | any resolved tier | full profile + records (records subject to hide_records) |
| Stranger | public |
full profile + records (subject to hide_records) |
| Stranger | friends |
limited shape — user_id, passport_visibility, joined_at |
| Stranger | private |
ErrProfileNotVisible |
hide_records strips the records JSONB from any non-self response, independent of the visibility tier.
The denorm wrap — how stats stay accurate¶
Passport totals are denormalized from the travel log and kept in sync inside one effective transaction per visit-bearing write. PassportService.Record (see passport) drives it:
EnsureProfile— idempotently creates the profile row if missing (lazy-bootstrap).ComputeVisitDeltas— a pre-insert read against the travel log; for each geographic unit on the new entry, asks "does the user already have a matching row?". Returns aVisitDeltasstruct of bools.- Travel Log INSERT.
ApplyVisitDeltas— one UPDATE onuser_profiles, incrementing only the totals whose delta is true (IncrementStatsno-ops when all deltas are zero).
Computing deltas before the insert is the key: an EXISTS check after insert would always match the just-inserted row and count every visit as new. The travel log is the source of truth; profile totals are a derivative that a reconciliation scan can rebuild if they ever drift.
Data model (user_profiles)¶
One row per user, PK user_id → users(id) ON DELETE CASCADE. Notable columns (full struct in backend/internal/models/user_profile.go):
| Group | Columns |
|---|---|
| Privacy / display | passport_visibility, default_moment_visibility, default_tagged_moment_visibility, hide_records |
| Account preferences | is_location_shared, marketing_consent, notifications_enabled, language, embedded chat + map preference blocks |
| Activity | last_active_at (touched by presence + chat hot paths) |
| Denorm totals | total_curated_stamps, total_curios, total_moments, total_unique_locations, total_districts, total_cities, total_regions, total_countries, total_continents, total_friends, top_country_by_depth |
| Records / lifecycle / home | records JSONB, joined_at, first_moment_at, home_city, home_country |
Population metrics¶
StatsService (backend/internal/services/user/stats_service.go) is a read-only cron worker. Refresh runs cheap aggregate queries and pushes gauges: total / active-24h / new-24h user counts, population and DAU by country, friend-graph size, and activity hotspots bucketed to a 0.5° grid from Redis user:location:* keys. It never mutates user state.
Identity-row helpers on UserStore¶
The user store (backend/internal/services/user/user_store.go) carries the hot-path helpers auth leans on:
TouchLastActiveAt— bumpsuser_profiles.last_active_atGetTokenVersion— reads justusers.token_version(called byJWTAuthon every protected request)BumpTokenVersion— atomically incrementstoken_version, invalidating every outstanding JWT (see auth)FindActorPreviews— lean name/username/avatar tuples for a batch of IDs, avoiding N+1 in detail buildersSearchUsers/SearchFriendUsers/SearchNonFriendUsers— friend-search reads that exclude non-standard account types
Dependencies¶
*pgxpool.Pool— the pgx pool the service and stores run their sqlc-generated queries over: the batch deletes / anonymisation of the GDPR cascade and the read-only stats aggregatesUserRepo— narrowFindByIDfor the deletion pathFriendChecker(friend domain) —AreFriendsfor the card'sis_friendand the profile privacy gateSessionManager(auth'sSessionService) —RevokeAllUserTokensEventCleaner(event domain) —CleanupUserEventsafter deleteAssetDeleter(platform/assets) — async avatar + moment media cleanupTravelLogStore(passport domain) — the delta math reads
Notable behavior¶
Two-stage delete
DELETE /auth/profile does not hard-delete immediately. It sets deactivated_at and revokes sessions. The actual destruction happens 30 days later in the deactivated_acct_purge worker. Within those 30 days a login reactivates the account transparently.
Group chat message anonymisation
On hard delete, messages in group rooms are anonymised (user_id = NULL) so history survives for remaining members. Direct (1:1) rooms are deleted wholesale because the other participant has nothing to preserve.
Object-storage cleanup is best-effort
Avatar and moment media deletes are enqueued asynchronously and logged at WARN on failure; they never block the user-row delete. Orphaned objects can be reclaimed by a bucket-lifecycle policy.
Auto-create is idempotent + lazy
EnsureProfile is safe to call repeatedly. It inserts on first call and returns the existing row afterward, so any visit-bearing write path auto-bootstraps the profile.
Where to look¶
backend/internal/services/user/handler.go— leanGetUserCardbackend/internal/services/user/user_service.go— lifecycle + GDPR cascadebackend/internal/services/user/user_store.go— identity reads, search,token_versionhelpersbackend/internal/services/user/profile_service.go,profile_store.go— profile row + privacy gate + denorm wrapbackend/internal/services/user/stats_service.go— population metrics cronbackend/internal/models/user.go,user_profile.go— the row shapesbackend/internal/async/scheduler.go—deactivated_acct_purge+user_statscron registration