ADR: Activities trainer autocomplete
ADR: Activities trainer autocomplete
Section titled “ADR: Activities trainer autocomplete”Status
Section titled “Status”PROPOSED (web + business consumers dropped post-UX review 2026-06-10 — see spec banner; mobile-only shipping path remains)
Context
Section titled “Context”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:
- Two new read-only autocomplete endpoints — one per surface (client
- business) — that return a minimal
{ userId, publicName, avatarUrl }[]list, ranked by name relevance whenqis provided or by future-session count whenqis empty.
- business) — that return a minimal
- Three UI consumers (web
/explore, business admin activity grid, mobilegym_appSearchPage filters) call those endpoints, surface the selection as acontributorUserIdquery param, and delegate filtering to the existing activities list endpoint.
The feature inherits three load-bearing pieces from prior ADRs:
pg_trgmextension +users_global_name_trgm_idx— shipped bycontributor-aware-search-and-session-filter(migrationlibs/shared/data-access-db/migrations/0043_pg_trgm_search_indexes.sql, verified live). Reused as-is; no new indexes.contributorUserIdparam onFindActivitiesClientDto— shipped in the same ticket (verified atlibs/features/activities/src/lib/dto/find-activities-client.dto.ts:77). Selection wires straight into it.- Public display name + avatar live on
usersdirectly — per ADRglobal-user-identityv2 #1.publicName←users.full_name(Drizzleusers.globalName);avatarUrl←users.avatar_url(Drizzleusers.avatarUrl).user_public_profilecarries bio/specializations/links/slug/verifiedAt/coverPhotoUrl ONLY — it has nopublic_nameoravatar_urlcolumn. The autocomplete query never joinsuser_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).
AC ↔ Decision mapping
Section titled “AC ↔ Decision mapping”| AC | Primary decisions | Notes |
|---|---|---|
| AC-1 | D1, D2, D3, D4, D6, D7 | GET /api/client/contributors autocomplete, trigram + ILIKE matching, response shape. |
| AC-2 | D4, D7 | Empty-q aggregate over published activities with future sessions; SQL composition + dedup. |
| AC-3 | D1, D2, D5, D6, D7 | GET /api/business/contributors, company-scoped via CompanyRolesGuard + @ActiveCompany(). |
| AC-4 | D11 | Web /explore tui-combo-box; URL-driven contributorUserId already established by D6 of dependency ADR. |
| AC-5 | D11 | Mobile gym_app SearchPage filters bottom-sheet; packages/ui may expose AvatarOptionTile. |
| AC-6 | D11 | Business admin activity list combobox; companyId from session context (no UI picker). |
| AC-7 | D2, D8 | Payload integrity — ContributorAutocompleteItemDto = { userId, publicName, avatarUrl }. Nothing else. |
| AC-8 | D11 | Placeholder icon on null avatarUrl — Taiga tuiIconPerson (web/business) and existing mobile widget. |
| AC-9 | D11 | Selection sets contributorUserId on the activities list call; clearing drops it. |
| AC-10 | D10 | Four new i18n keys; CSV in spec. |
Decision
Section titled “Decision”D1 — Endpoint placement
Section titled “D1 — Endpoint placement”Client surface: GET /api/client/contributors.
- Controller: new
ContributorsClientControllerinlibs/features/activities/src/lib/controllers/contributors-client.controller.ts. - Mounted by
ActivitiesClientModule(already part ofClientApiModule) — 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/activitieslisting itself is public viaActivitiesGlobalClientController’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(notAutocomplete) because the endpoint is shaped as a paginated-less list — the “autocomplete” framing is purely UI semantics. The spec’scontributorsClientAutocompleteoperationId 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
ContributorsAdminControllerinlibs/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 asActivitiesAdminController.findAll(verified atlibs/features/activities/src/lib/controllers/activities-admin.controller.ts:24-30). The activecompanyIdcomes from@ActiveCompany()(header-derived, set byCompanyRolesGuard); 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).
D2 — DTO surface
Section titled “D2 — DTO surface”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, filelibs/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), default20.
FindContributorsAdminDto(NEW, filelibs/features/activities/src/lib/dto/find-contributors-admin.dto.ts):- Same
qandlimitas above. - No
companyIdfield — the active company is sourced from@ActiveCompany()(header). The DTO does NOT carry it. See D5.
- Same
Response DTO — ContributorAutocompleteItemDto (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.
D3 — Service / repository layer
Section titled “D3 — Service / repository layer”One service — ContributorsListService 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-qpath (Drizzle supportscount().filterWhere(...)in recent versions but the explicitsql\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 asql\“ fragment.
Drizzle’s typed builder handles the GROUP BY, ORDER BY, and LIMIT.
D4 — Query plan
Section titled “D4 — Query plan”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 simFROM users.users uWHERE 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 NULLAND ( u.full_name ILIKE '%' || :q || '%' OR similarity(u.full_name, :q) >= 0.3)ORDER BY sim DESC, "publicName" ASC, "userId" ASCLIMIT :limit;- Trigram path:
similarity(...) >= 0.3is the standardpg_trgmfloor (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
ILIKEpath too (Postgres planner usesgin_trgm_opsforILIKEwith leading%when the index is present). - The inner
SELECT DISTINCT cm.user_idshort-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 fromusersand 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_countFROM users.users uJOIN companies.company_member cm ON cm.user_id = u.idJOIN activities.contributors ac ON ac.member_id = cm.idJOIN activities.activities a ON a.id = ac.activity_idWHERE /* client: a.status = 'PUBLISHED' */ /* admin: a.company_id = :companyId */ AND u.full_name IS NOT NULLGROUP BY u.id, u.full_name, u.avatar_urlORDER BY future_activity_count DESC, "publicName" ASC, "userId" ASCLIMIT :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 toActivitiesAdminController.findAll. companyIdis NOT a query param on the OpenAPI contract. It is injected into the controller via@ActiveCompany() companyId: stringfrom theCompanyRolesGuard-populated request context.- Missing/invalid active company →
401(errors.company.context_required) from the existing guard. No manual400 { 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_ACTIVITIESon the active company →403fromCompanyRolesGuard. 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
companyIdfrom the admin session context (per spec), so this change is transparent on the consumer side. - AC-3’s “400
{ error: 'companyId required' }” becomes “401errors.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.
D6 — Field nullability & filtering
Section titled “D6 — Field nullability & filtering”users.full_nameis nullable in the schema (text('full_name')with no.notNull(), verified atlibs/shared/data-access-db/src/lib/schema/users.schema.ts:13). The service filters outfull_name IS NULLrows (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_urlis nullable (verified at line 14). Passed through to the response asnull. 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.
D7 — Multi-membership dedup
Section titled “D7 — Multi-membership dedup”A trainer who is a contributor at three gyms must appear ONCE in the client-surface response.
- Path A (
qprovided): the outerSELECTis keyed byu.id(one row per user). The innerSELECT DISTINCT cm.user_idfromcontributorscollapses the gym-multiplicity before the user is considered. Result: unique byu.id. - Path B (
qempty):GROUP BY u.id, u.full_name, u.avatar_urlreduces 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.
D8 — OpenAPI contract patches
Section titled “D8 — OpenAPI contract patches”Client (contracts/client.openapi.yaml):
-
New path
/api/client/contributors:operationId: contributorsClientList- Query params:
q?: string—maxLength: 64. Trimmed server-side. (NominLength: 2declared on the contract — the trim+empty treatment is service-internal; on the wire,qis “any string up to 64 chars”. This matches the spec’s ”≤ 64 chars”.)limit?: integer—minimum: 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?: string—maxLength: 64.limit?: integer—minimum: 1, maximum: 20, default: 20.- No
companyIdquery 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_ACTIVITIESon the active company. - Tag:
Activities. security: [ - bearer: [] ]— required JWT bearer (matches all other admin endpoints in the contract).
-
New component schema
ContributorAutocompleteItemDto— same 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.
D9 — No DB migration
Section titled “D9 — No DB migration”Explicit: no new tables, no new columns, no new indexes. Reuses:
users_global_name_trgm_idx(GIN trgm onusers.full_name) — shipped by ADRcontributor-aware-search-and-session-filterD10 via migration0043_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.
D10 — i18n keys (carry-through)
Section titled “D10 — i18n keys (carry-through)”Per AC-10, four new keys (web + mobile, EN/UK/RU mandatory, DE/FR fall back to EN):
search#trainer_filter_labelsearch#trainer_filter_placeholdersearch#trainer_filter_clearsearch#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 forq,sphereId,contributorUserId(shipped bycontributor-aware-search-and-session-filter). - New combobox: a
tui-combo-boxlabelled withsearch#trainer_filter_labelplaced alongside the existing sphere/category facets. Debounce 250 ms (per AC-4). - Each option: avatar (existing
tui-avataror equivalent, withtuiIconPersonfallback on nullavatarUrl) +publicName. - Selection:
router.navigate([], { queryParams: { contributorUserId: item.userId }, queryParamsHandling: 'merge' }). ExistinginContributorModechip header — shipped by the dependency ADR — handles the post-selection state. - Clearing: drops
contributorUserIdfrom query params. - API call: generated
contributorsClientList(q, limit)viaApi.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
TextFieldwith 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 anAvatarOptionTilewidget topackages/ui— exposed for both apps, even thoughtickets_appcurrently has no consumer.packages/uiis already ingym_app’s dep graph (pertktspace-mobile-app/CLAUDE.md), so no new package dependency. - Selection: writes
contributorUserIdto 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/(verifiedactivities/directory exists at that path). The grid’s filter row gets a Taigatui-combo-boxwith the same shape as web. companyIdis 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)viaApi.invoke(...)(v1 pattern, identical to web). The active company header is set by the existing HTTP interceptor — UI does not passcompanyIdexplicitly. - Selection: appends
contributorUserIdto the admin activity grid’s existing query params and re-fetches.
tickets_app: not affected (no contributor concept).
tktspace-landing: not affected.
Considered alternatives
Section titled “Considered alternatives”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:
- Violates the three-contract rule. CLAUDE.md is explicit: each surface has its own contract; types are duplicated intentionally.
- JWT-scope-conditional behaviour is opaque — the contract can’t express “this endpoint behaves differently per token shape”.
- 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.
Alt 2 — Cursor-paginated list
Section titled “Alt 2 — Cursor-paginated list”Add cursor/pageSize to the response with a stable cursor.
Pros:
- Forward-compat with future “load more” UX.
- Stable ordering across pages.
Cons:
- Out of scope. AC-1 / AC-2 lock
limit ≤ 20and a single ranked page. No “load more” in the UX. - Adds DTO complexity for a feature that is never paginated by the spec’s UX.
- The trigram ordering doesn’t paginate cleanly —
similarity()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:
- 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. - The autocomplete is fundamentally a filter for the activities list — its callers, its data, and its UX context are activities.
- Existing precedent.
activity-contributors-admin.controller.tsand the contributor-reviews controllers all live underlibs/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:
- Premature optimisation at our row counts.
- Trigger entanglement across schemas (activities + companies +
users) — same anti-pattern called out by
cross-schema-references-without-fk. - 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:
| Path | Method | AC | Auth | Response |
|---|---|---|---|---|
/api/client/contributors | GET | AC-1, AC-2 | public | ContributorAutocompleteItemDto[] |
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 singlememberIdwould 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/:slugdeep-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:
| Path | Method | AC | Auth | Response |
|---|---|---|---|---|
/api/business/contributors | GET | AC-3 | JWT + CompanyRolesGuard + READ_ACTIVITIES | ContributorAutocompleteItemDto[] |
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 thoughcompanyMembers.internalNotesis 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.
Cross-surface field-difference rationale
Section titled “Cross-surface field-difference rationale”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.
Data model changes
Section titled “Data model changes”None. Pure read against existing tables:
| Table | Used for |
|---|---|
users.users | Final SELECT; trigram filter on full_name. |
companies.company_member | Join member_id → company_member.id → user_id. |
activities.contributors | Join activity_id → activities.id, member_id. |
activities.activities | status = 'PUBLISHED' (client); company_id (admin). |
activities.sessions | EXISTS 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.
Migration generation
Section titled “Migration generation”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.
Backend module placement
Section titled “Backend module placement”| Concern | Library | File(s) |
|---|---|---|
FindContributorsClientDto | libs/features/activities | NEW src/lib/dto/find-contributors-client.dto.ts |
FindContributorsAdminDto | libs/features/activities | NEW src/lib/dto/find-contributors-admin.dto.ts |
ContributorAutocompleteItemDto | libs/features/activities | NEW src/lib/dto/contributor-autocomplete.dto.ts |
ContributorsListService (one service, two methods) | libs/features/activities | NEW src/lib/services/contributors-list.service.ts |
ContributorsClientController (GET /api/client/contributors) | libs/features/activities | NEW src/lib/controllers/contributors-client.controller.ts |
ContributorsAdminController (GET /api/business/contributors) | libs/features/activities | NEW src/lib/controllers/contributors-admin.controller.ts |
| Module wiring (client surface) | libs/features/activities | src/lib/activities-client.module.ts — add ContributorsClientController + ContributorsListService to its arrays. |
| Module wiring (admin surface) | libs/features/activities | src/lib/activities-admin.module.ts — add ContributorsAdminController + ContributorsListService (re-export from ActivitiesModule if needed). |
| Tests | libs/features/activities | src/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.tsalready importsActivitiesClientModule(verified —ActivitiesClientModulehosts the existingActivitiesGlobalClientController). Adding the new controller to that module flows through to/api/client/*routing automatically.apps/api/src/app/modules/admin-api/admin-api.module.tsalready importsActivitiesAdminModule. 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).
Frontend implications
Section titled “Frontend implications”tktspace-web (Angular + Taiga UI, SSR)
Section titled “tktspace-web (Angular + Taiga UI, SSR)”src/app/pages/explore/explore.page.ts— add the trainer combobox (D11 web bullet). 250 ms debounce; clearable; selection writescontributorUserIdto URL query params; existing dependency ADR D6 already handles the post-selection state.src/app/core/api/— regenerate vianpm run generate. New symbols emitted:contributorsClientList(q?, limit?)operation fn.ContributorAutocompleteItemDtointerface.
- 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.
tktspace-mobile-app/apps/gym_app
Section titled “tktspace-mobile-app/apps/gym_app”packages/api— regen viamelos run sync:spec && melos run generate:api. Chopper client gainscontributorsClientListmethod.apps/gym_app/lib/pages/home/search_page.dart— wire the trainer filter into the existing filters bottom-sheet.packages/ui— addAvatarOptionTilewidget 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 incontributor-reviews-ratingsD17 covers this.
tktspace-business
Section titled “tktspace-business”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 vianpm run generate:api. New symbols:contributorsAdminList(q?, limit?)operation fn.ContributorAutocompleteItemDtointerface (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).
tktspace-landing
Section titled “tktspace-landing”Not affected.
- Empty-
qaggregate 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
qshorter than 2 characters (treated as empty); the empty-qpath is the same code path. - Telemetry post-launch monitors p95; follow-up adds covering indexes if needed (out of scope here).
- Backend service trims and rejects
- Trigram noise from very short
q. A 1-charqmatches every row above the 0.3 floor. Mitigation: the ILIKE-OR-similarity predicate is gated server-side by treating <2-charqas empty; the contract’smaxLength: 64is enforced (per CLAUDE.md validation rule). - Multi-membership leakage on admin endpoint. The
a.company_id = :companyIdpredicate 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, withfutureActivityCountreflecting only X’s activities — never Y’s. Test plan must include this assertion. - Spec-vs-reality drift on
companyIdshape (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. - 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. - 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’scontributors[]block on the same public surface — net-zero new exposure. No PII surfaces beyond what already ships. - 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/contributorsspecifically.
Rollout plan
Section titled “Rollout plan”No feature flag. Strictly additive — both endpoints are net-new, no existing behaviour changes.
_workflowMR (this ADR + the two contract patches) lands.tktspace-backendMR:- Add DTOs, service, two controllers (per Backend module placement).
- Wire into
ActivitiesClientModuleandActivitiesAdminModule. - 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:
qprovided (trigram + ILIKE branches), emptyq(future-session ordering), dedup across multi-gym users, nullfull_namefiltering, 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-qbranches as client.
- Client:
- Performance smoke: p95 under 100 ms on dev seed data.
tktspace-webMR (in parallel after backend deploys):npm run generate, wire AC-4 combobox, add i18n keys.tktspace-mobile-appMR (in parallel):melos run sync:spec && melos run generate:api, wire AC-5 bottom-sheet, add i18n keys topackages/i18n. OptionalAvatarOptionTileinpackages/ui.tktspace-businessMR (in parallel):npm run generate:api, wire AC-6 admin combobox.- Post-deploy smoke:
GET /api/client/contributors?q=mary— non-empty if aMarynafixture exists in dev seed; sorted by trigram similarity.GET /api/client/contributors(emptyq) — top-20 by future-session count.GET /api/business/contributorswith 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:
- Revert the consumer MRs (web + business + mobile) — UIs stop calling the new endpoints.
- Revert the backend MR — new endpoints disappear.
- No DB rollback needed (no schema change).
Stop scope (non-goals)
Section titled “Stop scope (non-goals)”- 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_appUI 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
companyIdquery param on the admin endpoint — uses the shipped@ActiveCompany()header convention instead (D5).
Open questions
Section titled “Open questions”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.
Cross-link
Section titled “Cross-link”- ADR
contributor-aware-search-and-session-filter— provides thepg_trgmextension,users_global_name_trgm_idx, and thecontributorUserIdparam onFindActivitiesClientDtothat this ticket’s UI selection writes into. - ADR
global-user-identity— confirmspublicNamelives onusers.full_nameandavatarUrlonusers.avatar_url;user_public_profilecarries no public-name/avatar columns. This ADR’s queries never joinuser_public_profile. - ADR
contributors-must-be-members— guarantees every contributor is acompanyMembersrow, so the join chaincontributors → companyMember → usersis FK-enforced end-to-end.
Contract patch outlines
Section titled “Contract patch outlines”Two contracts are patched in this MR.
contracts/client.openapi.yaml
Section titled “contracts/client.openapi.yaml”- New path
/api/client/contributorswithgetoperation: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).
contracts/business.openapi.yaml
Section titled “contracts/business.openapi.yaml”- New path
/api/business/contributorswithgetoperation:operationId: contributorsAdminList.- Query params
q,limit— same shape as client. - No
companyIdquery param — active company is header- derived via the existingCompanyRolesGuard(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