Company member
Purpose
A real platform User acting in a defined role inside one specific Company. One User can be a member of many companies simultaneously; one company has many members across different roles. The role decides what the member can do in that company — full owner control, admin-side writes, manager-level reads/writes, or coach-only writes.
Identity & key fields
- Primary key:
id(uuid, defaultgen_random_uuid()). userId(uuid, NOT NULL, FK →users.users.id) — no on-delete clause declared.companyId(uuid, NOT NULL, FK →companies.company.id, on-delete cascade).role(enumcompany_member_roles:ADMIN,OWNER,MANAGER,COACH, defaultMANAGER) — permission role.isActive(boolean, defaulttrue).roleLabel(nullable text) — per-company display role label (“yoga instructor”, “head trainer”). Decoupled from the permissionroleenum.internalNotes(nullable text) — admin-only per-tenant notes about the member. Never exposed on the client surface.
Identity columns dropped in migration 0040 (per ADR global-user-identity D3, D9): publicName, bio, avatarUrl, specializations, links no longer live here. All identity reads go via the join companyMember → users → user_public_profile — see User and User public profile. Historical pre-migration values are kept in the backup table companies.company_member_legacy_identity until release 2026.07.
isActive=false is a soft-disable that hides the member from new operations but does not delete historical references.
Invariants
userId,companyIdNOT NULL;companyIdON DELETE CASCADE;userIdFK has no on-delete declared (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).roledefaults toMANAGER;isActivedefaults totrue(enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).- Only
companyMembersof a company may becontributorsof its activities (FK from contributors → companyMembers with on-delete cascade) — see Contributor.
Business invariants (app-level only — no DB constraints):
- Exactly one OWNER per company. Enforced in
CompanyMembersService(libs/features/companies/src/lib/services/company-members.service.ts) by three rules: (1) cannot change OWNER’s role directly —BadRequestException('Cannot change role of the OWNER directly. Please transfer ownership to another member.'); (2) promoting another member to OWNER demotes the previous owner in the same transaction; (3) cannot remove an OWNER member —BadRequestException('errors.member.cannot_remove_owner'). OPEN: add a partial UNIQUE index(company_id) WHERE role = 'OWNER'in a future migration. - A user is at most one member per company. Service-side dedup; no DB UNIQUE on
(company_id, user_id). OPEN: add the UNIQUE constraint. - Transfer ownership is an implicit operation embedded in the regular
updatePATCH — not a first-class endpoint. The promoted member becomes OWNER and the previous owner is moved to a non-OWNER role in the same transaction. - Identity is NOT on this row. Display name, avatar, bio, specializations, links live on User (
globalName,avatarUrl) and User public profile (bio,specializations,links,slug). Reads come via JOINcompanyMember → users → user_public_profileper ADR global-user-identity D9 (which amends ADR contributors-must-be-members to v4). The admin business surface explicitly does NOT accept identity fields onUpdateMemberDto—whitelist: truestrips them silently. internalNotesis admin-only. Editable on the business surface (UpdateMemberDto); never declared on any client-surface DTO.whitelist: truestrips it from client-surface responses.
business invariants: TBD by human
Lifecycle
No status enum on this row; soft-deactivated via isActive=false.
- Create: three paths — (a) automatically as the OWNER during company creation transaction (
CompaniesService.create); (b) via accepted Company invitation; (c) manually by an admin via/api/business/companies/{id}/members. - Update: role change (via transfer-ownership flow described above), per-tenant fields (
roleLabel,internalNotes,isActive). Cannot change OWNER’s role directly. Identity fields (globalName,avatarUrl,bio, …) are NOT updatable here — the user owns them viaPATCH /api/client/me/public-profile. - Schema migration 0040 (per ADR global-user-identity) — one-shot atomic migration that (1) backfilled
users.user_public_profilefrom this table’s old identity columns (conflict-resolved by most-recently-updated row per user; conflicts logged), (2) backfilledusers.globalName/users.avatarUrlwhen previously NULL, (3) addedroleLabelandinternalNotescolumns, (4) snapshotted dropped columns intocompanies.company_member_legacy_identity, (5) droppedpublicName,bio,avatarUrl,specializations,linksfrom this table. - Deactivate: set
isActive=false. OWNER cannot be deactivated through normal flows. - Delete: hard delete possible for non-OWNER members. OWNER cannot be removed without first transferring ownership.
Relationships
- User (ENT-021) —
userId→users.users.id. N:1. Source ofglobalNameandavatarUrlfor every render that previously readmember.publicName/member.avatarUrl. - User public profile (ENT-042) — read-only join through
user. Source ofbio,specializations,links,slug,verifiedAt,coverPhotoUrl. No FK on this table — accessed via the joincompanyMember → users → user_public_profile. - Company (ENT-016) —
companyId→companies.company.id, on-delete cascade. N:1. - Contributor (ENT-010) — referenced by
contributors.member_id(on-delete cascade). 1:N. - Session (ENT-012) — N:M via
session_coaches(pure join, on-delete cascade). - Time slot (ENT-013) — N:M via
time_slot_coaches(pure join, on-delete cascade). - The company’s
ownerIdreferences one of these rows (nullable, no DB FK).
API surfaces
| Surface | Exposed | Notes |
|---|---|---|
| client | yes — exposed as ContributorMemberPreviewDto, SessionCoachClientDto (coach preview shapes embedded in activity/session DTOs). Field-stable: flat publicName, avatarUrl, bio, specializations, links keys preserved on the client surface, but values are now projected from users.* and user_public_profile.* | Swagger UI |
| business | yes — ContributorMemberPreviewAdminDto, CoachPreviewDto, CompanyMemberResponseDto. Restructured: identity now nests under user.globalName, user.avatarUrl, user.publicProfile.*. Per-company fields (role, roleLabel, internalNotes, isActive) stay at the member top level. Managed via companies-admin module | Swagger UI |
| super-admin | no | — |
Per-surface divergence is intentional (see ADR global-user-identity D9). The client surface keeps flat identity field names to avoid template churn on tktspace-web and gym_app; the business surface restructures to make the “Public profile (read-only, owned by user)” admin UX section map 1:1 to the DTO shape. Per the repo rule “never share types between surfaces” the two DTO families are intentionally not shared.
internalNotes is business-surface only — never exposed on the client surface.
Known gotchas / open questions
- The rule is one OWNER per company, but it is enforced only at the application layer today.
company.ownerIdpoints at one member; no DB constraint prevents another member from also carryingrole = 'OWNER'. OPEN: future migration should addUNIQUE (company_id) WHERE role = 'OWNER'to make the invariant DB-enforced. - Identity columns dropped in migration 0040.
publicName,bio,avatarUrl,specializations,linksare no longer on this table. UI and services must read identity via the joincompanyMember → users → user_public_profile. See ADR global-user-identity and the v4 amendment of ADR contributors-must-be-members. - Backup table for pre-migration values. Historical identity values are preserved in
companies.company_member_legacy_identity(soft refs tocompanyMemberId,companyId,userId; no FKs). Drop target: release 2026.07 — tracked as a separate follow-up ticket. If you need pre-migration values for audit, query that table. - DTO shape diverges by surface. The client-surface preview DTOs keep flat identity field names (
publicName,bio, …) for zero template churn; business-surface DTOs restructure identity underuser.*/user.publicProfile.*. Per the repo rule “never share types between surfaces”, this is intentional. - Transfer-ownership is an implicit operation inside the regular role-update PATCH. There is no dedicated
/transfer-ownershipendpoint, no audit log, and no explicit DTO — the same call promotes A to OWNER and demotes the previous OWNER in one transaction.
Recommendations
Forward-looking improvements suggested while filling this doc — not currently in place.
- Partial UNIQUE index
(company_id) WHERE role = 'OWNER'— promote the OWNER-uniqueness invariant from service to DB (already OPEN above). - UNIQUE
(company_id, user_id)— prevent a user from having multiple member rows in the same company. - First-class “Transfer ownership” endpoint with explicit DTO and audit logging — today this operation is hidden inside the generic update PATCH, which makes it both hard to discover and hard to monitor.
- Cron / monitoring for companies with no OWNER member or with
companies.ownerIdpointing at a deleted/non-OWNER member — data drift signal. - Document role permissions matrix — OWNER/ADMIN/MANAGER/COACH and what each can do across endpoints. Today the permissions checks are scattered across decorators.