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
| OperationID | Method + Path | RBAC | Notes |
|---|---|---|---|
get-current-user | POST /user/get | — (auth) | was GET /api/v1/user-info; returns the User DTO |
update-profile | POST /user/update | — (auth) | displayName in body (opId stays descriptive) |
avatar-upload-ticket | POST /user/avatar/upload-ticket | — (auth) | presigned R2 upload (step 1) |
avatar-finalize | POST /user/avatar/finalize | — (auth) | validate + set avatar (step 2) |
remove-avatar | POST /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).
organizationMembershipsembeds a light org summary (id, name, logoUrl) for the org switcher — noslug(the switcher displays name+logo and switches byid; slug lives only on the fullOrganization, for the invite-link). Not the fullOrganization(domains aren't needed here).manualStatusis the user's own manual presence override; the effective presence others see isMember.presenceStatus/Space.occupantUserIds.
update-profile
// Request
{ "displayName": "string" } // required, 2..100
// Response — the updated User
{ /* User */ }
§Image upload (shared pattern — used by avatars AND org-logo)
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
avatarUploadTriesRemainingon theUserDTO reflects the per-user rate limit on this flow.
Module / backend notes
userkeeps profile + avatar; signups →auth(task #8).get-current-userreturns theUserDTO.- Wire-boundary mappings: stringify
id;currentOrganizationId→ WorkOS org id;manualStatusint16 → string enum; droppendingAuthenticationTokenfrom the User entity. organizationMemberships[].organization→ the light summary, not the raw domainOrganization.
Verify at implementation
organizationMemberships[].statusenum values (OrganizationMembershipStatus).- Whether
firstName/lastNameare still used by the FE ordisplayNamealone suffices (candidate drops).