Skip to content

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, default gen_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 (enum company_member_roles: ADMIN, OWNER, MANAGER, COACH, default MANAGER) — permission role.
  • isActive (boolean, default true).
  • roleLabel (nullable text) — per-company display role label (“yoga instructor”, “head trainer”). Decoupled from the permission role enum.
  • 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, companyId NOT NULL; companyId ON DELETE CASCADE; userId FK has no on-delete declared (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).
  • role defaults to MANAGER; isActive defaults to true (enforced in tktspace-backend/libs/shared/data-access-db/src/lib/schema/companies.schema.ts).
  • Only companyMembers of a company may be contributors of 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 update PATCH — 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 JOIN companyMember → users → user_public_profile per ADR global-user-identity D9 (which amends ADR contributors-must-be-members to v4). The admin business surface explicitly does NOT accept identity fields on UpdateMemberDtowhitelist: true strips them silently.
  • internalNotes is admin-only. Editable on the business surface (UpdateMemberDto); never declared on any client-surface DTO. whitelist: true strips 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 via PATCH /api/client/me/public-profile.
  • Schema migration 0040 (per ADR global-user-identity) — one-shot atomic migration that (1) backfilled users.user_public_profile from this table’s old identity columns (conflict-resolved by most-recently-updated row per user; conflicts logged), (2) backfilled users.globalName / users.avatarUrl when previously NULL, (3) added roleLabel and internalNotes columns, (4) snapshotted dropped columns into companies.company_member_legacy_identity, (5) dropped publicName, bio, avatarUrl, specializations, links from 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) — userIdusers.users.id. N:1. Source of globalName and avatarUrl for every render that previously read member.publicName / member.avatarUrl.
  • User public profile (ENT-042) — read-only join through user. Source of bio, specializations, links, slug, verifiedAt, coverPhotoUrl. No FK on this table — accessed via the join companyMember → users → user_public_profile.
  • Company (ENT-016) — companyIdcompanies.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 ownerId references one of these rows (nullable, no DB FK).

API surfaces

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

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.ownerId points at one member; no DB constraint prevents another member from also carrying role = 'OWNER'. OPEN: future migration should add UNIQUE (company_id) WHERE role = 'OWNER' to make the invariant DB-enforced.
  • Identity columns dropped in migration 0040. publicName, bio, avatarUrl, specializations, links are no longer on this table. UI and services must read identity via the join companyMember → 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 to companyMemberId, 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 under user.* / 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-ownership endpoint, 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.ownerId pointing 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.