Skip to content

User

Purpose

The user domain owns three things about a person's account:

  1. The identity card — the lean GET /users/:userId read used by mention chips, chat headers, and link previews.
  2. 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.
  3. 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

user domain
Handler.GetUserCard
GET /users/:userId
lean identity + is_friend
UserService
deactivate → purge → hard delete
account lifecycle
ProfileService
user_profiles row
prefs + denorm stats
The full aggregated profile card is built by discovery, not this domain.

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.DeleteAccountUserService.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.CleanupUserEvents so 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:

  1. EnsureProfile — idempotently creates the profile row if missing (lazy-bootstrap).
  2. 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 a VisitDeltas struct of bools.
  3. Travel Log INSERT.
  4. ApplyVisitDeltas — one UPDATE on user_profiles, incrementing only the totals whose delta is true (IncrementStats no-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 — bumps user_profiles.last_active_at
  • GetTokenVersion — reads just users.token_version (called by JWTAuth on every protected request)
  • BumpTokenVersion — atomically increments token_version, invalidating every outstanding JWT (see auth)
  • FindActorPreviews — lean name/username/avatar tuples for a batch of IDs, avoiding N+1 in detail builders
  • SearchUsers / 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 aggregates
  • UserRepo — narrow FindByID for the deletion path
  • FriendChecker (friend domain) — AreFriends for the card's is_friend and the profile privacy gate
  • SessionManager (auth's SessionService) — RevokeAllUserTokens
  • EventCleaner (event domain) — CleanupUserEvents after delete
  • AssetDeleter (platform/assets) — async avatar + moment media cleanup
  • TravelLogStore (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