ADR: Contributor-aware search & session filter
ADR: Contributor-aware search & session filter
Section titled “ADR: Contributor-aware search & session filter”Status
Section titled “Status”PROPOSED (v2 — fixes three blockers raised by architect-critic against v1; see Changelog)
Changelog
Section titled “Changelog”-
v2 (2026-06-02) — fixes vs v1:
- Tier B name match runs against
users.global_nameONLY. v1 referenced auser_public_profile.public_namecolumn that does NOT exist in the shipped schema (verified atlibs/shared/data-access-db/src/lib/schema/users.schema.ts:49-63—user_public_profilecarriesbio, specializations, links, slug, verified_at, cover_photo_urlonly; comment at line 46 is explicit thatpublicNamelives onusers.global_name). All SQL, indexes, and contract descriptions corrected. Known spec-vs-reality drift: spec AC-1 (line 60) and AC-10 (line 154) referenceuser_public_profile.public_name— this is an inaccuracy in the approved spec; backend-dev should NOT chase it. The spec is authoritative for intent (match against the public display name); the schema dictates that the public display name lives onusers.global_name. - Drizzle-kit re-run safety pinned concretely. v1 left this as “a single-line drift marker comment”. v2 picks option (B) — declare each trigram index in
*.schema.tsviaindex(...).using('gin', table.col.op('gin_trgm_ops')).drizzle-orm ^0.45.2+drizzle-kit ^0.31.10(verified intktspace-backend/package.json) emit the exactUSING gin (col gin_trgm_ops)expression. Re-runningdrizzle-kit generateis a stable no-op. TheCREATE EXTENSIONstatement remains a one-time hand prepend on the FIRST migration only; subsequent generates do not touch the extension or the indexes. - Per-session vs per-activity fallback resolved as spec-literal per-activity. v1’s
NOT EXISTS (session_coaches sc2 WHERE sc2.session_id = sessions.id)was per-session. The spec’s literal wording is per-activity (“activity has no session_coaches rows”). v2 evaluatesNOT EXISTSagainst ALL sessions on the activity. Known limitation documented in D5.
- Tier B name match runs against
-
v1 (2026-06-02) — initial draft.
Context
Section titled “Context”The spec at specs/contributor-aware-search-and-session-filter.md
introduces two related discovery affordances on the client surface
only: (a) free-text + explicit-contributor search on the activities
list and (b) a contributor filter on the session-picker list inside an
activity. Both lean on the global identity model just shipped in ADR
global-user-identity — search and filter target users.id (the
person), not companyMembers.id (the per-tenant relationship row).
Every behavioural decision is pinned in the spec’s Decisions locked
Acceptance criteriablocks (search target, two search modes, index choice, fallback rules, precedence betweenqandcontributorUserId, empty-state semantics, rating tie-in). This ADR therefore does not re-state those decisions; its job is to:
- Frame the feature inside the existing architecture — which ADRs it
inherits (
global-user-identity,contributor-reviews-ratings,contributors-must-be-members). - Pin the SQL composition strategy for the two search tiers + the session-filter fallback, so backend-dev does not have to re-derive the query shapes from prose.
- Lock the trigram-extension ordering inside the additive migration so
gin_trgm_opsindexes do not race theCREATE EXTENSIONcall. - Name the endpoint shape choices and per-surface UX divergence (web chip behaviour driven by URL state, mobile chip-row inside the booking flow).
- Record what we deliberately did not build (Elasticsearch, specialisation-tag search, location search, full TF-IDF ranking, tickets_app wiring).
The feature touches exactly one surface contract
(contracts/client.openapi.yaml), two consumers (tktspace-web,
tktspace-mobile-app/apps/gym_app), and two mobile shared packages
(packages/api, packages/i18n). No business / super-admin contract
edits.
AC ↔ Decision mapping
Section titled “AC ↔ Decision mapping”| AC | Primary decisions | Notes |
|---|---|---|
| AC-1 | D1 (two-tier ranked search composition) | Tier A (title) + Tier B (contributor users.global_name via EXISTS subquery), pg_trgm similarity inside each tier; updatedAt tiebreaker. |
| AC-2 | D2 (explicit contributor filter) | contributorUserId UUID → EXISTS (contributors → companyMember → users); combinable with sphereId. |
| AC-1+2 | D3 (precedence) | contributorUserId overrides q. q silently ignored when both are passed (per OQ-2). Documented in endpoint desc. The legacy search param coexists: q is preferred; search is used only when q is absent. |
| AC-3 | D4 (public-profile endpoint) | GET /api/client/users/:userId/public-profile, public (no auth), reuses existing UserPublicProfileDto. 404 only when no profile row AND no global name. |
| AC-4 | D5 (session filter + fallback) | contributorUserId on FindSessionsClientDto. Primary EXISTS via session_coaches; fallback EXISTS via activity-level contributors when the entire activity has zero session_coaches rows (spec-literal per-activity check; partial-assignment edge case documented as a known limitation in D5). |
| AC-5 | D6 (web UX — URL-driven mode toggle) | Search input visible iff contributorUserId is NOT in URL; contributor chip header visible iff contributorUserId IS in URL. AC-3 powers the header. Result cards render a “Contributor match” chip when matchedByContributor === true (no per-contributor id leakage — see D1 / Surface impact). |
| AC-6 | D6 | Contributor card → routerLink="/explore" with queryParams={ contributorUserId }. The route guard strips any stale q from the URL when contributorUserId is set. |
| AC-7 | D7 (web session-picker chip row) | Derived from activity.contributors[].member.user.id (the contributor’s user id). Single-contributor activities hide the row entirely. |
| AC-8 | D8 (mobile search UI) | Free-text input on existing discover screen + contributor profile “Activities” tab; both call AC-1/AC-2 through packages/api. |
| AC-9 | D9 (mobile session-picker chip row) | Mirror of D7; chip row hidden when contributor count = 1. |
| AC-10 | D10 (migration ordering + extension) | Schema TS declares the two trigram indexes via index(...).using('gin', table.col.op('gin_trgm_ops')) — Drizzle 0.45 / drizzle-kit 0.31.10 emits the correct DDL. CREATE EXTENSION is hand-prepended to the FIRST generated migration only; subsequent generates are stable no-ops. Two indexes total (title, global_name) — the spec’s third index against a non-existent user_public_profile.public_name column is dropped. |
| AC-11 | D11 (i18n surface) | New keys under home#*, search#*, checkout#*. EN/UK/RU mandatory; DE/FR fall back to EN. Web + mobile share key namespaces (web has its own translations file, same key shapes). |
Decision
Section titled “Decision”D1 — AC-1 free-text search: two-tier ranked, single SQL
Section titled “D1 — AC-1 free-text search: two-tier ranked, single SQL”Free-text q (string, trimmed, length ≤ 64) is matched in two tiers
inside a single SQL query. The query is the existing
activitiesClientList query, conditionally extended when q is
non-empty.
Composition (Drizzle-side SQL sketch):
The composition is built in activities-client.service.ts using
Drizzle’s sql\…` raw template helper combined with the existing typed query builder (db.select(…).from(activities)…). The CASE expression and the WHERE-clause disjunction are appended as a single sql`…` fragment. Drizzle composes parameterised values (${qInput}`) safely; no string concatenation of user input.
WITH q_input AS ( SELECT :q::text AS q)SELECT a.*, CASE -- Tier A: activity title match WHEN a.title ILIKE '%' || q_input.q || '%' OR similarity(a.title, q_input.q) >= 0.3 THEN 0 -- Tier B: contributor global-name match -- (users.full_name only — user_public_profile carries bio/specializations/ -- links/slug, NOT a public_name column; canonical public display name -- lives on users.full_name per the schema added by global-user-identity) WHEN EXISTS ( SELECT 1 FROM activities.contributors c JOIN companies.company_member m ON m.id = c.member_id JOIN users.users u ON u.id = m.user_id WHERE c.activity_id = a.id AND u.full_name IS NOT NULL AND ( u.full_name ILIKE '%' || q_input.q || '%' OR similarity(u.full_name, q_input.q) >= 0.3 ) ) THEN 1 ELSE NULL -- filtered out by the WHERE clause below END AS match_tier, -- Relevance score within the tier (best of title-sim, name-sim) GREATEST( similarity(a.title, q_input.q), COALESCE(( SELECT MAX(similarity(u.full_name, q_input.q)) FROM activities.contributors c JOIN companies.company_member m ON m.id = c.member_id JOIN users.users u ON u.id = m.user_id WHERE c.activity_id = a.id AND u.full_name IS NOT NULL ), 0) ) AS relevanceFROM activities.activities a, q_inputWHERE -- existing visibility / sphere / category / pagination filters apply unchanged ... AND ( a.title ILIKE '%' || q_input.q || '%' OR similarity(a.title, q_input.q) >= 0.3 OR EXISTS ( ... same EXISTS as above ... ) )ORDER BY match_tier ASC, relevance DESC, a.updated_at DESCLIMIT :limit OFFSET (:page - 1) * :limit;Why a single SQL with CASE and not two UNION-ed subqueries:
- One round-trip; one set of indexes consulted; one pagination clause.
- Existing
activitiesClientListfilters (sphereId, type, etc.) apply to the combined result without re-implementing them in two halves. match_tieris computed once per row; the same EXISTS subquery is inlined forrelevance. Postgres CTE planner inlines effectively; the duplication is acceptable for clarity.- The 0.3 similarity threshold mirrors the standard
pg_trgmdefault. Lowering it (more results, noisier) or raising it (fewer results) is a Phase B / production-tuning concern; the spec pins 0.3 as the v1 floor.
Per the spec’s noise-control decision: Tier B matches
users.full_name (a.k.a. globalName) only. bio and
specializations[] are intentionally excluded — OQ-1 resolved NO.
Spec-vs-reality drift on user_public_profile.public_name: spec
AC-1 (line 60) and AC-10 (line 154) reference a public_name column
on user_public_profile. The shipped schema (verified at
libs/shared/data-access-db/src/lib/schema/users.schema.ts:49-63)
has no such column — user_public_profile carries only bio,
specializations, links, slug, verified_at, cover_photo_url,
plus timestamps. The 1:1 design comment at line 46 is explicit:
“publicName and avatarUrl stay on users (globalName / avatarUrl
columns)”. Backend-dev follows the schema as authoritative and
matches against users.full_name only; this ADR records the drift so
the spec is not chased.
Whether to also “include” the matched contributor on the result
card (so the web Tier B chip in AC-5 has data to render): the
spec asks for a “Contributor match” chip on Tier B cards. The chip
does NOT need to identify which specific contributor matched — AC-5
text is “Contributor match”, a presence indicator, not a per-
contributor attribution. Service projection adds a single boolean
matchedByContributor: boolean on ActivityPreviewClientResponseDto
ONLY when q is non-empty — see Surface impact / Field-level additions. false when only Tier A matched; true when Tier B
fired (with or without Tier A). The web/mobile clients render the
chip on matchedByContributor === true without a second roundtrip
and without leaking contributor UUIDs to the client.
D2 — AC-2 explicit contributor filter: pure EXISTS
Section titled “D2 — AC-2 explicit contributor filter: pure EXISTS”contributorUserId (UUID) is added as an optional param on the
existing activitiesClientList (and its company-scoped sibling). It
composes as a WHERE EXISTS (...) predicate against the contributors
join, with the join reaching out through companyMember.userId to
users.id:
WHERE EXISTS ( SELECT 1 FROM activities.contributors c JOIN companies.company_member m ON m.id = c.member_id WHERE c.activity_id = activities.activities.id AND m.user_id = :contributorUserId)No JOIN onto users or user_public_profile is required for this
filter — we only need to know that some companyMember with the
matching userId is on the contributors list. Index hits land on
company_member(user_id) (existing) and
activities.contributors(activity_id, member_id) (existing PK / FK).
Composable with sphereId, categoryId, type, search (legacy),
limit, page. NOT composable with q when both are passed —
see D3.
D3 — Precedence: contributorUserId overrides q
Section titled “D3 — Precedence: contributorUserId overrides q”When both q and contributorUserId are present in the same request,
q is silently ignored and contributorUserId wins. Service
applies the AC-2 EXISTS predicate, does NOT apply the AC-1 two-tier
ranking, returns results ordered by the default updated_at DESC
ordering.
Rationale (per spec OQ-2):
- The web UI (AC-5) hides the search input when
contributorUserIdis in the URL, so the UI never produces the combination. - Programmatic / direct API consumers passing both get the intuitive reading (“show me all of this contributor’s activities, full stop”).
- A 400 / explicit error would be more surface area for clients to handle, with no UX benefit.
This precedence is documented in the OpenAPI q parameter description
on activitiesClientList and activitiesClientListByCompany.
D4 — AC-3 public-profile endpoint: new public path, reuse existing DTO
Section titled “D4 — AC-3 public-profile endpoint: new public path, reuse existing DTO”Endpoint: GET /api/client/users/:userId/public-profile (NEW).
- Auth: public — no JWT required (the controller method opts
out of
ClientJwtGuard, e.g. via@Public()or an explicitly un-guarded sub-controller). This is the first public endpoint under/api/client/users/*— same surface as the private/api/client/me/public-profile(D7 ofglobal-user-identity), but reads ANY user’s profile by id. - Path placement:
/users/:userId/public-profilewas chosen deliberately over/contributors/:userIdper the spec’s “Decisions locked” reasoning:- Matches the symmetry already in the contract:
/me/public-profile(private, self) ↔/users/:userId/public-profile(public, any). - The profile is owned by the user, not by the contributor-relationship; the URL should reflect ownership.
- The endpoint may serve non-contributor users too (forward-compat for review authors, future leagues, etc.) without a rename.
- Matches the symmetry already in the contract:
- Response shape: existing
UserPublicProfileDto(currently at client contract lines 1657-1704). No new DTO. All eight fields already present (userId,globalName,avatarUrl,bio,specializations,links,slug,verifiedAt,coverPhotoUrl). - 404 vs 200 semantics (per spec AC-3):
- 200 if
users.global_name IS NOT NULLOR auser_public_profilerow exists (even if all its content fields are null). Nullable fields are emitted asnull; the contract already declares every rich field as nullable on this DTO. - 404 if
users.global_name IS NULLAND nouser_public_profilerow exists. Rationale: the person has no discoverable identity — surfacing an all-null body would mislead consumers into thinking the userId is valid for chip rendering. The 404 is a strict signal “no identity to render”. - Service implementation: a LEFT JOIN of
users⋈user_public_profile; if right side missing ANDusers.global_name IS NULL→ throwNotFoundExceptionwith codeerrors.user.public_profile_not_found. Otherwise project the row.
- 200 if
ratingSummaryfield: NOT added toUserPublicProfileDtoin this ticket. The spec describes a rating tie-in on result cards (AC-5: “search results can show contributor’s aggregate rating”), but that is rendered via the existingContributorPreviewDto.ratingSummaryon the activity-level contributor card already shipped bycontributor-reviews-ratings. The public-profile endpoint itself is identity-only; consumers that want the rating callGET /api/client/contributors/{userId}/rating-summary(also shipped bycontributor-reviews-ratings). Keeping AC-3 strictly identity avoids forcing aRatingSummaryPreviewDtore-export onto the public endpoint and matches the spec’s “reuses the existingUserPublicProfileDtoshape — no new DTO needed” wording.
D5 — AC-4 session-filter + activity-level fallback (spec-literal)
Section titled “D5 — AC-4 session-filter + activity-level fallback (spec-literal)”contributorUserId (UUID) is added as optional on FindSessionsClientDto
(used by both activitiesClientFindSessions and
activitiesClientFindSessionsByCompany).
SQL composition: the filter is a WHERE predicate added on top of
the existing sessions.startsAt >= now() + dateFrom/dateTo
constraints. The fallback is gated on a per-activity check, per
the spec’s literal wording (AC-4 lines 106-115: “if the activity has
no session_coaches rows”):
WHERE ... existing time-window filters ... AND ( -- Primary: session_coaches row exists for this session matching the user EXISTS ( SELECT 1 FROM activities.session_coaches sc JOIN companies.company_member m ON m.id = sc.member_id WHERE sc.session_id = sessions.id AND m.user_id = :contributorUserId ) OR -- Fallback: the ENTIRE ACTIVITY has zero session_coaches rows -- (solo-led activity, contributors assigned only at activity level) ( NOT EXISTS ( SELECT 1 FROM activities.session_coaches sc2 JOIN activities.sessions s2 ON s2.id = sc2.session_id WHERE s2.activity_id = sessions.activity_id ) AND EXISTS ( SELECT 1 FROM activities.contributors c JOIN companies.company_member m2 ON m2.id = c.member_id WHERE c.activity_id = sessions.activity_id AND m2.user_id = :contributorUserId ) ) )Why the fallback exists: spec AC-4 calls it out. Activities with a
single coach often don’t write per-session session_coaches rows
(the coach is implicit via the activity-level contributors join).
Without the fallback, filtering by that coach’s contributorUserId
would return zero sessions even though every session is logically led
by them — broken UX.
Why per-activity (not per-session) — and the known limitation: the
spec is literal about “the activity has no session_coaches rows”. v1
of this ADR used a per-session check (NOT EXISTS (... WHERE sc2.session_id = sessions.id)); architect-critic flagged that as a deviation from
the spec. v2 reverts to per-activity.
The resulting known limitation is the partial-assignment edge case:
an activity where some sessions have explicit session_coaches rows
and some do not. Under the per-activity rule, the fallback does NOT
fire for ANY session on that activity (because at least one session has
session_coaches rows). The activity-level contributor is therefore
NOT “smeared” onto the sessions that lack explicit coach rows — they
return empty when filtered by that contributor.
We accept this as an MVP trade-off:
- The spec literal wins; amending mid-build is higher friction.
- The partial-assignment scenario is rare in real data (operators either model coaches per-session everywhere or only at activity level — mixing is uncommon).
- If real users hit it, a follow-up ticket flips back to per-session, re-amends the spec, and rebuilds the reviews-eligibility parity rule alongside.
Parity warning: the spec calls out (and contributor-reviews-ratings
ADR D3 also recorded) that this fallback rule is duplicated between
this AC-4 session filter and the reviews-eligibility / review-target
chain (AC-2 step 5 in the reviews flow). The duplication is
intentional; any future refactor that changes the fallback semantics
must touch both call sites or they will diverge silently. Backend-
dev should leave a // keep in sync with reviews-eligibility.service.ts
comment on both sides.
Empty result is 200 + [], never 404, per the spec’s empty-state
rule.
D6 — AC-5/AC-6 web UX: URL state drives the mode toggle
Section titled “D6 — AC-5/AC-6 web UX: URL state drives the mode toggle”The /explore (home) page is a single Angular route. Its query-param state determines which UI mode renders:
| URL state | UI mode |
|---|---|
? (no params) | Default home (hero with search input + featured activities). |
?q=yoga | Hero + search input filled; results section below renders AC-1; per-card Tier B chip. |
?sphereId=... | Existing behaviour (sphere filter), unchanged. |
?contributorUserId=:uuid | Search input HIDDEN. Contributor chip header (“Activities by {name}”) rendered above results; “Clear” link removes the param. Calls AC-3 once for header data, AC-2 for results. |
?contributorUserId=:uuid&q=... | Same as ?contributorUserId=... alone — UI hides the search input regardless, and the backend ignores q per D3. The route resolver scrubs q from the URL on hydration via router.navigate([], { queryParams: { q: null }, queryParamsHandling: 'merge', replaceUrl: true }) so the URL stays canonical. Prevents user-visible-but-unused q from confusing copy/paste / browser-history. |
Why URL-driven instead of component-state-driven: deep-linking is
the headline UX (contributor card → “View activities” copies the
contributorUserId into the URL; the user can share the URL; refresh
preserves the filter). It also keeps Angular’s existing SSR rendering
stable (server can render the right mode from the URL alone).
The contributor chip header data flow:
- Route activator reads
contributorUserIdfromActivatedRoute. - Sends parallel requests: AC-2 (activities) + AC-3 (profile).
- Header renders
globalName(from AC-3) with optional avatar. - If AC-3 returns 404, the header renders the empty state “Activities
for unknown contributor” and AC-2 still runs (yields
[]).
Tier B chip on result cards is driven by the new
matchedByContributor: boolean projection on
ActivityPreviewClientResponseDto (see D1). The card renders a single
“Contributor match” chip when matchedByContributor === true. No
per-contributor attribution is rendered — the chip is a presence
indicator, matching the spec’s AC-5 copy. No new HTTP call. Web’s
i18n separator is # (per existing convention, e.g.
search#contributor_match_chip).
D7 — AC-7 web session-picker chip row in checkout
Section titled “D7 — AC-7 web session-picker chip row in checkout”The checkout flow (src/app/pages/checkout/...) renders the session
picker today. The chip row is added at the top of the picker when:
- The activity has >1 contributor (read from
activity.contributors[], which is already fetched as part of the activity-detail). - Chip values are derived client-side from
activity.contributors[]→{ userId: member.user.id, label: member.user.globalName }.
On chip click:
- “All contributors” → re-fetch sessions without
contributorUserId(current behaviour). - A specific contributor → re-fetch sessions with
contributorUserId=:userId(AC-4).
Why client-side derivation and not a new endpoint: the activity-
detail response already carries the contributor list (verified at
client contract ActivityDetailClientResponseDto.contributors). Hitting
the network a second time for the same data would be wasteful.
Single-contributor activities hide the row entirely — no “All contributors” / “{ContributorX}” toggle when there’s only one option.
D8 — AC-8 mobile search UX: existing discover + contributor profile tab
Section titled “D8 — AC-8 mobile search UX: existing discover + contributor profile tab”apps/gym_app already has a search / discover screen
(apps/gym_app/lib/pages/...). Changes:
- Search input wires to
q. Existing input now sendsqinstead of the legacysearchparam. The legacysearchparam remains in the contract for compatibility; new clients useq. Precedence rule (locked in this ADR):qis preferred;searchis used ONLY whenqis absent (q === undefined || q === ''). When both are passed,qruns the new two-tier path andsearchis silently ignored. The legacysearchparameter is markeddeprecated: truein the OpenAPI contract patch (see Surface impact); deprecation does not remove the param, only signals new clients to stop using it. A follow-up ticket retiressearchonce consumers are migrated. - Contributor chips on result cards are clickable. Tap navigates
to the contributor profile screen at
apps/gym_app/lib/pages/contributor/...(NEW or extended). The profile screen has tabs: “About” (uses AC-3) and “Activities” (uses AC-2 with the contributor’s userId). - No new shared package.
packages/profilealready exists for user-self profile editing (perglobal-user-identityD7). The “view another user’s profile” screen is app-level ingym_app, not a shared widget —tickets_apphas no contributor concept.
D9 — AC-9 mobile session-picker chip row
Section titled “D9 — AC-9 mobile session-picker chip row”Mirror of D7. Implemented in the booking flow inside
apps/gym_app/lib/pages/... (checkout-equivalent). Chip data is
derived from the already-fetched activity-detail’s contributors[].
On chip change, re-fetch sessions via the regenerated
packages/api client (AC-4).
Single-contributor activities hide the row entirely.
D10 — AC-10 migration: schema-side declaration + one-off CREATE EXTENSION prepend
Section titled “D10 — AC-10 migration: schema-side declaration + one-off CREATE EXTENSION prepend”Per memory feedback_drizzle_migrations, backend migrations are
generated via drizzle-kit generate. Pinned versions (verified at
tktspace-backend/package.json): drizzle-orm ^0.45.2, drizzle-kit ^0.31.10. This version pair supports:
index('name').using('gin', column.op('gin_trgm_ops'))— emits the exactUSING gin (col gin_trgm_ops)DDL in the generated.sqlfile. (Verified innode_modules/drizzle-orm/pg-core/indexes.d.tsandcolumns/common.d.ts:103—op(opClass: PgIndexOpClass)plususing(method: PgIndexMethod, ...). The literal'gin_trgm_ops'isn’t in the typed enum but the(string & {})fallback accepts it.)- Re-running
drizzle-kit generateagainst an unchanged schema is a stable no-op — drizzle-kit reads the existing migrations folder and diffs the schema; identical declarations emit nothing.
drizzle-kit does NOT model Postgres extensions; CREATE EXTENSION pg_trgm is hand-prepended to the migration file ONCE on first
generation. Subsequent generates do not see or touch the extension
statement.
Schema-side declaration — concrete code:
import { pgSchema, text, uuid, timestamp, index } from 'drizzle-orm/pg-core';
export const activitiesSchema = pgSchema('activities');
export const activities = activitiesSchema.table( 'activities', { id: uuid('id').primaryKey(), title: text('title').notNull(), // ... existing columns ... }, (t) => ({ // ... existing indexes ... titleTrgmIdx: index('activities_title_trgm_idx') .using('gin', t.title.op('gin_trgm_ops')), }),);import { pgSchema, text, uuid, index } from 'drizzle-orm/pg-core';
export const usersSchema = pgSchema('users');
export const users = usersSchema.table( 'users', { id: uuid('id').primaryKey(), email: text('email').notNull(), phone: text('phone'), globalName: text('full_name'), // ... existing columns ... }, (t) => ({ globalNameTrgmIdx: index('users_global_name_trgm_idx') .using('gin', t.globalName.op('gin_trgm_ops')), }),);Migration shape:
- Backend-dev edits
activities.schema.tsandusers.schema.tsto add the two index declarations above (NOTE: existing schema files may not declare a(t) => ({...})config block today — backend-dev adds it for these files.users.schema.tscurrently has no third-argument index block; introducing it is the only change to the table declaration). pnpm db:generate(alias fordrizzle-kit generate --config=drizzle.config.ts --name=pg_trgm_search_indexes) from the backend repo root. drizzle-kit emits a migration file containing exactly twoCREATE INDEX ... USING gin (col gin_trgm_ops);statements.- Hand-prepend ONCE to the top of the generated
.sql:(No-- Required: pg_trgm is not modelled by drizzle-kit. Idempotent.CREATE EXTENSION IF NOT EXISTS pg_trgm;SCHEMA publicqualifier — see CONCERN-3.) This edit is a one-time, file-local change. Re-runningpnpm db:generatelater does NOT produce a new migration: the schema is unchanged, drizzle- kit’s snapshot matches reality, the diff is empty. NotablesFilteror marker comment required. - Run
migration-safety-checkskill on the file before commit. - Re-run safety verification: after step 3, run
pnpm db:generate --name=verify_nooponce locally; the command must produce no new migration file (or produce an empty file that gets immediately deleted). If a new file appears, the schema-side.using('gin', ... .op('gin_trgm_ops'))did NOT match the generated SQL exactly — fix the schema TS before merging. - No backfill — the indexes build over existing data on
CREATE INDEX. The build is fast at our scale (low thousands of activities / users), soCONCURRENTLYis not required for v1. (PostgresCONCURRENTLYrequires a non-transactional migration; our migrator runs each file in a transaction. The trade-off is documented; if prod write-volume grows, a follow-up migration can drop + rebuildCONCURRENTLYoutside the regular runner.)
Why this ordering matters: the gin_trgm_ops operator class is
defined by the pg_trgm extension. Running CREATE INDEX ... gin (... gin_trgm_ops) before CREATE EXTENSION pg_trgm fails with
operator class "gin_trgm_ops" does not exist for access method "gin".
A single migration file with the right statement ordering is enough;
the migrator runs statements in file order within one transaction.
Index choice — gin_trgm_ops (not gist_trgm_ops): GIN is
faster for the read-heavy workload we expect (search reads >> writes
to activity titles / global names). The size cost is higher than GIST
but still small at our row count. The spec pins GIN, this ADR
confirms.
Two indexes, not three. The spec (AC-10 line 154) lists a third
trigram index against user_public_profile.public_name — that column
does not exist (see Changelog v2 #1). The third index is dropped.
D11 — i18n surface (AC-11)
Section titled “D11 — i18n surface (AC-11)”New keys, namespaced per the existing convention:
| Key | EN |
|---|---|
home#search_by_contributor_chip | ”Search by contributor” |
search#contributor_match_chip | ”Contributor match” |
search#filter_by_contributor | ”Filter by contributor” |
search#no_results_for_contributor | ”No activities found for this contributor” |
checkout#filter_by_contributor | ”Filter sessions by contributor” |
checkout#all_contributors | ”All contributors” |
- EN/UK/RU mandatory. DE/FR fall back to EN (existing convention,
matches
favorites#*andpasses#*rollouts). - Mobile keys land in
packages/i18n/assets/i18n/{en,ru,uk}.json. - Web keys land in the web app’s own translations file
(
tktspace-web/src/...). Key shapes are identical to mobile to ease product copy/paste; the runtime catalogues are independent.
Considered alternatives
Section titled “Considered alternatives”Alt 1 — Elasticsearch / Meilisearch / Algolia
Section titled “Alt 1 — Elasticsearch / Meilisearch / Algolia”External search engine indexing activities, users,
user_public_profile. Synchronised via change-data-capture or
periodic batch.
Pros:
- Best-in-class ranking and fuzzy matching out of the box.
- Scales beyond what Postgres trigram can handle.
Cons:
- Massive operational surface for v1 returns. A search engine is a separate process to deploy, monitor, backup, and patch. At our current row counts (thousands of activities, hundreds of public profiles), Postgres trigram is sub-millisecond — we would be paying ops cost with no measurable user-perceived benefit.
- CDC pipeline is non-trivial. Keeping the index consistent
across activity edits, contributor edits, and global-profile edits
requires either a
LISTEN/NOTIFYconsumer or a batch reconciler — either way, more code than the spec calls for. - The spec explicitly rules this out. Out-of-scope #1.
Rejected for v1. Reopens when (a) row counts cross ~100k activities,
or (b) the search-result quality bar requires more than pg_trgm
similarity inside a two-tier rank.
Alt 2 — TF-IDF / BM25 ranking via tsvector / tsquery
Section titled “Alt 2 — TF-IDF / BM25 ranking via tsvector / tsquery”Use Postgres full-text-search (FTS) with a tsvector column on
activities.title and a stored tsvector derived from
user_public_profile.public_name + users.global_name. Rank with
ts_rank or ts_rank_cd.
Pros:
- True relevance ranking (term frequency, inverse document frequency).
- Stemming, stopword removal, language-aware lexers.
Cons:
- Stemming is a footgun for short proper nouns. A coach named “Maryna” gets stemmed; a typo “Marina” no longer matches; the “lexer per language” config has to know which column is which language. Trigram is character-N-gram-based — agnostic to language, resilient to typos out of the box.
- OQ-3 of the spec resolved this — two-tier with
pg_trgmsimilarity is the chosen approach. TF-IDF rejected as overkill. - Activity titles are short and few — TF-IDF gains come from long-document scoring; our domain is “find Maryna’s yoga”, not “find documents matching keywords”.
Rejected.
Alt 3 — Two separate UNION-ed queries (Tier A query UNION Tier B query)
Section titled “Alt 3 — Two separate UNION-ed queries (Tier A query UNION Tier B query)”Run two SQL queries — one for title matches, one for contributor-name matches — and UNION them, tagging the source tier.
Pros:
- Each query is simpler in isolation; the planner sees two narrower predicates.
- Adding a new tier (e.g. category-name match) later is a third UNION.
Cons:
- Pagination breaks.
LIMIT/OFFSETover a UNION requires either a wrapping subquery or applying the limit per branch — neither matches the spec’s “order by tier, then relevance, then updatedAt” semantics cleanly. - Duplicate matches across tiers (an activity whose title matches AND whose contributor matches) would appear twice. Deduping requires another wrapping layer.
- Existing filter logic (sphereId, type, …) would have to be duplicated across both branches.
The single-SQL CASE-tier approach (D1) is simpler operationally even
if its EXISTS subquery is repeated for match_tier and relevance.
Rejected.
Alt 4 — Materialise a search_index table
Section titled “Alt 4 — Materialise a search_index table”A denormalised activities_search_index table populated by trigger
or batch job — one row per activity with a flat searchable_text
column concatenating title + contributor names. Trigram-indexed.
Pros:
- Simpler runtime query (single column scan).
- Inserts/updates to source tables fire a trigger to refresh the row.
Cons:
- Triggers across schemas. Updating
user_public_profile.public_namewould have to fire a trigger that re-renders the row for every activity the user contributes to — cross-schema trigger entanglement, which ADRcross-schema-references-without-fkexplicitly avoids. - Staleness during the migration window. Until the trigger logic ships, the materialised table is stale; backfill complexity grows.
- Buys nothing at our scale. The runtime EXISTS subquery is fast over the trigram index already; materialisation is a premature optimisation.
Rejected. Re-opens if row counts grow past ~100k and the EXISTS subquery measurably slows reads.
Alt 5 — Reuse the existing search query param instead of adding q
Section titled “Alt 5 — Reuse the existing search query param instead of adding q”The contract today carries search: string on activitiesClientList
and the company-scoped sibling (verified at client.openapi.yaml
lines 2068-2072 and 2377-2381). Could re-purpose it: add the two-tier
logic behind the existing param.
Pros:
- No new param. Slightly smaller contract patch.
Cons:
- Existing
searchsemantics may differ. The currentactivitiesClientListimplementation may treatsearchas title-only ILIKE (no trigram, no contributor-name fallback). Re-purposing it changes behaviour silently for any current consumer. - The spec asks for
qexplicitly. AC-1 names the paramq, length ≤ 64, trimmed. - Gradual rollout safer. Adding
qlets backend implement the two-tier logic side-by-side with the legacysearchand migrate clients before deprecatingsearch. (Deprecation is a future ticket.)
Rejected. Both params coexist; precedence locked in D8: q is
preferred; search is used only when q is absent. search is
marked deprecated: true in the contract.
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)”Modified endpoints (parameter additions only — response shape stable except for one optional projection field):
| Endpoint | New params | Existing params |
|---|---|---|
GET /api/client/activities (activitiesClientList) | q?: string (≤64, minLength 2), contributorUserId?: uuid | search → marked deprecated: true; precedence: q wins when both present |
GET /api/client/companies/{companyId}/activities (activitiesClientListByCompany) | same | same |
GET /api/client/activities/{activityId}/sessions (activitiesClientFindSessions) | contributorUserId?: uuid | unchanged |
GET /api/client/companies/{companyId}/activities/{activityId}/sessions (activitiesClientFindSessionsByCompany) | contributorUserId?: uuid | unchanged |
New endpoint:
| Path | Method | AC | Auth | Response |
|---|---|---|---|---|
/api/client/users/{userId}/public-profile | GET | AC-3 | public | UserPublicProfileDto (reused) |
Field additions to existing schemas:
-
ActivityPreviewClientResponseDto.matchedByContributor?: boolean— optional, NOT nullable. Present only when the request carried a non-emptyq.trueiff this row’s Tier B (contributor-name) EXISTS branch fired (with or without Tier A);falseiff only Tier A fired or the row matched viacontributorUserId. Absent whenqwas not passed. Used by web/mobile cards to render the “Contributor match” chip. This is a strict-boolean presence indicator — no per-contributor UUIDs are leaked to the client; the chip itself carries no attribution.v1 of this ADR proposed
matchedContributorIds: string[]. v2 simplified to a boolean: AC-5 only needs “Contributor match” as a presence indicator, the specific matched UUIDs are not required for rendering and would be PII leakage (a client could enumerate which contributors are at which gym via probe queries). Smaller payload, same UX.
Field-visibility justification — what stays off the client surface:
- No trigram similarity score in the response. The spec does not require exposing the raw similarity number. Adding it would encourage clients to re-sort, breaking the server-pinned ordering.
- No “tier” enum in the response. Tier-A vs Tier-B is communicated
implicitly:
matchedByContributor === truemeans Tier B fired (possibly also Tier A — order is pinned server-side anyway, so the client doesn’t need to know). - No matched contributor UUIDs. Per the simplification above — attribution is not needed for the chip.
- No
ratingSummaryonUserPublicProfileDto. Per D4 — clients fetch the rating via the existing/api/client/contributors/{userId}/rating-summaryendpoint when they need it. - No phone/email on the public-profile response. Existing DTO already excludes them; this ADR maintains that.
Business surface (contracts/business.openapi.yaml)
Section titled “Business surface (contracts/business.openapi.yaml)”Not touched. Business surface has separate admin-side search semantics (member directory, customer directory) which are out of scope for this ticket.
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”No cross-surface DTO sharing in this ticket. The new
matchedByContributor field is client-surface-only because (a) it’s
search-relevance metadata, not a domain field, and (b) the business
and super-admin surfaces don’t run this search pipeline.
Data model changes
Section titled “Data model changes”| Table / object | Change |
|---|---|
Postgres extension pg_trgm | NEW. Enabled (no schema qualifier — see CONCERN-3 below). Not present in any prior migration (verified — zero matches for CREATE EXTENSION across all 42 migration files in libs/shared/data-access-db/migrations/). |
activities.activities (existing) | New index activities_title_trgm_idx USING gin (title gin_trgm_ops). |
users.users (existing) | New index users_global_name_trgm_idx USING gin (full_name gin_trgm_ops). Column is full_name (Postgres-side); Drizzle field name is globalName. |
users.user_public_profile (third index) | DROPPED. The shipped user_public_profile schema has no public_name column (see Changelog v2 #1). Canonical public display name lives on users.full_name; the second index above covers Tier B name matching entirely. |
No new tables. No column changes. No data backfill. Purely additive: one extension, two GIN indexes. No risk to existing rows.
Operational requirement — pg_trgm privilege. CREATE EXTENSION pg_trgm requires the migration runner role to have CREATE privilege
on the target schema (defaults to public per the role’s
search_path). If the role lacks this in any environment, the
migration fails loudly with a clear Postgres error — this is an
ops-escalation, not silent corruption. On managed Postgres (Supabase,
RDS, etc.) pg_trgm is in the allowed-extensions list and the default
service role has the privilege. Verify before deploy on each env.
Cross-schema reference notes
Section titled “Cross-schema reference notes”This feature reads across three Postgres schemas in the same query. Verified by inspecting the shipped schema files; the chain is FK-backed at every cross-schema hop:
activities.contributors.member_id→companies.company_member.id— real cross-schema FK withonDelete: 'cascade'(contributors.schema.ts:.references(() => companyMembers.id, { onDelete: 'cascade' })).companies.company_member.user_id→users.users.id— real cross-schema FK (companies.schema.ts:.references(() => users.id)).users.users.id→users.user_public_profile.user_id— in-schema FK (1:1 declared byglobal-user-identity).
This is NOT a “cross-schema-soft-ref” case. v1 of this ADR mistakenly
invoked cross-schema-references-without-fk here — corrected in v2.
The referential integrity story for this feature is straightforward:
deleting a companyMember cascades-deletes the contributors row that
points at it; the activity drops out of the EXISTS subquery
automatically. No application-level orphan handling required for the
search pipeline.
Read-time join behaviour:
users.full_namemay be NULL — the EXISTS predicate handles this by includingAND u.full_name IS NOT NULLsosimilarity(NULL, q)never appears in the score.- All FK constraints prevent dangling references in the read-side
query; no defensive
LEFT JOINis needed beyond the explicit optional joins shown in D1.
No FK additions in this ticket — the chain is already FK-enforced end-to-end.
Migration generation
Section titled “Migration generation”See D10 for the full procedure (schema-side .using('gin', col.op('gin_trgm_ops'))
declarations, one-time CREATE EXTENSION hand prepend, re-run no-op
verification). Preview SQL lives in
drafts/migration-contributor-aware-search-and-session-filter.sql —
illustrative, not executed.
Backend module placement
Section titled “Backend module placement”| Concern | Library | File(s) |
|---|---|---|
q + contributorUserId on activities list | libs/features/activities | src/lib/dto/find-activities-client.dto.ts (extend with q?: string @MaxLength(64) @Trim and contributorUserId?: string @IsUUID) |
| Two-tier search SQL composition | libs/features/activities | src/lib/services/activities-client.service.ts — extend list() / listByCompany() with the CASE-tiered SQL when q is non-empty |
| AC-2 EXISTS predicate | libs/features/activities | same service file; new whereContributorIs(userId) helper appended to the query builder |
| AC-3 public-profile endpoint | libs/features/user-profile | NEW controller method on a new public sub-controller, e.g. src/lib/users-public.controller.ts, mounted at /api/client/users. Reuses the existing UserPublicProfileService (added by global-user-identity) — new method findPublicByUserId(userId) that does the LEFT JOIN + 404 logic. |
| AC-4 session filter + fallback | libs/features/activities | src/lib/dto/find-sessions-client.dto.ts (extend) + src/lib/services/sessions-client.service.ts (the disjunction predicate, per-activity gate). The activity-level fallback must be co-located with the reviews-eligibility fallback parity note (see D5). |
matchedByContributor projection | libs/features/activities | src/lib/services/activities-client.service.ts — extend the row mapper to set a single boolean per row when q is non-empty: true iff Tier B EXISTS branch fired for this row, false otherwise. Field absent when q was empty. |
No new shared utility in libs/shared/. The trigram threshold and
the SQL helpers are activity-feature internals.
Module placement on the routing side:
apps/api/src/app/modules/client-api/client-api.module.ts— imports the (already-mounted)UserProfileClientModule; the new public sub-controller is registered inside that module so it inherits the/api/clientprefix automatically. If the existing controller is JWT-guarded at the controller level, backend-dev splits the guard onto the existing methods (@UseGuards(ClientJwtGuard)per method) rather than at the controller class — this preserves the authenticated/me/public-profileendpoints while letting/users/{userId}/public-profileopt out.
Frontend implications
Section titled “Frontend implications”tktspace-web (Angular SSR)
Section titled “tktspace-web (Angular SSR)”src/app/pages/explore/...(home) — extend the route to readq,sphereId,contributorUserIdfromActivatedRoute.queryParamMap. Render branches per the D6 table. On hydration with bothcontributorUserIdandqset, the route resolver immediately scrubsqfrom the URL (router.navigate([], { queryParams: { q: null }, queryParamsHandling: 'merge', replaceUrl: true })) so the URL stays canonical.src/app/pages/explore/...— new “Contributor match” chip on result cards, driven bymatchedByContributor === true.src/app/pages/checkout/...— chip-row at the top of the session picker (D7). Taiga primitive:tui-chip(or whatever the existing contributor-card uses); state held in asignal<string | null>for the selectedcontributorUserId.src/app/core/api/— regenerated vianpm run generate. New types:FindActivitiesClientDtogainsq?andcontributorUserId?;search?is marked@deprecatedin the generated TS doc-comment (ng-openapi-gen propagatesdeprecated: truefrom OpenAPI).FindSessionsClientDtogainscontributorUserId?.ActivityPreviewClientResponseDto.matchedByContributor?: boolean.- new operation
usersClientGetPublicProfile(or similar — ng-openapi-gen autogenerates fromoperationId).
- New i18n keys (D11) under web’s translations file. The web’s home
- checkout pages reference them; no extraction tooling change.
- SSR considerations: AC-3 and AC-2 both render server-side when the
URL has
contributorUserId. The contributor chip header arrives in the SSR HTML. Hydration is naturally stable — same data flows in CSR after hydrate.
tktspace-mobile-app/apps/gym_app
Section titled “tktspace-mobile-app/apps/gym_app”packages/api— regenerated viamelos run sync:spec && melos run generate:api. New endpoint- parameters appear in the Chopper-generated client.
apps/gym_app/lib/pages/...(discover / search screen) — wireqto the search input (D8). Existingsearchparam retired in this app (legacy param still accepted by backend for any other caller).apps/gym_app/lib/pages/contributor/...(NEW or extended) — contributor profile screen with “About” + “Activities” tabs. “About” callsusersClientGetPublicProfile; “Activities” callsactivitiesClientListwithcontributorUserId.apps/gym_app/lib/pages/...(checkout / booking flow) — chip-row on session-picker (D9). Hidden whenactivity.contributors.length <= 1.packages/i18n/assets/i18n/{en,ru,uk}.json— new keys underhome.*,search.*,checkout.*(D11). DE/FR fall back to EN.packages/profile— not touched. The contributor profile screen is app-level (gym_app), not a shared widget, becausetickets_apphas no contributor concept (Out of scope #4).
apps/tickets_app
Section titled “apps/tickets_app”Regen-only. No UI wiring. Unused DTO bytes (~tens of bytes for the
new query params) are acceptable per the precedent in
contributor-reviews-ratings D17.
tktspace-business
Section titled “tktspace-business”Not affected. Business surface contract is untouched.
tktspace-landing
Section titled “tktspace-landing”Not affected.
- Search performance under sustained load. Trigram indexes
provide good lookups for short queries, but
similarity()is computed per row in the relevance projection. Worst case is a one-characterqthat matches every row. Mitigation:- Backend service trims and rejects
qshorter than 2 characters (treated as empty, AC-1 falls back to “no search” semantics). Spec pinsq ≤ 64upper bound; lower bound is a service-side decision documented here. - Index sizes monitored post-launch; if
users_global_name_trgm_idxgrows beyond a few MB, revisit (unlikely at our row counts).
- Backend service trims and rejects
pg_trgmextension privilege requirement.CREATE EXTENSIONneeds the migration runner role to haveCREATEprivilege on the target schema (default:publicfrom the role’ssearch_path). There is no priorCREATE EXTENSIONcall in this repo (verified: zero matches acrosslibs/shared/data-access-db/migrations/), so we cannot lean on a “we’ve done this before” precedent. On managed Postgres environments (Supabase, RDS)pg_trgmis in the allowed-extensions list and the default service role has the privilege. If a target environment lacks the privilege, the migration fails LOUDLY with a clear Postgres error — this is an ops escalation (grantCREATEonpublic, or enable the “managed extension” toggle), not a silent failure. Document in the deploy runbook for ops awareness.- AC-3 404 vs 200 surface — semantics easy to misinterpret. Consumers might mistake 404 for “server error” and retry. Mitigation: the contract description on the endpoint explicitly enumerates when 404 fires; the chip / contributor-card UI must show an empty state (“Profile not available”) rather than a network-error toast.
- Public endpoint exposure surface. AC-3 is the first endpoint
on
/api/client/users/*that returns another user’s data without auth. Two mitigations:- The DTO exposes only fields already declared public-safe by
global-user-identity(no email, no phone, nocompanyMember.internalNotes). - Rate-limiting on the unauthenticated endpoint follows the
existing global rate-limit posture; no per-endpoint custom
limit in v1. If abused (scraping), the follow-up is to add a
per-IP rate limit on
/api/client/users/*only.
- The DTO exposes only fields already declared public-safe by
- Tier ranking quality at small data sizes. With few activities
per coach, the Tier B EXISTS branch can promote a barely-related
activity above a closer title match if the title’s similarity is
borderline. Mitigation: Tier A is always ranked first
unconditionally (
match_tier ASC). Within-tierrelevanceis the secondary sort, so Tier B cannot eclipse Tier A. If product later wants a merged-tier ranking, that’s a follow-up tuning. session_coaches-fallback parity drift. D5 records the intentional duplication of the activity-level fallback rule between AC-4 (this ticket, per-activity gate) and the reviews-eligibility flow (already shipped). The ”// keep in sync” comment is the only forcing function. If a future PR changes one without the other, the integration test for AC-4 must catch the divergence — backend-dev includes test cases for (a) a solo-led activity asserting the fallback fires, and (b) a partial-assignment activity (1 session has coaches, 9 don’t) asserting the fallback does NOT fire and the contributor’s filter returns only the 1 session that names them explicitly (per the documented MVP limitation).- Web URL-state precedence ambiguity. Programmatic links could
include both
qandcontributorUserId. D6 / spec precedence says “contributorUserId wins”. Web UI never produces this combination, but a copy-pasted URL with both params still resolves to a sensible state. Test plan: add a unit test on the route guard / resolver that asserts the precedence. matchedByContributorprojection cost. Computing the boolean reuses the same Tier B EXISTS subquery already in the query plan for ranking — no extra subquery. Mitigation: the projection is computed only whenqis non-empty; for the common no-search case, the field is absent and the existing query plan is unchanged.tickets_appunused-DTO bytes. Same ascontributor-reviews-ratingsD17. Intentionally accepted.
Rollout plan
Section titled “Rollout plan”No feature flag. Single coordinated rollout — strictly additive.
_workflowMR (this ADR + the client.openapi.yaml patch) lands onmain.tktspace-backendMR:- Edit
activities.schema.ts+users.schema.tsto declare the two trigram indexes viaindex(...).using('gin', col.op('gin_trgm_ops'))(see D10). pnpm db:generate --name=pg_trgm_search_indexes.- Hand-prepend
CREATE EXTENSION IF NOT EXISTS pg_trgm;to the top of the generated.sqlfile (noSCHEMA public). - Verify re-run no-op:
pnpm db:generate --name=verify_noop— must produce zero new migration files. If it does, fix the schema TS until it matches drizzle-kit’s expected output. - Run
migration-safety-check. - Implement search service changes (D1, D2, D3, D5, D8 backend bit).
- Implement AC-3 controller + service (D4).
- Integration tests: AC-1 two-tier ranking, AC-2 EXISTS, AC-3 200/404,
AC-4 with + without fallback (incl. partial-assignment edge case
per D5), precedence (D3),
qvssearchprecedence (D8). - Deploys independently — every new endpoint and param is additive; existing callers see no behavioural change.
- Edit
- In parallel:
tktspace-webMR —npm run generate, wire AC-5/AC-6 (D6), AC-7 (D7). Ships after backend deploys. - In parallel:
tktspace-mobile-appMR —melos run sync:spec && melos run generate:api, wire AC-8 (D8), AC-9 (D9). Add i18n keys (D11) to EN/UK/RU. Ships a new gym_app build.tickets_appregen-only. - Post-deploy validation:
- Smoke
GET /api/client/activities?q=yogaagainst dev — expect non-empty results, Tier A items above Tier B. - Smoke
GET /api/client/users/<known-user>/public-profile— 200 withglobalNamepopulated. - Smoke
GET /api/client/users/<random-uuid>/public-profile— 404 witherrors.user.public_profile_not_found. - Smoke
GET /api/client/activities/<known-activity>/sessions? contributorUserId=<known-user>— non-empty for an activity led by that user; empty ([]) for one not led.
- Smoke
Backwards-compat:
- All param additions are optional → existing callers pass nothing → existing behaviour.
- The new
matchedByContributorfield onActivityPreviewClientResponseDtois optional → old clients ignore the unknown field. searchremains functional; only the contract marker changes (deprecated: true). Existing callers continue to work unchanged.- The new endpoint is net-new → no existing caller breaks.
Rollback:
- Revert the consumer MRs (web + mobile) so UIs stop calling the new endpoint.
- Revert the backend MR — new endpoint + params disappear; existing list endpoints revert to their pre-AC-1 shape.
- Optionally drop the indexes and
pg_trgmextension in a follow-up migration. The indexes are inert when no query references them; leaving them in place during a rollback is also safe.
Stop scope (non-goals)
Section titled “Stop scope (non-goals)”- No Elasticsearch / external search engine (spec Out of scope #1).
- No specialisation-tag search (spec Out of scope #2; separate ticket).
- No location/radius search (spec Out of scope #3; geography is not modelled).
- No
tickets_appUI wiring (spec Out of scope #4). - No web
/contributor/:slugprofile page (spec Out of scope #5; depends onuserPublicProfile.slugUI landing in a separate ticket). - No saved searches / alerts (spec Out of scope #6).
- No search analytics / query logging (spec Out of scope #7; separate product-instrumentation ticket).
- No
ratingSummaryfield onUserPublicProfileDto(D4 — clients use the existing rating endpoint when they need it). - No deprecation of the legacy
searchquery param. Backend accepts both;qis the new canonical,searchcontinues to work for legacy callers until a future cleanup ticket retires it. - No
CREATE INDEX CONCURRENTLYin v1 (D10 — the regular migration runner runs inside a transaction; concurrent build is a follow-up if write-volume grows).
Cross-link
Section titled “Cross-link”- ADR
global-user-identity— providesusers.idas the contributor identity, theusers.global_namecolumn that AC-1 Tier B matches against, and the existingUserPublicProfileDtothat AC-3 reuses. (Note: this ADR’s Tier B readsusers.full_nameonly; theuser_public_profiletable has nopublic_namecolumn despite the spec wording — see Changelog v2 #1.) - ADR
contributor-reviews-ratings— providesContributorPreviewDto.ratingSummaryalready on the activity-page contributor card; AC-5 search result cards render the same chip. Also the source of the AC-4 fallback parity warning (D5). - ADR
contributors-must-be-members— guarantees thecontributors → companyMember → userschain is FK-enforced end-to-end (every cross-schema hop is a real Postgres FK, verified incontributors.schema.tsandcompanies.schema.ts), which is what makes the AC-2 EXISTS predicate consistent.
Contract patch outlines
Section titled “Contract patch outlines”Only contracts/client.openapi.yaml is patched in this MR.
contracts/client.openapi.yaml
Section titled “contracts/client.openapi.yaml”New path:
/api/client/users/{userId}/public-profile—get(AC-3).operationId: usersClientGetPublicProfile. Public (no auth). Response 200UserPublicProfileDto; 404 when the user has no discoverable identity (no profile row + noglobalName).
Modified endpoints (parameter additions + one deprecation flag):
GET /api/client/activities(activitiesClientList) — addq?: string minLength 2 maxLength 64andcontributorUserId?: string format uuid. Mark the existingsearchquery parameter asdeprecated: true(precedence rule:qwins when both present;searchconsulted only whenqis empty).GET /api/client/companies/{companyId}/activities(activitiesClientListByCompany) — same two params, same deprecation flag onsearch.GET /api/client/activities/{activityId}/sessions(activitiesClientFindSessions) — addcontributorUserId?: string format uuid.GET /api/client/companies/{companyId}/activities/{activityId}/sessions(activitiesClientFindSessionsByCompany) — same param.
Field additions to existing schemas:
ActivityPreviewClientResponseDto.matchedByContributor?: boolean— optional, NOT nullable.trueiffqwas passed AND Tier B (contributor-name match) fired for this row.falseiffqwas passed but only Tier A fired. Absent iffqwas not passed. No per-contributor identification leaked.
No schema deletions, no breaking changes. deprecated: true on
search does NOT remove the param from generated clients — it adds a
deprecation marker that ng-openapi-gen / openapi-generator propagate as
@deprecated JSDoc / @Deprecated Dart annotation.
STATUS: READY_FOR_REVIEW