Skip to main content

Resource: User (+ Avatar) — the shared User DTO & image-upload pattern

Tag: User · Module: user (profile + avatar; signups move to auth)

The current user ("me") and their profile/avatar. This resource defines the canonical User DTO that Auth/Session sign-in & finalize responses, org create/switch, and get-current-user all return — so it's sequenced first. It also defines the shared two-step image-upload pattern reused by org-logo (task #6).

Grounded: internal/domain/user/types.go (User), user/models/{models,avatar}.go, user/api/{getinfo,updateprofile,avatar_upload_ticket,avatar_finalize,removephoto}.go, internal/domain/organization/types.go (OrganizationMembership).


Operations

OperationIDMethod + PathRBACNotes
get-current-userPOST /user/get— (auth)was GET /api/v1/user-info; returns the User DTO
update-profilePOST /user/update— (auth)displayName in body (opId stays descriptive)
avatar-upload-ticketPOST /user/avatar/upload-ticket— (auth)presigned R2 upload (step 1)
avatar-finalizePOST /user/avatar/finalize— (auth)validate + set avatar (step 2)
remove-avatarPOST /user/avatar/remove— (auth)was /user/remove-photo

Avatar paths consolidate under /user/avatar/* (were split across /auth/avatar/* + /user/remove-photo). Signups (signup, admin-sign-up, member-sign-up, generate-pilot-invite) currently live in the user feature but are registration/auth ops → they move to Auth/Session (task #8).


The User DTO (canonical — reused everywhere a user/session is returned)

{
"id": "string", // C2 — canonical user id (stringified int64); same id space as
// Member.id, Space.occupantUserIds, recording participants
"email": "string",
"firstName?": "string", // C8 — optional
"lastName?": "string", // C8 — optional
"displayName": "string",
"avatarUrl": "string | null",
"currentOrganizationId": "string", // WorkOS org id (matches Organization.id)
"organizationMemberships": [
{
"organization": { "id": "string", "name": "string", "logoUrl": "string | null" },
"role": "guest | member | moderator | admin",
"status": "membership-status enum" // verify values (e.g. active | pending)
}
],
"integrations": { "googleCalendar": { "connected": true, "enabled": true } }, // connected = has token; enabled = feature toggle (see calendar.md)
"manualStatus": "offline | online | away | busy | null", // C6/C8 — was dbmodels.PresenceStatus leak; nullable (null = no manual override)
"avatarUploadTriesRemaining": 0, // rate-limit counter shown in the UI
"createdAt": "2026-06-14T10:00:00Z", // C3
"updatedAt": "2026-06-14T10:00:00Z" // C3
}
// REMOVED:
// pendingAuthenticationToken — a transient auth-flow token; moves to the auth-flow response (task #8).
// socialUrl — vestigial: never set by the backend, never read by the FE (dropped, YAGNI).
  • organizationMemberships embeds a light org summary (id, name, logoUrl) for the org switcher — no slug (the switcher displays name+logo and switches by id; slug lives only on the full Organization, for the invite-link). Not the full Organization (domains aren't needed here).
  • manualStatus is the user's own manual presence override; the effective presence others see is Member.presenceStatus / Space.occupantUserIds.

update-profile

// Request
{ "displayName": "string" } // required, 2..100
// Response — the updated User
{ /* User */ }

A two-step presigned direct-to-R2 upload; image validation is offloaded to Cloudflare Images (no server-side decode). The same shape is reused wherever the contract uploads an image (user avatar here, org logo in task #6) — only the path prefix and the returned URL field name differ.

// {x}-upload-ticket — Request
{ "contentType": "image/jpeg | image/png | image/webp" } // enum
// Response
{ "uploadUrl": "string", "tmpKey": "string", "expiresInSeconds": 0 }

// client PUTs the file directly to uploadUrl, then:

// {x}-finalize — Request
{ "tmpKey": "string" }
// Response
{ "avatarUrl": "string" } // org-logo's equivalent returns { logoUrl }

// {x}-remove — Request body empty {} → Response 204 No Content
  • avatarUploadTriesRemaining on the User DTO reflects the per-user rate limit on this flow.

Module / backend notes

  • user keeps profile + avatar; signups → auth (task #8). get-current-user returns the User DTO.
  • Wire-boundary mappings: stringify id; currentOrganizationId → WorkOS org id; manualStatus int16 → string enum; drop pendingAuthenticationToken from the User entity.
  • organizationMemberships[].organization → the light summary, not the raw domain Organization.

Verify at implementation

  • organizationMemberships[].status enum values (OrganizationMembershipStatus).
  • Whether firstName/lastName are still used by the FE or displayName alone suffices (candidate drops).