ADR: Global user identity
Global user identity
Section titled “Global user identity”Context
Section titled “Context”Identity is fragmented today. companyMembers.publicName, bio, avatarUrl,
specializations, and links are per-tenant — a coach working at three gyms
has three independent profiles, three independent reputations, and three places
to update on a haircut. companyCustomers.name shadows users.globalName for
online customers, with no clear winner. Marketplace patterns put identity on the
person and per-tenant labels on the relationship. This ADR moves us there:
- Identity (display name, avatar, bio, specializations, links) becomes global
on
users+ a new 1:1user_public_profiletable. - Per-tenant
companyMemberretains only what is genuinely per-tenant —role(permission enum),roleLabel(display label),internalNotes,isActive. companyCustomers.namebecomes write-locked when the row is linked to a user (userId IS NOT NULL), with a one-shot backfill intousers.globalNameat link time when the user has no global name yet.- Coach owns their own profile via a new
PATCH /api/client/me/public-profileendpoint; admins see it read-only. - Reviews are designed (
target_user_id+booking_id) but explicitly NOT implemented in this ticket — a follow-up materialises them. The chosen shape ensuresuser_public_profileis forward-compatible.
This is a one-way migration with a release-cycle backup table and a conflict CSV for diverging per-company values, so no data is dropped silently.
Decision
Section titled “Decision”The following nine decisions are the source of truth for downstream test writers and devs. Implementation code lives in the named files.
AC mapping note: companyMember.internalNotes (nullable text) is added
in this ticket as an admin-only field; never exposed on the client surface.
The spec’s per-field decision table (specs/global-user-identity.md line 49)
assigns “Internal admin notes” to companyMember. The spec’s AC checklist
is amended in the same MR to call this out explicitly.
D1. user_public_profile table (NEW, 1:1 with users)
Section titled “D1. user_public_profile table (NEW, 1:1 with users)”- Schema:
users(Postgres schema, matches existingusersSchema). - Drizzle file: add to
libs/shared/data-access-db/src/lib/schema/users.schema.tsdirectly afteruserProfiles(visual proximity — both are 1:1 extensions ofusers, same Postgres schema, see ADRcross-schema-references-without-fk— in-schema FK is allowed and used). - Columns:
id uuid PK default gen_random_uuid().userId uuid NOT NULL UNIQUE references users.id ON DELETE CASCADE.bio text(nullable).specializations text[](nullable).links jsonb({label, url}[], nullable;$type<{label:string;url:string}[]>()).slug text UNIQUE(nullable; pretty handle like/coach/ivan-petrov). Forward-compat for v2 vanity URLs. Slug-collision validation lives in the PATCH service, not in DB triggers, but DB UNIQUE is the safety net.verifiedAt timestamptz(nullable). Admin/system-set only. NEVER user-editable — explicit guard at the controller boundary on the client surface. No business endpoint to set it in this ticket (placeholder for future verified-coach feature).coverPhotoUrl text(nullable). For richer profile pages.createdAt timestamptz NOT NULL default now().updatedAt timestamptz NOT NULL default now().
- Indexes:
- UNIQUE on
(user_id)(via.unique()on the column). - UNIQUE on
(slug)(partial indexWHERE slug IS NOT NULLif Drizzle supports it; otherwise plain UNIQUE — NULLs are distinct in Postgres so plain UNIQUE works).
- UNIQUE on
- Relation:
export const userPublicProfilesRelations = relations(userPublicProfiles, ({ one }) => ({user: one(users, { fields: [userPublicProfiles.userId], references: [users.id] }),}));
publicName and avatarUrl stay on users.globalName (mapped from
column full_name) and users.avatarUrl. The reading layer for the public
profile assembles the response from users + user_public_profile in one
LEFT JOIN.
D2. users table — no schema change
Section titled “D2. users table — no schema change”users.globalName (column full_name) and users.avatarUrl remain canonical
for display name + avatar. Avoiding a rename keeps the migration minimal and
keeps the existing /me/avatar upload path intact. The public-profile reading
DTO maps both into the response (name and avatarUrl).
D3. companyMember — column drops + roleLabel add + internalNotes add
Section titled “D3. companyMember — column drops + roleLabel add + internalNotes add”After the conflict-resolved backfill into user_public_profile:
- DROP
publicName,bio,avatarUrl,specializations,linksfromcompanies.company_member. - ADD
roleLabel text(nullable). Display label for the company-specific role string (“yoga instructor”, “head trainer”), decoupled from the permission enumrole. - ADD
internalNotes text(nullable). Per-tenant admin-only notes about the member. The spec’s per-field decision table (specs/global-user-identity.mdline 49) assigns “Internal admin notes” tocompanyMember; this column add makes the schema match the documented intent. The field is editable on the business surface (UpdateMemberDto), never exposed on the client surface (no DTO on/api/clientdeclares it), and never visible to other companies. Today’scompanyMemberschema has no such column — this ticket introduces it. - KEEP
role(enum),isActive,createdAt,updatedAt.
D4. company_member_legacy_identity (NEW, backup table)
Section titled “D4. company_member_legacy_identity (NEW, backup table)”- Schema:
companies(same ascompany_member). - Columns mirror the dropped fields, plus origin metadata so we can
reconstruct the source row if needed:
id uuid PK default gen_random_uuid().companyMemberId uuid NOT NULL(no FK — the source row is about to be edited but stays; this is a soft historical reference).companyId uuid NOT NULL,userId uuid NOT NULL(denormalised for fast lookups during audit).publicName text,bio text,avatarUrl text,specializations text[],links jsonb.archivedAt timestamptz NOT NULL default now().
- Retention: dropped in a follow-up migration targeting release 2026.07. The named release is the source of truth so the table does not calcify; tracked as a separate follow-up ticket. Documented in the migration preview.
- Population:
INSERT INTO ... SELECT ...fromcompany_memberran inside the same migration that drops the columns (atomic within the Drizzle migration file).
D5. Customer write-guard (application-layer, 409)
Section titled “D5. Customer write-guard (application-layer, 409)”- No DDL — no DB CHECK constraint. The guard lives in
libs/features/companies/src/lib/services/company-customers.service.ts, methodupdate. - Rule: when the incoming
UpdateCustomerDtocontainsname(any value, including null/empty string), the service loads the target row first; ifcustomer.userId IS NOT NULL→ throwConflictExceptionwith codeerrors.customer.name_locked. HTTP409. - Read shape: every customer read DTO carries
nameLocked: boolean(= customer.userId !== null) so the business UI can disable the input pre-emptively. (We expose it asnameLocked, notcustomerNameLocked, because the DTO is already namespaced asCustomer*.) - Display semantics: every read path that displays a customer name
must prefer
users.globalNamewhencustomer.userId IS NOT NULL, else fall back tocompanyCustomers.name. This is enforced in the service projection (coalesce(users.global_name, companyCustomers.name) AS name) so consumers don’t reinvent the rule.
D6. Linking flow backfill into users.globalName
Section titled “D6. Linking flow backfill into users.globalName”- Location:
libs/features/auth/src/lib/services/auth-sync.service.ts, inside the existing customer-linking-by-email branch (methodlinkUnlinkedCustomersByEmail). After theUPDATE companyCustomers SET userId = ?returns the linked rows, the service runs a follow-up conditional update onusersfor the caller’s id only:Drizzle equivalent (with explicit guards): aUPDATE users.usersSET full_name = (SELECT cc.nameFROM companies.company_customer ccWHERE cc.id = $linkedCustomerIdLIMIT 1)WHERE id = $supabaseIdAND full_name IS NULLAND (SELECT cc.name FROM companies.company_customer ccWHERE cc.id = $linkedCustomerId LIMIT 1) IS NOT NULL;SELECTof the linked customer’sname, then a conditionalUPDATE users SET globalName = name WHERE id = ? AND globalName IS NULL— only when the customer name is non-null. - Multi-company determinism. When the linking sweep finds multiple
offline
companyCustomerrows for the same email (e.g. the same person was added as offline to three gyms), the chosennamefor backfill MUST be deterministic. The selection rule is:— the OLDEST offline customer row wins (withORDER BY company_customer.created_at ASC, company_customer.id ASCLIMIT 1idas a stable tiebreaker for the unlikely identical-timestamp case). Rationale: the oldest CRM entry is the one with the most history behind it, and the rule is trivially testable. Document this in the service’s docblock and in the Phase C integration test. - Idempotency: the
globalName IS NULLpredicate makes the backfill one-shot per user; subsequent logins are no-ops.
D7. Client-surface profile self-edit endpoint
Section titled “D7. Client-surface profile self-edit endpoint”- Path:
PATCH /api/client/me/public-profile. - Controller: add a new controller method on the existing
UserProfileController(libs/features/user-profile/src/lib/user-profile.controller.ts). Rationale: the controller is already mounted atmewithClientJwtGuard, and the public profile is the user’s own data — the separation from/me//me/avataris purely a payload concern (different DTO shape), not a routing concern. - Request DTO
UpdateMyPublicProfileDto(inlibs/features/user-profile/src/lib/dto/update-my-public-profile.dto.ts):Final v1 DTO fields:{globalName?: string; // maps to users.full_nameavatarUrl?: string; // maps to users.avatar_url (read-only via this PATCH;// upload uses POST /me/avatar — we accept the field// here so a future "set from URL" flow doesn't need// a contract change, but the controller forbids// setting it from this endpoint with `@IsOptional()`// + a class-validator custom rule that returns 400// `errors.profile.avatar_via_upload_only`. Final// decision: OMIT from the DTO entirely for v1,// keep avatar upload on its own route. See OQ in// Risks.)bio?: string;specializations?: string[];links?: { label: string; url: string }[];slug?: string; // ^[a-z0-9-]{3,64}$ regex; 409 on UNIQUE collisioncoverPhotoUrl?: string; // same caveat as avatarUrl — omit for v1; cover// photo upload will be a sibling POST in a future// ticket. Final v1 DTO: { globalName, bio,// specializations, links, slug }.}globalName,bio,specializations,links,slug.avatarUrlandcoverPhotoUrlare NOT writeable from this endpoint in v1 — they go through dedicated upload routes (existingPOST /me/avatar, futurePOST /me/cover).verifiedAtis NEVER in this DTO. - Response DTO
UserPublicProfileDto— read shape exposed viaGET /me/public-profileand as the response of the PATCH:{userId: string; // PK by foreign key, not the profile-row idglobalName: string | null; // from usersavatarUrl: string | null; // from usersbio: string | null;specializations: string[] | null;links: { label: string; url: string }[] | null;slug: string | null;verifiedAt: string | null; // ISO date-time; read-onlycoverPhotoUrl: string | null;} - GET endpoint:
GET /api/client/me/public-profilereturns the sameUserPublicProfileDtofor the calling user, includingverifiedAt(read). - GET behaviour when no
user_public_profilerow exists. The endpoint ALWAYS returns 200 with an all-null payload (every nullable field set to null;userIdset to the caller’s id) — NEVER 404. The service synthesises the response by LEFT JOINinguserswithuser_public_profile: if the right side is missing, the projection emits nulls. This pins behaviour for both client SDKs and test-writers so they don’t diverge between “no row yet” and “row exists with all nulls”. The PATCH performs an INSERT … ON CONFLICT (user_id) DO UPDATE so the row materialises on first write. - Slug normalisation + denylist (server-side). Service validates the
incoming slug in this order before any DB write:
- Auto-lowercase the input.
- Collapse runs of
-into a single-; strip leading/trailing-. - Match against
^[a-z0-9-]{3,64}$; reject 400errors.profile.slug_invalidon mismatch. - Reject 400
errors.profile.slug_reservedif the slug is in the v1 denylist:me,admin,support,coach,api,business,superadmin,auth. Denylist lives in a single constant inside the service (RESERVED_SLUGS), not in the database — it is policy, not schema. Future slug squatting / reserved-handle expansion is out of scope here; see Open Questions.
- Slug-collision race. The service MUST use the transactional UPSERT
shape
INSERT INTO user_public_profile (...) VALUES (...) ON CONFLICT (user_id) DO UPDATE SET ... RETURNING ...and let the DB UNIQUE constraint onslugbe the arbiter. The Postgres23505error code withconstraint = 'user_public_profile_slug_unique'is caught and mapped to 409errors.profile.slug_taken. The service MUST NEVER doSELECT WHERE slug = ?followed by anINSERT— that pattern races under concurrent submissions. - Multi-tenancy edge case. Any authenticated user (gated by
ClientJwtGuard) can read and edit their own public profile, regardless of whether they currently belong to anycompanyMemberorcompanyCustomerrow. The public profile is identity-owned, not company-gated. A user with zero company memberships can still set their bio, slug, etc. - Errors:
400 errors.profile.validation— class-validator failure on any field (e.g. non-arrayspecializations, malformedlink.url, slug regex mismatch).409 errors.profile.slug_taken—slugcollides with another user’s. Service catches Postgres23505whoseconstraint = 'user_public_profile_slug_unique'and maps to this code.
verifiedAtenforcement: the DTO does not declare the field, sowhitelist: trueon the globalValidationPipestrips it. Defence in depth: the service ignoresverifiedAteven if it leaks through (does not include it in the UPDATE/INSERT set).
D8. Business panel — global profile is read-only
Section titled “D8. Business panel — global profile is read-only”- Member admin DTO change: the business surface returns the existing
per-company fields plus an embedded
user.publicProfileread-only block (see Contract patches below). - Editable from business surface:
role(existing — permission enum).roleLabel(NEW — D3).internalNotes(NEW — D3; admin-only per-tenant field).isActive(existing).
- NOT editable from business surface: every identity field on the user
(
globalName,avatarUrl,bio,specializations,links,slug,coverPhotoUrl). BackendUpdateMemberDtowill not declare these fields →whitelist: truestrips them silently → no admin can write a user’s identity by mistake. The business UI shows the read-only block with a notice “Public profile is owned by the user — ask them to update if needed.”
D9. ADR contributors-must-be-members amendment v4
Section titled “D9. ADR contributors-must-be-members amendment v4”This ADR amends adrs/contributors-must-be-members.md to v4. The wording
change is mechanical but its consequences span every consumer.
- Old (v3): “every name/avatar/bio render goes through
member.*.” Consumers readContributorMemberPreviewDto.publicName,.avatarUrl,.bio,.specializations,.linksdirectly off the contributor’s member row. - New (v4): “every name/avatar/bio render goes through
member.user.*andmember.user.publicProfile.*.” Consumers readContributorMemberPreviewDto.user.globalName,.user.avatarUrl,.user.publicProfile.bio,.user.publicProfile.specializations,.user.publicProfile.links. - Member row still required. The contributor → companyMember FK with
ON DELETE CASCADEstays; v4 changes the reading layer only, not the graph topology. A contributor still cannot exist without acompanyMemberrow; we just read identity through that row’suserrelation instead of its own (now-deleted) columns.member.roleLabelis the only member-level identity-ish field, and it is per-company by design.
The v4 amendment must be applied to adrs/contributors-must-be-members.md
in the same MR that ships this ADR — bump frontmatter version: 4 and
add a “v4 amendment” subsection describing the read-path move.
Field-shape per surface — divergence by design
Section titled “Field-shape per surface — divergence by design”The two affected surface contracts diverge in how they expose the identity fields, and that divergence is intentional. Test-writers and devs MUST design against the per-surface shape and not assume a shared structure.
Client surface — flat fields kept.
ContributorMemberPreviewDtoand related coach-preview DTOs (e.g.SessionCoachClientDto) retain their existing flat fields (publicName,avatarUrl,bio,specializations,links) at the top level. The server-side projection moves to read fromusers.*/user_public_profile.*, but the JSON shape consumers see is unchanged. Goal: zero template churn ontktspace-webandgym_apprendering paths.Business surface — restructured under
user.*/user.publicProfile.*.ContributorMemberPreviewAdminDto,CoachPreviewDto, and any other admin-side member-preview DTOs nest identity under an embeddeduserblock withuser.publicProfile.*for the rich fields. Goal: the admin UI renders a clearly-distinct “Public profile (read-only, owned by coach)” section, and the nested DTO maps 1:1 to that section. Per-company fields (roleLabel,internalNotes,isActive,role) stay at the member top level. Admin reviewers seeing the DTO immediately understand which fields they can edit.This divergence is intentional. It is NOT a contract bug. The patched contracts already reflect it —
contracts/client.openapi.yamlat theContributorMemberPreviewDtoschema keeps flat fields;contracts/business.openapi.yamlatContributorMemberPreviewAdminDtoandCoachPreviewDtouses the nested form. Per the repo rule “never share types between surfaces” (_workflow/CLAUDE.md), this is the correct outcome.
When the rest of this ADR speaks of DTOs being “field-stable”, that claim applies to the client surface only unless explicitly qualified. The business surface is restructured by design.
Considered alternatives
Section titled “Considered alternatives”(a) Put public-profile columns directly on users
Section titled “(a) Put public-profile columns directly on users”Add bio, specializations, links, slug, verifiedAt,
coverPhotoUrl to users.users.
- Pros: one less table; one less join; simpler schema.
/mecould return everything in one row. - Cons:
usersis documented as the “thin Supabase shadow” (perdocs/content/domain/identity/user.mdx). Fattening it with public- facing columns conflates the auth-mirror role with the marketing surface; the domain model gets muddier, not cleaner.- Future
player_profile(leagues / competitions, OQ-6 in the spec) either has to repeat the 1:1-table choice anyway or pile MORE columns ontousers. Today is the right time to draw the line. - Reviews aggregate (count, average) — a follow-up ticket — will
benefit from a clear 1:1 table to hang materialised aggregates off
of (
user_public_profile.review_countetc., or a separateuser_public_profile_stats1:1). verifiedAtis admin-set; mixing it into the Supabase-mirror row blurs which writes are auth-driven vs identity-driven.
Rejected.
(b) Per-company companyMember.* override on top of global
Section titled “(b) Per-company companyMember.* override on top of global”Keep companyMembers.publicName, .bio, etc. as nullable, where non-null
overrides the global user.publicProfile.* for that company only.
- Pros: zero migration risk on read paths (clients fall back to member fields, just like today, and only honour the new global fields when the member fields are NULL).
- Cons:
- The reading layer becomes TWO sources of truth per field —
COALESCE(member.publicName, user.globalName). Every renderer has to merge; reviews are even worse (do they target the member-override name or the global name?). - The whole point of going global is that a coach at three gyms has one identity. Per-tenant overrides re-introduce N identities through the back door.
- The spec explicitly rules this out (Decisions → Per-field placement table, “Per-company override = No” for every identity field). Doing it anyway would be a quiet contradiction.
- The reading layer becomes TWO sources of truth per field —
Rejected.
(c) Move identity to users columns + user_public_profile for only the long-tail fields
Section titled “(c) Move identity to users columns + user_public_profile for only the long-tail fields”Hybrid: users gains bio and specializations (small fields used
everywhere). user_public_profile only holds slug, verifiedAt,
coverPhotoUrl, links (less common).
- Pros: marginally fewer joins for the common “render a coach card” case.
- Cons: boundary is arbitrary — every future field needs a debate about which table it lives in. The single-table-for-all-public-profile rule is simpler and cleaner.
Rejected.
API design (per surface)
Section titled “API design (per surface)”Client surface (/api/client)
Section titled “Client surface (/api/client)”New: GET /me/public-profile
operationId:meClientGetPublicProfile.- Security:
BearerAuth(existing pattern). - Response 200:
UserPublicProfileDto. - Errors: 401 (unauthenticated).
New: PATCH /me/public-profile
operationId:meClientUpdatePublicProfile.- Security:
BearerAuth. - Request body:
UpdateMyPublicProfileDto(see D7 for shape). - Response 200:
UserPublicProfileDto(updated row). - Errors:
- 400
errors.profile.validation— body schema invalid. - 401 — unauthenticated.
- 409
errors.profile.slug_taken— UNIQUE collision onslug.
- 400
Modified: coach / contributor previews on activity DTOs
ContributorMemberPreviewDto keeps its name (the field-shape rename
would force a cascade of consumer renames for no semantic gain). Its
fields are restructured:
- BEFORE (today):
publicName,avatarUrl,bio,specializations,linksdirectly on the member preview, sourced fromcompanyMembers.publicNameetc. - AFTER: same field names, same JSON shape — but values are sourced
from
users.global_name/users.avatar_url/user_public_profile.bio/.specializations/.linksvia the join the service performs. The DTO is field-stable on the client surface; the projection moves. The business surface DTO for the same logical entity (ContributorMemberPreviewAdminDto) is restructured underuser.*/user.publicProfile.*— see D9 “Field-shape per surface”.
Rationale: keeping the field names on the client surface lets the v4 ADR
ship without renaming generated TypeScript types in tktspace-web or
gym_app. The business surface accepts the restructure because admins
benefit from the clearer “public profile read-only block” semantics.
Unchanged: SessionCoachClientDto
The DTO already only carries id, publicName, avatarUrl (verified
at lines 2476-2486 of the current client contract). After the migration
those values come from users.global_name and users.avatar_url —
field names stay; data source moves.
Modified: CustomerProfileDto (returned by GET /companies/{companyId}/me)
- ADD
nameLocked: boolean—truewhen the customer row’suserIdis set. The client surface uses this to show the user that their name is now sourced from their global profile, not the company’s CRM entry. Thenamefield on this DTO is already populated from a service-side COALESCE (see D5 read semantics);nameLockedlets the client UI explain it.
Business surface (/api/business)
Section titled “Business surface (/api/business)”New schemas (no new paths in this ticket — the customer admin CRUD isn’t yet in the contract; we add the schemas needed by today’s reads, and document the 409 on the update path that the spec mandates):
EmbeddedUserPublicProfileDto— read-only block for member-admin DTOs. Same JSON shape asUserPublicProfileDtoon the client surface but intentionally a separate schema in the business file (the contracts are intentionally not shared — see CLAUDE.md “never share types between surfaces”). CarriesglobalName,avatarUrl,bio,specializations,links,slug,verifiedAt,coverPhotoUrl.- Embed
user: EmbeddedUserDto(with nestedpublicProfile: EmbeddedUserPublicProfileDto) into every member-admin DTO that today carries identity fields directly:ContributorMemberPreviewAdminDto(currently at lines 1718-1741 of the business contract).CoachPreviewDto(currently at lines 1840-1856).
- These admin DTOs DROP the direct
bio/specializations/linksfields.publicNameandavatarUrlare RENAMED to live underuser.globalName/user.avatarUrlin the response. The contract patch encodes this restructure. - ADD
roleLabelto whatever member-admin DTO carries member info on the business side.CoachPreviewDtodoes NOT addroleLabel(it’s a coach card for sessions, not a member admin record) — only the full member-admin payload gets it. Since the contract doesn’t yet declareGET /membersorPATCH /members/{id}, this ticket only ADDS the field toContributorMemberPreviewAdminDto. A future “members admin” ticket will exposeMemberAdminDtowith the full read shape.
Modified: UpdateContributorDto — no change. Already trimmed in v3
to {roles?, order?}. v4 doesn’t touch DTOs of the contributor
endpoints; the read shape change is in the response.
New: MemberRoleLabelUpdateDto — NOT in this ticket. The “edit
member” admin endpoint isn’t in the contract yet (the spec doesn’t
require us to add it). The backend UpdateMemberDto (in
libs/features/companies/src/lib/dto/) gains roleLabel?: string; when
a future ticket promotes the member-admin endpoints into the contract,
the roleLabel field rides along.
Customer admin operations (deferred contract surface):
The customer admin CRUD (GET /customers, POST /customers, PATCH /customers/{id}, DELETE /customers/{id}) is implemented in the backend
but not yet declared in the business OpenAPI contract. The spec
requires a 409 on PATCH /api/business/customers/{id} when name is
present and userId is set. This is enforced at the service layer
(see D5). When customer CRUD is brought into the contract in a future
ticket, the 409 response and the nameLocked boolean on the read DTO
are documented there. This ticket adds the schemas as forward-compat
fragments only (see Contract patches → business).
Super-admin surface
Section titled “Super-admin surface”Not affected. The super-admin contract is untouched.
Surface impact
Section titled “Surface impact”| Contract | Touched? | What changes |
|---|---|---|
contracts/client.openapi.yaml | YES | UserPublicProfileDto (NEW), UpdateMyPublicProfileDto (NEW), GET /me/public-profile (NEW), PATCH /me/public-profile (NEW). ContributorMemberPreviewDto keeps its flat field shape (intentional — see D9 “Field-shape per surface”); schema description updated to reflect the new data source. CustomerProfileDto adds nameLocked: boolean. |
contracts/business.openapi.yaml | YES | EmbeddedUserDto (NEW), EmbeddedUserPublicProfileDto (NEW). ContributorMemberPreviewAdminDto restructured under user.* / user.publicProfile.* (intentional divergence from the client surface — see D9); roleLabel and internalNotes exposed as editable per-tenant fields on member-admin DTOs. CoachPreviewDto restructured: publicName / avatarUrl move under user.globalName / user.avatarUrl (the DTO already only carries those two identity fields plus id). New customer-admin fragments added as forward-compat hooks. |
contracts/super-admin.openapi.yaml | NO | No change. |
Field-level differences between surfaces (and why)
Section titled “Field-level differences between surfaces (and why)”slug— exposed on client (the user reads + writes their own slug) AND on business (admin sees the slug as part of read-only public profile). Same field name, same semantics, both surfaces. No leak risk — slug is public by design (it’s literally the public URL handle).verifiedAt— exposed read-only on BOTH surfaces. Client reads it to render the verified badge on their own profile; business reads it to render the same badge for admins. Neither surface accepts it on PATCH. Admin-set / system-set only (no endpoint to write it in this ticket — placeholder).internalNotes— business only. Not onEmbeddedUserPublicProfileDto; it lives on the member row, not on the user. Client must never see this.nameLocked— both surfaces. Client gets it insideCustomerProfileDto(so the user understands their name is global); business gets it inside the future customer-admin DTO (so the admin sees the input disabled). Same boolean, same source (customer.userId IS NOT NULL).roleLabel— business only. This is a per-company display label (“yoga instructor here”) set by the admin; it’s a member-relationship property, not a person property. The client surface has no concept of “this person’s role inside that specific company” — when the client needs to render a coach, it usesuser.globalName, full stop.avatarUrl/coverPhotoUrlwrite — neither in theUpdateMyPublicProfileDtofor v1 (see D7). Upload-based routes (POST /me/avataralready exists; cover-photo upload is a future ticket). The fields are returned on the read DTO.bio/specializations/links— readable on BOTH client and business (business reads throughuser.publicProfile.*). Writable only on client (the owner). The business admin DTO that embedsuser.publicProfileis intentionally not editable — even if an admin PATCHed those fields in the embedded path, the backend service for member admin updates would silently strip them viawhitelist: true.
Data model
Section titled “Data model”NEW table: users.user_public_profile
Section titled “NEW table: users.user_public_profile”Drizzle preview (lands in libs/shared/data-access-db/src/lib/schema/users.schema.ts):
export const userPublicProfiles = usersSchema.table('user_public_profile', { id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), userId: uuid('user_id') .notNull() .unique() .references(() => users.id, { onDelete: 'cascade' }), bio: text('bio'), specializations: text('specializations').array(), links: jsonb('links').$type<{ label: string; url: string }[]>(), slug: text('slug').unique(), verifiedAt: timestamp('verified_at', { withTimezone: true }), coverPhotoUrl: text('cover_photo_url'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(),});
export const userPublicProfilesRelations = relations(userPublicProfiles, ({ one }) => ({ user: one(users, { fields: [userPublicProfiles.userId], references: [users.id] }),}));
export type UserPublicProfile = typeof userPublicProfiles.$inferSelect;export type NewUserPublicProfile = typeof userPublicProfiles.$inferInsert;Indexes:
user_public_profile_user_id_unique(auto from.unique()on column).user_public_profile_slug_unique(auto from.unique()on column). Postgres treats NULLs as distinct, so plain UNIQUE allows many users without slugs.
MODIFIED table: companies.company_member
Section titled “MODIFIED table: companies.company_member”Drizzle preview — edit
libs/shared/data-access-db/src/lib/schema/companies.schema.ts lines
56-74:
export const companyMembers = companiesSchema.table('company_member', { id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), userId: uuid('user_id').notNull().references(() => users.id), companyId: uuid('company_id') .notNull() .references(() => companies.id, { onDelete: 'cascade' }), role: CompanyMemberRoleEnum('role').default('MANAGER').notNull(), isActive: boolean('is_active').default(true).notNull(), roleLabel: text('role_label'), // NEW — D3 internalNotes: text('internal_notes'), // NEW — D3. Per-tenant // admin notes about the // member. Admin-only; // never on the client // surface. Matches the // spec's per-field // decision table. // DROPPED: publicName, bio, avatarUrl, specializations, links createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(),});NEW table: companies.company_member_legacy_identity
Section titled “NEW table: companies.company_member_legacy_identity”Drizzle preview (lands in companies.schema.ts just below companyMembers):
export const companyMemberLegacyIdentity = companiesSchema.table('company_member_legacy_identity', { id: uuid('id').primaryKey().default(sql`gen_random_uuid()`), companyMemberId: uuid('company_member_id').notNull(), // soft ref, no FK companyId: uuid('company_id').notNull(), // soft ref userId: uuid('user_id').notNull(), // soft ref publicName: text('public_name'), bio: text('bio'), avatarUrl: text('avatar_url'), specializations: text('specializations').array(), links: jsonb('links').$type<{ label: string; url: string }[]>(), archivedAt: timestamp('archived_at').defaultNow().notNull(),});No FKs by design — this is a backup, not a live reference. Removed in a follow-up migration one release cycle from now.
Customer write-guard — NOT a DDL change
Section titled “Customer write-guard — NOT a DDL change”No CHECK constraint, no trigger. The 409 lives in
CompanyCustomersService.update, with code errors.customer.name_locked.
A DB constraint would be overkill and would surface as 500s instead of
clean 409s when the UI accidentally sent name.
Indexes summary
Section titled “Indexes summary”| Index | Table | Type | Why |
|---|---|---|---|
user_public_profile_user_id_unique | users.user_public_profile | UNIQUE | 1:1 with users |
user_public_profile_slug_unique | users.user_public_profile | UNIQUE | pretty-URL handle |
| (none new) | companies.company_member | — | drops only, no new indexes |
| (none) | companies.company_member_legacy_identity | — | backup table; no live reads expected |
Backend module placement
Section titled “Backend module placement”| Concern | Library | File(s) |
|---|---|---|
user_public_profile schema | libs/shared/data-access-db | src/lib/schema/users.schema.ts |
companyMember schema edits | libs/shared/data-access-db | src/lib/schema/companies.schema.ts |
company_member_legacy_identity backup table | libs/shared/data-access-db | src/lib/schema/companies.schema.ts |
| Migration files | libs/shared/data-access-db | migrations/*.sql (generated by drizzle-kit generate; the conflict-resolved backfill needs a HAND-INSERTED block — see drafts/migration-global-user-identity.sql) |
PATCH /me/public-profile + GET /me/public-profile | libs/features/user-profile | src/lib/user-profile.controller.ts (extend), src/lib/user-profile.service.ts (new methods), src/lib/dto/update-my-public-profile.dto.ts (NEW), src/lib/dto/user-public-profile.dto.ts (NEW), src/lib/user-profile-client.module.ts (no change — controller is already imported) |
| Customer write-guard + nameLocked read field | libs/features/companies | src/lib/services/company-customers.service.ts (update method), src/lib/dto/update-customer.dto.ts (DTO already exists — no field change needed; service enforces the rule), src/lib/dto/customer-response.dto.ts (add nameLocked to read DTO if it exists; else add the field projection in service) |
Linking-flow backfill into users.globalName | libs/features/auth | src/lib/services/auth-sync.service.ts (extend the existing customer-linking branch) |
Member-admin DTO restructure (read user.publicProfile.*) | libs/features/companies | src/lib/dto/member-*.dto.ts (whichever currently carries publicName/bio/avatarUrl); read-time JOIN added in company-members.service.ts |
Contributor read DTO restructure (read member.user.publicProfile.*) | libs/features/activities | src/lib/services/activity-contributors.service.ts (JOIN companyMembers → users → userPublicProfiles); src/lib/dto/activity-contributor.dto.ts (response DTO updates — field-stable on the client surface, restructured under user.* on the business surface; data source moves on both) |
| Coach preview restructure for sessions | libs/features/activities | src/lib/services/sessions.service.ts (JOIN through userPublicProfiles); preserve the existing CoachPreviewDto field shape on the client surface, restructure on the business surface |
No new libs/shared/ utility is needed. The “read identity through
user.publicProfile” projection is a normal Drizzle LEFT JOIN; a tiny
helper to assemble the embedded shape is encapsulated inside each
feature.
Frontend implications (per app)
Section titled “Frontend implications (per app)”tktspace-business (Angular)
Section titled “tktspace-business (Angular)”client/src/app/features/dashboard/member/pages/*— split the member edit page into TWO visual sections:- Global profile (read-only). Read off
member.user.publicProfile.*. Renders global name, avatar, bio, specializations, links, slug, verifiedAt badge, cover photo. Shows a Taigatui-notificationwith “Public profile is owned by the user — ask them to update if needed.” NoformControlNamebindings — pure display. - Company-specific (editable). Existing FormGroup, plus
roleLabel: FormControl<string | null>(null)androle,internalNotes,isActive. The form submit hits the existing member-update endpoint with the trimmed payload.
- Global profile (read-only). Read off
client/src/app/features/dashboard/customers/forms/*andpages/*— customer edit form must disable thenameinput when the customer row isnameLocked = true(<input formControlName="name" [readOnly]="customer.nameLocked">). A small notice explains that online customers’ display name comes from their global profile. On submit, the form must NOT includenamein the PATCH body whennameLockedis true — defensive client-side guard mirroring the server-side 409.client/src/app/features/dashboard/activities/*— contributor pages already usemember.publicNamevia the v3 schema. After v4, the generated DTO will instead exposemember.user.globalName(and optionallymember.user.publicProfile.bioetc.). All template references likec.member?.publicNamebecomec.member?.user?.globalName. TypeScript compile errors afternpm run generate:apiflag every stale reference — the intended forcing function.client/src/app/core/api/**— regenerated vianpm run generate:api.- Taiga components used:
tui-avatarfor global avatar in read-only block,tui-badgeforverifiedAtcheckmark,tui-chip/tui-tagfor specializations,tui-notificationfor the ownership notice.
tktspace-web (Angular SSR)
Section titled “tktspace-web (Angular SSR)”src/app/pages/event/*— coach previews. After regen, theContributorMemberPreviewDtoschema still haspublicName,avatarUrl,bio,specializations,links(field-stable on the client surface per D9 “Field-shape per surface”); web only needs to regenerate. NO template changes expected.src/app/pages/profile/*— if the web app exposes a user-self profile edit screen, add a “Public profile” section that callsmeClientUpdatePublicProfile. Out of scope for v1 unless product asks; the spec’s primary edit path is the mobile gym app.src/app/core/api/**— regenerated vianpm run generate.ng-openapi-gen.json+swagger.json— the client contract feeds the regen; no manual API edits.
tktspace-landing (Astro)
Section titled “tktspace-landing (Astro)”- Not affected. Landing has no profile rendering.
Mobile implications
Section titled “Mobile implications”Affected apps: apps/gym_app ONLY. apps/tickets_app does not
render coach / contributor / global identity flows today (verified
during v3 contributors work — grep history in
adrs/contributors-must-be-members.md). tickets_app regenerates its
API client as a no-op.
Affected shared packages:
packages/api— Chopper regen viamelos run sync:spec && melos run generate:api. The regeneratedswagger_api.swagger.dartcarries the new endpoints (meClientGetPublicProfile,meClientUpdatePublicProfile) and the new DTOs (UserPublicProfileDto,UpdateMyPublicProfileDto).packages/profile— NEW screens / state:- Edit public profile modal/sheet under
packages/profile/lib/src/modals/edit_public_profile_modal.dart(NEW). Form fields: globalName, bio, specializations (chip input), links (label + url pairs), slug (with debounced availability hint — server returns 409 on collision; UI shows inline). NO avatarUrl / coverPhotoUrl / verifiedAt fields. The existingedit_profile_modal.dartkeeps handling/me(name, language, birthDate) — splitting “core me” from “public-facing me” matches the backend split. - Read-only view for the user’s own public profile, accessible
from the profile page. Lives next to
profile_page.dart. Shows avatar (uploaded via existing/me/avatarflow), name, bio, specializations (chips), links (tappable), slug, verified badge ifverifiedAt != null, cover photo ifcoverPhotoUrl != null.
- Edit public profile modal/sheet under
packages/auth,packages/checkout,packages/core,packages/notifications,packages/ui— no direct change.packages/i18n— IS touched. New i18n keys land underpackages/i18n/assets/i18n/{en,ru,uk}.jsonin theprofile.public.*group (title, hints, slug helper text, validation errors includingslug_invalid,slug_reserved,slug_taken). The spec’smobile-packagesfrontmatter is amended in the same MR to includei18nso build-phase tooling counts this package as in-scope.
apps/gym_app/lib/pages/activity/activity_page.dart — coach
preview rendering. After regen, Contributor.member.publicName is
still present as a field-stable name on the client surface. No
template change required for v4. Only apps/gym_app/lib/pages/profile/
(or wherever the user’s “me” tab lives) needs to gain the “Edit public
profile” entry point. The existing MyProfile page imports
packages/profile’s tktspace_profile.dart barrel — adding the new
modal there propagates automatically.
Offline considerations. The public-profile read is small (a few KB);
no offline cache is mandatory. The PATCH must surface server 409
(errors.profile.slug_taken) as an inline form error, not a global
toast — matches the existing gym-app form-error pattern.
Deep-linking. Slug-based vanity URLs (/coach/{slug}) are not in
scope for v1. The slug column exists for forward compatibility; routing
is added by a future ticket.
No new app added. Brand/flavor setup unchanged.
- Migration race on multi-instance backfill. The conflict-resolved
backfill from
companyMemberintouser_public_profilewalks every row, so two instances running migrations concurrently could write the same row twice. Mitigation: Drizzle migrations run via the project’s plain-SQL runner, single-process — verified in thecontributors-must-be-membersADR Migration notes. We document explicitly that the migration must NOT be run on a hot multi-pod deployment, only via the standard one-shot release process. - Conflict CSV review burden. Diverging per-company values (a
coach with three different bios across three gyms) write a row to
data/global-user-identity-conflicts.csv. On dev that’s a small handful; on prod it’s an open question. Mitigation: the migration self-counts before and after; if the conflict count exceeds 50 rows the build engineer or the on-call team member who runs the deploy reviews the file with product before the release ships. Logged in the migration’s NOTICE output so it lands in deployment logs. - v4 ADR amendment carries behavioural changes for shipped
features. Every consumer that renders a
ContributorMemberPreviewmoves its data source. Field-stable DTOs (we kept names) reduce blast radius, but the displayed VALUES change (a coach who differed between gyms will now show one canonical name everywhere — by design). The conflict CSV is the audit trail. - Customer linking backfill races with concurrent admin edits. If
an admin is editing
companyCustomers.nameat the same instant the user signs up and the linking flow fires, the backfill could copy a stale name intousers.globalName. Mitigation: the backfill runs inside the sameauth-sync.servicetransaction as the linkUPDATE, so it sees a snapshot — the worst case is that the admin races and overwrites their own change on the next save. Acceptable. - Slug collisions on first use. When the slug feature flips on,
two users could submit the same slug simultaneously. The DB UNIQUE
- service 409 mapping handles this cleanly. Tested via integration test (Phase C).
verifiedAtwrite leak. A misbehaving client could try to PATCHverifiedAt. Mitigation: not in theUpdateMyPublicProfileDto→ stripped bywhitelist: true. Defence in depth: service code does not include the field in its UPDATE set. Integration test asserts a PATCH withverifiedAtin the body leaves the row unchanged.internalNotesadd oncompanyMember— domain-doc / schema gap closed. The currentcompanyMemberschema has nointernalNotescolumn;companyCustomerhas its owninternalNotes(per-tenant customer notes — different field, different table). The spec’s per-field decision table assigns “Internal admin notes” tocompanyMember, and this ticket closes the gap by adding the column. The field is admin-only (business surface) and never exposed on the client surface. The spec’s AC checklist is amended in the same MR to make this explicit.
Rollout plan
Section titled “Rollout plan”Phased, single migration, no feature flag.
- Phase 0 — Audit on dev + prod. Run the conflict-count query (see drafts/migration-global-user-identity.sql for SQL). If conflict count is unexpectedly high (>50), product reviews the CSV before release.
- Phase 1 — Migration (one drizzle-generated file, hand-edited
backfill block). Sequence inside the file:
CREATE TABLE user_public_profile.CREATE TABLE company_member_legacy_identity.INSERT INTO company_member_legacy_identity SELECT ...— atomic snapshot of every populated identity field oncompanyMember.INSERT INTO user_public_profile (user_id, bio, specializations, links) SELECT ...— conflict-resolved (most-recently-updated row wins per user; conflicts also row-written to a TEMP TABLEtmp_global_user_identity_conflictsand dumped to NOTICE so the deployment logs preserve them).UPDATE users SET global_name = ... WHERE global_name IS NULL— same conflict resolution for global name (only when the user had no global name yet).UPDATE users SET avatar_url = ... WHERE avatar_url IS NULL— same.ALTER TABLE company_member ADD COLUMN role_label text.ALTER TABLE company_member ADD COLUMN internal_notes text— unconditional (see D3; admin-only per-tenant field).ALTER TABLE company_member DROP COLUMN public_name, ... links.
- Phase 2 — Backend deploy. New endpoint + service changes ship. Contract regen kicks off consumer rebuilds.
- Phase 3 — Consumer deploys (parallel). Business, web, gym_app regenerate their clients; templates / forms updated; deployed.
- Phase 4 — Conflict review. Product reads
data/global-user-identity-conflicts.csv(extracted from the deployment NOTICE log into a CSV) and reaches out to users whose global name was chosen vs the alternatives. Out of scope for the technical migration; documented as a product follow-up. - Phase 5 — Backup table cleanup. Drop target: release 2026.07
(the second scheduled release after Phase 1 ships). A new migration
drops
company_member_legacy_identity. Tracked as a separate follow-up ticket; the named release prevents the table from calcifying past its retention window.
No feature flag. The migration is one-shot data restructuring; the
endpoints are additive (existing /me, /me/avatar, etc. all keep
working). The flag-vs-migrate tradeoff is documented in the spec.
AC → test-file mapping
Section titled “AC → test-file mapping”Phase B test-writers use this table to know where each AC’s tests live. File paths are conventions — backend tests are co-located with the feature they cover.
| AC (from spec) | Test file(s) |
|---|---|
New table user_public_profile exists with shape + 1:1 FK | libs/shared/data-access-db/test/schema/user-public-profile.spec.ts (schema/migration smoke) |
publicName/avatarUrl stay on users | libs/features/user-profile/test/user-profile.service.spec.ts (read projection includes both) |
companyMember loses identity fields after backfill | libs/shared/data-access-db/test/migrations/global-user-identity.spec.ts (migration integration test) |
| Migration backfills cleanly with conflict report | libs/shared/data-access-db/test/migrations/global-user-identity.spec.ts (asserts NOTICE output + winner selection) |
companyMember.roleLabel (text) added | libs/features/companies/test/company-members.service.spec.ts (CRUD path includes roleLabel) |
companyMember.internalNotes (text) added; admin-only | libs/features/companies/test/company-members.service.spec.ts (asserts internalNotes editable on business surface, whitelist: true strips from client-surface DTOs — covered in client surface DTO tests under libs/features/companies/test/dto/) |
Identity DTOs read via user.* (not member.*) | libs/features/activities/test/activity-contributors.service.spec.ts; libs/features/activities/test/sessions.service.spec.ts |
| Business panel surfaces global profile read-only | libs/features/companies/test/dto/contributor-member-preview-admin.dto.spec.ts (DTO shape includes nested user.publicProfile) |
Web + mobile render coach previews from user.* | Consumer-side template tests live in tktspace-web and tktspace-mobile-app/apps/gym_app; out of scope for this backend ADR but flagged for Phase B consumer test-writers |
| Reviews table designed (not implemented) | None — design-only AC; covered by ADR text |
PATCH /api/business/customers/{id} 409 when name + userId IS NOT NULL | libs/features/companies/test/company-customers.service.spec.ts (update guard) |
Linking flow backfills users.globalName | libs/features/auth/test/auth-sync.service.spec.ts (single-customer + multi-customer determinism cases) |
Read paths display users.globalName when userId set | libs/features/companies/test/company-customers.service.spec.ts (COALESCE projection) |
PATCH /api/client/me/public-profile new endpoint | libs/features/user-profile/test/user-profile.controller.spec.ts (PATCH happy path + slug validation + whitelist: true strips verifiedAt) |
| Business “view member” renders global profile read-only | libs/features/companies/test/dto/contributor-member-preview-admin.dto.spec.ts |
ADR contributors-must-be-members v4 amendment | Repo-level review — no test file (ADR text only) |
The mapping is deliberately rough: Phase B test-writers will design the case-level matrix. This table only unblocks the “where do my tests live?” question.
Open questions
Section titled “Open questions”- Slug squatting / reserved-handle expansion beyond the v1 denylist
(
me,admin,support,coach,api,business,superadmin,auth). Punted to a future “slugs are routable” ticket (CU-TBD) — the v1 denylist is enough until slugs are actually surfaced as public URLs. - Cover photo upload route.
POST /me/coveris referenced as a future addition in D7; tracked as CU-TBD. Not in scope here.
v3 amendment — Symmetric per-surface identity (2026-05-27)
Section titled “v3 amendment — Symmetric per-surface identity (2026-05-27)”The original spec treated users as “one row per real person.” This is wrong: there are three independent Supabase projects (admin / client / superadmin) and our users.users table holds the union of admin + client (superadmin doesn’t sync into it — see superadmin-jwt.strategy.ts). UUIDs from different Supabase projects never collide, so a real person who uses both surfaces has two independent users rows. The same email can belong to a business-Ivan and a client-Ivan as two different system identities.
What was wrong with v1/v2
Section titled “What was wrong with v1/v2”- v1/v2 phrased the goal as “one global identity per person.” Reality is “one identity per
usersrow, whereusers.id = auth.users.idfrom the issuing Supabase project.” - Phase C added
GET/PATCH /api/client/me/public-profileandEditPublicProfileScreeningym_app— but did NOT add the symmetric business-side endpoint. Asymmetric. - Docs claimed “single identity across surfaces” — incorrect.
What v3 makes explicit
Section titled “What v3 makes explicit”Symmetric per-surface model. Both business and client surfaces have their own /me/public-profile endpoint, gated by their respective JWT strategy, operating on their respective users row. Each user owns and edits their profile; cross-surface reads (coach card on client side, customer name on business side) traverse the users table by FK but never edit across the boundary.
Concretely:
PATCH /api/business/me/public-profile(new in v3) — business user edits own profile. Gated byAdminJwtGuard(Admin Supabase).PATCH /api/client/me/public-profile(existing from v1) — client user edits own profile. Gated byClientJwtGuard(Client Supabase).- Both endpoints share
UserPublicProfileService.updatePublicProfile(userId, dto)internals — same slug normalisation, sameverifiedAtstrip, same UPSERT logic. - DTOs (
UserPublicProfileDto,UpdateMyPublicProfileDto,PublicProfileLinkDto) intentionally duplicated in both contracts (contracts/client.openapi.yaml+contracts/business.openapi.yaml) per the repo’s “never share types between surfaces” convention.
New schema column — users.scope ('business' | 'client')
Section titled “New schema column — users.scope ('business' | 'client')”Added in migration 0041_users_scope.sql:
ALTER TABLE users.users ADD COLUMN scope text CHECK (scope IS NULL OR scope IN ('business', 'client'));
-- Backfill via inferred relationships:UPDATE users.users SET scope = 'business' WHERE id IN (SELECT user_id FROM companies.company_member);
UPDATE users.users SET scope = 'client' WHERE id IN (SELECT user_id FROM companies.company_customer WHERE user_id IS NOT NULL) AND scope IS NULL;
-- Orphans (no relations) → NULL; AuthSyncService fills on next login.The column is nullable initially to allow gradual backfill; AuthSyncService.validateBusiness() / validateClient() (refactored) write the scope on each new auth-sync. A separate ticket can flip the column to NOT NULL once all rows are populated.
Superadmin is not in this enum because superadmin-jwt.strategy.ts does NOT invoke AuthSyncService — superadmin users never get a users row. Their identity is JWT bearer + an external Supabase project, full stop.
What stayed the same
Section titled “What stayed the same”- Migration 0040 (drop
companyMember.{publicName,bio,avatarUrl,specializations,links}→user_public_profile) — still correct. It’s a business-scope unification: one business-Ivan, one profile across all hiscompanyMemberrows. - Customer name-lock guard + COALESCE projection +
nameLocked: boolean— still correct (client-scope concern, doesn’t cross to business). - Admin member-edit split (read-only global section + editable per-company) — still correct: admin sees the member’s business-side profile read-only.
- ADR
contributors-must-be-membersv4 amendment — still correct: identity reading viamember.user.publicProfile.*is the business-Supabase user’s profile.
Why we are NOT adding scope as required (yet)
Section titled “Why we are NOT adding scope as required (yet)”- Backfill is straightforward for rows with relations (companyMember → business, companyCustomer → client) but ambiguous for orphans — users who logged in once and never interacted with anything. Flipping to NOT NULL would force a guess.
- Auth-sync now writes the value on every login, so orphans will heal naturally.
- A follow-up ticket can flip NOT NULL once
count(*) WHERE scope IS NULL = 0is stable for a release cycle.
What v3 does NOT solve (deliberately)
Section titled “What v3 does NOT solve (deliberately)”- Cross-surface identity unification — making business-Ivan and client-Ivan recognise each other across Supabase projects is a much bigger ADR (consolidate to one Supabase OR add a
persontable linking by email/phone). Out of scope here. - Removing client-side public profile — was considered, rejected: clients have legitimate use for bio/specializations/links (future leagues, player profile, comments-on-coaches with bio context).
Changelog
Section titled “Changelog”v3 (2026-05-27) — symmetric per-surface identity; added scope column
Section titled “v3 (2026-05-27) — symmetric per-surface identity; added scope column”- Recognised the spec’s “one identity per person” was wrong: 3 Supabase projects, admin + client both write to
users.users, superadmin doesn’t. - Added symmetric business-side endpoint
GET/PATCH /api/business/me/public-profile. - Added
users.scope textcolumn (nullable) with auth-sync backfill viavalidateBusiness()/validateClient()refactor. - Migration
0041_users_scope.sql— column add + relationship-based backfill. - Patched
contracts/business.openapi.yaml— new endpoint + DTO schemas (PublicProfileLinkDto, UserPublicProfileDto, UpdateMyPublicProfileDto), intentionally duplicated from client.openapi.yaml per repo convention. - Business panel: added “My profile” page for self-edit.
- Docs updated: identity context, user.mdx, user-public-profile.mdx, glossary.mdx — phrased correctly as “per-surface identity, symmetric on business and client.”
v2 (2026-05-26) — addressed critic blockers 1/2/3, folded high-value NITs.
Section titled “v2 (2026-05-26) — addressed critic blockers 1/2/3, folded high-value NITs.”- Blocker 1: migration
DO $$block now uses a separateloser RECORDvariable for the inner conflict-emit loop so the outer cursor’srec.user_idis no longer clobbered. AddedIF NOT EXISTSindex oncompanies.company_member(user_id)before the backfill (supports the per-user scan). - Blocker 2: committed the
companyMember.internalNotesadd unconditionally. Removed every “if reverting” / “if product disagrees” hedge from D3, Risks #7, Rollout step 8, and the Drizzle preview comment. Spec amended in the same MR (added AC line; addedi18ntomobile-packages). - Blocker 3: added D9 subsection “Field-shape per surface — divergence by design” pinning the flat-client / nested-business divergence. Audited and qualified every “field-stable” mention in the ADR to “field-stable on the client surface” where it had read as a global statement (Surface impact table, contributor DTO description, backend module placement, tktspace-web section).
- NIT (high-value): added
company_member_user_id_idxto the migration with rationale comment. - NIT (high-value): pinned multi-company linking determinism in D6 —
ORDER BY company_customer.created_at ASC, id ASC LIMIT 1. - NIT (high-value): pinned slug normalisation (auto-lowercase + collapse-
hyphens + regex), denylist constant, slug-collision race-safe UPSERT,
and
GET /me/public-profile200-all-null synthesise behaviour in D7. - NIT (high-value): added multi-tenancy edge case statement in D7 (any authenticated user can edit own profile, identity-owned not company-gated).
- NIT (high-value): replaced
auth-sync.service.tsline numbers in D6 with a method-name reference (linkUnlinkedCustomersByEmail). - NIT (high-value): named the conflict-CSV operator (build engineer / on-call deploy runner) in Risks #2.
- NIT (high-value): set concrete drop target (release 2026.07) for
company_member_legacy_identityin D4, Rollout Phase 5, and the migration footer. - NIT (high-value): added AC → test-file mapping table at the end of the Decision section.
- NIT: added Open Questions section punting slug squatting and tracking cover-photo upload route to CU-TBD.
STATUS: READY_FOR_REVIEW