Skip to content

ADR: Favorite Activities

PROPOSED

ACPrimary decisionsNotes
AC-1D1 (DB shape), D2 (user FK target)New activities.user_activity_favorites join table; FK to users.users(id) (Supabase id).
AC-2D3 (POST idempotency), D4 (activity guard)INSERT ... ON CONFLICT DO NOTHING + RETURNING; 404 on missing/non-PUBLISHED.
AC-3D5 (DELETE idempotency)DELETE by composite (user_id, activity_id); always returns 204.
AC-4D6 (cursor pagination), D7 (status join)Cursor = base64(`created_at
AC-5D8 (isFavorited projection)Detail endpoint uses scalar EXISTS subquery; favorites-list items hard-code isFavorited:true.
AC-6D9 (analytics endpoint)New GET /api/business/analytics/favorites; tenancy via activities.company_id = @ActiveCompany.
AC-7D10 (mobile package shape), D11 (state)New packages/favorites with FavoritesRepository/State/Page/HeartButton.
AC-8noneNo code change; Favorites tab already routes to /home/favorites.
AC-9noneNo code change; profile menu entry already routes to /home/favorites.
AC-10D11 (optimistic toggle)FavoritesState.toggleFavorite(activityId, current) with revert-on-error.
AC-11D11Heart icon variants driven by isFavorited flag.
AC-12D10Public surface of packages/favorites mirrors packages/core PassesRepository pattern.
AC-13D11MultiProvider registration + sign-out reset.
AC-14D12 (i18n keys)Three new keys in en/uk/ru/de/fr locale files.
AC-15D13 (business panel section)New “Top Activities by Favorites” section on existing AnalyticsDashboardPage.
AC-16D14 (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-17D14New GET /api/business/analytics/favorites + FavoriteActivityAnalyticsItemDto schema.
AC-18noneMobile regen via melos run sync:spec && melos run generate:api.
AC-19noneBusiness regen via npm run generate:api.

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:

  1. Three new client endpoints on /api/client/favorites/* (add / remove / list) wired into a new mobile package packages/favorites.
  2. A heart toggle on ActivityPage and a real FavoritesPage replacing the existing placeholder. Bottom-nav tab and profile menu shortcut are already wired in router.
  3. An isFavorited: boolean field added at the backend internal DTO level (ActivityDetailClientResponseDto) and surfaced on the activity-detail wire response (drives the heart on ActivityPage). 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 exposes isFavorited: true on every item via a slim FavoriteActivityDto (drives the inline remove-heart on each card). Activity preview cards in search/home/recommendations do not receive the field in v1 — deferred.
  4. A new business analytics endpoint GET /api/business/analytics/favorites returning a Top-N list of activities ranked by favoriteCount within a date range, scoped by company via the existing @ActiveCompany() decorator + Permission.READ_PAYMENTS guard.
  5. A new section on the existing AnalyticsDashboardPage at /analytics in tktspace-business rendering the top-10 list as a Taiga UI table filtered by the existing TuiInputDateRange control.

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).

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 for GET /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_id in GET /api/business/analytics/favorites). The ON DELETE CASCADE from the FK already implies an internal index on activity_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 NOTHING
RETURNING 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_favorites
WHERE 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 f
JOIN activities.activities a ON a.id = f.activity_id
WHERE 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 DESC
LIMIT $limit + 1
  • We fetch limit + 1 rows. If the result has limit + 1 items, we drop the last one and emit its (created_at, id) as the next cursor; if it has <= limit items, 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”).

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 a
WHERE 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:

  1. Replace ActivityPreviewClientResponseDto in the contract with a slim FavoriteActivityDto carrying ONLY the fields the favorites screen renders today (id, title, type, companyName, posterUrl?) plus isFavorited: boolean (always true in this response, required: true in the schema). This is the entity the FavoriteListResponseDto.items array 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) and companyName (joined from companies.name) replace price and currency. Backend mapper joins activities.company_id = companies.id to pull the company display name.

  2. Remove ActivityDetailClientResponseDto from the contract entirely. The activity-detail path is not yet contract-ified, so the schema would have no owning path. The isFavorited field required by AC-5 on activity detail is added at the backend level by extending the existing internal NestJS DTO at libs/features/activities/src/lib/dto/activity-client.response.dto.ts (same DTO that the un-versioned global and per-company findOne endpoints 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.

  3. 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 widen FavoriteActivityDto or 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 f
JOIN activities.activities a ON a.id = f.activity_id
WHERE a.company_id = $companyId
AND f.created_at >= $from
AND f.created_at < $to
GROUP BY a.id, a.title
ORDER BY "favoriteCount" DESC
LIMIT $limit
  • Tenancy guarantee: a.company_id = $companyId is the JOIN-side filter, where $companyId is supplied by the @ActiveCompany() decorator (same as every existing analytics endpoint — getActivities, getSummary, etc., see apps/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 by CompanyRolesGuard, which itself validates the x-company-id header against the JWT.
  • Range semantics: identical to the existing AnalyticsRangeDto + normaliseDateRange helper — from is start-of-day, to is 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-uses READ_PAYMENTS rather than introducing a new permission like READ_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 by favorites:${companyId}:${from}:${to}:${limit}) — same pattern as getActivitiesBreakdown.
  • No cursor: limit clamps 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.dart

pubspec.yaml:

name: tktspace_favorites
description: 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.1

Public 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 PassesStateFavoritesPage, 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.

Three new keys added to all five locale files (packages/i18n/assets/i18n/{en,uk,ru,de,fr}.json):

Keyen (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, the loadAll Promise.allSettled entry, the analyticsControllerGetFavorites import.
  • 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.

See “Contract patch outlines” section below. Both patches are applied in this MR.

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 companyCustomers row.
  • 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 FavoritesPage shows 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_at within 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 the passes-per-extra-coverage ADR 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_at and either skip or duplicate during pagination.
  • Postgres timestamptz resolution 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/api generated 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/201 FavoriteRecordDto, 404, 401.
  • DELETE /favorites/{activityId} — response 204, 401.
  • GET /favorites — query limit (1–100, default 20), cursor (opaque string, optional). Response FavoriteListResponseDto = { items: FavoriteActivityDto[], nextCursor: string|null }. Errors 400 (invalid cursor), 401.

New schemas:

  • FavoriteRecordDto:

    • id: uuid — favorite row id
    • activityId: uuid
    • createdAt: 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: string
    • type: string — activity type code (rendered as a badge)
    • companyName: string — joined from companies.name
    • posterUrl: string | null
    • isFavorited: booleanrequired: true, NOT nullable, always true on items returned by GET /favorites.
  • FavoriteListResponseDto:

    • items: FavoriteActivityDto[]
    • nextCursor: string | null — opaque cursor; null when 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 in packages/api. The slim FavoriteActivityDto covers the one place this contract actually needs an activity-shaped object.
  • ActivityDetailClientResponseDto — same reasoning. The isFavorited field 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 on GET /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 — query from (ISO date, required), to (ISO date, required), limit (1–50, default 10). Response FavoriteActivityAnalyticsItemDto[] (flat array, no envelope — consistent with getActivities and the other analytics endpoints). Errors 400, 401, 403.

New schema:

  • FavoriteActivityAnalyticsItemDto:
    • activityId: uuid
    • title: string
    • favoriteCount: 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.

Not touched.

TableChangeRationale
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

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:

  1. Edit libs/shared/data-access-db/src/lib/schema/activities.schema.ts to add the userActivityFavorites table 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),
    }),
    );

    (users imported from ./users.schema; unique and index from drizzle-orm/pg-core.)

  2. From the backend repo root, run the project’s drizzle generate command:

    Terminal window
    npx drizzle-kit generate --config=drizzle.config.ts --name=favorite_activities

    This emits a new migration file at libs/shared/data-access-db/migrations/00XX_favorite_activities.sql.

  3. 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");
  4. Run migration-safety-check skill on the generated SQL (verify no destructive ops, reversibility, etc.).

  5. 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.

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.ts

Wired 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 — add getFavoritesBreakdown(companyId, from, to, limit) method using the existing in-memory cache helper.
  • analytics.dto.ts — add FavoriteActivityAnalyticsItemDto response class and a FavoritesAnalyticsRangeDto extends AnalyticsRangeDto with the additional limit?: number field.
  • 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.

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;
}
  • New mobile UX:
    • FavoritesPage: real implementation in packages/favorites — pull-to-refresh, infinite-scroll, empty state, per-card heart for removal. Replaces the existing placeholder.
    • ActivityPage (existing): heart IconButton added to AppBar.actions reading isFavorited from ActivityDetailClientResponseDto for initial state and using FavoritesState.toggleFavorite(activityId, currentState) for the optimistic toggle + revert-on-error + favorites#error SnackBar.
  • Files touched:
    • apps/gym_app/lib/main.dart — add ChangeNotifierProvider<FavoritesState> to MultiProvider; add FavoritesState.reset() to sign-out cleanup.
    • apps/gym_app/lib/pages/home/favorites_page.dart — replace placeholder with import '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’s FavoritesPage directly in app_router.dart.
    • apps/gym_app/lib/pages/activity/activity_page.dart — add HeartButton(activityId: id) to AppBar.actions.
    • apps/gym_app/pubspec.yaml — add tktspace_favorites: { path: ../../packages/favorites }.
  • Mobile packages touched (per spec frontmatter):
    • packages/favorites — NEW (D10).
    • packages/api — auto-regenerated from the patched client.openapi.yaml (melos run sync:spec && melos run generate:api).
    • packages/i18n — add three new keys to all five locale files (D12).

Not touched (out of scope per spec).

  • Files touched:
    • client/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.ts — add favorites signal, analyticsControllerGetFavorites import, 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 by npm 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).

Not touched (out of scope per spec).

ACSurfaceTest file path (suggested)
AC-1backenddrizzle migration + apps/api-e2e/src/favorites/favorites.e2e-spec.ts (table exists, unique constraint enforced)
AC-2backendlibs/features/favorites/src/lib/services/favorites-client.service.spec.ts (idempotent insert) + e2e POST
AC-3backendlibs/features/favorites/src/lib/services/favorites-client.service.spec.ts (idempotent delete) + e2e DELETE
AC-4backendunit test on cursor encode/decode + e2e GET with status-filter and pagination
AC-5backendlibs/features/activities/src/lib/services/activities-client.service.spec.tsisFavorited true/false branches
AC-6backendapps/api-e2e/src/analytics/analytics-favorites.e2e-spec.ts (tenancy isolation, ordering, limit clamp, status not filtered)
AC-7gym_appapps/gym_app/test/pages/home/favorites_page_test.dart + packages/favorites/test/favorites_page_test.dart
AC-8gym_appmanual / smoke (no code change) — covered by HomeShell golden test
AC-9gym_appmanual / smoke (no code change)
AC-10gym_apppackages/favorites/test/favorites_state_test.dart (toggleFavorite optimistic + revert on error)
AC-11gym_appgolden test on ActivityPage AppBar with isFavorited=true/false
AC-12gym_appThis MR (package skeleton review)
AC-13gym_appapps/gym_app/test/main_test.dart (MultiProvider wiring) — minimal smoke
AC-14mobile/i18npackages/i18n/scripts/sync-i18n.ts run; verify all 5 locales have new keys
AC-15tktspace-businessclient/src/app/features/dashboard/analytics/pages/analytics-dashboard/analytics-dashboard.page.spec.ts (favorites table)
AC-16_workflowThis MR (contract review)
AC-17_workflowThis MR
AC-18mobilemelos run sync:spec && melos run generate:api runs cleanly
AC-19tktspace-businessnpm 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_favorites filtered by (activity_id → company_id) and created_at window. Worst case: a multi-year from..to on a popular company. Mitigation: 10-minute cache (D9) absorbs repeated hits; the (activity_id) index helps the JOIN; the existing READ_PAYMENTS permission 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.
  • isFavorited subquery 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 via FavoritesState._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 the GET /api/client/activities list. 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 checking mounted / a fetch cancellation token. The PassesState has 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 isFavorited on list/search/home/recommendation responses, the backend will need to decide whether to populate the field on findAll for 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 widen FavoriteActivityDto to the full preview shape or introduce a richer schema referenced by the newly-contract-ified list/search paths.

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:

  1. _workflow MR (this ADR + both contract patches) lands on main.
  2. tktspace-backend MR — schema edit, drizzle-kit generate, run migration-safety-check skill, implement service code, e2e tests, isFavorited projection on activity detail. Deploys independently; has zero observable customer impact (no client/business code calls the endpoints yet).
  3. In parallel: tktspace-business MR — npm run generate:api, wire the favorites section on AnalyticsDashboardPage. Ship after backend deploys.
  4. In parallel: tktspace-mobile-app MR — melos run sync:spec && melos run generate:api, create packages/favorites, replace FavoritesPage placeholder, add HeartButton to ActivityPage, add three i18n keys to all locales, wire FavoritesState in main.dart. Ship a new gym_app build.

Backwards-compat:

  • POST/DELETE/GET /api/client/favorites are net-new — no existing client behavior changes.
  • isFavorited is added to the activity-detail wire response (via the backend internal NestJS DTO ActivityDetailClientResponseDto). It is always populated for authenticated callers (the only callers ClientJwtGuard admits — 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/favorites is net-new — no existing dashboard behavior changes; old tktspace-business builds won’t call it.
  • Migration is additive — no destructive ops.
  • No tickets_app work — deferred to a follow-up ticket per the spec.
  • No tktspace-web or tktspace-landing work.
  • 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 isFavorited field on the contract preview DTO — see D8 CONCERN #7 decision; the backend internal DTO carries an optional isFavorited? for forward-compat but it is unset and not contract-published).

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.

New paths (under paths:):

  • /favoritespost (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
  • /favorites/{activityId}delete
    • Path param: activityId: uuid
    • Responses: 204 No Content, 401

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; isFavorited is always true.
  • FavoriteListResponseDto{ items: FavoriteActivityDto[], nextCursor: string|null }

Schemas explicitly NOT added in this contract patch (CONCERN #7):

  • No ActivityPreviewClientResponseDto and no ActivityDetailClientResponseDto. 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. The isFavorited field on the activity-detail response is satisfied at backend level by extending the internal NestJS DTO at libs/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.

New path (under paths:):

  • /analytics/favoritesget
    • Parameters: from (ISO date, required), to (ISO date, required), limit (1–50, default 10)
    • Responses: 200 FavoriteActivityAnalyticsItemDto[] (flat array, no envelope), 400, 401, 403

New schema:

  • FavoriteActivityAnalyticsItemDto{ activityId: uuid, title: string, favoriteCount: integer }

STATUS: READY_FOR_REVIEW