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 in this order:

  • Collect every object-storage key the purge owns (avatar, moment media, direct-chat, hosted-event, hosted-plan and plan-item photos)
  • Erase the Stripe customer via BillingTeardown.DeleteCustomer, which also cancels any active subscription
  • Revoke sessions
  • Anonymise messages in group chats (user_id = NULL); hard-delete messages, room, and participants for direct chats
  • Hard-delete the user row via HardDeleteUserRow, which cascades friendships, event participations, refresh tokens, sessions, login history, WebAuthn credentials, API keys, audit logs, check-ins, checkin-companions, moment tags, and the travel log
  • Enqueue async deletes for the collected asset keys
  • Call EventCleaner.CleanupUserEvents so events the user hosted are dropped

The ordering is load-bearing. Both the asset keys and stripe_customer_id live on rows the cascade destroys, so they have to be read while the user row is still there. The Stripe call goes first because it is the step most likely to fail: aborting there leaves nothing locally mutated.

Steps whose failure would strand personal data abort the purge and return. deactivated_at stays set, so the daily deactivated_acct_purge cron retries the whole account rather than leaving it half-erased with no job to finish the work. Steps whose failure costs nothing (session revocation, the post-delete event sweep) log and continue.

A Stripe outage delays purges rather than completing them

If Stripe is unreachable, DeleteUser returns an error and the account survives to the next daily run. That trades a bounded delay for the guarantee that no purge reports success while the user's email, name and billing history are still at the processor. The account is already deactivated and its sessions revoked throughout, so the delay is not user-visible. A prolonged outage would hold accounts past the 30-day mark; if that becomes real, the fix is to move the erasure onto a durable job that carries the customer id, so the purge can complete and the processor-side delete retries independently.

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

Writing preferences

PATCH /api/v1/auth/profile is the single write path. A key only lands if it appears in both models.UpdateProfileRequest and the profileSettingColumns allowlist in backend/internal/services/user/profile_store.go; a key present in the allowlist but missing from the request struct is dropped during decode and the caller still gets a 200. Adding a preference means editing both, plus a field on models.UserResponse so the PATCH response and GET /auth/profile echo it back. Enum-valued preferences are checked with IsValid() before the write.

UpdateSettings seeds the profile row when none exists. Only email registration inserts one, so an account created through Google, Apple, or LINE reaches this path with no row to update.

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
  • BillingTeardown (payment domain) — DeleteCustomer to erase the account at Stripe during the purge
  • 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