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 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.CleanupUserEventsso 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:
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 |
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— 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 cleanupBillingTeardown(payment domain) —DeleteCustomerto erase the account at Stripe during the purgeTravelLogStore(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