ADR: Contributors must be company members
Contributors must be company members
Section titled “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.*forglobalName/avatarUrlandmember.user.publicProfile.*forbio/specializations/links. ThecompanyMemberrow provides only the join, the role/permission enum, the per-tenantroleLabel,internalNotes, andisActive.
Per-surface DTO shape (per global-user-identity ADR D9):
- Client surface —
ContributorMemberPreviewDtoretains flat field names (publicName,bio,avatarUrl,specializations,links) at the top level. The projection moves; the JSON shape is unchanged. Goal: zero template churn ontktspace-webandgym_app. - Business surface —
ContributorMemberPreviewAdminDtorestructures identity underuser.*/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.
Context
Section titled “Context”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.
Decision
Section titled “Decision”-
Schema (
activities.contributors)memberId uuid NOT NULL(was nullable). FK switches toON DELETE CASCADE(wasSET NULL).- Drop
personName textand the legacyroleenum column. - (v3 amendment) Drop
description text— no per-activity free-text note any more; member-levelbiocovers it. - (v3 amendment) Drop
avatarUrl text— no per-activity avatar override any more; member-levelavatarUrlcovers it. The S3 keyspaceactivities/{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 NULLwithCHECK (array_length(roles, 1) >= 1). Backfilled asARRAY[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 namecontributors_activity_id_member_id_unique(see §Code touch points — service maps23505with that exact name to409 errors.contributor.duplicate).
-
Contracts
business.openapi.yaml: register member-onlyCreateContributorDto/UpdateContributorDto/ContributorPreviewDtoschemas (nopersonNamefield;memberIdrequired on create;roles: string[]instead ofrole: 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 intopaths:(GET / POST /activities/{activityId}/contributors,PATCH / DELETE /activities/{activityId}/contributors/{contributorId}) so ng-openapi-gen emits them whentktspace-businessrunsnpm 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? };ContributorPreviewDtono longer carriesdescriptionoravatarUrl— every name/avatar/bio render goes throughmember.*. (Removed fields:descriptionon both DTOs and the response,avatarUrlon the response.avatarUrlwas never on the create/update DTOs.) client.openapi.yaml: tighten existingContributorPreviewDto—memberIdbecomes required + non-nullable,personNameis removed,role: stringbecomesroles: string[](min 1). Update the schema description to drop the “either memberId or personName” language. (v3 amendment) Drop the contributor-levelavatarUrlfield too — the client surface gets the avatar frommember.avatarUrlexclusively post-amendment. (Nodescriptionon client to begin with.)- Update DTO:
memberIdis immutable after creation — drop it fromUpdateContributorDto. 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 intime_slot_coaches/session_coaches).
-
Validation
- On
POST /api/business/activities/{id}/contributors:memberIdmust belong to the same company as the activity (404 errors.member.not_foundotherwise);rolesmust be non-empty (400 errors.contributor.roles_required); duplicate(activityId, memberId)returns409 errors.contributor.duplicate. - On
POST /api/business/time-slotsandPATCH /api/business/time-slots/{id}: extend the currentassertCoachesAreActivecheck (time-slots.service.ts:55-67) into a fixed-order pipelineassertCoachesAreContributors(activityId, coachIds)that runs before the existing active check, with this order per id incoachIds:- Member exists and belongs to the active company (
companyMembers.companyId === activeCompanyId) →404 errors.coach.not_found. - Member is a contributor of the activity (row in
contributorswithactivityId = X AND memberId = Y) →404 errors.time_slot.coach_not_contributor. - 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 onroles.
- Member exists and belongs to the active company (
- On
-
Frontend
- tktspace-business: drop the
inputModetoggle andpersonNamecontrol 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.idand passc.memberIdinstead;stringifyContributorresolves viamemberId. No role-based filter on contributors — the picker shows every contributor of the activity regardless ofroles[]. 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 nametime_slot_rule#form#coachto avoid a template churn; only the i18n catalogue values change. - tktspace-web + gym_app: regenerate clients; remove every
?? personNamefallback; renderrolesas a chip-list (Taigatui-badgeon web/business, FlutterChipwidget on gym_app). Pick chip-style uniformly — do not mix ” · ” joins on one surface and chips on another. Wheremember.publicNameis null (it is nullable in the DB — confirmed incompanies.schema.ts:66), fall back to''(empty string), notpersonName.
- tktspace-business: drop the
-
(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.descriptionandcontributors.avatarUrlcolumns (see Decision 1). - Drops
POST /api/business/activities/{id}/contributors/{cid}/avatar— route, controller method, service method, and the S3 keyspaceactivities/{id}/contributors/.... Avatar maintenance happens exclusively at the company-member level (existingcompanyMembers.avatarUrlupload flow, owned by the members feature — not in scope for this ticket). - Tightens
CreateContributorDto/UpdateContributorDtoto{memberId, roles, order?}/{roles?, order?}. - Tightens
ContributorPreviewDto(both business and client surfaces) to dropdescriptionandavatarUrl. Web/business/gym_app now renderc.member.publicName,c.member.avatarUrl,c.member.bioexclusively — 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.
- Drops
Considered alternatives
Section titled “Considered alternatives”- 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.
- Add
contributor_idFK ontime_slot_coaches/session_coaches(re-key away frommember_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 makesmember_idthe canonical key and contributor_id redundant. - Hybrid: keep
personNameas nullable, but ALSO requirememberId. Rejected:personNamewould 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. - Keep single
rolecolumn and create N contributor rows per person. Rejected: violates the newUNIQUE (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.
Migration
Section titled “Migration”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_rolesFROM 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.
Migration N — schema edit #1
Section titled “Migration N — schema edit #1”Schema edit (drives drizzle-kit):
contributors.rolesadded as.array()only (NOT NULL not yet asserted).contributors.personNamedropped.contributors.roledropped.- (v3 amendment)
contributors.descriptiondropped. - (v3 amendment)
contributors.avatarUrldropped. contributors.memberIdbecomes.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 editALTER TABLE activities.contributors DROP COLUMN role;ALTER TABLE activities.contributors DROP COLUMN person_name;ALTER TABLE activities.contributors DROP COLUMN description; -- v3 amendmentALTER TABLE activities.contributors DROP COLUMN avatar_url; -- v3 amendmentALTER 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.rolesbecomes.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.
Consequences
Section titled “Consequences”Easier
- One render path per contributor (
member.publicName,member.avatarUrl). Web/mobile drop the?? personNameternary 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 checkscompanyMembersactivity — 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). memberIdimmutability 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 = falsedoes NOT remove theirtime_slot_coaches/session_coachesrows; existing slots continue to render them in the customer-facing UI. Only NEW slot create/update is blocked by step 3 ofassertCoachesAreContributors(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.contributorscolumns. No data loss onrole → roles(backfilled).person_namediscarded (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
rolevsroles[]→ array, non-empty. - Slot coach role gating → role-agnostic (any contributor).
- Orphan data on prod → audit confirmed 0 (2026-05-18), unconditional
NOT NULLis safe. - Guests/external people → out of scope, separate
activity_gueststable if ever needed. companyMembers.publicNamenullability → confirmed NULLABLE in DB (companies.schema.ts:66—publicName: text('public_name')with no.notNull()). Both client and businessContributorMemberPreviewDtokeeppublicNamenullable. AC-8 promise reads: “drop the?? personNameternary, fall back to''whenpublicNameis 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, thedescription/avatarUrlcolumns, the avatar-upload endpoint, and the S3 keyspaceactivities/{id}/contributors/...are all redundant — member-levelbio,publicName, andavatarUrlare the single source of truth across every render path. See Decision 1 (schema drops), Decision 2 (contract DTO trims), and Decision 5 (rationale).
Code touch points
Section titled “Code touch points”Implementation map for Phase C. No code is touched in Phase B (this ADR + contracts only).
tktspace-backend (Nx)
Section titled “tktspace-backend (Nx)”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; addroles(ContributorRoleEnum('roles').array()— nullable for now),memberId.notNull(), FKonDelete: 'cascade',unique().on(t.activityId, t.memberId). - Edit #2: tighten
rolesto.array().notNull(); addcheck('contributors_roles_not_empty', sql\array_length(roles, 1) >= 1`)` in the table options.
- Edit #1: drop
libs/shared/data-access-db/migrations/<timestamp_1>_contributors_members_only.sql— generated bydrizzle-kit generatefrom edit #1; hand-add (a) theDO $$ BEGIN ASSERT ... END $$;audit line at the top, and (b) the singleUPDATE activities.contributors SET roles = ARRAY[role] WHERE role IS NOT NULL;line betweenADD COLUMN rolesandDROP COLUMN role.libs/shared/data-access-db/migrations/<timestamp_2>_contributors_roles_not_empty.sql— generated bydrizzle-kit generatefrom edit #2; ideally zero hand edits (Drizzle emits the NOT NULL + CHECK).libs/features/activities/src/lib/dto/activity-contributor.dto.ts—CreateContributorDto:memberIdrequired UUID,roles: ContributorRole[]with@ArrayNotEmpty() @IsEnum(ContributorRole, { each: true }),order?integer; droppersonName, singularrole, (v3 amendment)description.UpdateContributorDto: dropmemberIdentirely (immutable), droppersonName, (v3 amendment) dropdescription, replacerolewithroles(same array validators, still optional),order?integer.libs/features/activities/src/lib/services/activity-contributors.service.ts—- Drop all
personNameselects/inserts. - (v3 amendment) Drop all
descriptionselects/inserts (currently lines 26, 62, 77, 120 — the field is gone from the schema and the DTOs). - (v3 amendment) Drop all
avatarUrlselects/inserts on the contributor row (currently line 27, 121 in the select map; line for the update set inuploadAvatar). Member-level avatar still comes throughcompanyMembers.avatarUrlvia the join. - (v3 amendment) Remove
uploadAvatarmethod entirely (currently lines 87-99). Remove the now-unusedStorageServiceandBufferedFileimports 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 parameterprivate readonly storage: StorageServiceis dropped. (Verify there are no other consumers in this file before removing; the v3 grep confirmsuploadAvataris the only storage caller.) - On create: assert
memberIdbelongs to same company (404 errors.member.not_found); catch Postgres23505whoseconstraintfield equalscontributors_activity_id_member_id_uniqueand map to409 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(nordescription), so neither can reach the service. Add a defensive assertion test (see backend-test note below). findAll/findOneno longer left-join —inner join companyMembersand drop thec.memberId ? {...} : nullternary.
- Drop all
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 theFileInterceptor/ApiConsumes/ApiBodydecorators and theuploadAvatarcontroller method. Drop now-unused imports:Poststays (still used by the create handler at line 25), butUploadedFile,UseInterceptors,FileInterceptor,ApiBody,ApiConsumes,ParseUploadFilePipe, andBufferedFileare 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— renameassertCoachesAreActivetoassertCoachesAreContributors(activityId, coachIds)and implement the fixed three-step order from §Decision 3 (exists+same-company → 404, is-contributor → 404, is-active → 400). Wire into bothcreate(line 87) andupdate(line 576).- Backend test (Phase C) — add an e2e/integration test for
PATCH /api/business/activities/{id}/contributors/{contributorId}: post a body that includesmemberId: <different-uuid>alongside legal fields, then read the row from the DB after the PATCH succeeds and assertrow.memberId === ORIGINAL_MEMBER_ID. Background: the project’s globalValidationPipeis configured withwhitelist: truebut NOTforbidNonWhitelisted(tktspace-backend/apps/api/src/main.ts:30-33), so unknown fields are silently stripped. If a stale business build sendsmemberId(the currentactivity-contributor.form.ts:80-96 toUpdateDtodoes), the PATCH still returns 200 but the FK does not move. This test pins that behaviour and protects against accidentally restoringmemberIdto the DTO later. We deliberately DO NOT enableforbidNonWhitelistedglobally — that would break unrelated endpoints across the codebase and is out of scope.
tktspace-business (Angular)
Section titled “tktspace-business (Angular)”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
memberIdcontrol in the FormGroup at all — display the existing member’spublicNameas read-only text. The form is built offmode, so two FormGroup shapes are produced from one constructor. - Replace
rolecontrol withroles: FormControl<ContributorRole[]>([], [Validators.required, minLengthArray(1)]). toCreateDto: emit{ memberId, roles, order }. (v3 amendment) Nodescription. NopersonName. No singularrole.toUpdateDto: emit{ roles, order }only — do NOT includememberId,personName, ordescription(the current implementation on lines 80-96 includesmemberId+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. Afternpm run generate:apithe regeneratedUpdateContributorDtoonly exposesroles?andorder?— every stale field reference in the form / modal becomes a TypeScript compile error. This is the intended forcing function.
- Drop
client/src/app/features/dashboard/activities/forms/activity-contributor.modal.html(and sibling templates — find viagrep -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 + anyuploadAvatarservice call wiring); convert the role select to Taigatui-multi-selectwith 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 viac.memberId === memberIdand rendersmember?.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(anden.json,ru.jsonif present) — rename the value for keytime_slot_rule#form#coachfrom «Тренер» / “Coach” to «Ведущий занятия» / “Slot leader” (the picker now surfaces any contributor regardless of role). Key name stays so we don’t touch the.htmltemplate atcreate-time-slot-rule.modal.html:110and itsupdate-time-slot-rule.modal.htmltwin. Also add toast keys:errors.time_slot.coach_not_contributor,errors.contributor.duplicate,errors.contributor.roles_required,errors.member.not_foundif not already present.client/src/app/core/api/**— regenerated bynpm run generate:api.
tktspace-web (Angular SSR)
Section titled “tktspace-web (Angular SSR)”src/app/core/api/**— regenerated bynpm run generate.src/app/pages/event/event.page.html(line 77) — removec.personName ??fallback:{{ c.member?.publicName ?? '' }}. Renderc.rolesas a chip-list (Taigatui-badgeper 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.avatarUrlfallback. The contributor-levelavatarUrlis 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 aWrapofChip(label: Text(role))widgets bound toc.roles. The singularc.roleis gone after regen; Dart compile will fail on the stale reference — also intended. - Chip style must match the business/web
tui-badgelook (small, neutral background, role label uppercase or title-cased — pick once for consistency).
- Name (line ~220):
packages/api/lib/**— regenerated bymelos run sync:spec && melos run generate:api.packages/api/openapi/client.openapi.yaml(or the sync target) — pulled from_workflow/contracts/client.openapi.yamlbymelos 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 — regentktspace-mobile-app/packages/api/lib/src/generated/swagger_api.swagger.chopper.dart # generated — regentktspace-mobile-app/packages/api/lib/src/generated/swagger_api.swagger.dart # generated — regentktspace-mobile-app/packages/api/lib/src/generated/swagger_api.swagger.g.dart # generated — regentktspace-mobile-app/packages/api/.dart_tool/build/generated/... # generated — regentktspace-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 referencestktspace-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 pointtktspace-mobile-app/apps/tickets_app/lib/ # 0 hits — tickets_app does not render contributorsThe hand-written surface is exactly one file: activity_page.dart. Everything else is generated or i18n. tickets_app is untouched.
Out of scope this ticket (do not touch)
Section titled “Out of scope this ticket (do not touch)”tickets_app— does not render contributors today (grep above returned 0 hits).tktspace-landing— static, does not consume the contributors block.super-admincontract / module — not affected (no contributor admin views).- Global
ValidationPipeconfig (forbidNonWhitelisted) — would regress other endpoints; out of scope.
STATUS: READY_FOR_REVIEW