Skip to content

ADR: Activities trainer autocomplete

PROPOSED (web + business consumers dropped post-UX review 2026-06-10 — see spec banner; mobile-only shipping path remains)

The spec at specs/activities-trainer-autocomplete.md adds a trainer (contributor) autocomplete UI entry point on top of the contributorUserId filter already shipped by contributor-aware-search-and-session-filter. The activities list endpoint itself is not modified — this ticket is strictly:

  1. Two new read-only autocomplete endpoints — one per surface (client
    • business) — that return a minimal { userId, publicName, avatarUrl }[] list, ranked by name relevance when q is provided or by future-session count when q is empty.
  2. Three UI consumers (web /explore, business admin activity grid, mobile gym_app SearchPage filters) call those endpoints, surface the selection as a contributorUserId query param, and delegate filtering to the existing activities list endpoint.

The feature inherits three load-bearing pieces from prior ADRs:

  • pg_trgm extension + users_global_name_trgm_idx — shipped by contributor-aware-search-and-session-filter (migration libs/shared/data-access-db/migrations/0043_pg_trgm_search_indexes.sql, verified live). Reused as-is; no new indexes.
  • contributorUserId param on FindActivitiesClientDto — shipped in the same ticket (verified at libs/features/activities/src/lib/dto/find-activities-client.dto.ts:77). Selection wires straight into it.
  • Public display name + avatar live on users directly — per ADR global-user-identity v2 #1. publicNameusers.full_name (Drizzle users.globalName); avatarUrlusers.avatar_url (Drizzle users.avatarUrl). user_public_profile carries bio/specializations/links/slug/verifiedAt/coverPhotoUrl ONLY — it has no public_name or avatar_url column. The autocomplete query never joins user_public_profile.

The feature touches two surface contracts (contracts/client.openapi.yaml, contracts/business.openapi.yaml), three consumers (tktspace-web, tktspace-mobile-app/apps/gym_app, tktspace-business), and two mobile shared packages (packages/api, packages/ui).

ACPrimary decisionsNotes
AC-1D1, D2, D3, D4, D6, D7GET /api/client/contributors autocomplete, trigram + ILIKE matching, response shape.
AC-2D4, D7Empty-q aggregate over published activities with future sessions; SQL composition + dedup.
AC-3D1, D2, D5, D6, D7GET /api/business/contributors, company-scoped via CompanyRolesGuard + @ActiveCompany().
AC-4D11Web /explore tui-combo-box; URL-driven contributorUserId already established by D6 of dependency ADR.
AC-5D11Mobile gym_app SearchPage filters bottom-sheet; packages/ui may expose AvatarOptionTile.
AC-6D11Business admin activity list combobox; companyId from session context (no UI picker).
AC-7D2, D8Payload integrity — ContributorAutocompleteItemDto = { userId, publicName, avatarUrl }. Nothing else.
AC-8D11Placeholder icon on null avatarUrl — Taiga tuiIconPerson (web/business) and existing mobile widget.
AC-9D11Selection sets contributorUserId on the activities list call; clearing drops it.
AC-10D10Four new i18n keys; CSV in spec.

Client surface: GET /api/client/contributors.

  • Controller: new ContributorsClientController in libs/features/activities/src/lib/controllers/contributors-client.controller.ts.
  • Mounted by ActivitiesClientModule (already part of ClientApiModule) — adding the controller to the existing client activities module avoids creating a new feature library just to host one read endpoint that is purely a filter aid for the activities listing.
  • Auth: @Public() — the parent /api/client/activities listing itself is public via ActivitiesGlobalClientController’s @Public() decorator. An autocomplete that drives a public listing must be consistent (no log-in wall for a filter helper).
  • operationId: contributorsClientList — matches the CLAUDE.md <resource>Client<Verb> convention; List (not Autocomplete) because the endpoint is shaped as a paginated-less list — the “autocomplete” framing is purely UI semantics. The spec’s contributorsClientAutocomplete operationId is overridden here for convention conformity; the spec uses “autocomplete” descriptively.
  • Tag: Activities — autocomplete is a filter aid for the activities list; no new tag bucket.

Business surface: GET /api/business/contributors.

  • Controller: new ContributorsAdminController in libs/features/activities/src/lib/controllers/contributors-admin.controller.ts.
  • Mounted by ActivitiesAdminModule (already wired into the admin-api module).
  • Auth: @UseGuards(CompanyRolesGuard) + @RequirePermissions(Permission.READ_ACTIVITIES) — identical guard stack as ActivitiesAdminController.findAll (verified at libs/features/activities/src/lib/controllers/activities-admin.controller.ts:24-30). The active companyId comes from @ActiveCompany() (header-derived, set by CompanyRolesGuard); NOT from a query param — see D5 on the spec drift.
  • operationId: contributorsAdminList.
  • Tag: Activities.

Why not a fresh libs/features/contributors/ library? The autocomplete is one DTO + one service + two controllers. Splitting it out would create three new files of module wiring with no domain logic. The data it reads (activities, contributors join, sessions, companyMembers, users) is the same data the activities feature already queries. Keeping the code in libs/features/activities aligns with the existing layout (activity-contributors-admin.controller.ts, contributor-reviews-*.controller.ts are all under activities).

Request DTOs — two distinct DTOs, kept separate because the admin DTO has no public counterpart of q (admin sees draft activities too) and we don’t want the client DTO carrying an inert companyId property:

  • FindContributorsClientDto (NEW, file libs/features/activities/src/lib/dto/find-contributors-client.dto.ts):
    • q?: string@MaxLength(64) + @Transform(({ value }) => typeof value === 'string' ? value.trim() : value) to match the spec’s “trimmed”. Optional; absent or empty triggers the AC-2 aggregate path.
    • limit?: number@Transform(({ value }) => parseInt(value))
      • @IsInt() + @Min(1) + @Max(20), default 20.
  • FindContributorsAdminDto (NEW, file libs/features/activities/src/lib/dto/find-contributors-admin.dto.ts):
    • Same q and limit as above.
    • No companyId field — the active company is sourced from @ActiveCompany() (header). The DTO does NOT carry it. See D5.

Response DTOContributorAutocompleteItemDto (NEW, file libs/features/activities/src/lib/dto/contributor-autocomplete.dto.ts) with the exactly-three fields locked by spec OQ-3 / AC-7:

export class ContributorAutocompleteItemDto {
@ApiProperty({ format: 'uuid' })
userId!: string;
@ApiProperty()
publicName!: string;
@ApiProperty({ nullable: true, type: 'string' })
avatarUrl!: string | null;
}

publicName is not nullable on the response — rows with users.full_name IS NULL are filtered out at the query level (see D6). avatarUrl is nullable per AC-8. Both endpoints share the SAME DTO — there is no admin-side enrichment of this shape.

Per CLAUDE.md OpenAPI authoring rules: the controller declares @ApiResponse({ status: 200, type: ContributorAutocompleteItemDto, isArray: true }). The DTO uses format: 'uuid' on userId.

One serviceContributorsListService in libs/features/activities/src/lib/services/contributors-list.service.ts, exposing two methods:

listForClient(q: string | undefined, limit: number): Promise<ContributorAutocompleteItemDto[]>
listForCompany(companyId: string, q: string | undefined, limit: number): Promise<ContributorAutocompleteItemDto[]>

Why one service, not two: the two methods share 90% of the same Drizzle composition — the only differences are (a) the company scope predicate (a.companyId = :companyId) added by listForCompany, and (b) the a.status = 'PUBLISHED' predicate which only listForClient applies (admin sees all statuses per AC-3). Sharing one private buildQuery(...) helper keeps the SQL DRY. Two distinct services would duplicate the join graph.

Query strategy — Drizzle db.select(...) typed query builder for the joins and projection, with sql\…“ raw fragments for:

  • The similarity(u.full_name, :q) projection and threshold check (pg_trgm function not modelled by Drizzle’s typed API).
  • The aggregate count with FILTER (WHERE ...) clause used in the empty-q path (Drizzle supports count().filterWhere(...) in recent versions but the explicit sql\COUNT(…) FILTER (WHERE …)“ template is more readable for the multi-condition filter here).
  • The EXISTS (SELECT 1 FROM sessions ... WHERE starts_at >= now()) subquery — again clearer as a sql\“ fragment.

Drizzle’s typed builder handles the GROUP BY, ORDER BY, and LIMIT.

Two code paths, selected by q:

A. Non-empty q (≥ 2 chars after trim) — name match:

SELECT
u.id AS "userId",
u.full_name AS "publicName",
u.avatar_url AS "avatarUrl",
GREATEST(similarity(u.full_name, :q), 0) AS sim
FROM users.users u
WHERE u.id IN (
-- contributors with at least one PUBLISHED activity (client) /
-- at least one activity in the company (admin)
SELECT DISTINCT cm.user_id
FROM activities.contributors ac
JOIN companies.company_member cm ON cm.id = ac.member_id
JOIN activities.activities a ON a.id = ac.activity_id
WHERE
/* client: a.status = 'PUBLISHED' */
/* admin: a.company_id = :companyId */
...
)
AND u.full_name IS NOT NULL
AND (
u.full_name ILIKE '%' || :q || '%'
OR similarity(u.full_name, :q) >= 0.3
)
ORDER BY sim DESC, "publicName" ASC, "userId" ASC
LIMIT :limit;
  • Trigram path: similarity(...) >= 0.3 is the standard pg_trgm floor (matches the activities-search ADR’s choice).
  • ILIKE fallback handles sub-trigram strings (e.g. 2-character fragments where trigram similarity is unreliable). The two predicates are ORed; the GIN trigram index serves the ILIKE path too (Postgres planner uses gin_trgm_ops for ILIKE with leading % when the index is present).
  • The inner SELECT DISTINCT cm.user_id short-circuits the user set before the trigram filter — most users in the DB are not contributors at all, so this is much cheaper than starting from users and EXISTS-ing into contributors.

B. Empty q — future-session aggregate:

SELECT
u.id AS "userId",
u.full_name AS "publicName",
u.avatar_url AS "avatarUrl",
COUNT(DISTINCT a.id) FILTER (
WHERE
/* client only: a.status = 'PUBLISHED' */
EXISTS (
SELECT 1 FROM activities.sessions s
WHERE s.activity_id = a.id AND s.starts_at >= now()
)
) AS future_activity_count
FROM users.users u
JOIN companies.company_member cm ON cm.user_id = u.id
JOIN activities.contributors ac ON ac.member_id = cm.id
JOIN activities.activities a ON a.id = ac.activity_id
WHERE
/* client: a.status = 'PUBLISHED' */
/* admin: a.company_id = :companyId */
AND u.full_name IS NOT NULL
GROUP BY u.id, u.full_name, u.avatar_url
ORDER BY future_activity_count DESC, "publicName" ASC, "userId" ASC
LIMIT :limit;

Critical: ActivityStatusEnum is 'PUBLISHED' (uppercase), verified at libs/shared/data-access-db/src/lib/schema/activities.schema.ts:47-52. The spec’s SQL sketch uses lowercase 'published' — this is an inaccuracy in the spec. Backend-dev must use the uppercase enum value.

Performance budget: target < 100 ms p95 on dev (low-thousands of activities, low-hundreds of contributors). The trigram index covers path A; the FK indexes on activities.contributors(activity_id, member_id), companies.company_member(user_id), and activities.sessions(activity_id) cover path B’s joins. No new indexes per OQ-4.

No special covering index, no materialised view. If post-launch telemetry shows path B regressing on volume, a follow-up ticket can add a partial index on activities(company_id) WHERE status = 'PUBLISHED' and a covering index on sessions(activity_id, starts_at). Not in scope for v1.

D5 — Admin authorization (and the companyId drift)

Section titled “D5 — Admin authorization (and the companyId drift)”

The spec says companyId is a required query param on GET /api/business/contributors (AC-3 + OQ-2). The shipped admin convention sources the active company from a header read by CompanyRolesGuard and surfaced via the @ActiveCompany() decorator (verified at libs/shared/common/src/decorators/active-company.decorator.ts:1-17 and across every other /api/business/activities* endpoint).

This ADR follows the existing convention:

  • Guard stack: @UseGuards(CompanyRolesGuard) + @RequirePermissions(Permission.READ_ACTIVITIES) — identical to ActivitiesAdminController.findAll.
  • companyId is NOT a query param on the OpenAPI contract. It is injected into the controller via @ActiveCompany() companyId: string from the CompanyRolesGuard-populated request context.
  • Missing/invalid active company → 401 (errors.company.context_required) from the existing guard. No manual 400 { error: 'companyId required' } shape — the spec’s proposed shape is overridden because it would conflict with the global error envelope the guard already emits.
  • Caller lacking READ_ACTIVITIES on the active company → 403 from CompanyRolesGuard. Matches AC-3’s “no access → 403, not silent []” intent.

Known spec-vs-reality drift documented for backend-dev:

  • AC-3’s “companyId required query param” semantics are honoured via the header / guard / decorator chain, not via a literal query parameter. Web/business UI (AC-6) was already going to drive companyId from the admin session context (per spec), so this change is transparent on the consumer side.
  • AC-3’s “400 { error: 'companyId required' }” becomes “401 errors.company.context_required” — the standard admin-surface error code emitted by the existing guard.

If product later wants a true cross-company admin lookup (one admin queries trainers across the companies they have access to without switching active company), that is a follow-up ticket with a new endpoint shape — out of scope here.

  • users.full_name is nullable in the schema (text('full_name') with no .notNull(), verified at libs/shared/data-access-db/src/lib/schema/users.schema.ts:13). The service filters out full_name IS NULL rows (a user with no display name has no useful autocomplete entry; surfacing them with an empty label would render as a blank option in the combobox). This applies on BOTH endpoints (client and admin).
  • users.avatar_url is nullable (verified at line 14). Passed through to the response as null. AC-8 covers the placeholder-icon rendering on all UIs.

The DTO declares publicName: string (non-nullable) and avatarUrl: string | null (nullable) — the contract reflects this. ng-openapi-gen and the Dart codegen both propagate nullable: true correctly.

A trainer who is a contributor at three gyms must appear ONCE in the client-surface response.

  • Path A (q provided): the outer SELECT is keyed by u.id (one row per user). The inner SELECT DISTINCT cm.user_id from contributors collapses the gym-multiplicity before the user is considered. Result: unique by u.id.
  • Path B (q empty): GROUP BY u.id, u.full_name, u.avatar_url reduces the JOIN explosion (one input row per contributor-row → many per user) to a single output row per user. COUNT(DISTINCT a.id) ensures the future-activity-count is per-distinct-activity, not per-contributor-row.

Admin endpoint: the a.company_id = :companyId predicate scopes to a single company. A user who is a contributor at company X and company Y will still appear once in the X-scoped response (the contributors join filters out Y’s rows), with their future-activity- count reflecting only X’s activities. No cross-company leakage.

Client (contracts/client.openapi.yaml):

  • New path /api/client/contributors:

    • operationId: contributorsClientList
    • Query params:
      • q?: stringmaxLength: 64. Trimmed server-side. (No minLength: 2 declared on the contract — the trim+empty treatment is service-internal; on the wire, q is “any string up to 64 chars”. This matches the spec’s ”≤ 64 chars”.)
      • limit?: integerminimum: 1, maximum: 20, default: 20.
    • 200 response: ContributorAutocompleteItemDto[].
    • Empty result → 200 [] (per AC-1).
    • Tag: Activities.
  • New component schema ContributorAutocompleteItemDto:

    • userId: string (format: uuid)
    • publicName: string (non-nullable; required)
    • avatarUrl: string | null (nullable; required field but value may be null)
    • required: [userId, publicName, avatarUrl]

Business (contracts/business.openapi.yaml):

  • New path /api/business/contributors:

    • operationId: contributorsAdminList
    • Query params:
      • q?: stringmaxLength: 64.
      • limit?: integerminimum: 1, maximum: 20, default: 20.
      • No companyId query param — sourced from @ActiveCompany() (header), per D5.
    • 200 response: ContributorAutocompleteItemDto[].
    • 401 response: errors.company.context_required (missing active company header).
    • 403 response: caller’s JWT does not grant READ_ACTIVITIES on the active company.
    • Tag: Activities.
    • security: [ - bearer: [] ] — required JWT bearer (matches all other admin endpoints in the contract).
  • New component schema ContributorAutocompleteItemDtosame shape, duplicated into the business contract per CLAUDE.md rule (“Never share types between surfaces — duplicate intentionally”). Field-level differences: none in this ticket. The fact that the two surfaces happen to expose the same shape is acceptable — duplication is the rule, not a sign of bad design.

Super-admin contract: not touched.

Explicit: no new tables, no new columns, no new indexes. Reuses:

  • users_global_name_trgm_idx (GIN trgm on users.full_name) — shipped by ADR contributor-aware-search-and-session-filter D10 via migration 0043_pg_trgm_search_indexes.sql.
  • Existing FK indexes on activities.contributors(activity_id, member_id), activities.contributors(member_id), companies.company_member(user_id), activities.sessions(activity_id).

The migration outline at drafts/migration-activities-trainer-autocomplete.sql is intentionally empty — “N/A — no schema change.”

If post-launch telemetry shows the empty-q aggregate regressing, follow-up indexes (partial activities(company_id) filtered on status = 'PUBLISHED', covering sessions(activity_id, starts_at)) land in their own ticket — not here.

Per AC-10, four new keys (web + mobile, EN/UK/RU mandatory, DE/FR fall back to EN):

  • search#trainer_filter_label
  • search#trainer_filter_placeholder
  • search#trainer_filter_clear
  • search#no_trainers_found

CSV stays in the spec (AC-10) — copy/pasted into the shared Google Sheet at build time per memory feedback_i18n_csv_output. Mobile keys land in tktspace-mobile-app/packages/i18n/assets/i18n/{en,ru,uk}.json; web keys land in tktspace-web/src/... translations file (the exact file name is web-app-internal — backend doesn’t dictate it).

D11 — Frontend integration points (annotated, not implemented)

Section titled “D11 — Frontend integration points (annotated, not implemented)”

These are pointers only; Phase C implements them.

tktspace-web/explore:

  • Page: src/app/pages/explore/explore.page.ts (verified to exist at that path). The page already has query-param ↔ filter wiring for q, sphereId, contributorUserId (shipped by contributor-aware-search-and-session-filter).
  • New combobox: a tui-combo-box labelled with search#trainer_filter_label placed alongside the existing sphere/category facets. Debounce 250 ms (per AC-4).
  • Each option: avatar (existing tui-avatar or equivalent, with tuiIconPerson fallback on null avatarUrl) + publicName.
  • Selection: router.navigate([], { queryParams: { contributorUserId: item.userId }, queryParamsHandling: 'merge' }). Existing inContributorMode chip header — shipped by the dependency ADR — handles the post-selection state.
  • Clearing: drops contributorUserId from query params.
  • API call: generated contributorsClientList(q, limit) via Api.invoke(...) (the v1 ng-openapi-gen pattern documented in CLAUDE.md).

tktspace-mobile-app/apps/gym_app — SearchPage filters bottom-sheet:

  • Page: apps/gym_app/lib/pages/home/search_page.dart (verified to exist).
  • Filters bottom-sheet — the existing widget — gets a new autocomplete TextField with the four new i18n keys.
  • 250 ms debounce on each keystroke before calling the regenerated ApiClient.contributorsClientList(q, limit).
  • Option tile widget: if tktspace-mobile-app/packages/ui/lib/src/avatar_option_tile.dart (or equivalent) already exists with the avatar + label + null- placeholder behaviour, reuse it. Otherwise add an AvatarOptionTile widget to packages/ui — exposed for both apps, even though tickets_app currently has no consumer. packages/ui is already in gym_app’s dep graph (per tktspace-mobile-app/CLAUDE.md), so no new package dependency.
  • Selection: writes contributorUserId to the activities-list request, closes the bottom-sheet, re-runs the search.
  • Clearing: “None” option at the top of the option list (per AC-5).

tktspace-business — admin activity grid:

  • Module: client/src/app/features/dashboard/activities/ (verified activities/ directory exists at that path). The grid’s filter row gets a Taiga tui-combo-box with the same shape as web.
  • companyId is sourced from the currently active company in the admin session context — same @ActiveCompany() header the API side reads. The combobox does not expose a companyId picker (locked by AC-6).
  • API call: generated contributorsAdminList(q, limit) via Api.invoke(...) (v1 pattern, identical to web). The active company header is set by the existing HTTP interceptor — UI does not pass companyId explicitly.
  • Selection: appends contributorUserId to the admin activity grid’s existing query params and re-fetches.

tickets_app: not affected (no contributor concept).

tktspace-landing: not affected.

Alt 1 — Single endpoint shared between surfaces

Section titled “Alt 1 — Single endpoint shared between surfaces”

One /api/contributors endpoint that switches behaviour based on the caller’s JWT scope (client vs business).

Pros:

  • One controller, one DTO, one OpenAPI path.
  • Half the contract patch surface.

Cons:

  1. Violates the three-contract rule. CLAUDE.md is explicit: each surface has its own contract; types are duplicated intentionally.
  2. JWT-scope-conditional behaviour is opaque — the contract can’t express “this endpoint behaves differently per token shape”.
  3. Mixed auth posture. The client endpoint is public (no JWT); the admin endpoint requires JWT + company role. A shared endpoint would need to handle both, undermining the per-surface guard stack.

Rejected. Two endpoints, two contract entries.

Add cursor/pageSize to the response with a stable cursor.

Pros:

  • Forward-compat with future “load more” UX.
  • Stable ordering across pages.

Cons:

  1. Out of scope. AC-1 / AC-2 lock limit ≤ 20 and a single ranked page. No “load more” in the UX.
  2. Adds DTO complexity for a feature that is never paginated by the spec’s UX.
  3. The trigram ordering doesn’t paginate cleanlysimilarity() ties + multi-tie-breaker rules don’t survive cursor encoding without a substantial cursor schema.

Rejected. If a future “trainer directory” page wants pagination, that’s a new endpoint with its own design.

Alt 3 — Reuse GET /api/client/users/:userId/public-profile per-user

Section titled “Alt 3 — Reuse GET /api/client/users/:userId/public-profile per-user”

Have the UI maintain a debounced “search index” client-side and call the public-profile endpoint per match.

Cons: absurd N+1; no server-side ranking; no future-session ordering. Mentioned for completeness only — instantly rejected.

Alt 4 — New libs/features/contributors/ library

Section titled “Alt 4 — New libs/features/contributors/ library”

Spin out a fresh feature lib for contributor read endpoints.

Pros:

  • Cleaner separation if the contributors domain grows beyond activities (e.g. cross-feature contributor directory).

Cons:

  1. Module-sprawl tax. This ticket adds one DTO + one service + two controllers. A new lib needs a project.json, tsconfigs, index barrel, two module files (client + admin), wiring into both surface routers. All overhead, no domain gain.
  2. The autocomplete is fundamentally a filter for the activities list — its callers, its data, and its UX context are activities.
  3. Existing precedent. activity-contributors-admin.controller.ts and the contributor-reviews controllers all live under libs/features/activities/ already.

Rejected. Keep in libs/features/activities.

Alt 5 — Materialise a contributor_autocomplete projection table

Section titled “Alt 5 — Materialise a contributor_autocomplete projection table”

A denormalised table refreshed on contributor / activity / session writes, holding pre-computed (user_id, public_name, avatar_url, future_activity_count) rows.

Pros: O(1) read cost.

Cons:

  1. Premature optimisation at our row counts.
  2. Trigger entanglement across schemas (activities + companies + users) — same anti-pattern called out by cross-schema-references-without-fk.
  3. OQ-4 of the spec already locks “no new indexes/migration” — a projection table is a much bigger change than an index.

Rejected. Re-opens only if real telemetry shows path B regressing beyond budget.

Surface impact and field-visibility justification

Section titled “Surface impact and field-visibility justification”

Client surface (contracts/client.openapi.yaml)

Section titled “Client surface (contracts/client.openapi.yaml)”

New path:

PathMethodACAuthResponse
/api/client/contributorsGETAC-1, AC-2publicContributorAutocompleteItemDto[]

New component schema: ContributorAutocompleteItemDto.

No field additions to existing schemas. No deprecations. No removals.

Field-visibility justification — what stays off the client response:

  • No companyMemberId — the autocomplete answer is at the USER level (a contributor at three gyms is one row), so a single memberId would mis-attribute.
  • No roleLabel / roles[] / specializations[] — UX is a bare-bones autocomplete (AC-7). Roles / labels would invite the UI to render extra metadata the spec explicitly rejects.
  • No ratingSummary — same reason; AC-7 locks { userId, publicName, avatarUrl }.
  • No slug — slug is for /contributor/:slug deep-links, which are out of scope (spec Out of scope #3).
  • No futureActivityCount — the count is server-side ordering metadata, not user-facing. Surfacing it would invite clients to re-sort, breaking the server-pinned ordering.

Business surface (contracts/business.openapi.yaml)

Section titled “Business surface (contracts/business.openapi.yaml)”

New path:

PathMethodACAuthResponse
/api/business/contributorsGETAC-3JWT + CompanyRolesGuard + READ_ACTIVITIESContributorAutocompleteItemDto[]

New component schema: ContributorAutocompleteItemDto — duplicated from the client contract (intentionally — see CLAUDE.md rule).

Field-visibility justification — admin sees the SAME minimal shape:

  • Admin UX (AC-6) is a combobox; same as the client UX (AC-4). The minimal shape is sufficient.
  • No internalNotes — even though companyMembers.internalNotes is admin-readable elsewhere, the autocomplete payload is small by design. If product wants a richer admin trainer-picker later, it’s a new endpoint.
  • No cross-company breadcrumbs — the response is scoped to the active company; the admin does not see “user X is also at company Y”.

Super-admin surface (contracts/super-admin.openapi.yaml)

Section titled “Super-admin surface (contracts/super-admin.openapi.yaml)”

Not touched.

Both surfaces happen to expose the same minimal shape. Per CLAUDE.md the DTO is still duplicated in each contract YAML. The duplication is the rule; the symmetry is an artefact of this ticket being a thin filter helper. If future work adds admin-only metadata (e.g. internalNotes), it lands on the admin DTO without touching the client DTO.

None. Pure read against existing tables:

TableUsed for
users.usersFinal SELECT; trigram filter on full_name.
companies.company_memberJoin member_id → company_member.id → user_id.
activities.contributorsJoin activity_id → activities.id, member_id.
activities.activitiesstatus = 'PUBLISHED' (client); company_id (admin).
activities.sessionsEXISTS future session check (empty-q path).

Cross-schema reference notes: the join chain is identical to the one already shipped by contributor-aware-search-and-session-filter ADR D2 — all hops are real Postgres FKs:

  • activities.contributors.member_id → companies.company_member.id (FK, ON DELETE CASCADE).
  • companies.company_member.user_id → users.users.id (FK).
  • activities.contributors.activity_id → activities.activities.id (FK, ON DELETE CASCADE).
  • activities.sessions.activity_id → activities.activities.id (FK, ON DELETE CASCADE).

No application-level orphan handling required.

No FK additions in this ticket.

drafts/migration-activities-trainer-autocomplete.sql exists for process consistency (the build pipeline expects a file at that path) but contains only the explicit “no-op” marker. Backend-dev runs no drizzle-kit generate step for this ticket.

ConcernLibraryFile(s)
FindContributorsClientDtolibs/features/activitiesNEW src/lib/dto/find-contributors-client.dto.ts
FindContributorsAdminDtolibs/features/activitiesNEW src/lib/dto/find-contributors-admin.dto.ts
ContributorAutocompleteItemDtolibs/features/activitiesNEW src/lib/dto/contributor-autocomplete.dto.ts
ContributorsListService (one service, two methods)libs/features/activitiesNEW src/lib/services/contributors-list.service.ts
ContributorsClientController (GET /api/client/contributors)libs/features/activitiesNEW src/lib/controllers/contributors-client.controller.ts
ContributorsAdminController (GET /api/business/contributors)libs/features/activitiesNEW src/lib/controllers/contributors-admin.controller.ts
Module wiring (client surface)libs/features/activitiessrc/lib/activities-client.module.ts — add ContributorsClientController + ContributorsListService to its arrays.
Module wiring (admin surface)libs/features/activitiessrc/lib/activities-admin.module.ts — add ContributorsAdminController + ContributorsListService (re-export from ActivitiesModule if needed).
Testslibs/features/activitiessrc/lib/services/contributors-list.service.spec.ts — unit tests for both methods, including dedup, null full_name filtering, ordering.
E2E(existing e2e harness)Integration smoke: client lists, admin lists with active company header, 401 missing-company, 403 missing-permission.

No new shared utilities in libs/shared/. The trigram threshold (0.3) and the SQL composition helpers are activity-feature internals.

Module placement on the routing side:

  • apps/api/src/app/modules/client-api/client-api.module.ts already imports ActivitiesClientModule (verified — ActivitiesClientModule hosts the existing ActivitiesGlobalClientController). Adding the new controller to that module flows through to /api/client/* routing automatically.
  • apps/api/src/app/modules/admin-api/admin-api.module.ts already imports ActivitiesAdminModule. Same routing-by-import story; the admin-api module mounts at /api/business/* (per the CLAUDE.md “Backend module gotcha” — admin-api/ mounts at /api/business).
  • src/app/pages/explore/explore.page.ts — add the trainer combobox (D11 web bullet). 250 ms debounce; clearable; selection writes contributorUserId to URL query params; existing dependency ADR D6 already handles the post-selection state.
  • src/app/core/api/ — regenerate via npm run generate. New symbols emitted:
    • contributorsClientList(q?, limit?) operation fn.
    • ContributorAutocompleteItemDto interface.
  • New i18n keys (D10) land in the web translations file.
  • SSR considerations: the combobox is client-only (interactive); SSR renders the closed combobox with search#trainer_filter_label. No data-fetch on SSR.
  • packages/api — regen via melos run sync:spec && melos run generate:api. Chopper client gains contributorsClientList method.
  • apps/gym_app/lib/pages/home/search_page.dart — wire the trainer filter into the existing filters bottom-sheet.
  • packages/ui — add AvatarOptionTile widget if not already present (D11 mobile bullet).
  • packages/i18n/assets/i18n/{en,ru,uk}.json — add the four AC-10 keys.
  • tickets_app — regen-only. The DTO is small (three fields); the unused-bytes precedent in contributor-reviews-ratings D17 covers this.
  • client/src/app/features/dashboard/activities/ — add the trainer combobox to the activity grid filter row (D11 business bullet).
  • client/src/app/core/api/ — regenerate via npm run generate:api. New symbols:
    • contributorsAdminList(q?, limit?) operation fn.
    • ContributorAutocompleteItemDto interface (admin contract).
  • The existing admin HTTP interceptor sets the active-company header; no new wiring required.
  • New i18n keys (D10) — admin has its own translations file (the business app stores translations in a separate i18n structure; exact paths are app-internal).

Not affected.

  1. Empty-q aggregate cost at scale. The path B aggregate scans contributors × companyMember × activities × sessions per response. At low-thousands of activities and low-hundreds of contributors, sub-100 ms p95 is achievable on existing indexes. Mitigation:
    • Backend service trims and rejects q shorter than 2 characters (treated as empty); the empty-q path is the same code path.
    • Telemetry post-launch monitors p95; follow-up adds covering indexes if needed (out of scope here).
  2. Trigram noise from very short q. A 1-char q matches every row above the 0.3 floor. Mitigation: the ILIKE-OR-similarity predicate is gated server-side by treating <2-char q as empty; the contract’s maxLength: 64 is enforced (per CLAUDE.md validation rule).
  3. Multi-membership leakage on admin endpoint. The a.company_id = :companyId predicate scopes contributors to activities OWNED by the active company, not to the user’s full membership graph. Edge case: a user is a contributor at company X AND at company Y; when admin of X queries, the user appears once, with futureActivityCount reflecting only X’s activities — never Y’s. Test plan must include this assertion.
  4. Spec-vs-reality drift on companyId shape (D5). A reader of the spec might expect a literal ?companyId=... query param. The contract patch and the admin controller make clear the active company is header-derived. Documented in D5 and in the OpenAPI description of the new admin endpoint.
  5. ActivityStatusEnum case drift (D4). The spec sketch uses lowercase 'published'; the schema enum is uppercase 'PUBLISHED'. Backend-dev must follow the schema enum value; ADR D4 calls this out explicitly.
  6. Public client endpoint exposure. The client autocomplete is public (no JWT). It exposes (userId, publicName, avatarUrl) for any user who is a contributor of a published activity. This is the same data already exposed by the activity-detail’s contributors[] block on the same public surface — net-zero new exposure. No PII surfaces beyond what already ships.
  7. No rate limiting on the public client endpoint. Follows the existing global rate-limit posture for the client surface; no per-endpoint custom limit in v1. If the endpoint is scraped, a follow-up adds an IP-based rate limit on /api/client/contributors specifically.

No feature flag. Strictly additive — both endpoints are net-new, no existing behaviour changes.

  1. _workflow MR (this ADR + the two contract patches) lands.
  2. tktspace-backend MR:
    • Add DTOs, service, two controllers (per Backend module placement).
    • Wire into ActivitiesClientModule and ActivitiesAdminModule.
    • Run npm run sync:contracts (per CLAUDE.md) to regenerate _workflow/contracts/*.openapi.yaml. The output must match the hand-written patches in this MR exactly (or backend-dev updates the ADR + contract patches and re-runs).
    • Integration tests:
      • Client: q provided (trigram + ILIKE branches), empty q (future-session ordering), dedup across multi-gym users, null full_name filtering, limit clamp.
      • Admin: 401 on missing active-company header, 403 on insufficient permission, scoping (X-admin doesn’t see Y-only users), same q / empty-q branches as client.
    • Performance smoke: p95 under 100 ms on dev seed data.
  3. tktspace-web MR (in parallel after backend deploys): npm run generate, wire AC-4 combobox, add i18n keys.
  4. tktspace-mobile-app MR (in parallel): melos run sync:spec && melos run generate:api, wire AC-5 bottom-sheet, add i18n keys to packages/i18n. Optional AvatarOptionTile in packages/ui.
  5. tktspace-business MR (in parallel): npm run generate:api, wire AC-6 admin combobox.
  6. Post-deploy smoke:
    • GET /api/client/contributors?q=mary — non-empty if a Maryna fixture exists in dev seed; sorted by trigram similarity.
    • GET /api/client/contributors (empty q) — top-20 by future-session count.
    • GET /api/business/contributors with an admin JWT + active company X — only users contributing to X’s activities.
    • 401 / 403 negative cases on the admin endpoint.

Backwards-compat:

  • Net-new endpoints; no existing caller is affected.
  • Contract additions only; no removals; no deprecations.

Rollback:

  1. Revert the consumer MRs (web + business + mobile) — UIs stop calling the new endpoints.
  2. Revert the backend MR — new endpoints disappear.
  3. No DB rollback needed (no schema change).
  • No DB migration (D9) — reuses existing indexes only.
  • No new feature library — keeps code in libs/features/activities.
  • No pagination — single ranked page of ≤ 20 results per call.
  • No cross-company admin lookup — admin endpoint is scoped to one active company (spec Out of scope #2).
  • No contributor profile deep-link / slug (spec Out of scope #3).
  • No tickets_app UI wiring (spec Out of scope #4).
  • No ratings / specializations / slug in the payload (spec Out of scope #5, AC-7).
  • No change to the activities list endpoint (spec Out of scope #6).
  • No companyId query param on the admin endpoint — uses the shipped @ActiveCompany() header convention instead (D5).

None. All four OQs from the spec are locked. The two design points that emerged during the read-through (companyId-as-header vs companyId-as-query-param, and ActivityStatusEnum case) are resolved in D5 and D4 respectively.

  • ADR contributor-aware-search-and-session-filter — provides the pg_trgm extension, users_global_name_trgm_idx, and the contributorUserId param on FindActivitiesClientDto that this ticket’s UI selection writes into.
  • ADR global-user-identity — confirms publicName lives on users.full_name and avatarUrl on users.avatar_url; user_public_profile carries no public-name/avatar columns. This ADR’s queries never join user_public_profile.
  • ADR contributors-must-be-members — guarantees every contributor is a companyMembers row, so the join chain contributors → companyMember → users is FK-enforced end-to-end.

Two contracts are patched in this MR.

  • New path /api/client/contributors with get operation:
    • operationId: contributorsClientList.
    • Query params q (string, maxLength: 64), limit (integer, minimum: 1, maximum: 20, default: 20).
    • 200: array of ContributorAutocompleteItemDto.
    • Tag: Activities.
  • New schema ContributorAutocompleteItemDto:
    • userId: string, format: uuid (required).
    • publicName: string (required, not nullable).
    • avatarUrl: string, nullable: true (required field, nullable value).
  • New path /api/business/contributors with get operation:
    • operationId: contributorsAdminList.
    • Query params q, limit — same shape as client.
    • No companyId query param — active company is header- derived via the existing CompanyRolesGuard (D5).
    • 200: array of ContributorAutocompleteItemDto.
    • 401: errors.company.context_required (missing active company).
    • 403: insufficient permission.
    • security: [ - bearer: [] ].
    • Tag: Activities.
  • New schema ContributorAutocompleteItemDto — duplicated from client per CLAUDE.md “duplicate intentionally” rule.

No schema deletions, no breaking changes, no deprecations.


STATUS: READY_FOR_REVIEW