Skip to content

ADR: Contributors must be company members

v4 amendment — global-user-identity (2026-05-26)

Section titled “v4 amendment — global-user-identity (2026-05-26)”

The companyMember identity columns (publicName, bio, avatarUrl, specializations, links) were dropped by ADR global-user-identity. The rule that every contributor IS a company member is unchanged; the constraint (memberId NOT NULL, FK ON DELETE CASCADE, UNIQUE (activityId, memberId)) stays exactly as v3 specified.

What changes is the identity reading layer:

  • v3 read rule (superseded): “every name/avatar/bio render goes through member.* exclusively.”
  • v4 read rule (current): identity renders through member.user.* for globalName/avatarUrl and member.user.publicProfile.* for bio / specializations / links. The companyMember row provides only the join, the role/permission enum, the per-tenant roleLabel, internalNotes, and isActive.

Per-surface DTO shape (per global-user-identity ADR D9):

  • Client surfaceContributorMemberPreviewDto retains flat field names (publicName, bio, avatarUrl, specializations, links) at the top level. The projection moves; the JSON shape is unchanged. Goal: zero template churn on tktspace-web and gym_app.
  • Business surfaceContributorMemberPreviewAdminDto restructures identity under user.* / user.publicProfile.*. Per-company fields (roleLabel, internalNotes, isActive, role) stay at the member top level. The admin UI renders a distinct “Public profile (read-only, owned by coach)” section that maps 1:1 to the nested DTO.

The migration 0040_global_user_identity.sql performs the column drop after a conflict-resolved backfill into user_public_profile + users.{global_name, avatar_url} and snapshots the dropped values to companyMember_legacy_identity (drop target: release 2026.07).

No behavioural change to time-slot / session coach validation, role-array model, or the rest of v3 — only the identity read-source moves.

See specs/contributors-must-be-members.md. Today activities.contributors is polymorphic: either memberId → companyMembers.id, or a static row carrying personName/avatarUrl with memberId = NULL. This dual mode (a) blocks static contributors from ever being assigned to a time_slot / session (both join tables FK-require memberId), and (b) doubles every read/render path because two identity columns must be merged. The user has decided: every contributor IS a company member. A future “guests” feature, if ever needed, gets its own table.

In the same change we move from a single role to a roles array, because one person commonly plays multiple roles on one activity (coach + speaker, host + coach, etc.). The slot/session coach is intentionally role-agnostic — any contributor of the activity may lead a slot, not just role = COACH.

  1. Schema (activities.contributors)

    • memberId uuid NOT NULL (was nullable). FK switches to ON DELETE CASCADE (was SET NULL).
    • Drop personName text and the legacy role enum column.
    • (v3 amendment) Drop description text — no per-activity free-text note any more; member-level bio covers it.
    • (v3 amendment) Drop avatarUrl text — no per-activity avatar override any more; member-level avatarUrl covers it. The S3 keyspace activities/{id}/contributors/... is therefore retired (no new objects written; existing dev objects are orphaned and safe to leave — bucket cleanup is out of scope).
    • Add roles ContributorRoleEnum[] NOT NULL with CHECK (array_length(roles, 1) >= 1). Backfilled as ARRAY[role] from the existing single value.
    • Add UNIQUE (activity_id, member_id) — one row per (activity, member). Lets PATCH be idempotent and prevents UI from accidentally creating duplicate contributor rows for the same person. Constraint is declared via Drizzle .unique().on(t.activityId, t.memberId), which generates the default constraint name contributors_activity_id_member_id_unique (see §Code touch points — service maps 23505 with that exact name to 409 errors.contributor.duplicate).
  2. Contracts

    • business.openapi.yaml: register member-only CreateContributorDto / UpdateContributorDto / ContributorPreviewDto schemas (no personName field; memberId required on create; roles: string[] instead of role: string). Note: this contract did not previously carry these schemas — we are adding them in their final shape, not editing legacy ones. (v3 amendment) Four REST operations are wired into paths: (GET / POST /activities/{activityId}/contributors, PATCH / DELETE /activities/{activityId}/contributors/{contributorId}) so ng-openapi-gen emits them when tktspace-business runs npm run generate:api. operationIds match the existing generated client functions: activityContributorsAdminControllerFindAll, ...Create, ...Update, ...Remove. The fifth route (POST .../avatar...UploadAvatar) is dropped — see Decision 5.
    • (v3 amendment) DTO shapes: CreateContributorDto = { memberId, roles, order? }; UpdateContributorDto = { roles?, order? }; ContributorPreviewDto no longer carries description or avatarUrl — every name/avatar/bio render goes through member.*. (Removed fields: description on both DTOs and the response, avatarUrl on the response. avatarUrl was never on the create/update DTOs.)
    • client.openapi.yaml: tighten existing ContributorPreviewDtomemberId becomes required + non-nullable, personName is removed, role: string becomes roles: string[] (min 1). Update the schema description to drop the “either memberId or personName” language. (v3 amendment) Drop the contributor-level avatarUrl field too — the client surface gets the avatar from member.avatarUrl exclusively post-amendment. (No description on client to begin with.)
    • Update DTO: memberId is immutable after creation — drop it from UpdateContributorDto. To re-assign a person, the user deletes the contributor and creates a new one (which is identical work given the UNIQUE constraint and cascade behavior, and avoids a class of FK-rewire bugs in time_slot_coaches/session_coaches).
  3. Validation

    • On POST /api/business/activities/{id}/contributors: memberId must belong to the same company as the activity (404 errors.member.not_found otherwise); roles must be non-empty (400 errors.contributor.roles_required); duplicate (activityId, memberId) returns 409 errors.contributor.duplicate.
    • On POST /api/business/time-slots and PATCH /api/business/time-slots/{id}: extend the current assertCoachesAreActive check (time-slots.service.ts:55-67) into a fixed-order pipeline assertCoachesAreContributors(activityId, coachIds) that runs before the existing active check, with this order per id in coachIds:
      1. Member exists and belongs to the active company (companyMembers.companyId === activeCompanyId) → 404 errors.coach.not_found.
      2. Member is a contributor of the activity (row in contributors with activityId = X AND memberId = Y) → 404 errors.time_slot.coach_not_contributor.
      3. Member is active (isActive = true) → 400 errors.time_slot.coach_deactivated. Errors are thrown for the first failing id (no aggregation) so the toast surfaces a single, actionable message. The role-agnostic rule stands — step 2 does not filter on roles.
  4. Frontend

    • tktspace-business: drop the inputMode toggle and personName control from the contributor form; only the member dropdown remains. Replace the single role control with a chip multi-select (required, min length 1).
    • tktspace-business: the two time-slot modals stop passing c.id and pass c.memberId instead; stringifyContributor resolves via memberId. No role-based filter on contributors — the picker shows every contributor of the activity regardless of roles[]. Because non-COACH roles (SPEAKER, ACTOR, etc.) will now surface in the picker labelled «Тренер» / time_slot_rule#form#coach, rename the i18n key value (not the key) to «Ведущие занятия» / “Slot leaders” so the language is correct for non-sport spheres. Keep the key name time_slot_rule#form#coach to avoid a template churn; only the i18n catalogue values change.
    • tktspace-web + gym_app: regenerate clients; remove every ?? personName fallback; render roles as a chip-list (Taiga tui-badge on web/business, Flutter Chip widget on gym_app). Pick chip-style uniformly — do not mix ” · ” joins on one surface and chips on another. Where member.publicName is null (it is nullable in the DB — confirmed in companies.schema.ts:66), fall back to '' (empty string), not personName.
  5. (v3 amendment) Contributor has no per-activity overrides — member-level fields are the single source of truth. With Decision 1 making every contributor a companyMember, the previously-supported per-activity overrides (contributors.description, contributors.avatarUrl, and the avatar-upload endpoint) are redundant and actively harmful: they create two render paths per field, two write paths per field, and a way for the same person to look inconsistent across activities of the same company. The amendment:

    • Drops contributors.description and contributors.avatarUrl columns (see Decision 1).
    • Drops POST /api/business/activities/{id}/contributors/{cid}/avatar — route, controller method, service method, and the S3 keyspace activities/{id}/contributors/.... Avatar maintenance happens exclusively at the company-member level (existing companyMembers.avatarUrl upload flow, owned by the members feature — not in scope for this ticket).
    • Tightens CreateContributorDto / UpdateContributorDto to {memberId, roles, order?} / {roles?, order?}.
    • Tightens ContributorPreviewDto (both business and client surfaces) to drop description and avatarUrl. Web/business/gym_app now render c.member.publicName, c.member.avatarUrl, c.member.bio exclusively — no fallback to a contributor-level field. This decision is a clean simplification, not a behaviour rollback: members already carry every field the UI needs. Any future requirement to brand a contributor differently per activity is out of scope and would warrant a new ADR.
  1. Status quo (keep polymorphic contributor). Rejected: leaves the production bug (static contributors can never be slot coaches, surface 404 on save) and forces every renderer to handle two identities forever.
  2. Add contributor_id FK on time_slot_coaches / session_coaches (re-key away from member_id). Rejected: strictly bigger migration (forces backfill of two more correctly-working tables, breaks every existing coach query) for no extra capability. The spec’s “members-only” rule makes member_id the canonical key and contributor_id redundant.
  3. Hybrid: keep personName as nullable, but ALSO require memberId. Rejected: personName would become dead weight — UI is told to ignore it but the column lingers. Either it has meaning (then we are back to dual identity) or it does not (then drop it). No middle ground worth keeping.
  4. Keep single role column and create N contributor rows per person. Rejected: violates the new UNIQUE (activity_id, member_id) invariant, doubles the data volume for multi-role people, and forces every UI to deduplicate by member on read. roles[] is the natural model.

Per project memory feedback_drizzle_migrations.md — backend migrations are produced by drizzle-kit generate from schema edits; hand edits are minimised. The change is therefore split into two drizzle-generated migrations (one schema edit each), with at most one hand-inserted SQL line in the first one.

Step 0 — audit (run before either migration)

Section titled “Step 0 — audit (run before either migration)”

Reproducible audit query that BOTH locally and on prod must return 0 rows before the migration is run:

SELECT count(*) FILTER (WHERE member_id IS NULL) AS orphan_members,
count(*) FILTER (WHERE role IS NULL) AS null_roles
FROM activities.contributors;

Result on dev + prod (2026-05-18): orphan_members = 0, null_roles = 0. The migration also self-verifies via a DO $$ BEGIN ASSERT (SELECT count(*) FROM activities.contributors WHERE role IS NULL) = 0; ASSERT (SELECT count(*) FROM activities.contributors WHERE member_id IS NULL) = 0; END $$; block placed at the very top of the FIRST migration file — fits the project’s plain-.sql migration runner (Drizzle’s migrate()), which executes statements verbatim. If either assert fails the migration aborts before any DDL runs.

Schema edit (drives drizzle-kit):

  • contributors.roles added as .array() only (NOT NULL not yet asserted).
  • contributors.personName dropped.
  • contributors.role dropped.
  • (v3 amendment) contributors.description dropped.
  • (v3 amendment) contributors.avatarUrl dropped.
  • contributors.memberId becomes .notNull().
  • FK changes to onDelete: 'cascade'.
  • .unique().on(t.activityId, t.memberId) added.

drizzle-kit emits roughly:

-- AUTOGENERATED by drizzle-kit (header omitted)
DO $$ BEGIN
ASSERT (SELECT count(*) FROM activities.contributors WHERE role IS NULL) = 0;
ASSERT (SELECT count(*) FROM activities.contributors WHERE member_id IS NULL) = 0;
END $$; -- HAND ADDED — step 0 audit guard
ALTER TABLE activities.contributors ADD COLUMN roles activities.contributor_role[];
-- >>> HAND ADDED, single line between ADD COLUMN roles and DROP COLUMN role <<<
UPDATE activities.contributors SET roles = ARRAY[role] WHERE role IS NOT NULL;
-- <<< end hand edit
ALTER TABLE activities.contributors DROP COLUMN role;
ALTER TABLE activities.contributors DROP COLUMN person_name;
ALTER TABLE activities.contributors DROP COLUMN description; -- v3 amendment
ALTER TABLE activities.contributors DROP COLUMN avatar_url; -- v3 amendment
ALTER TABLE activities.contributors ALTER COLUMN member_id SET NOT NULL;
ALTER TABLE activities.contributors DROP CONSTRAINT contributors_member_id_company_members_id_fk;
ALTER TABLE activities.contributors ADD CONSTRAINT contributors_member_id_company_members_id_fk
FOREIGN KEY (member_id) REFERENCES companies.company_members(id) ON DELETE CASCADE;
ALTER TABLE activities.contributors ADD CONSTRAINT contributors_activity_id_member_id_unique UNIQUE (activity_id, member_id);

The UPDATE ... WHERE role IS NOT NULL is belt-and-braces — the audit at step 0 guarantees no NULL roles exist, but the WHERE clause makes the statement safe if it is ever rerun against a database the audit did not check.

(v3 amendment) DROP COLUMN description / avatar_url are pure drizzle-kit output — no hand edits needed for these two lines. When description and avatarUrl are removed from the schema TypeScript, drizzle-kit generate auto-emits the two ALTER lines above. Data discarded by these drops: free-text descriptions and per-activity avatar URLs that were only used as overrides on a member.* value the UI now reads directly. No backfill into another table — this is intentional deletion, not migration. The avatar URLs pointed into the S3 keyspace activities/{id}/contributors/...; the objects themselves are orphaned by this migration and left in place (bucket cleanup is out of scope for this ticket — they cost effectively nothing on dev and prod has zero such objects per the same Step 0 audit).

Migration N+1 — schema edit #2 (tighten roles)

Section titled “Migration N+1 — schema edit #2 (tighten roles)”

Schema edit (drives drizzle-kit):

  • contributors.roles becomes .array().notNull().
  • Add .$default(sql\’{}’`)` ONLY if Drizzle insists; otherwise leave default-less (every row already has a backfilled value from migration N).
  • Add check('contributors_roles_not_empty', sql\array_length(roles, 1) >= 1`)via Drizzle'scheck()` clause in the table options.

drizzle-kit emits roughly:

ALTER TABLE activities.contributors ALTER COLUMN roles SET NOT NULL;
ALTER TABLE activities.contributors ADD CONSTRAINT contributors_roles_not_empty CHECK (array_length(roles, 1) >= 1);

If Drizzle’s .check() helper is unavailable on the running version, the CHECK becomes the single hand-added line in this second migration — still satisfying the “minimize hand-written SQL” rule.

Run the migration-safety-check skill on BOTH generated files. Destructive steps (DROP COLUMN role, DROP COLUMN person_name) live in migration N — role data is preserved via the backfill UPDATE; person_name data is intentionally discarded (audit was empty).

The spec’s AC-1 wording (one logical migration result) is unchanged; this ADR specifies that the implementation is two drizzle-generated files rather than one.

Easier

  • One render path per contributor (member.publicName, member.avatarUrl). Web/mobile drop the ?? personName ternary in every template.
  • Slot/session coach assignment can no longer silently 404 — the bug class is gone because every contributor maps 1:1 to a member.
  • PATCH-by-member-id is idempotent thanks to the UNIQUE index.

Harder / risks

  • Existing assertCoachesAreActive (time-slots.service.ts:55-67) only checks companyMembers activity — it does NOT yet check that the member is a contributor of the activity. We are adding that check; the new failure mode (errors.time_slot.coach_not_contributor) must be surfaced in the business UI as a toast (Phase C).
  • memberId immutability on PATCH is a UX shift — implementer must hide the member dropdown in the edit form (read-only display).
  • Cascade delete of contributors when a member is removed will silently empty an activity’s roster. Acceptable: matches the existing cascade on time_slot_coaches / session_coaches, and “deleted member” is a rare admin action.
  • Deactivated members on existing slots are NOT scrubbed. Marking a companyMember.isActive = false does NOT remove their time_slot_coaches / session_coaches rows; existing slots continue to render them in the customer-facing UI. Only NEW slot create/update is blocked by step 3 of assertCoachesAreContributors (coach_deactivated). Hard deletion of the member (vs deactivation) cascades and is the only way to scrub historical assignments. This is consistent with current behaviour — we are not changing it under this ticket — but document it explicitly.

Migrates

  • Schema: activities.contributors columns. No data loss on role → roles (backfilled). person_name discarded (audit was empty).
  • Contracts: business adds the schemas AND 4 path operations (the fifth, avatar upload, is intentionally dropped — see Decision 5); client tightens ContributorPreviewDto. Both consumers regenerate clients.

Open questions resolved at /approve-spec time

Section titled “Open questions resolved at /approve-spec time”

None remain. The spec carries an explicit user decision on every previously-open item:

  • Polymorphic vs members-only → members-only.
  • Single role vs roles[] → array, non-empty.
  • Slot coach role gating → role-agnostic (any contributor).
  • Orphan data on prod → audit confirmed 0 (2026-05-18), unconditional NOT NULL is safe.
  • Guests/external people → out of scope, separate activity_guests table if ever needed.
  • companyMembers.publicName nullability → confirmed NULLABLE in DB (companies.schema.ts:66publicName: text('public_name') with no .notNull()). Both client and business ContributorMemberPreviewDto keep publicName nullable. AC-8 promise reads: “drop the ?? personName ternary, fall back to '' when publicName is null.”
  • (v3 amendment, 2026-05-18) Per-activity overrides at the contributor level → dropped entirely (spec AC-10 + AC-11). With every contributor guaranteed to be a companyMember, the description / avatarUrl columns, the avatar-upload endpoint, and the S3 keyspace activities/{id}/contributors/... are all redundant — member-level bio, publicName, and avatarUrl are the single source of truth across every render path. See Decision 1 (schema drops), Decision 2 (contract DTO trims), and Decision 5 (rationale).

Implementation map for Phase C. No code is touched in Phase B (this ADR + contracts only).

  • libs/shared/data-access-db/src/lib/schema/activities.schema.ts — TWO sequential schema edits, one per migration (see §Migration):
    • Edit #1: drop personName + role + (v3 amendment) description + (v3 amendment) avatarUrl; add roles (ContributorRoleEnum('roles').array() — nullable for now), memberId.notNull(), FK onDelete: 'cascade', unique().on(t.activityId, t.memberId).
    • Edit #2: tighten roles to .array().notNull(); add check('contributors_roles_not_empty', sql\array_length(roles, 1) >= 1`)` in the table options.
  • libs/shared/data-access-db/migrations/<timestamp_1>_contributors_members_only.sql — generated by drizzle-kit generate from edit #1; hand-add (a) the DO $$ BEGIN ASSERT ... END $$; audit line at the top, and (b) the single UPDATE activities.contributors SET roles = ARRAY[role] WHERE role IS NOT NULL; line between ADD COLUMN roles and DROP COLUMN role.
  • libs/shared/data-access-db/migrations/<timestamp_2>_contributors_roles_not_empty.sql — generated by drizzle-kit generate from edit #2; ideally zero hand edits (Drizzle emits the NOT NULL + CHECK).
  • libs/features/activities/src/lib/dto/activity-contributor.dto.tsCreateContributorDto: memberId required UUID, roles: ContributorRole[] with @ArrayNotEmpty() @IsEnum(ContributorRole, { each: true }), order? integer; drop personName, singular role, (v3 amendment) description. UpdateContributorDto: drop memberId entirely (immutable), drop personName, (v3 amendment) drop description, replace role with roles (same array validators, still optional), order? integer.
  • libs/features/activities/src/lib/services/activity-contributors.service.ts
    • Drop all personName selects/inserts.
    • (v3 amendment) Drop all description selects/inserts (currently lines 26, 62, 77, 120 — the field is gone from the schema and the DTOs).
    • (v3 amendment) Drop all avatarUrl selects/inserts on the contributor row (currently line 27, 121 in the select map; line for the update set in uploadAvatar). Member-level avatar still comes through companyMembers.avatarUrl via the join.
    • (v3 amendment) Remove uploadAvatar method entirely (currently lines 87-99). Remove the now-unused StorageService and BufferedFile imports if no other method in this file uses them — at v3 the file no longer touches storage at all, so both imports are deleted and the constructor parameter private readonly storage: StorageService is dropped. (Verify there are no other consumers in this file before removing; the v3 grep confirms uploadAvatar is the only storage caller.)
    • On create: assert memberId belongs to same company (404 errors.member.not_found); catch Postgres 23505 whose constraint field equals contributors_activity_id_member_id_unique and map to 409 errors.contributor.duplicate (any other unique violation rethrows as 500 — the constraint name match is mandatory so we do not swallow unrelated dupes).
    • On update (PATCH): the DTO no longer carries memberId (nor description), so neither can reach the service. Add a defensive assertion test (see backend-test note below).
    • findAll/findOne no longer left-join — inner join companyMembers and drop the c.memberId ? {...} : null ternary.
  • libs/features/activities/src/lib/controllers/activity-contributors-admin.controller.ts(v3 amendment) remove the entire @Post(':contributorId/avatar') block (currently lines 50-64), including the FileInterceptor/ApiConsumes/ApiBody decorators and the uploadAvatar controller method. Drop now-unused imports: Post stays (still used by the create handler at line 25), but UploadedFile, UseInterceptors, FileInterceptor, ApiBody, ApiConsumes, ParseUploadFilePipe, and BufferedFile are all removed if not referenced by any other handler in the file. The controller after this edit exposes exactly four routes — GET /, POST /, PATCH /:contributorId, DELETE /:contributorId — matching the four operationIds in the v3 business contract.
  • libs/features/activities/src/lib/services/time-slots.service.ts — rename assertCoachesAreActive to assertCoachesAreContributors(activityId, coachIds) and implement the fixed three-step order from §Decision 3 (exists+same-company → 404, is-contributor → 404, is-active → 400). Wire into both create (line 87) and update (line 576).
  • Backend test (Phase C) — add an e2e/integration test for PATCH /api/business/activities/{id}/contributors/{contributorId}: post a body that includes memberId: <different-uuid> alongside legal fields, then read the row from the DB after the PATCH succeeds and assert row.memberId === ORIGINAL_MEMBER_ID. Background: the project’s global ValidationPipe is configured with whitelist: true but NOT forbidNonWhitelisted (tktspace-backend/apps/api/src/main.ts:30-33), so unknown fields are silently stripped. If a stale business build sends memberId (the current activity-contributor.form.ts:80-96 toUpdateDto does), the PATCH still returns 200 but the FK does not move. This test pins that behaviour and protects against accidentally restoring memberId to the DTO later. We deliberately DO NOT enable forbidNonWhitelisted globally — that would break unrelated endpoints across the codebase and is out of scope.
  • client/src/app/features/dashboard/activities/forms/activity-contributor.form.ts (full rewrite of the FormGroup shape) —
    • Drop inputMode, personName, and (v3 amendment) description (descriptionControl) controls.
    • In create mode: memberId: FormControl<string>(null, Validators.required).
    • In edit mode: DO NOT include the memberId control in the FormGroup at all — display the existing member’s publicName as read-only text. The form is built off mode, so two FormGroup shapes are produced from one constructor.
    • Replace role control with roles: FormControl<ContributorRole[]>([], [Validators.required, minLengthArray(1)]).
    • toCreateDto: emit { memberId, roles, order }. (v3 amendment) No description. No personName. No singular role.
    • toUpdateDto: emit { roles, order } only — do NOT include memberId, personName, or description (the current implementation on lines 80-96 includes memberId + personName; that path is the silent-data-loss bug from B2 and must be deleted). Add an inline comment referencing this ADR so a future contributor does not re-add the fields. After npm run generate:api the regenerated UpdateContributorDto only exposes roles? and order? — every stale field reference in the form / modal becomes a TypeScript compile error. This is the intended forcing function.
  • client/src/app/features/dashboard/activities/forms/activity-contributor.modal.html (and sibling templates — find via grep -l "inputMode\|personName\|description\|avatar" client/src/app/features/dashboard/activities/forms/) — remove the inputMode switch UI; (v3 amendment) remove the description textarea and the avatar upload widget (file input + preview thumb + “Upload avatar” button + any uploadAvatar service call wiring); convert the role select to Taiga tui-multi-select with chips; in edit mode render member as read-only label. The contributor modal after v3 has exactly three controls visible: member picker (create only) / read-only member name (edit only), roles chip-multi-select, order numeric input.
  • client/src/app/features/dashboard/activities/modals/create-time-slot-rule/create-time-slot-rule.modal.ts (lines 133-141) — contributorIds = computed(() => contributorsData().map(c => c.memberId)); stringifyContributor(memberId) looks up via c.memberId === memberId and renders member?.publicName ?? ''.
  • client/src/app/features/dashboard/activities/modals/update-time-slot-rule/update-time-slot-rule.modal.ts (lines 103-111) — same change.
  • client/src/assets/i18n/uk.json (and en.json, ru.json if present) — rename the value for key time_slot_rule#form#coach from «Тренер» / “Coach” to «Ведущий занятия» / “Slot leader” (the picker now surfaces any contributor regardless of role). Key name stays so we don’t touch the .html template at create-time-slot-rule.modal.html:110 and its update-time-slot-rule.modal.html twin. Also add toast keys: errors.time_slot.coach_not_contributor, errors.contributor.duplicate, errors.contributor.roles_required, errors.member.not_found if not already present.
  • client/src/app/core/api/** — regenerated by npm run generate:api.
  • src/app/core/api/** — regenerated by npm run generate.
  • src/app/pages/event/event.page.html (line 77) — remove c.personName ?? fallback: {{ c.member?.publicName ?? '' }}. Render c.roles as a chip-list (Taiga tui-badge per role), matching the chip style chosen for business.
  • Any other template/component matched by grep -r "personName\|\.role\b" src/app (likely just the event page).

tktspace-mobile-app/apps/gym_app (Flutter)

Section titled “tktspace-mobile-app/apps/gym_app (Flutter)”
  • apps/gym_app/lib/pages/activity/activity_page.dart (lines 217-234):
    • Name (line ~220): final name = c.member?.publicName ?? ''; (drop ?? c.personName).
    • (v3) Avatar (line 225): final avatarUrl = c.member?.avatarUrl; — drop the ?? c.avatarUrl fallback. The contributor-level avatarUrl is gone after regen; Dart null-safe compile will fail on the stale reference — that is the intended forcing function.
    • (v3) Roles (line 234): replace subtitle: Text(c.role) with a Wrap of Chip(label: Text(role)) widgets bound to c.roles. The singular c.role is gone after regen; Dart compile will fail on the stale reference — also intended.
    • Chip style must match the business/web tui-badge look (small, neutral background, role label uppercase or title-cased — pick once for consistency).
  • packages/api/lib/** — regenerated by melos run sync:spec && melos run generate:api.
  • packages/api/openapi/client.openapi.yaml (or the sync target) — pulled from _workflow/contracts/client.openapi.yaml by melos run sync:spec.

Mobile inventory (run 2026-05-18):

$ grep -rln "Contributor\|personName" tktspace-mobile-app/packages/ tktspace-mobile-app/apps/tickets_app/lib/ tktspace-mobile-app/apps/gym_app/lib/
tktspace-mobile-app/packages/api/lib/swagger/swagger-api.json # generated — regen
tktspace-mobile-app/packages/api/lib/src/generated/swagger_api.swagger.chopper.dart # generated — regen
tktspace-mobile-app/packages/api/lib/src/generated/swagger_api.swagger.dart # generated — regen
tktspace-mobile-app/packages/api/lib/src/generated/swagger_api.swagger.g.dart # generated — regen
tktspace-mobile-app/packages/api/.dart_tool/build/generated/... # generated — regen
tktspace-mobile-app/packages/favorites/build/unit_test_assets/.../i18n/en.json # i18n build artifact (no code)
tktspace-mobile-app/packages/i18n/assets/i18n/en.json # i18n catalogue — no Contributor key references
tktspace-mobile-app/packages/i18n/build/unit_test_assets/assets/i18n/en.json # build artifact (no code)
tktspace-mobile-app/apps/gym_app/lib/pages/activity/activity_page.dart # ONLY hand-written touch point
tktspace-mobile-app/apps/tickets_app/lib/ # 0 hits — tickets_app does not render contributors

The hand-written surface is exactly one file: activity_page.dart. Everything else is generated or i18n. tickets_app is untouched.

  • tickets_app — does not render contributors today (grep above returned 0 hits).
  • tktspace-landing — static, does not consume the contributors block.
  • super-admin contract / module — not affected (no contributor admin views).
  • Global ValidationPipe config (forbidNonWhitelisted) — would regress other endpoints; out of scope.

STATUS: READY_FOR_REVIEW