Skip to content

ADR: Contributor-aware search & session filter

ADR: Contributor-aware search & session filter

Section titled “ADR: Contributor-aware search & session filter”

PROPOSED (v2 — fixes three blockers raised by architect-critic against v1; see Changelog)

  • v2 (2026-06-02) — fixes vs v1:

    1. Tier B name match runs against users.global_name ONLY. v1 referenced a user_public_profile.public_name column that does NOT exist in the shipped schema (verified at libs/shared/data-access-db/src/lib/schema/users.schema.ts:49-63user_public_profile carries bio, specializations, links, slug, verified_at, cover_photo_url only; comment at line 46 is explicit that publicName lives on users.global_name). All SQL, indexes, and contract descriptions corrected. Known spec-vs-reality drift: spec AC-1 (line 60) and AC-10 (line 154) reference user_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 on users.global_name.
    2. 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.ts via index(...).using('gin', table.col.op('gin_trgm_ops')). drizzle-orm ^0.45.2 + drizzle-kit ^0.31.10 (verified in tktspace-backend/package.json) emit the exact USING gin (col gin_trgm_ops) expression. Re-running drizzle-kit generate is a stable no-op. The CREATE EXTENSION statement remains a one-time hand prepend on the FIRST migration only; subsequent generates do not touch the extension or the indexes.
    3. 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 evaluates NOT EXISTS against ALL sessions on the activity. Known limitation documented in D5.
  • v1 (2026-06-02) — initial draft.

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 criteria blocks (search target, two search modes, index choice, fallback rules, precedence between q and contributorUserId, empty-state semantics, rating tie-in). This ADR therefore does not re-state those decisions; its job is to:
  1. Frame the feature inside the existing architecture — which ADRs it inherits (global-user-identity, contributor-reviews-ratings, contributors-must-be-members).
  2. 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.
  3. Lock the trigram-extension ordering inside the additive migration so gin_trgm_ops indexes do not race the CREATE EXTENSION call.
  4. Name the endpoint shape choices and per-surface UX divergence (web chip behaviour driven by URL state, mobile chip-row inside the booking flow).
  5. 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.

ACPrimary decisionsNotes
AC-1D1 (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-2D2 (explicit contributor filter)contributorUserId UUID → EXISTS (contributors → companyMember → users); combinable with sphereId.
AC-1+2D3 (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-3D4 (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-4D5 (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-5D6 (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-6D6Contributor card → routerLink="/explore" with queryParams={ contributorUserId }. The route guard strips any stale q from the URL when contributorUserId is set.
AC-7D7 (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-8D8 (mobile search UI)Free-text input on existing discover screen + contributor profile “Activities” tab; both call AC-1/AC-2 through packages/api.
AC-9D9 (mobile session-picker chip row)Mirror of D7; chip row hidden when contributor count = 1.
AC-10D10 (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-11D11 (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).

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 relevance
FROM activities.activities a, q_input
WHERE
-- 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 DESC
LIMIT :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 activitiesClientList filters (sphereId, type, etc.) apply to the combined result without re-implementing them in two halves.
  • match_tier is computed once per row; the same EXISTS subquery is inlined for relevance. Postgres CTE planner inlines effectively; the duplication is acceptable for clarity.
  • The 0.3 similarity threshold mirrors the standard pg_trgm default. 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 contributorUserId is 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 of global-user-identity), but reads ANY user’s profile by id.
  • Path placement: /users/:userId/public-profile was chosen deliberately over /contributors/:userId per the spec’s “Decisions locked” reasoning:
    1. Matches the symmetry already in the contract: /me/public-profile (private, self) ↔ /users/:userId/public-profile (public, any).
    2. The profile is owned by the user, not by the contributor-relationship; the URL should reflect ownership.
    3. The endpoint may serve non-contributor users too (forward-compat for review authors, future leagues, etc.) without a rename.
  • 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 NULL OR a user_public_profile row exists (even if all its content fields are null). Nullable fields are emitted as null; the contract already declares every rich field as nullable on this DTO.
    • 404 if users.global_name IS NULL AND no user_public_profile row 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 usersuser_public_profile; if right side missing AND users.global_name IS NULL → throw NotFoundException with code errors.user.public_profile_not_found. Otherwise project the row.
  • ratingSummary field: NOT added to UserPublicProfileDto in 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 existing ContributorPreviewDto.ratingSummary on the activity-level contributor card already shipped by contributor-reviews-ratings. The public-profile endpoint itself is identity-only; consumers that want the rating call GET /api/client/contributors/{userId}/rating-summary (also shipped by contributor-reviews-ratings). Keeping AC-3 strictly identity avoids forcing a RatingSummaryPreviewDto re-export onto the public endpoint and matches the spec’s “reuses the existing UserPublicProfileDto shape — 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 stateUI mode
? (no params)Default home (hero with search input + featured activities).
?q=yogaHero + search input filled; results section below renders AC-1; per-card Tier B chip.
?sphereId=...Existing behaviour (sphere filter), unchanged.
?contributorUserId=:uuidSearch 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:

  1. Route activator reads contributorUserId from ActivatedRoute.
  2. Sends parallel requests: AC-2 (activities) + AC-3 (profile).
  3. Header renders globalName (from AC-3) with optional avatar.
  4. 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:

  1. Search input wires to q. Existing input now sends q instead of the legacy search param. The legacy search param remains in the contract for compatibility; new clients use q. Precedence rule (locked in this ADR): q is preferred; search is used ONLY when q is absent (q === undefined || q === ''). When both are passed, q runs the new two-tier path and search is silently ignored. The legacy search parameter is marked deprecated: true in 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 retires search once consumers are migrated.
  2. 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).
  3. No new shared package. packages/profile already exists for user-self profile editing (per global-user-identity D7). The “view another user’s profile” screen is app-level in gym_app, not a shared widget — tickets_app has 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 exact USING gin (col gin_trgm_ops) DDL in the generated .sql file. (Verified in node_modules/drizzle-orm/pg-core/indexes.d.ts and columns/common.d.ts:103op(opClass: PgIndexOpClass) plus using(method: PgIndexMethod, ...). The literal 'gin_trgm_ops' isn’t in the typed enum but the (string & {}) fallback accepts it.)
  • Re-running drizzle-kit generate against 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:

libs/shared/data-access-db/src/lib/schema/activities.schema.ts
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')),
}),
);
libs/shared/data-access-db/src/lib/schema/users.schema.ts
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:

  1. Backend-dev edits activities.schema.ts and users.schema.ts to 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.ts currently has no third-argument index block; introducing it is the only change to the table declaration).
  2. pnpm db:generate (alias for drizzle-kit generate --config=drizzle.config.ts --name=pg_trgm_search_indexes) from the backend repo root. drizzle-kit emits a migration file containing exactly two CREATE INDEX ... USING gin (col gin_trgm_ops); statements.
  3. Hand-prepend ONCE to the top of the generated .sql:
    -- Required: pg_trgm is not modelled by drizzle-kit. Idempotent.
    CREATE EXTENSION IF NOT EXISTS pg_trgm;
    (No SCHEMA public qualifier — see CONCERN-3.) This edit is a one-time, file-local change. Re-running pnpm db:generate later does NOT produce a new migration: the schema is unchanged, drizzle- kit’s snapshot matches reality, the diff is empty. No tablesFilter or marker comment required.
  4. Run migration-safety-check skill on the file before commit.
  5. Re-run safety verification: after step 3, run pnpm db:generate --name=verify_noop once 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.
  6. No backfill — the indexes build over existing data on CREATE INDEX. The build is fast at our scale (low thousands of activities / users), so CONCURRENTLY is not required for v1. (Postgres CONCURRENTLY requires 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 + rebuild CONCURRENTLY outside 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.

New keys, namespaced per the existing convention:

KeyEN
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#* and passes#* 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.

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:

  1. 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.
  2. CDC pipeline is non-trivial. Keeping the index consistent across activity edits, contributor edits, and global-profile edits requires either a LISTEN/NOTIFY consumer or a batch reconciler — either way, more code than the spec calls for.
  3. 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:

  1. 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.
  2. OQ-3 of the spec resolved this — two-tier with pg_trgm similarity is the chosen approach. TF-IDF rejected as overkill.
  3. 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:

  1. Pagination breaks. LIMIT/OFFSET over 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.
  2. Duplicate matches across tiers (an activity whose title matches AND whose contributor matches) would appear twice. Deduping requires another wrapping layer.
  3. 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:

  1. Triggers across schemas. Updating user_public_profile.public_name would have to fire a trigger that re-renders the row for every activity the user contributes to — cross-schema trigger entanglement, which ADR cross-schema-references-without-fk explicitly avoids.
  2. Staleness during the migration window. Until the trigger logic ships, the materialised table is stale; backfill complexity grows.
  3. 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:

  1. Existing search semantics may differ. The current activitiesClientList implementation may treat search as title-only ILIKE (no trigram, no contributor-name fallback). Re-purposing it changes behaviour silently for any current consumer.
  2. The spec asks for q explicitly. AC-1 names the param q, length ≤ 64, trimmed.
  3. Gradual rollout safer. Adding q lets backend implement the two-tier logic side-by-side with the legacy search and migrate clients before deprecating search. (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):

EndpointNew paramsExisting params
GET /api/client/activities (activitiesClientList)q?: string (≤64, minLength 2), contributorUserId?: uuidsearch → marked deprecated: true; precedence: q wins when both present
GET /api/client/companies/{companyId}/activities (activitiesClientListByCompany)samesame
GET /api/client/activities/{activityId}/sessions (activitiesClientFindSessions)contributorUserId?: uuidunchanged
GET /api/client/companies/{companyId}/activities/{activityId}/sessions (activitiesClientFindSessionsByCompany)contributorUserId?: uuidunchanged

New endpoint:

PathMethodACAuthResponse
/api/client/users/{userId}/public-profileGETAC-3publicUserPublicProfileDto (reused)

Field additions to existing schemas:

  • ActivityPreviewClientResponseDto.matchedByContributor?: boolean — optional, NOT nullable. Present only when the request carried a non-empty q. true iff this row’s Tier B (contributor-name) EXISTS branch fired (with or without Tier A); false iff only Tier A fired or the row matched via contributorUserId. Absent when q was 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 === true means 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 ratingSummary on UserPublicProfileDto. Per D4 — clients fetch the rating via the existing /api/client/contributors/{userId}/rating-summary endpoint 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.

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.

Table / objectChange
Postgres extension pg_trgmNEW. 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.

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:

  1. activities.contributors.member_idcompanies.company_member.idreal cross-schema FK with onDelete: 'cascade' (contributors.schema.ts: .references(() => companyMembers.id, { onDelete: 'cascade' })).
  2. companies.company_member.user_idusers.users.idreal cross-schema FK (companies.schema.ts: .references(() => users.id)).
  3. users.users.idusers.user_public_profile.user_idin-schema FK (1:1 declared by global-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_name may be NULL — the EXISTS predicate handles this by including AND u.full_name IS NOT NULL so similarity(NULL, q) never appears in the score.
  • All FK constraints prevent dangling references in the read-side query; no defensive LEFT JOIN is needed beyond the explicit optional joins shown in D1.

No FK additions in this ticket — the chain is already FK-enforced end-to-end.

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.

ConcernLibraryFile(s)
q + contributorUserId on activities listlibs/features/activitiessrc/lib/dto/find-activities-client.dto.ts (extend with q?: string @MaxLength(64) @Trim and contributorUserId?: string @IsUUID)
Two-tier search SQL compositionlibs/features/activitiessrc/lib/services/activities-client.service.ts — extend list() / listByCompany() with the CASE-tiered SQL when q is non-empty
AC-2 EXISTS predicatelibs/features/activitiessame service file; new whereContributorIs(userId) helper appended to the query builder
AC-3 public-profile endpointlibs/features/user-profileNEW 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 + fallbacklibs/features/activitiessrc/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 projectionlibs/features/activitiessrc/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/client prefix 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-profile endpoints while letting /users/{userId}/public-profile opt out.
  • src/app/pages/explore/... (home) — extend the route to read q, sphereId, contributorUserId from ActivatedRoute.queryParamMap. Render branches per the D6 table. On hydration with both contributorUserId and q set, the route resolver immediately scrubs q from 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 by matchedByContributor === 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 a signal<string | null> for the selected contributorUserId.
  • src/app/core/api/ — regenerated via npm run generate. New types:
    • FindActivitiesClientDto gains q? and contributorUserId?; search? is marked @deprecated in the generated TS doc-comment (ng-openapi-gen propagates deprecated: true from OpenAPI).
    • FindSessionsClientDto gains contributorUserId?.
    • ActivityPreviewClientResponseDto.matchedByContributor?: boolean.
    • new operation usersClientGetPublicProfile (or similar — ng-openapi-gen autogenerates from operationId).
  • 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.
  • packages/api — regenerated via melos run sync:spec && melos run generate:api. New endpoint
    • parameters appear in the Chopper-generated client.
  • apps/gym_app/lib/pages/... (discover / search screen) — wire q to the search input (D8). Existing search param 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” calls usersClientGetPublicProfile; “Activities” calls activitiesClientList with contributorUserId.
  • apps/gym_app/lib/pages/... (checkout / booking flow) — chip-row on session-picker (D9). Hidden when activity.contributors.length <= 1.
  • packages/i18n/assets/i18n/{en,ru,uk}.json — new keys under home.*, search.*, checkout.* (D11). DE/FR fall back to EN.
  • packages/profilenot touched. The contributor profile screen is app-level (gym_app), not a shared widget, because tickets_app has no contributor concept (Out of scope #4).

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.

Not affected. Business surface contract is untouched.

Not affected.

  1. 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-character q that matches every row. Mitigation:
    • Backend service trims and rejects q shorter than 2 characters (treated as empty, AC-1 falls back to “no search” semantics). Spec pins q ≤ 64 upper bound; lower bound is a service-side decision documented here.
    • Index sizes monitored post-launch; if users_global_name_trgm_idx grows beyond a few MB, revisit (unlikely at our row counts).
  2. pg_trgm extension privilege requirement. CREATE EXTENSION needs the migration runner role to have CREATE privilege on the target schema (default: public from the role’s search_path). There is no prior CREATE EXTENSION call in this repo (verified: zero matches across libs/shared/data-access-db/migrations/), so we cannot lean on a “we’ve done this before” precedent. On managed Postgres environments (Supabase, RDS) pg_trgm is 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 (grant CREATE on public, or enable the “managed extension” toggle), not a silent failure. Document in the deploy runbook for ops awareness.
  3. 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.
  4. 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, no companyMember.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.
  5. 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-tier relevance is the secondary sort, so Tier B cannot eclipse Tier A. If product later wants a merged-tier ranking, that’s a follow-up tuning.
  6. 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).
  7. Web URL-state precedence ambiguity. Programmatic links could include both q and contributorUserId. 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.
  8. matchedByContributor projection 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 when q is non-empty; for the common no-search case, the field is absent and the existing query plan is unchanged.
  9. tickets_app unused-DTO bytes. Same as contributor-reviews-ratings D17. Intentionally accepted.

No feature flag. Single coordinated rollout — strictly additive.

  1. _workflow MR (this ADR + the client.openapi.yaml patch) lands on main.
  2. tktspace-backend MR:
    • Edit activities.schema.ts + users.schema.ts to declare the two trigram indexes via index(...).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 .sql file (no SCHEMA 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), q vs search precedence (D8).
    • Deploys independently — every new endpoint and param is additive; existing callers see no behavioural change.
  3. In parallel: tktspace-web MR — npm run generate, wire AC-5/AC-6 (D6), AC-7 (D7). Ships after backend deploys.
  4. In parallel: tktspace-mobile-app MR — 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_app regen-only.
  5. Post-deploy validation:
    • Smoke GET /api/client/activities?q=yoga against dev — expect non-empty results, Tier A items above Tier B.
    • Smoke GET /api/client/users/<known-user>/public-profile — 200 with globalName populated.
    • Smoke GET /api/client/users/<random-uuid>/public-profile — 404 with errors.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.

Backwards-compat:

  • All param additions are optional → existing callers pass nothing → existing behaviour.
  • The new matchedByContributor field on ActivityPreviewClientResponseDto is optional → old clients ignore the unknown field.
  • search remains 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:

  1. Revert the consumer MRs (web + mobile) so UIs stop calling the new endpoint.
  2. Revert the backend MR — new endpoint + params disappear; existing list endpoints revert to their pre-AC-1 shape.
  3. Optionally drop the indexes and pg_trgm extension in a follow-up migration. The indexes are inert when no query references them; leaving them in place during a rollback is also safe.
  • 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_app UI wiring (spec Out of scope #4).
  • No web /contributor/:slug profile page (spec Out of scope #5; depends on userPublicProfile.slug UI 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 ratingSummary field on UserPublicProfileDto (D4 — clients use the existing rating endpoint when they need it).
  • No deprecation of the legacy search query param. Backend accepts both; q is the new canonical, search continues to work for legacy callers until a future cleanup ticket retires it.
  • No CREATE INDEX CONCURRENTLY in v1 (D10 — the regular migration runner runs inside a transaction; concurrent build is a follow-up if write-volume grows).
  • ADR global-user-identity — provides users.id as the contributor identity, the users.global_name column that AC-1 Tier B matches against, and the existing UserPublicProfileDto that AC-3 reuses. (Note: this ADR’s Tier B reads users.full_name only; the user_public_profile table has no public_name column despite the spec wording — see Changelog v2 #1.)
  • ADR contributor-reviews-ratings — provides ContributorPreviewDto.ratingSummary already 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 the contributors → companyMember → users chain is FK-enforced end-to-end (every cross-schema hop is a real Postgres FK, verified in contributors.schema.ts and companies.schema.ts), which is what makes the AC-2 EXISTS predicate consistent.

Only contracts/client.openapi.yaml is patched in this MR.

New path:

  • /api/client/users/{userId}/public-profileget (AC-3). operationId: usersClientGetPublicProfile. Public (no auth). Response 200 UserPublicProfileDto; 404 when the user has no discoverable identity (no profile row + no globalName).

Modified endpoints (parameter additions + one deprecation flag):

  • GET /api/client/activities (activitiesClientList) — add q?: string minLength 2 maxLength 64 and contributorUserId?: string format uuid. Mark the existing search query parameter as deprecated: true (precedence rule: q wins when both present; search consulted only when q is empty).
  • GET /api/client/companies/{companyId}/activities (activitiesClientListByCompany) — same two params, same deprecation flag on search.
  • GET /api/client/activities/{activityId}/sessions (activitiesClientFindSessions) — add contributorUserId?: 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. true iff q was passed AND Tier B (contributor-name match) fired for this row. false iff q was passed but only Tier A fired. Absent iff q was 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