ADR: Favorite Activities
ADR: Favorite Activities
Section titled “ADR: Favorite Activities”Status
Section titled “Status”PROPOSED
AC ↔ D# mapping
Section titled “AC ↔ D# mapping”| AC | Primary decisions | Notes |
|---|---|---|
| AC-1 | D1 (DB shape), D2 (user FK target) | New activities.user_activity_favorites join table; FK to users.users(id) (Supabase id). |
| AC-2 | D3 (POST idempotency), D4 (activity guard) | INSERT ... ON CONFLICT DO NOTHING + RETURNING; 404 on missing/non-PUBLISHED. |
| AC-3 | D5 (DELETE idempotency) | DELETE by composite (user_id, activity_id); always returns 204. |
| AC-4 | D6 (cursor pagination), D7 (status join) | Cursor = base64(`created_at |
| AC-5 | D8 (isFavorited projection) | Detail endpoint uses scalar EXISTS subquery; favorites-list items hard-code isFavorited:true. |
| AC-6 | D9 (analytics endpoint) | New GET /api/business/analytics/favorites; tenancy via activities.company_id = @ActiveCompany. |
| AC-7 | D10 (mobile package shape), D11 (state) | New packages/favorites with FavoritesRepository/State/Page/HeartButton. |
| AC-8 | none | No code change; Favorites tab already routes to /home/favorites. |
| AC-9 | none | No code change; profile menu entry already routes to /home/favorites. |
| AC-10 | D11 (optimistic toggle) | FavoritesState.toggleFavorite(activityId, current) with revert-on-error. |
| AC-11 | D11 | Heart icon variants driven by isFavorited flag. |
| AC-12 | D10 | Public surface of packages/favorites mirrors packages/core PassesRepository pattern. |
| AC-13 | D11 | MultiProvider registration + sign-out reset. |
| AC-14 | D12 (i18n keys) | Three new keys in en/uk/ru/de/fr locale files. |
| AC-15 | D13 (business panel section) | New “Top Activities by Favorites” section on existing AnalyticsDashboardPage. |
| AC-16 | D14 (client contract patch) | Three new endpoints + slim FavoriteActivityDto for favorites-list items (no full activity DTOs in v1; activity detail’s isFavorited lives in the backend-internal DTO until that path is contract-ified). |
| AC-17 | D14 | New GET /api/business/analytics/favorites + FavoriteActivityAnalyticsItemDto schema. |
| AC-18 | none | Mobile regen via melos run sync:spec && melos run generate:api. |
| AC-19 | none | Business regen via npm run generate:api. |
Context
Section titled “Context”gym_app users browse activities but have no way to bookmark them — they
must search or scroll repeatedly. This ADR designs the technical
implementation for the approved spec which delivers:
- Three new client endpoints on
/api/client/favorites/*(add / remove / list) wired into a new mobile packagepackages/favorites. - A heart toggle on
ActivityPageand a realFavoritesPagereplacing the existing placeholder. Bottom-nav tab and profile menu shortcut are already wired in router. - An
isFavorited: booleanfield added at the backend internal DTO level (ActivityDetailClientResponseDto) and surfaced on the activity-detail wire response (drives the heart onActivityPage). The activity-detail path is not yet contract-ified, so the field’s contract publication is deferred (see D8 CONCERN #7 decision); it surfaces in the typed mobile client when the path is published in a future ticket. The new favorites-list endpoint exposesisFavorited: trueon every item via a slimFavoriteActivityDto(drives the inline remove-heart on each card). Activity preview cards in search/home/recommendations do not receive the field in v1 — deferred. - A new business analytics endpoint
GET /api/business/analytics/favoritesreturning a Top-N list of activities ranked byfavoriteCountwithin a date range, scoped by company via the existing@ActiveCompany()decorator +Permission.READ_PAYMENTSguard. - A new section on the existing
AnalyticsDashboardPageat/analyticsintktspace-businessrendering the top-10 list as a Taiga UI table filtered by the existingTuiInputDateRangecontrol.
Two surfaces affected: client (mobile only — tickets_app deferred,
no web/landing impact) and business (admin analytics dashboard). No
super-admin work, no schema soft-delete (favorites are hard-deleted on
removal — if the activity itself transitions to ARCHIVED/CANCELLED the
backend silently filters it out of the list via the JOIN condition).
Decision
Section titled “Decision”D1 — Storage shape: activities.user_activity_favorites join table
Section titled “D1 — Storage shape: activities.user_activity_favorites join table”activities.user_activity_favorites id uuid PK default gen_random_uuid() user_id uuid NOT NULL REFERENCES users.users(id) ON DELETE CASCADE activity_id uuid NOT NULL REFERENCES activities.activities(id) ON DELETE CASCADE created_at timestamptz NOT NULL DEFAULT now() UNIQUE (user_id, activity_id)Indexes (in addition to the unique constraint, which doubles as a btree on
(user_id, activity_id)):
(user_id, created_at DESC)— primary read path forGET /api/client/favorites(“newest favorited first” per AC-4 list ordering and the cursor pagination in D6).(activity_id)— read path for the analytics aggregation (GROUP BY activity_idinGET /api/business/analytics/favorites). TheON DELETE CASCADEfrom the FK already implies an internal index onactivity_id, but Drizzle’s generator does not always emit one explicitly; we add it to make the intent and the analytics query plan clear.
The schema file location is
libs/shared/data-access-db/src/lib/schema/activities.schema.ts (the
table belongs to the activities Postgres schema and lives next to
activities, activityExtras, and the other activity-scoped tables).
D2 — User FK target: users.users(id), NOT companies.company_customers(id)
Section titled “D2 — User FK target: users.users(id), NOT companies.company_customers(id)”Bookings reference companyCustomers.id because a booking is always
scoped to one company (the customer is a per-company entity). Favorites
are platform-wide — a user can favorite activities across many companies
without being a customer of any of them. The convention used by other
platform-wide tables (e.g. wallet.transactions.performedBy,
wallet.refundRequests.resolvedBy) is to reference users.users(id)
directly — the Supabase auth user id surfaced via the @ActiveUser('id')
decorator on /api/client/* controllers.
This matters for the GET /api/business/analytics/favorites aggregation:
the count is “distinct rows in user_activity_favorites for an activity”
— we explicitly do not aggregate by company-customer, because a user
who favorites an activity without being a registered customer of that
company still contributes to the demand signal the operator wants to see.
ON DELETE CASCADE on both FKs because:
- when a user is deleted (Supabase user removal), their favorites should vanish — there’s no useful retention case;
- when an activity is hard-deleted (DRAFT activities can be hard-deleted; PUBLISHED activities normally transition to ARCHIVED instead), favorite rows pointing to it become meaningless. Note that the spec’s “soft filter on archive” rule is enforced at the QUERY layer (D7), not via CASCADE — ARCHIVED activities are NOT deleted, so favorite rows survive but are excluded from the list response.
D3 — POST is idempotent: INSERT ... ON CONFLICT DO NOTHING RETURNING
Section titled “D3 — POST is idempotent: INSERT ... ON CONFLICT DO NOTHING RETURNING”The spec requires a duplicate POST /api/client/favorites with the same
activityId to return 200 OK with the existing record (no 409, no
duplicate row). The cleanest expression is:
INSERT INTO activities.user_activity_favorites (user_id, activity_id)VALUES ($1, $2)ON CONFLICT (user_id, activity_id) DO NOTHINGRETURNING id, activity_id, created_at;RETURNING on conflict yields zero rows (Postgres semantic: skipped rows
do not return). The service then checks result.length:
result.length === 1→ first-time favorite → respond 201 Created with the inserted row.result.length === 0→ already favorited → SELECT the existing row by(user_id, activity_id)and respond 200 OK.
Why this over a transactional check-then-insert:
- atomic in a single round-trip (no race window between SELECT and INSERT where another concurrent request could win);
- no need for
db.transaction(...); the unique constraint is the lock; - maps 1:1 to the AC-2 wording (no duplicate, no 4xx on second call).
The activity-existence guard runs BEFORE the upsert as a separate query
(SELECT id FROM activities WHERE id = $1 AND status = ‘PUBLISHED’). If
that query returns zero rows, the service throws NotFoundException
mapped to HTTP 404 with code errors.activity.not_found (the existing
key already used by activities-client.service.ts:117). This is
intentionally NOT a CHECK constraint or a partial unique index —
status can change after the favorite is created (PUBLISHED →
ARCHIVED), and we still want the row to survive (so the analytics count
is faithful). The guard only blocks NEW favorites of non-PUBLISHED
activities.
D4 — Activity-existence guard: WHERE status = 'PUBLISHED'
Section titled “D4 — Activity-existence guard: WHERE status = 'PUBLISHED'”For POST: only PUBLISHED activities can be added to favorites. DRAFT,
ARCHIVED, and CANCELLED activities all return 404 — same shape and code
as the existing findById flow in activities-client.service.ts
(errors.activity.not_found). This avoids leaking the existence of
DRAFT activities that aren’t visible to the customer at all.
For DELETE: no activity-existence check is needed. The DELETE operates
purely on (user_id, activity_id) of the favorites row — even if the
activity was archived in the meantime, the user still gets to remove
their bookmark (and the row simply vanishes).
For GET (list): the JOIN with activities filters
status = 'PUBLISHED' (D7). Archived/cancelled activities silently
disappear from the list response. The favorite row remains in the DB
(so analytics is unaffected); only the read projection hides it.
D5 — DELETE is idempotent: always returns 204
Section titled “D5 — DELETE is idempotent: always returns 204”DELETE FROM activities.user_activity_favoritesWHERE user_id = $1 AND activity_id = $2;No need to check existence first. Postgres returns rowcount 0 if there was nothing to delete, 1 if the row existed. Either way the service returns HTTP 204 No Content with no body. This matches the AC-3 wording (“If it does not exist: returns 204 No Content (idempotent delete)”) and gives the mobile a stable contract — a tap on the heart never produces a 404, even after multiple optimistic toggles racing.
The DELETE endpoint takes :activityId (NOT the favorite row’s id) in
the URL because the mobile client only knows the activity id — the
favorite-row id is not exposed in the heart button context. This is
consistent with the spec’s DELETE /api/client/favorites/:activityId.
D6 — Cursor pagination on the list endpoint
Section titled “D6 — Cursor pagination on the list endpoint”The spec locks cursor-based pagination consistent with other client list
endpoints, with default limit=20 and max limit=100. The cursor
encodes (created_at, favorite_id) because created_at alone is not
unique (two favorites created in the same microsecond would collide and
break pagination). Concretely:
- Cursor format on the wire: opaque base64-url string,
base64url(JSON.stringify({ ts: createdAt.toISOString(), id: favoriteId })). - Cursor decoding on read: the server validates JSON shape; on
malformed cursor returns HTTP 400 with code
errors.favorites.invalid_cursor. The mobile generated client should treat the cursor as an opaque string (it is), so this only triggers on tampered values. - Query (Drizzle pseudo-code):
SELECT f.id AS favorite_id, f.created_at, a.* (preview projection)FROM activities.user_activity_favorites fJOIN activities.activities a ON a.id = f.activity_idWHERE f.user_id = $userId AND a.status = 'PUBLISHED' -- when cursor is supplied: AND (f.created_at, f.id) < ($cursor.ts, $cursor.id)ORDER BY f.created_at DESC, f.id DESCLIMIT $limit + 1- We fetch
limit + 1rows. If the result haslimit + 1items, we drop the last one and emit its(created_at, id)as the next cursor; if it has<= limititems,nextCursor: null. - The composite tuple comparison
(f.created_at, f.id) < (...)is Postgres-native and uses the(user_id, created_at DESC)index efficiently for the common case (no cursor); with a cursor, it falls back to a tighter index range scan. The exact plan is acceptable for v1 — favorites lists per user are bounded by user behavior (typical users will have tens to low hundreds of favorites).
The default and max limit clamps live in the request DTO via
@IsInt() @Min(1) @Max(100) + @DefaultValue(20) (matching how other
client list DTOs in libs/features/activities/src/lib/dto/ handle
limit).
D7 — JOIN with activities filters status = 'PUBLISHED'
Section titled “D7 — JOIN with activities filters status = 'PUBLISHED'”The JOIN condition embeds the status filter directly. Archived and cancelled activities silently drop from the list — no tombstone, no “removed” label (this is the explicit policy in the spec’s “Decisions locked” section). The favorite row stays in the DB, so:
- if the activity is later un-archived (admin transitions it back to PUBLISHED — currently rare but supported), the favorite re-appears in the list automatically;
- the analytics aggregation still counts the row (the analytics query does NOT filter by activity status — see D9 — so an admin can still see “this activity got N favorites in the period it was published”).
D8 — isFavorited projection
Section titled “D8 — isFavorited projection”On ActivityDetailClientResponseDto (returned by GET /api/client/activities/:id):
A scalar EXISTS subquery added to the existing findById SELECT:
SELECT ...existing columns, EXISTS ( SELECT 1 FROM activities.user_activity_favorites f WHERE f.user_id = $userId AND f.activity_id = a.id ) AS "isFavorited"FROM activities.activities aWHERE a.id = $id AND a.status = 'PUBLISHED'Auth posture (locked, not deferred): ClientJwtGuard
(tktspace-backend/libs/features/auth/src/lib/guards/client-jwt.guard.ts:11-17)
strictly throws UnauthorizedException whenever request.user is
absent — there is no “soft” anonymous mode, and the global
JwtAuthGuard only bypasses authentication when a route declares the
@Public() decorator
(tktspace-backend/libs/features/auth/src/lib/guards/jwt-auth.guard.ts:18-24).
Both detail controllers
(activities-global-client.controller.ts:12 and
activities-client.controller.ts:24) apply
@UseGuards(ClientJwtGuard) at class level and neither marks
findOne @Public(). Therefore GET /api/client/activities/:id and
GET /api/client/companies/:companyId/activities/:id return HTTP 401
to anonymous callers and the AC-5 “unauthenticated → false” branch
never fires on these routes: a userId is guaranteed when the
service reaches findById. The userId?: string parameter on the
service is still typed optional (the service is internal and called
from other paths like search where the parameter is intentionally not
plumbed), but for these two surfaces the EXISTS subquery always runs
with a real user id. The contract reflects this by marking
isFavorited as required: true on ActivityDetailClientResponseDto
(see CONCERN #8 fix below) — it is always populated, never null.
Threading userId into the service: the existing
ActivitiesClientService.findById(id, companyId?, include?) signature
gains an optional userId?: string parameter. The two controllers
calling it (activities-global-client.controller.ts:findOne and
activities-client.controller.ts:findOne) add an
@ActiveUser('id') userId: string parameter and forward it. The single
EXISTS subquery is cheap (uses the unique (user_id, activity_id)
index) and adds at most one tuple to the SELECT — no extra round trip.
On items returned by GET /api/client/favorites:
Every item in the favorites list is, by definition, favorited by the
calling user. We hard-code isFavorited: true in the response mapper
(no subquery, no SELECT). This is a deliberate simplification — the
mobile uses this field to drive the “already filled” heart on the
remove-action; the value is never false here.
Decision (CONCERN #7 — orphan DTOs in client.openapi.yaml):
The activity list and detail PATHS (GET /api/client/activities,
GET /api/client/activities/:id, plus the company-scoped variants)
are NOT declared in contracts/client.openapi.yaml today. Publishing
full ActivityPreviewClientResponseDto and
ActivityDetailClientResponseDto schemas in this contract — without
their owning paths — would emit dead types in the mobile packages/api
generated client. We pick option (a) — minimal stand-in:
-
Replace
ActivityPreviewClientResponseDtoin the contract with a slimFavoriteActivityDtocarrying ONLY the fields the favorites screen renders today (id,title,type,companyName,posterUrl?) plusisFavorited: boolean(alwaystruein this response,required: truein the schema). This is the entity theFavoriteListResponseDto.itemsarray references.2026-05-08 amendment (per AC-7 update): the favorites card was originally specced to show price/currency. UX feedback flipped this to “what & where” —
type(rendered as a badge) andcompanyName(joined fromcompanies.name) replacepriceandcurrency. Backend mapper joinsactivities.company_id = companies.idto pull the company display name. -
Remove
ActivityDetailClientResponseDtofrom the contract entirely. The activity-detail path is not yet contract-ified, so the schema would have no owning path. TheisFavoritedfield required by AC-5 on activity detail is added at the backend level by extending the existing internal NestJS DTO atlibs/features/activities/src/lib/dto/activity-client.response.dto.ts(same DTO that the un-versioned global and per-companyfindOneendpoints already return). It will surface in the contract whenever the activity-detail path is contract-ified in a future ticket — that is a contract-coverage concern, not a feature-blocking one. -
The full
ActivityPreviewClientResponseDto(with all fields:slug,type,status,durationMinutes,tags, etc.) lives only in the backend DTO — not in this contract. When a future ticket contract-ifies the search/list paths, the contract will either widenFavoriteActivityDtoor introduce a separate richer schema; either move is purely additive.
This avoids dead generated types in the mobile client today and keeps
the contract surface honest — every schema in client.openapi.yaml is
referenced by at least one declared path.
Considered alternatives:
- option (b) — declare the activity list/detail paths in this contract. Out of scope: spec ACs cover the favorites flow, not the activity-browse flow, and the activity DTOs at backend level have many fields whose stability we have not vetted for contract publication. Rejected.
- option (c) — keep full DTOs in the contract anyway. Forces the mobile generated client to ship dead types. Rejected; no good reason.
Field-visibility note: the isFavorited field on the slim
FavoriteActivityDto is required: true, boolean, NOT nullable —
the favorites-list endpoint always populates it. The mobile generated
client surfaces it as bool (non-nullable).
The backend internal DTO ActivityPreviewClientResponseDto (used by
list/search/home) gains an OPTIONAL isFavorited?: boolean field at
the NestJS level for forward-compatibility (so a future v2 ticket
populating it on those endpoints does not require another internal-DTO
churn), but this is a backend implementation detail and is NOT
contract-published in v1. The favorites-list endpoint constructs the
response by mapping the shared internal DTO down to FavoriteActivityDto
shape and hard-coding isFavorited: true.
D9 — Analytics endpoint: SQL shape and tenancy
Section titled “D9 — Analytics endpoint: SQL shape and tenancy”SELECT a.id AS "activityId", a.title, COUNT(f.id)::int AS "favoriteCount"FROM activities.user_activity_favorites fJOIN activities.activities a ON a.id = f.activity_idWHERE a.company_id = $companyId AND f.created_at >= $from AND f.created_at < $toGROUP BY a.id, a.titleORDER BY "favoriteCount" DESCLIMIT $limit- Tenancy guarantee:
a.company_id = $companyIdis the JOIN-side filter, where$companyIdis supplied by the@ActiveCompany()decorator (same as every existing analytics endpoint —getActivities,getSummary, etc., seeapps/api/src/app/modules/analytics/analytics.service.ts). A business operator from company A cannot see counts for activities of company B even if they tamper with the request —@ActiveCompany()reads from the request context populated byCompanyRolesGuard, which itself validates thex-company-idheader against the JWT. - Range semantics: identical to the existing
AnalyticsRangeDto+normaliseDateRangehelper —fromis start-of-day,tois exclusive start-of-next-day. We reuse the existing helper verbatim (AnalyticsService.normaliseDateRange). - Activity status filter: intentionally NOT applied. Operators want to see “activity X got 30 favorites last quarter” even if X is now ARCHIVED (the favorites are real demand history; archiving the activity does not retroactively erase that signal).
- Permission: the controller annotates
@RequirePermissions(Permission.READ_PAYMENTS)and@UseGuards(CompanyRolesGuard)— same as every existing analytics endpoint. This intentionally re-usesREAD_PAYMENTSrather than introducing a new permission likeREAD_ANALYTICS; the existing module already groups all analytics under that permission and we keep the surface minimal. - Caching: reuses the existing in-memory cache helper on
AnalyticsService(10-minute TTL, keyed byfavorites:${companyId}:${from}:${to}:${limit}) — same pattern asgetActivitiesBreakdown. - No cursor:
limitclamps to max 50, default 10 (consistent with the analytics convention — flat array, no pagination cursor).
D10 — Mobile package shape: packages/favorites
Section titled “D10 — Mobile package shape: packages/favorites”packages/favorites/├── pubspec.yaml├── lib/│ ├── tktspace_favorites.dart (barrel — public exports)│ └── src/│ ├── favorites_repository.dart (FavoritesRepository + ApiFavoritesRepository + StubFavoritesRepository + FavoriteActivityDto)│ ├── favorites_state.dart (FavoritesState extends ChangeNotifier)│ ├── favorites_page.dart (the `/home/favorites` screen)│ └── heart_button.dart (HeartButton widget — used on ActivityPage AppBar)└── test/ ├── favorites_state_test.dart └── favorites_page_test.dartpubspec.yaml:
name: tktspace_favoritesdescription: Favorites domain — repository, state, page, heart UI.version: 0.1.0
environment: sdk: ">=3.3.0 <4.0.0"
dependencies: flutter: sdk: flutter tktspace_api: path: ../api tktspace_i18n: path: ../i18n tktspace_ui: path: ../ui provider: ^6.1.1Public exports from lib/tktspace_favorites.dart:
library tktspace_favorites;
export 'src/favorites_repository.dart' show FavoritesRepository, ApiFavoritesRepository, StubFavoritesRepository, FavoriteActivityDto;export 'src/favorites_state.dart' show FavoritesState;export 'src/favorites_page.dart' show FavoritesPage;export 'src/heart_button.dart' show HeartButton;Why a new package and not packages/core? packages/core already
holds passes domain types (PassesRepository, PassesState) and is
intentionally narrow. The spec’s frontmatter explicitly lists
packages/favorites (new) as the touched mobile package; following the
spec contract keeps the package boundaries tight and lets future
favorites work (e.g. push notifications on favorite-related events,
recommendations engine) live alongside the repository/state without
polluting core.
FavoriteActivityDto is the local Dart mirror of the slim
FavoriteActivityDto schema published in client.openapi.yaml —
following the pattern documented in passes_repository.dart lines
5–15 (“local DTOs that mirror contracts/client.openapi.yaml
exactly… we intentionally do NOT re-export the generated chopper
DTOs”). Fields: { id: String, title: String, type: String, companyName: String, posterUrl: String?, isFavorited: bool } (note
isFavorited is non-nullable bool — always true on items returned
by GET /favorites).
D11 — FavoritesState and optimistic toggle
Section titled “D11 — FavoritesState and optimistic toggle”FavoritesState extends ChangeNotifier mirrors PassesState exactly:
class FavoritesState extends ChangeNotifier { FavoritesState({FavoritesRepository? repository}) : _repo = repository ?? const ApiFavoritesRepository();
final FavoritesRepository _repo;
// List of favorites for the FavoritesPage. List<FavoriteActivityDto> _items = const []; String? _nextCursor; bool _loading = false; String? _error;
// Per-activityId optimistic state used by the HeartButton on // ActivityPage. Null = unknown (use the source-of-truth from // ActivityDetailClientResponseDto.isFavorited). final Map<String, bool> _favoritedByActivity = {};
List<FavoriteActivityDto> get items => _items; bool get loading => _loading; String? get error => _error; bool? favoritedFor(String activityId) => _favoritedByActivity[activityId];
Future<void> load() async { /* loads first page */ } Future<void> loadMore() async { /* paginates by cursor */ }
/// AC-10: optimistic toggle on ActivityPage. `current` is the /// last-known state from the server (from /// ActivityDetailClientResponseDto.isFavorited or a previous toggle). /// /// CONCERN #13 decision (option 1 — match spec minimum): on /// successful add, do NOT auto-refresh or auto-prepend to `_items`. /// The spec (AC-7 / AC-10) requires pull-to-refresh, not auto-sync; /// users see a freshly-favorited activity on the next pull-to-refresh /// or next mount of `FavoritesPage`. This avoids an extra GET round /// trip on every heart-tap and keeps `FavoritesState` decoupled /// from the in-memory representation of an activity that /// `ActivityPage` happens to know about. /// /// On successful remove, we DO prune the entry from `_items` /// optimistically — that keeps `FavoritesPage` consistent if the /// user un-favorites from `ActivityPage` and immediately switches /// back to the favorites tab. The pruning is local-only (no GET); /// any stale cursor pagination state is reconciled on next /// pull-to-refresh. Future<void> toggleFavorite(String activityId, bool current) async { final next = !current; _favoritedByActivity[activityId] = next; notifyListeners(); try { if (next) { await _repo.add(activityId); // No _items change on add — see decision note above. } else { await _repo.remove(activityId); _items = _items.where((i) => i.id != activityId).toList(growable: false); notifyListeners(); } } catch (e) { _favoritedByActivity[activityId] = current; // revert _error = e.toString(); notifyListeners(); rethrow; // ActivityPage catches and shows favorites#error SnackBar } }
void reset() { _items = const []; _nextCursor = null; _loading = false; _error = null; _favoritedByActivity.clear(); notifyListeners(); }}Wiring in apps/gym_app/main.dart:
runApp( i18nWrap(MultiProvider( providers: [ ChangeNotifierProvider.value(value: authService), ChangeNotifierProvider.value(value: notificationsService), ChangeNotifierProvider<PassesState>(create: (_) => PassesState()), ChangeNotifierProvider<FavoritesState>(create: (_) => FavoritesState()), // NEW ], ...Sign-out cleanup in _GymAppState._onAuthChanged:
if (_wasAuthenticated && !isAuth && mounted) { context.read<PassesState>().reset(); context.read<FavoritesState>().reset(); // NEW — same pattern as PassesState}Why hoist to app level (not page-scoped)? Same reasoning as
PassesState — FavoritesPage, HomeShell (via the bottom-nav badge if
ever added), and ActivityPage (via HeartButton) all need to share
the same instance so a toggle on the activity page is reflected on the
favorites page without a manual reload. The app-level provider also
survives navigation between routes, avoiding refetches.
D12 — i18n keys
Section titled “D12 — i18n keys”Three new keys added to all five locale files
(packages/i18n/assets/i18n/{en,uk,ru,de,fr}.json):
| Key | en (canonical) |
|---|---|
favorites#added | "Added to favourites" |
favorites#removed | "Removed from favourites" |
favorites#error | "Something went wrong. Please try again." |
Existing keys (favorites#title, favorites#empty) MUST not be
removed (AC-14 explicit). Implementation note: the i18n:sync script in
packages/i18n/scripts can be used to verify all locales have the new
keys.
D13 — Business panel: section on AnalyticsDashboardPage
Section titled “D13 — Business panel: section on AnalyticsDashboardPage”The existing page at
tktspace-business/client/src/app/features/dashboard/analytics/pages/analytics-dashboard/
gains a new “Top Activities by Favorites” section — added as a Taiga UI
tuiCardLarge block, same shape as the existing per-activity revenue
breakdown. AC-15 requires a two-column table (“Activity title” |
“Favorites count”). Since the existing page does not currently use
tui-table (it uses charts and inline lists), we introduce a minimal
Taiga tui-table wrapped in a tuiCardLarge block. The table is fed by
a new signal favoritesData = signal<FavoriteActivityAnalyticsItemDto[]>([])
populated alongside the other Promise.allSettled calls in loadAll().
Concrete additions to analytics-dashboard.page.ts:
import { FavoriteActivityAnalyticsItemDto, analyticsControllerGetFavorites } from '@core/api';
readonly favoritesData = signal<FavoriteActivityAnalyticsItemDto[]>([]);
private async loadAll(range: TuiDayRange): Promise<void> { const from = range.from.toJSON(); const to = range.to.toJSON(); this.showLoader.set(true); try { const results = await Promise.allSettled([ // ...existing calls, this.api.invoke(analyticsControllerGetFavorites, { from, to, limit: 10 }), ]); const [/* existing */, /* ... */, favorites] = results; this.favoritesData.set(favorites.status === 'fulfilled' ? (favorites.value ?? []) : []); } finally { this.showLoader.set(false); }}Files touched in tktspace-business:
client/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.ts— add the signal, theloadAllPromise.allSettled entry, theanalyticsControllerGetFavoritesimport.client/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.html— add a new<tui-card-large>section with the table.client/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.scss— optional, only if the table needs spacing tweaks.
No new route. No new service file — the generated @core/api already
exposes analyticsControllerGetFavorites after npm run generate:api
runs against the patched business.openapi.yaml. The existing
api.invoke helper handles errors and loader integration.
The limit: 10 is hard-coded per AC-15 (“limit is fixed at 10”).
i18n keys for the section title and column headers should be added to
the business app’s translations under the existing analytics.*
namespace (e.g. analytics.favorites.title,
analytics.favorites.column_activity,
analytics.favorites.column_count). These are business-side keys and
do NOT live in the mobile packages/i18n.
D14 — Contract patches
Section titled “D14 — Contract patches”See “Contract patch outlines” section below. Both patches are applied in this MR.
Considered alternatives
Section titled “Considered alternatives”Alt 1 — Per-company favorites (FK to companyCustomers.id) instead of platform-wide
Section titled “Alt 1 — Per-company favorites (FK to companyCustomers.id) instead of platform-wide”Trade-off: simpler tenancy story for the analytics endpoint (no need to
explicitly join with activities.company_id; the FK chain itself
guarantees scope). But:
- Forces the customer to be a registered customer of the company before
they can favorite anything — the spec is explicit that a logged-in
user should be able to favorite “Йога з Мариною” without first
establishing a
companyCustomersrow. - Loses the “favorite-as-demand-signal” use case: a user who favorites but never books still tells the operator “this activity is wanted”. Tying the favorite to the customer record forces a booking-or-purchase to ever appear in analytics.
- Doesn’t scale to cross-company favorites lists (“show me everything I
bookmarked, no matter the company”), which is exactly what
FavoritesPageshows in v1.
Rejected. users.users(id) is the right FK.
Alt 2 — Single isFavorited column on activities (denormalized per-user-activity flag)
Section titled “Alt 2 — Single isFavorited column on activities (denormalized per-user-activity flag)”Not a real alternative — Postgres doesn’t support per-(user, activity)
columns on a row keyed only by activity. Listed for completeness:
denormalizing to a JSON map activities.metadata.favoritedByUsers is
strictly worse (no FK, no indexing, write contention on every favorite
of any activity). Rejected.
Alt 3 — Soft-delete favorites (add is_active / removed_at)
Section titled “Alt 3 — Soft-delete favorites (add is_active / removed_at)”Trade-off: preserves a “favorites history” for analytics (e.g. “which activities did this user favorite then unfavorite?”). But:
- The spec is explicit that DELETE is idempotent and the row goes away — no historical retention required.
- The analytics endpoint counts
created_atwithin a window, so the “raw demand at time T” signal is still computable from active rows alone. - Soft-delete would force every read query to add
WHERE is_active = true, doubling the surface area for bugs (see thepasses-per-extra-coverageADR for how soft-delete cascade had to be exhaustively enumerated).
Rejected. Hard-delete on DELETE; archived activities filtered at JOIN time.
Alt 4 — Auto-resolve cursor from created_at alone (no composite tuple)
Section titled “Alt 4 — Auto-resolve cursor from created_at alone (no composite tuple)”Trade-off: simpler cursor encoding (just an ISO timestamp). But:
- Two favorites created in the same microsecond (same user, two
back-to-back POSTs from a script or a distributed-clock test) would
share a
created_atand either skip or duplicate during pagination. - Postgres
timestamptzresolution is ~1µs; the bug only triggers on pathological inputs but is real. - The composite
(created_at, id)tuple is two extra UUIDs in the cursor blob — cheap.
Rejected. Composite cursor.
Alt 5 — Publish full ActivityPreviewClientResponseDto / ActivityDetailClientResponseDto in client.openapi.yaml
Section titled “Alt 5 — Publish full ActivityPreviewClientResponseDto / ActivityDetailClientResponseDto in client.openapi.yaml”Trade-off: contract-level reuse — the favorites-list response could
embed the full preview DTO (every field a future v2 list/search/home
endpoint will eventually need), and the contract would publish the
detail DTO so consumers see isFavorited in the typed client today.
But:
- The activity list/detail PATHS are not declared in this contract in
v1 (out of scope per the spec). Publishing schemas without owning
paths emits dead types in the mobile
packages/apigenerated client, which contradicts the “no orphan DTOs” rule and bloats the consumer surface. - AC-4 wording — “items have the same DTO shape as
GET /api/client/activities” — describes the wire payload at runtime, not the contract publication. The backend mapper still maps from the same internal DTO; the contract just publishes a slim view of the fields the favorites screen actually consumes. - Forcing a full preview/detail DTO into this contract today
pre-commits to a stable shape we have not vetted (which subset of
tags,durationMinutes,status, etc. is canonical).
Rejected. The chosen v1 approach (D8 CONCERN #7 decision) ships only
a slim FavoriteActivityDto in the contract; activity detail’s
isFavorited field is added at the backend internal DTO level and
will surface in the contract when the activity-detail path is
contract-ified in a follow-up ticket.
Surface impact and field-visibility justification
Section titled “Surface impact and field-visibility justification”Client surface (contracts/client.openapi.yaml)
Section titled “Client surface (contracts/client.openapi.yaml)”New paths:
POST /favorites— body{ activityId: uuid }, responses 200/201FavoriteRecordDto, 404, 401.DELETE /favorites/{activityId}— response 204, 401.GET /favorites— querylimit(1–100, default 20),cursor(opaque string, optional). ResponseFavoriteListResponseDto = { items: FavoriteActivityDto[], nextCursor: string|null }. Errors 400 (invalid cursor), 401.
New schemas:
-
FavoriteRecordDto:id: uuid— favorite row idactivityId: uuidcreatedAt: string(date-time)
-
FavoriteActivityDto(slim activity shape used ONLY by the favorites-list response — see D8 CONCERN #7 decision; 2026-05-08 amendment swapped price/currency for type/companyName):id: uuid— activity id (activities.id)title: stringtype: string— activity type code (rendered as a badge)companyName: string— joined fromcompanies.nameposterUrl: string | nullisFavorited: boolean—required: true, NOT nullable, alwaystrueon items returned byGET /favorites.
-
FavoriteListResponseDto:items: FavoriteActivityDto[]nextCursor: string | null— opaque cursor;nullwhen no more pages.
-
CreateFavoriteDto(request body):activityId: uuid(required)
Field additions to existing schemas: none.
Schemas explicitly NOT added (CONCERN #7):
ActivityPreviewClientResponseDto— full preview shape is NOT published in this contract; the activity list/search paths are not declared here in v1, so publishing the full DTO would emit dead types inpackages/api. The slimFavoriteActivityDtocovers the one place this contract actually needs an activity-shaped object.ActivityDetailClientResponseDto— same reasoning. TheisFavoritedfield required by AC-5 on activity detail is added at the backend internal DTO level (NestJS class). It will appear in the contract whenever the activity-detail path is contract-ified in a future ticket. AC-5 is satisfied at runtime (the wire response carries the field onGET /api/client/activities/:id) even though the contract does not yet publish that path.
Why no client-side analytics field exposure: the /api/client
surface intentionally does NOT expose favorite counts or any
operator-side signal. The spec is explicit that “Showing favorites
count publicly on activity cards (e.g. ‘142 favorites’)” is out of
scope. This keeps the client DTOs free of operator-only data.
Business surface (contracts/business.openapi.yaml)
Section titled “Business surface (contracts/business.openapi.yaml)”New path:
GET /analytics/favorites— queryfrom(ISO date, required),to(ISO date, required),limit(1–50, default 10). ResponseFavoriteActivityAnalyticsItemDto[](flat array, no envelope — consistent withgetActivitiesand the other analytics endpoints). Errors 400, 401, 403.
New schema:
FavoriteActivityAnalyticsItemDto:activityId: uuidtitle: stringfavoriteCount: integer
Why this minimal shape on business: the operator panel just renders
a top-N table; it doesn’t need the activity’s poster, currency, or
status — only the title (already snapshotted into the analytics
payload, identical to how ActivityAnalyticsItemDto carries title).
Activity slug, posterUrl, etc. are intentionally NOT included; if a
future ticket wants click-through-to-detail UX, a separate join can
fetch those at click-time without fattening the analytics payload.
Why no companyId in the response: it’s already implied by the
tenancy guard (@ActiveCompany); echoing it in every row would be
duplication.
Super-admin surface
Section titled “Super-admin surface”Not touched.
Data model changes
Section titled “Data model changes”| Table | Change | Rationale |
|---|---|---|
activities.user_activity_favorites (NEW) | id, user_id (fk → users.users on cascade), activity_id (fk → activities.activities on cascade), created_at, unique(user_id, activity_id), index(user_id, created_at DESC), index(activity_id) | D1 |
Migration generation
Section titled “Migration generation”Per the team rule “Drizzle migrations generated only” (and the
feedback_drizzle_migrations.md memory note), the actual SQL is NOT
hand-written. The implementer (backend-dev) follows this sequence:
-
Edit
libs/shared/data-access-db/src/lib/schema/activities.schema.tsto add theuserActivityFavoritestable definition — same Drizzle syntax pattern as the other tables in the file. Concrete shape:export const userActivityFavorites = activitiesSchema.table('user_activity_favorites',{id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),activityId: uuid('activity_id').notNull().references(() => activities.id, { onDelete: 'cascade' }),createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),},(t) => ({userActivityUnique: unique('user_activity_favorites_user_activity_unique').on(t.userId, t.activityId),userCreatedIdx: index('user_activity_favorites_user_id_created_at_idx').on(t.userId, t.createdAt.desc()),activityIdx: index('user_activity_favorites_activity_id_idx').on(t.activityId),}),);(
usersimported from./users.schema;uniqueandindexfromdrizzle-orm/pg-core.) -
From the backend repo root, run the project’s drizzle generate command:
Terminal window npx drizzle-kit generate --config=drizzle.config.ts --name=favorite_activitiesThis emits a new migration file at
libs/shared/data-access-db/migrations/00XX_favorite_activities.sql. -
Expected diff shape of the generated SQL (for review only — do NOT hand-edit unless the generator misses something):
CREATE TABLE "activities"."user_activity_favorites" (...);ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY (user_id) REFERENCES "users"."users"("id") ON DELETE cascade ...;ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY (activity_id) REFERENCES "activities"."activities"("id") ON DELETE cascade ...;ALTER TABLE ... ADD CONSTRAINT "user_activity_favorites_user_activity_unique" UNIQUE("user_id","activity_id");CREATE INDEX "user_activity_favorites_user_id_created_at_idx" ON "activities"."user_activity_favorites" USING btree ("user_id","created_at" DESC);CREATE INDEX "user_activity_favorites_activity_id_idx" ON "activities"."user_activity_favorites" USING btree ("activity_id"); -
Run
migration-safety-checkskill on the generated SQL (verify no destructive ops, reversibility, etc.). -
No backfill needed — the table is empty on creation; favorites accrue as users tap hearts post-deploy.
The drafts/migration-favorite-activities.sql file in this repo is a
preview only — illustrative, not executed. The real migration is
the file Drizzle emits.
Backend module placement
Section titled “Backend module placement”Client favorites endpoints — new lib libs/features/favorites/
Section titled “Client favorites endpoints — new lib libs/features/favorites/”The favorites domain doesn’t fit cleanly inside any existing feature lib
(activities is for activity CRUD/booking; passes is for pass
templates; user-profile is for profile fields). A new dedicated lib
mirrors the structure of passes and activities:
libs/features/favorites/├── project.json├── tsconfig.json├── tsconfig.lib.json├── eslint.config.mjs (or .json)└── src/ ├── index.ts └── lib/ ├── favorites.module.ts (shared providers) ├── favorites-client.module.ts (controller + service for /api/client/favorites/*) ├── controllers/ │ └── favorites-client.controller.ts ├── services/ │ └── favorites-client.service.ts └── dto/ ├── create-favorite.dto.ts ├── favorite-record.response.dto.ts ├── favorite-list-response.dto.ts └── find-favorites.dto.tsWired into apps/api/src/app/modules/client-api/client-api.module.ts
imports list as FavoritesClientModule (new entry alongside the
existing PassesClientModule, ActivitiesClientModule, etc.).
isFavorited projection on activity detail — extend existing service
Section titled “isFavorited projection on activity detail — extend existing service”libs/features/activities/src/lib/services/activities-client.service.ts
gains userId?: string parameters on findById (and on the controller
methods). The implementation adds the EXISTS subquery scalar to the
SELECT projection. This is the path of least change — no new module,
no cross-feature dependency. The findAll (preview list) and search
methods are NOT modified in v1 (per AC-5).
Analytics endpoint — extend existing analytics module
Section titled “Analytics endpoint — extend existing analytics module”apps/api/src/app/modules/analytics/:
analytics.controller.ts— add@Get('favorites')method calling the new service method.analytics.service.ts— addgetFavoritesBreakdown(companyId, from, to, limit)method using the existing in-memory cache helper.analytics.dto.ts— addFavoriteActivityAnalyticsItemDtoresponse class and aFavoritesAnalyticsRangeDto extends AnalyticsRangeDtowith the additionallimit?: numberfield.analytics.module.ts— no change (the new service method lives on the existing service; the new DTO is exported from the existing file).
The analytics module is already imported by admin-api.module.ts, so
no wiring change in the admin-api umbrella module.
Service-layer outlines
Section titled “Service-layer outlines”favorites-client.service.ts:
class FavoritesClientService { constructor(private readonly drizzle: DrizzleService) {}
// POST /api/client/favorites async addFavorite(userId, activityId) { // 1. Activity-existence + status guard const [activity] = await db.select({ id: activities.id }) .from(activities) .where(and(eq(activities.id, activityId), eq(activities.status, 'PUBLISHED'))); if (!activity) throw new NotFoundException('errors.activity.not_found');
// 2. Idempotent insert const [inserted] = await db.insert(userActivityFavorites) .values({ userId, activityId }) .onConflictDoNothing({ target: [userActivityFavorites.userId, userActivityFavorites.activityId] }) .returning({ id, activityId, createdAt });
if (inserted) { return { record: inserted, created: true }; // controller maps to 201 }
// 3. Already favorited — fetch existing const [existing] = await db.select() .from(userActivityFavorites) .where(and( eq(userActivityFavorites.userId, userId), eq(userActivityFavorites.activityId, activityId), )); return { record: existing, created: false }; // controller maps to 200 }
// DELETE /api/client/favorites/:activityId async removeFavorite(userId, activityId) { await db.delete(userActivityFavorites) .where(and( eq(userActivityFavorites.userId, userId), eq(userActivityFavorites.activityId, activityId), )); // Always 204 — controller returns no content regardless of rowCount }
// GET /api/client/favorites async listFavorites(userId, dto: { limit, cursor }) { const limit = Math.min(Math.max(dto.limit ?? 20, 1), 100); const cursor = dto.cursor ? this.decodeCursor(dto.cursor) : null;
const rows = await db.select({ favoriteId: userActivityFavorites.id, favoriteCreatedAt: userActivityFavorites.createdAt, ...activityPreviewProjection, }) .from(userActivityFavorites) .innerJoin(activities, eq(activities.id, userActivityFavorites.activityId)) .where(and( eq(userActivityFavorites.userId, userId), eq(activities.status, 'PUBLISHED'), cursor ? sql`(${userActivityFavorites.createdAt}, ${userActivityFavorites.id}) < (${cursor.ts}, ${cursor.id})` : undefined, )) .orderBy(desc(userActivityFavorites.createdAt), desc(userActivityFavorites.id)) .limit(limit + 1);
const hasMore = rows.length > limit; const items = (hasMore ? rows.slice(0, limit) : rows).map(row => ({ ...this.toPreviewDto(row), isFavorited: true, // every item in this list is, by definition, favorited })); const nextCursor = hasMore ? this.encodeCursor({ ts: rows[limit - 1].favoriteCreatedAt, id: rows[limit - 1].favoriteId, }) : null; return { items, nextCursor }; }
private encodeCursor(payload) { return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); } private decodeCursor(cursor) { try { return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); } catch { throw new BadRequestException('errors.favorites.invalid_cursor'); } }}activities-client.service.ts.findById changes:
async findById(id, companyId?, include = {}, userId?) { const isFavoritedSubquery = userId ? sql<boolean>`EXISTS ( SELECT 1 FROM activities.user_activity_favorites f WHERE f.user_id = ${userId}::uuid AND f.activity_id = ${activities.id} )`.as('isFavorited') : sql<boolean>`false`.as('isFavorited');
const [activity] = await db.select({ ...existingProjection, isFavorited: isFavoritedSubquery }) .from(activities) .where(and(eq(activities.id, id), eq(activities.status, 'PUBLISHED'), companyId ? eq(activities.companyId, companyId) : undefined)); // ...rest unchanged}The two controller methods (activities-global-client.controller.ts:findOne
and activities-client.controller.ts:findOne) gain
@ActiveUser('id') userId: string and forward it.
analytics.service.ts.getFavoritesBreakdown:
async getFavoritesBreakdown(companyId, from, to, limit) { ({ from, to } = this.normaliseDateRange(from, to)); const clamped = Math.min(Math.max(limit ?? 10, 1), 50); const key = `favorites:${companyId}:${from}:${to}:${clamped}`; const cached = this.getCached(key); if (cached) return cached;
const rows = await this.drizzle.db.execute(sql` SELECT a.id AS "activityId", a.title, COUNT(f.id)::int AS "favoriteCount" FROM activities.user_activity_favorites f JOIN activities.activities a ON a.id = f.activity_id WHERE a.company_id = ${companyId}::uuid AND f.created_at >= ${from}::timestamptz AND f.created_at < ${to}::timestamptz GROUP BY a.id, a.title ORDER BY "favoriteCount" DESC LIMIT ${clamped} `);
const data = rows.rows.map((r: any) => ({ activityId: r.activityId, title: r.title, favoriteCount: Number(r.favoriteCount), })); this.setCached(key, data); return data;}Frontend implications
Section titled “Frontend implications”tktspace-mobile-app/apps/gym_app
Section titled “tktspace-mobile-app/apps/gym_app”- New mobile UX:
FavoritesPage: real implementation inpackages/favorites— pull-to-refresh, infinite-scroll, empty state, per-card heart for removal. Replaces the existing placeholder.ActivityPage(existing): heartIconButtonadded toAppBar.actionsreadingisFavoritedfromActivityDetailClientResponseDtofor initial state and usingFavoritesState.toggleFavorite(activityId, currentState)for the optimistic toggle + revert-on-error +favorites#errorSnackBar.
- Files touched:
apps/gym_app/lib/main.dart— addChangeNotifierProvider<FavoritesState>to MultiProvider; addFavoritesState.reset()to sign-out cleanup.apps/gym_app/lib/pages/home/favorites_page.dart— replace placeholder withimport 'package:tktspace_favorites/tktspace_favorites.dart';re-exporting / aliasing the page from the package. The simplest shape is to delete the local placeholder file and import the package’sFavoritesPagedirectly inapp_router.dart.apps/gym_app/lib/pages/activity/activity_page.dart— addHeartButton(activityId: id)toAppBar.actions.apps/gym_app/pubspec.yaml— addtktspace_favorites: { path: ../../packages/favorites }.
- Mobile packages touched (per spec frontmatter):
packages/favorites— NEW (D10).packages/api— auto-regenerated from the patchedclient.openapi.yaml(melos run sync:spec && melos run generate:api).packages/i18n— add three new keys to all five locale files (D12).
apps/tickets_app
Section titled “apps/tickets_app”Not touched (out of scope per spec).
tktspace-business
Section titled “tktspace-business”- Files touched:
client/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.ts— add favorites signal,analyticsControllerGetFavoritesimport, and Promise.allSettled entry (D13).client/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.html— add<tui-card-large>with the table.client/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.scss— optional spacing tweak.client/src/app/core/api/— regenerated bynpm run generate:api(no hand-edits).- i18n files for the section title and column headers (existing
analytics.*namespace).
- No router or sidebar changes. Permission unchanged
(
Permission.READ_PAYMENTS).
tktspace-web, tktspace-landing
Section titled “tktspace-web, tktspace-landing”Not touched (out of scope per spec).
Test plan per AC
Section titled “Test plan per AC”| AC | Surface | Test file path (suggested) |
|---|---|---|
| AC-1 | backend | drizzle migration + apps/api-e2e/src/favorites/favorites.e2e-spec.ts (table exists, unique constraint enforced) |
| AC-2 | backend | libs/features/favorites/src/lib/services/favorites-client.service.spec.ts (idempotent insert) + e2e POST |
| AC-3 | backend | libs/features/favorites/src/lib/services/favorites-client.service.spec.ts (idempotent delete) + e2e DELETE |
| AC-4 | backend | unit test on cursor encode/decode + e2e GET with status-filter and pagination |
| AC-5 | backend | libs/features/activities/src/lib/services/activities-client.service.spec.ts — isFavorited true/false branches |
| AC-6 | backend | apps/api-e2e/src/analytics/analytics-favorites.e2e-spec.ts (tenancy isolation, ordering, limit clamp, status not filtered) |
| AC-7 | gym_app | apps/gym_app/test/pages/home/favorites_page_test.dart + packages/favorites/test/favorites_page_test.dart |
| AC-8 | gym_app | manual / smoke (no code change) — covered by HomeShell golden test |
| AC-9 | gym_app | manual / smoke (no code change) |
| AC-10 | gym_app | packages/favorites/test/favorites_state_test.dart (toggleFavorite optimistic + revert on error) |
| AC-11 | gym_app | golden test on ActivityPage AppBar with isFavorited=true/false |
| AC-12 | gym_app | This MR (package skeleton review) |
| AC-13 | gym_app | apps/gym_app/test/main_test.dart (MultiProvider wiring) — minimal smoke |
| AC-14 | mobile/i18n | packages/i18n/scripts/sync-i18n.ts run; verify all 5 locales have new keys |
| AC-15 | tktspace-business | client/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.spec.ts (favorites table) |
| AC-16 | _workflow | This MR (contract review) |
| AC-17 | _workflow | This MR |
| AC-18 | mobile | melos run sync:spec && melos run generate:api runs cleanly |
| AC-19 | tktspace-business | npm run generate:api runs cleanly |
- Unbounded favorites list per user. The spec explicitly defers a
hard cap. Mitigation: cursor pagination (D6) keeps queries fast even
on a list of 10K+ favorites; the
(user_id, created_at DESC)index is the read primary path. If a future ticket reveals abuse (one user with 100K+ favorites), revisit with a soft cap or rate-limit on POST. - Analytics performance on large date ranges. The analytics query
scans
user_activity_favoritesfiltered by(activity_id → company_id)andcreated_atwindow. Worst case: a multi-yearfrom..toon a popular company. Mitigation: 10-minute cache (D9) absorbs repeated hits; the(activity_id)index helps the JOIN; the existingREAD_PAYMENTSpermission gates the endpoint so only operator users can hit it. If query plan degrades on production data, add a composite(activity_id, created_at)index in a follow-up. isFavoritedsubquery on every detail load. The EXISTS subquery uses the unique(user_id, activity_id)btree — index-only scan, no heap fetch. Cost is one logical I/O per detail load. Acceptable for v1; if profiling shows hot-spotting, cache the result on the mobile side viaFavoritesState._favoritedByActivity(already populated on toggle).- Race when an activity transitions from PUBLISHED to ARCHIVED
mid-list-fetch. The list query JOINs on
status='PUBLISHED', so a race might silently drop a row that the user just added. The user’s next reload will simply not show it — same UX as the equivalent race on theGET /api/client/activitieslist. Acceptable. - Cursor staleness across schema changes. If a future ticket changes the favorite ordering (e.g. user-reorder), old client cursors break. Mitigation: the cursor is opaque, the mobile re-fetches from the start on cursor decode failure (HTTP 400 → trigger a fresh load).
- Sign-out cleanup race.
FavoritesState.reset()clears the cached list; if a stale fetch resolves AFTER reset, it could repopulate. Mitigation:reset()is called synchronously on auth-change; the in-flight fetch should be aborted by checkingmounted/ a fetch cancellation token. ThePassesStatehas the same risk and chooses the simpler “last-write-wins” strategy — we mirror that. If observed in practice, add a fetch token. - Future v2 hearts on list cards. When a future ticket populates
isFavoritedon list/search/home/recommendation responses, the backend will need to decide whether to populate the field onfindAllfor ALL activities at once (one EXISTS-IN subquery joining the favorites table) or fall back to a per-card fetch. The latter would N+1; the former needs a composite index or an array intersect. Document in the v1 follow-up backlog. The contract migration at that point is also additive — either widenFavoriteActivityDtoto the full preview shape or introduce a richer schema referenced by the newly-contract-ified list/search paths.
Rollout plan
Section titled “Rollout plan”No feature flag. Single coordinated rollout — favorites is purely additive (new endpoints, new field, new table). The mobile and business builds need to be released after the backend ships, but the backend can ship first independently:
_workflowMR (this ADR + both contract patches) lands onmain.tktspace-backendMR — schema edit,drizzle-kit generate, runmigration-safety-checkskill, implement service code, e2e tests,isFavoritedprojection on activity detail. Deploys independently; has zero observable customer impact (no client/business code calls the endpoints yet).- In parallel:
tktspace-businessMR —npm run generate:api, wire the favorites section onAnalyticsDashboardPage. Ship after backend deploys. - In parallel:
tktspace-mobile-appMR —melos run sync:spec && melos run generate:api, createpackages/favorites, replaceFavoritesPageplaceholder, addHeartButtontoActivityPage, add three i18n keys to all locales, wireFavoritesStateinmain.dart. Ship a new gym_app build.
Backwards-compat:
- POST/DELETE/GET
/api/client/favoritesare net-new — no existing client behavior changes. isFavoritedis added to the activity-detail wire response (via the backend internal NestJS DTOActivityDetailClientResponseDto). It is always populated for authenticated callers (the only callersClientJwtGuardadmits — see D8 CONCERN #3 decision); old mobile client builds that haven’t regenerated against the new shape simply ignore the unknown field. The contract publication of this field is deferred to whenever the activity-detail path is contract-ified (CONCERN #7 decision).GET /api/business/analytics/favoritesis net-new — no existing dashboard behavior changes; oldtktspace-businessbuilds won’t call it.- Migration is additive — no destructive ops.
Stop scope (non-goals)
Section titled “Stop scope (non-goals)”- No
tickets_appwork — deferred to a follow-up ticket per the spec. - No
tktspace-webortktspace-landingwork. - No super-admin endpoint for favorites — operator surface gets analytics only; no per-user-favorite admin manipulation.
- No push notifications triggered by favorites.
- No recommendations engine based on favorites.
- No public favorites count on activity cards (e.g. “142 favorites”).
- No user-reordering of favorites list.
- No favorites-trend-over-time chart in v1.
- No “favorited but never booked” cross-signal report.
- No hard cap on favorites per user.
- No hearts on list/search/home/recommendation cards in v1 (no
isFavoritedfield on the contract preview DTO — see D8 CONCERN #7 decision; the backend internal DTO carries an optionalisFavorited?for forward-compat but it is unset and not contract-published).
Contract patch outlines
Section titled “Contract patch outlines”Both contract files are PATCHED in this MR (see commits in the same branch). The summary below mirrors the YAML edits applied to each file so reviewers can audit field-by-field.
contracts/client.openapi.yaml
Section titled “contracts/client.openapi.yaml”New paths (under paths:):
/favorites—post(add) +get(list)- POST request body:
CreateFavoriteDto({ activityId: uuid }) - POST responses:
200 FavoriteRecordDto(already-favorited / idempotent),201 FavoriteRecordDto(newly added),401,404 ErrorResponse(activity not found / not PUBLISHED) - GET parameters:
limit(1–100, default 20),cursor(opaque string, optional) - GET responses:
200 FavoriteListResponseDto,400 ErrorResponse(invalid cursor),401
- POST request body:
/favorites/{activityId}—delete- Path param:
activityId: uuid - Responses:
204 No Content,401
- Path param:
New schemas (under components.schemas):
CreateFavoriteDto—{ activityId: uuid }(required)FavoriteRecordDto—{ id: uuid, activityId: uuid, createdAt: string(date-time) }FavoriteActivityDto(slim activity shape for favorites-list items — see D8 CONCERN #7 decision; 2026-05-08 amendment swapped price/currency for type/companyName):{ id: uuid, title: string, type: string, companyName: string, posterUrl: string|null, isFavorited: boolean }. All fields required;isFavoritedis alwaystrue.FavoriteListResponseDto—{ items: FavoriteActivityDto[], nextCursor: string|null }
Schemas explicitly NOT added in this contract patch (CONCERN #7):
- No
ActivityPreviewClientResponseDtoand noActivityDetailClientResponseDto. Their owning paths (GET /api/client/activities,GET /api/client/activities/:id, and the company-scoped variants) are not declared in this contract in v1; publishing the full DTOs would emit dead types in the mobile generated client. TheisFavoritedfield on the activity-detail response is satisfied at backend level by extending the internal NestJS DTO atlibs/features/activities/src/lib/dto/activity-client.response.dto.ts, and will surface in the contract when the activity-detail path is contract-ified in a future ticket.
Field additions to existing schemas: none.
contracts/business.openapi.yaml
Section titled “contracts/business.openapi.yaml”New path (under paths:):
/analytics/favorites—get- Parameters:
from(ISO date, required),to(ISO date, required),limit(1–50, default 10) - Responses:
200 FavoriteActivityAnalyticsItemDto[](flat array, no envelope),400,401,403
- Parameters:
New schema:
FavoriteActivityAnalyticsItemDto—{ activityId: uuid, title: string, favoriteCount: integer }
STATUS: READY_FOR_REVIEW