User public profile
Purpose
The per-surface source of truth for what other users see about a person. One user_public_profile row per User row — and since the platform runs separate Supabase projects for the business panel and the client app, a real person who uses both surfaces has two user_public_profile rows, one per scope, with independent bios, slugs and verifications. Within a single scope the profile stays unified: a business-Ivan staffing three gyms has one bio, one set of specializations, one slug — owned and edited by the coach themselves, not by any admin. The model is symmetric — both business and client surfaces expose a /me/public-profile endpoint with identical shape; admins on either side read but never edit the other side’s profile.
Distinct from User (the Supabase shadow holding name and avatar) and User profile (private preferences like locale and notification toggles).
Identity & key fields
- Primary key:
id(uuid, defaultgen_random_uuid()). userId(uuid, NOT NULL, UNIQUE, FK →users.users.id, on-delete cascade) — 1:1 with User.bio(nullable text).specializations(nullabletext[]).links(nullable jsonb, shape{label: string, url: string}[]).slug(nullable text, UNIQUE) — forward-compat handle for future vanity URLs like/coach/ivan-petrov. Lowercase-kebab, regex^[a-z0-9-]{3,64}$, denylist enforced in the service (constantRESERVED_SLUGS).verifiedAt(nullable timestamptz) — admin/system-set only; NEVER user-editable. Placeholder for a future verified-coach badge. No endpoint sets it today.coverPhotoUrl(nullable text) — readable on v1; upload route is a future ticket.createdAt,updatedAt(timestamps, NOT NULL).
Display name and avatarUrl are NOT here — they live on User (users.globalName, users.avatarUrl). The reading layer for the public profile assembles the response from users + user_public_profile in one LEFT JOIN.
Invariants
userIdis UNIQUE and NOT NULL → at most one row per user (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/users.schema.ts).userIdON DELETE CASCADE — profile disappears with the user (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/users.schema.ts).slugis UNIQUE platform-wide (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/users.schema.ts). Postgres treats NULLs as distinct, so many users can have no slug.slugMUST match^[a-z0-9-]{3,64}$AND must not be in the v1 denylist (me,admin,support,coach,api,business,superadmin,auth) — enforced intktspace-backend/libs/features/user-profile/src/lib/user-profile.service.ts(constantRESERVED_SLUGS).verifiedAtis never writeable from the client surface —UpdateMyPublicProfileDtodoes not declare the field, soValidationPipe({ whitelist: true })strips it; defence in depth, the service never includes it in INSERT/UPDATE sets (see ADR global-user-identity D7).- Slug-collision handling MUST use the transactional upsert shape
INSERT ... ON CONFLICT (user_id) DO UPDATE ... RETURNING ...and let the DB UNIQUE onslugarbitrate; Postgres23505withconstraint = 'user_public_profile_slug_unique'is mapped to HTTP 409errors.profile.slug_taken(enforced inuser-profile.service.ts; see ADR global-user-identity D7). - Identity-owned, not company-gated — any authenticated user (
ClientJwtGuard) can read and edit their own row regardless of anycompanyMember/companyCustomermembership (see ADR global-user-identity D7).
Lifecycle
No status column.
- No row state. A user may have NO
user_public_profilerow at all. BothGET /api/client/me/public-profileANDGET /api/business/me/public-profileALWAYS return 200 with a synthesised all-null payload in that case — NEVER 404 — by LEFT JOINinguserswithuser_public_profile. The public endpointGET /api/client/users/{userId}/public-profile(AC-3) behaves differently: it returns 404 whenusers.global_name IS NULLAND nouser_public_profilerow exists — the person has no discoverable identity to render. Error code:errors.user.public_profile_not_found. - Mutation sources (symmetric per surface, both v3):
PATCH /api/client/me/public-profile— client user self-edit; gated byClientJwtGuard(client Supabase). Operates on the client-scope User row only.PATCH /api/business/me/public-profile— business user self-edit; gated byAdminJwtGuard(admin Supabase). Operates on the business-scope User row only.- Both endpoints share
UserPublicProfileService.updatePublicProfile(userId, dto)internals — same slug normalisation, sameverifiedAtstrip, same UPSERT logic. Routing differs; behaviour does not.
- Create: on first PATCH from either surface. The service performs
INSERT ... ON CONFLICT (user_id) DO UPDATE ..., so the row materialises on first write. - Update: only via the surface-specific
/me/public-profile(the user owns their own identity, within their own scope). Admin-side views of OTHER users’ profiles are read-only. - Delete: cascades when the underlying User is hard-deleted via Supabase admin API.
Relationships
- User (ENT-021) —
userId→users.users.id, UNIQUE, on-delete cascade. 1:1. - Company member (ENT-019) — referenced through the join
companyMember → users → userPublicProfile. After ADRglobal-user-identityD9 every business-surface member-admin DTO embedsuser.publicProfile.*as a read-only block; the client surface keeps flat field names but the projection moves to source from this table. - Contributor (ENT-010) — cross-context read-only join via
contributor → companyMember → users → userPublicProfile. No FK; read-only projection inactivity-contributors.service.ts.
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — GET /me/public-profile (200 + synthesised nulls when no row), PATCH /me/public-profile (UpdateMyPublicProfileDto, whitelist: true strips verifiedAt). Client user edits the client-scope row only. Plus GET /users/{userId}/public-profile (public, no auth) — returns UserPublicProfileDto for any user; 404 when the target user has no discoverable identity (global_name IS NULL AND no profile row). Implemented in users-public.controller.ts, served by UserPublicProfileService.findPublicByUserId(userId). See specs/contributor-aware-search-and-session-filter.md AC-3. | Swagger UI |
| business | yes — symmetric self-edit: GET /api/business/me/public-profile, PATCH /api/business/me/public-profile (same DTOs, same shape, same whitelist-strip; routes through the same UserPublicProfileService.updatePublicProfile internals). Business user edits the business-scope row only. Plus read-only embed as EmbeddedUserPublicProfileDto inside member-admin DTOs (ContributorMemberPreviewAdminDto, CoachPreviewDto) — admins viewing a coach’s profile cannot edit it. | Swagger UI |
| super-admin | no | — |
Per-surface model (by design — see ADR global-user-identity D9 + v3 amendment):
- Symmetric self-edit. Both surfaces expose
/me/public-profilewith the same DTO shape (UserPublicProfileDto,UpdateMyPublicProfileDto,PublicProfileLinkDto). The DTOs are intentionally duplicated incontracts/client.openapi.yamlandcontracts/business.openapi.yamlper the repo rule “never share types between surfaces”. Behaviour, validation, slug rules,verifiedAtstrip, and UPSERT semantics are identical; the only difference is the guard and whichusersrow gets touched. - Two profiles for one real person. A real person who uses both panels has two
user_public_profilerows — one anchored to the business-scope User, one to the client-scope User. They are independent — different bios, different slugs, different verifications. Unifying them is out of scope. - Client surface preview DTOs keep flat field names on coach/contributor preview DTOs (
ContributorMemberPreviewDto.bio,.specializations,.links) — projection moves, JSON shape stays. - Business surface preview DTOs restructure member-admin DTOs under
user.publicProfile.*— admins read a clearly-distinct read-only block. The embedded DTO carriesglobalName,avatarUrl,bio,specializations,links,slug,verifiedAt,coverPhotoUrl(nointernalNotes— that field lives oncompanyMember, not on the user). avatarUrl/coverPhotoUrlare returned on the read DTOs but NOT writeable throughPATCH /me/public-profileon either surface. Avatar uploads use the existingPOST /me/avatar(per surface); cover-photo upload is a future ticket.
Known gotchas / open questions
- The self-profile endpoints (
GET /me/public-profile) NEVER return 404 for “no profile row yet” — they synthesise an all-null payload, on BOTH surfaces. SDK consumers and tests must not branch on a 404 path; the row materialises on first PATCH viaON CONFLICT (user_id) DO UPDATE. - The public endpoint (
GET /users/{userId}/public-profile) DOES return 404 when the target user has no discoverable identity (global_name IS NULLAND nouser_public_profilerow). This is intentional — an all-null payload would mislead clients into rendering a contributor chip with no data. Clients must handle 404 as “Profile not available” (empty state), not as a network error. - Cross-surface read is normal; cross-surface edit is not. An admin viewing a coach sees that coach’s business-scope profile through
member.user.publicProfile.*; a client viewing a customer (or a customer viewing themselves) sees the client-scope profile. Both are read paths and traverse the same DB table via differentusersrows. Edits stay per-surface: eachPATCH /me/public-profileonly ever writes the user row that matches its own guard. - The same real person may have two
user_public_profilerows (one business, one client). They are independent — updating one does not update the other. This is by design today; cross-surface identity unification is a separate ADR. - Never
SELECT WHERE slug = ?thenINSERT— that pattern races under concurrent submissions. The service MUST use the transactional UPSERT and rely on the DB UNIQUE + the23505→ 409 mapping for slug collisions. Slugs are globally unique across both scopes (UNIQUE is on the column, not scoped) — a business-Ivan and a client-Ivan cannot both holdivan-petrov. verifiedAtis admin/system-set; a misbehaving client cannot raise it. The DTO does not declare the field, sowhitelist: truestrips it on the way in (both surfaces), and the service ignores it as a second line of defence.- The display name and avatar live on User, not here. UI code that reads “the public profile” must pull
users.globalName/users.avatarUrlANDuser_public_profile.*together — the backend already does this in one LEFT JOIN. - Slug denylist is policy, not schema —
RESERVED_SLUGSis a service-level constant. Future expansion (slug squatting, reserved-handle list) is tracked as a future ticket; the v1 list isme,admin,support,coach,api,business,superadmin,auth.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Cover-photo upload route (
POST /me/coveror similar) — referenced in the ADR as a future addition;coverPhotoUrlis read-only in v1. - Slug history / redirect — when a user renames their slug, today the old slug is free for anyone else. Adding a
user_public_profile_slug_historytable would let old vanity URLs redirect instead of 404. - Verified-coach administration endpoint —
verifiedAtexists but no endpoint sets it. A super-admin or business-admin tool to mark coaches verified is a future ticket. - Aggregate stats (review count, average rating) —
contributor_reviewshas landed (see Contributor review andspecs/contributor-reviews-ratings.md). Aggregates are computed on read (no materialised table v1); a siblinguser_public_profile_statstable is deferred until thecontributor_reviewsrow count crosses ~1M. - Materialised slug routing — once vanity URLs
/coach/{slug}become public, a partial UNIQUE index on(slug) WHERE slug IS NOT NULLplus a slug-resolution endpoint will be needed.