Skip to content

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, default gen_random_uuid()).
  • userId (uuid, NOT NULL, UNIQUE, FK → users.users.id, on-delete cascade) — 1:1 with User.
  • bio (nullable text).
  • specializations (nullable text[]).
  • 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 (constant RESERVED_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

  • userId is 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).
  • userId ON DELETE CASCADE — profile disappears with the user (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/users.schema.ts).
  • slug is 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.
  • slug MUST match ^[a-z0-9-]{3,64}$ AND must not be in the v1 denylist (me, admin, support, coach, api, business, superadmin, auth) — enforced in tktspace-backend/libs/features/user-profile/src/lib/user-profile.service.ts (constant RESERVED_SLUGS).
  • verifiedAt is never writeable from the client surface — UpdateMyPublicProfileDto does not declare the field, so ValidationPipe({ 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 on slug arbitrate; Postgres 23505 with constraint = 'user_public_profile_slug_unique' is mapped to HTTP 409 errors.profile.slug_taken (enforced in user-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 any companyMember / companyCustomer membership (see ADR global-user-identity D7).

Lifecycle

No status column.

  • No row state. A user may have NO user_public_profile row at all. Both GET /api/client/me/public-profile AND GET /api/business/me/public-profile ALWAYS return 200 with a synthesised all-null payload in that case — NEVER 404 — by LEFT JOINing users with user_public_profile. The public endpoint GET /api/client/users/{userId}/public-profile (AC-3) behaves differently: it returns 404 when users.global_name IS NULL AND no user_public_profile row 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 by ClientJwtGuard (client Supabase). Operates on the client-scope User row only.
    • PATCH /api/business/me/public-profile — business user self-edit; gated by AdminJwtGuard (admin Supabase). Operates on the business-scope User row only.
    • Both endpoints share UserPublicProfileService.updatePublicProfile(userId, dto) internals — same slug normalisation, same verifiedAt strip, 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) — userIdusers.users.id, UNIQUE, on-delete cascade. 1:1.
  • Company member (ENT-019) — referenced through the join companyMember → users → userPublicProfile. After ADR global-user-identity D9 every business-surface member-admin DTO embeds user.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 in activity-contributors.service.ts.

API surfaces

SurfaceExposedNotes
clientyes — 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
businessyes — 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-adminno

Per-surface model (by design — see ADR global-user-identity D9 + v3 amendment):

  • Symmetric self-edit. Both surfaces expose /me/public-profile with the same DTO shape (UserPublicProfileDto, UpdateMyPublicProfileDto, PublicProfileLinkDto). The DTOs are intentionally duplicated in contracts/client.openapi.yaml and contracts/business.openapi.yaml per the repo rule “never share types between surfaces”. Behaviour, validation, slug rules, verifiedAt strip, and UPSERT semantics are identical; the only difference is the guard and which users row gets touched.
  • Two profiles for one real person. A real person who uses both panels has two user_public_profile rows — 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 carries globalName, avatarUrl, bio, specializations, links, slug, verifiedAt, coverPhotoUrl (no internalNotes — that field lives on companyMember, not on the user).
  • avatarUrl / coverPhotoUrl are returned on the read DTOs but NOT writeable through PATCH /me/public-profile on either surface. Avatar uploads use the existing POST /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 via ON 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 NULL AND no user_public_profile row). 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 different users rows. Edits stay per-surface: each PATCH /me/public-profile only ever writes the user row that matches its own guard.
  • The same real person may have two user_public_profile rows (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 = ? then INSERT — that pattern races under concurrent submissions. The service MUST use the transactional UPSERT and rely on the DB UNIQUE + the 23505 → 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 hold ivan-petrov.
  • verifiedAt is admin/system-set; a misbehaving client cannot raise it. The DTO does not declare the field, so whitelist: true strips 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.avatarUrl AND user_public_profile.* together — the backend already does this in one LEFT JOIN.
  • Slug denylist is policy, not schema — RESERVED_SLUGS is a service-level constant. Future expansion (slug squatting, reserved-handle list) is tracked as a future ticket; the v1 list is me, 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/cover or similar) — referenced in the ADR as a future addition; coverPhotoUrl is 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_history table would let old vanity URLs redirect instead of 404.
  • Verified-coach administration endpointverifiedAt exists 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_reviews has landed (see Contributor review and specs/contributor-reviews-ratings.md). Aggregates are computed on read (no materialised table v1); a sibling user_public_profile_stats table is deferred until the contributor_reviews row count crosses ~1M.
  • Materialised slug routing — once vanity URLs /coach/{slug} become public, a partial UNIQUE index on (slug) WHERE slug IS NOT NULL plus a slug-resolution endpoint will be needed.