Skip to content

ADR: Cross-company /me/bookings (P1 mobile pre-req)

Cross-company /me/bookings (P1 mobile pre-req)

Section titled “Cross-company /me/bookings (P1 mobile pre-req)”

Per the spec Goal: “Add a cross-company GET /api/client/me/bookings endpoint plus a top-level ‘My Bookings’ screen in gym_app, so a signed-in customer can see every booking they hold across every company they’ve interacted with — in one list, with two tabs (Upcoming / Past), instead of having to drill into a specific company → activity.”

Verified pre-state (read 2026-06-17):

  • Per-company endpoint GET /api/client/companies/:companyId/my-bookings lives on ActivitiesClientController (libs/features/activities/src/lib/controllers/activities-client.controller.ts:108), operationId: 'bookingsClientListMine'. Note: it does NOT live on bookings-client.controller.ts (which only handles create/pay/cancel).
  • Service: ActivitiesClientService.findMyBookings(companyId, userId, opts) at services/activities-client.service.ts:547. Helper resolveCustomer(companyId, userId) at :833 enforces the companyCustomers.userId = $userId scope.
  • DTO: CustomerBookingClientResponseDto (dto/booking-client.response.dto.ts:20) currently has no company field. Sibling BookingActivityDto (:11) has no sphereCode.
  • /me/* precedent: WalletClientController is @Controller('me/wallet') with operationId family walletClient*. Same auth pattern (@UseGuards(ClientJwtGuard) + @ActiveUser('id') userId).
  • Mobile per-activity screen at apps/gym_app/lib/pages/my_bookings/my_bookings_page.dart stays as drilldown. Hand-wrapper packages/api/lib/src/my_bookings_paginated.dart bridges the untyped paginated response.
  • Router: apps/gym_app/lib/router/app_router.dart:34 defines _profileConfig with items edit_public_profile → favorites → settings → logout. The drilldown route /my-bookings/:companyId/:activityId is registered at :250.

D1 — Controller placement and operationId

Section titled “D1 — Controller placement and operationId”

Add a new file libs/features/activities/src/lib/controllers/bookings-global-client.controller.ts with a single endpoint:

  • @Controller('me/bookings'), @UseGuards(ClientJwtGuard), @ApiTags('Bookings').
  • @Get() decorated with @ApiOperation({ operationId: 'bookingsClientListMineGlobal', ... }) and @OpenApiPaginationResponse(CustomerBookingClientResponseDto).
  • Reuses FindMyBookingsDto for query params (upcoming, page, limit; dateFrom / dateTo are tolerated but unused — same as the per-company endpoint).
  • Delegates to ActivitiesClientService.findMyBookingsGlobal(userId, opts).

Naming rationale: the per-company endpoint already owns bookingsClientListMine. …Global mirrors the precedent activitiesClientList (global) vs activitiesClientListByCompany (scoped). Sits in the /me/* controller family alongside walletClient*.

Add ActivitiesClientService.findMyBookingsGlobal(userId, opts) (sibling of the existing findMyBookings(companyId, userId, opts) at line 547). Recommendation: duplicate the query body first (option b), refactor a shared helper later as a separate ticket.

Rationale: the spec explicitly recommends this (“duplicate first, refactor once both work”); the diff is reviewed faster, and the second copy will need a different WHERE filter (companyCustomers.userId = $userId instead of eq(bookings.customerId, customer.id) + eq(activities.companyId, companyId)). Both methods share:

  • JOIN graph: bookings → sessions → activities → companies plus activities → spheres (the existing method joins bookings → sessions → activities only; the global variant must add innerJoin(companies, eq(companies.id, activities.companyId)) to project company { id, name, logoUrl }, and innerJoin(spheres, eq(spheres.id, activities.sphereId)) to project activity.sphereCode via spheres.code). The per-company method (findMyBookings) must be widened identically since they share the DTO.
  • Status filter: notInArray(bookings.status, ['CANCELLED', 'REFUNDED']).
  • Time-window filter:
    • upcoming=truegt(sessions.startsAt, new Date()), ORDER ASC.
    • upcoming=falselte(sessions.endsAt, new Date()), ORDER DESC.
  • Pagination: page default 1, limit default 20, capped at 50.
  • paymentMethod derivation via derivePaymentMethod().

Note: the per-company method’s existing JOIN MUST also be extended to join companies so the projection can include the new company DTO slice (see D3).

D3 — DTO extensions (shared between both endpoints)

Section titled “D3 — DTO extensions (shared between both endpoints)”

Per OQ-1: extend the shared CustomerBookingClientResponseDto. Per OQ-5: add sphereCode to BookingActivityDto.

In libs/features/activities/src/lib/dto/booking-client.response.dto.ts:

  1. Add a new BookingCompanyDto:
    • id: string (@ApiProperty()).
    • name: string (@ApiProperty()).
    • logoUrl: string | null (@ApiProperty({ nullable: true, type: String })).
  2. Extend CustomerBookingClientResponseDto:
    • Add required company: BookingCompanyDto (@ApiProperty({ type: BookingCompanyDto })).
  3. Extend BookingActivityDto:
    • Add sphereCode: string (@ApiProperty({ description: 'Sphere code (SPORT | EVENTS | SERVICES)' })).
    • Not an enum on the DTO — the DB column is text not enum (activities.schema.ts:80 code: text('code').notNull().unique()). Per CLAUDE.md OpenAPI rules an enum would only be added if there’s a server-side TS enum; there isn’t (sphere codes are seeded rows, not a TS type). Keep as plain string — client maps to icon via SPHERE_FALLBACK_ICONS map (Mobile D4).
  4. Register BookingCompanyDto on both controllers via @ApiExtraModels so consumer codegens (web, mobile) emit BookingCompanyDto as a top-level named schema rather than inlining it. Without this, the existing per-company endpoint’s generated client would inline company anonymously, breaking consumer type-naming.
    • NEW BookingsGlobalClientController@ApiExtraModels(BookingSessionDto, BookingActivityDto, BookingCompanyDto, CustomerBookingClientResponseDto).
    • EXISTING ActivitiesClientController (activities-client.controller.ts:47) — extend the existing decorator: BookingSessionDto, BookingActivityDtoBookingSessionDto, BookingActivityDto, BookingCompanyDto.

Non-breaking impact: adding a required field to an OpenAPI schema is source-compatible for json_serializable (unknown fields ignored, missing required fields fail-soft on missing data — but the server always populates) and for ng-openapi-gen (regenerated TS interfaces gain the new property, existing call sites that don’t read it stay green). The hand-wrapper’s CustomerBookingClientResponseDto.fromJson(...) call ignores extras identically.

New page: apps/gym_app/lib/pages/my_bookings/my_bookings_global_page.dart (co-located with the existing per-activity page; names disambiguate).

Structure:

  • StatefulWidget, DefaultTabController(length: 2) wrapping a Scaffold with TabBar (Upcoming, Past) above a TabBarView. Note: my_passes_page.dart uses a single list filtered by chips instead of a TabBar — different use cases (bookings has 2 distinct lists with enough content to want swipe-between-tabs), so the precedent divergence is intentional.
  • Two child widgets _BookingsTab(upcoming: true/false) — each owns its own _scrollController, _loading, _allLoaded, _page, _bookings. Independent state per tab (no cross-tab refetch on switch).
  • Each tab body is wrapped in a RefreshIndicator (mirrors my_passes_page.dart:115) — pull-to-refresh resets _page=1, clears _bookings, and re-fetches. Lets users sync after creating a booking elsewhere.
  • Infinite scroll mirrors my_bookings_page.dart:52 — load more at maxScrollExtent - 200.
  • Card layout (single row):
    • Leading: company logo (32×32, circular, fallback to Icons.business_outlined when company.logoUrl is null).
    • Title row: company name (caption) + sphere icon (resolved via SPHERE_FALLBACK_ICONS[booking.activity.sphereCode] — gym / events / services).
    • Body: activity title + session startsAt (formatted via package:intl).
    • Trailing: payment method badge (existing widget in the per-activity page — extract to shared if not already).
  • Tap → context.push('/my-bookings/${booking.company.id}/${booking.activity.id}'). Reuses the existing per-activity drilldown screen (AC-8).
  • Empty state: localised text via easy_localization (bookings#global_empty_upcoming / bookings#global_empty_past — OQ-4 finalised in Phase C).

Router entry (per OQ-3): add a ProfileMenuItem to _profileConfig (app_router.dart:34) between favorites and settings:

ProfileMenuItem(
id: 'my_bookings_global',
label: 'bookings#global_menu',
icon: Icons.event_note_outlined,
route: '/my-bookings',
),

Add the route to the top-level GoRoute list (siblings of the existing /my-bookings/:companyId/:activityId at line 250):

GoRoute(
path: '/my-bookings',
builder: (_, __) => const MyBookingsGlobalPage(),
),

D5 — Mobile API consumption (OQ-2 resolution)

Section titled “D5 — Mobile API consumption (OQ-2 resolution)”

Finding (verified 2026-06-17): the Chopper codegen pipeline IS working — apiClientMeWalletUpcomingGet() and apiClientMeWalletGet() generate cleanly with typed responses (Future<chopper.Response<List<UpcomingActivityGroupDto>>>). packages/api/lib/src/generated/swagger_api.swagger.dart has all 58 operationIds present in the canonical client.openapi.yaml.

The pain point is different from what the wrapper’s docstring claims: the per-company bookingsClientListMine generates as Future<chopper.Response> (untyped, body=dynamic) because the @OpenApiPaginationResponse(...) macro produces an inline anonymous { items, total, page, limit } schema at every call site, with no named DTO that swagger_dart_code_generator can hang a typed response off of. The hand-wrapper exists to materialise that into MyBookingsPageResponse.

The new /me/bookings endpoint uses the same @OpenApiPaginationResponse(CustomerBookingClientResponseDto) macro — so it will hit the same untyped response in codegen.

Decision: extract a shared helper to avoid duplicating the URI-fetch + decode + map logic across two hand-wrappers. Create:

  • NEW packages/api/lib/src/paginated_bookings_response.dart exposing:
    • class MyBookingsPageResponse — moved from my_bookings_paginated.dart (already non-company-scoped despite living there).
    • Future<MyBookingsPageResponse> fetchPaginatedBookings(Uri uri) — private/library-internal helper performing the Chopper client.send<dynamic, dynamic> + jsonDecode + manual CustomerBookingClientResponseDto.fromJson mapping. Extras (company, activity.sphereCode) flow through fromJson once codegen has regenerated the DTOs.
  • EDIT packages/api/lib/src/my_bookings_paginated.dart — collapses to a 3-line URI builder composing the helper. Keeps the per-company companyId path segment.
  • NEW packages/api/lib/src/my_bookings_global_paginated.dart — 3-line URI builder composing the helper. URI: '$base/api/client/me/bookings' (no companyId path segment). Signature drops companyId: fetchMyBookingsGlobalPaginated({ bool upcoming, int page, int limit }).

Phase B follow-up (separate ticket, not blocking this work): lift PaginatedBookingsResponseDto as a named DTO server-side so future endpoints get a typed Chopper response and the wrapper can retire. Out of scope for this ticket.

The global service method MUST filter on companyCustomers.userId = $userId (NOT email — the canonical scope key for the client-jwt strategy is userId, per the existing resolveCustomer() at activities-client.service.ts:833).

Join shape:

.from(bookings)
.innerJoin(companyCustomers, eq(companyCustomers.id, bookings.customerId))
.innerJoin(sessions, eq(sessions.id, bookings.sessionId))
.innerJoin(activities, eq(activities.id, sessions.activityId))
.innerJoin(companies, eq(companies.id, activities.companyId))
.innerJoin(spheres, eq(spheres.id, activities.sphereId))
.where(and(
eq(companyCustomers.userId, userId),
notInArray(bookings.status, ['CANCELLED', 'REFUNDED']),
upcoming
? gt(sessions.startsAt, new Date())
: lte(sessions.endsAt, new Date()),
))

Crucial: every customer record where userId IS NULL (offline-only customer; spec companies.schema.ts:117) is implicitly excluded by eq(companyCustomers.userId, userId) — a SQL = against NULL is UNKNOWN, filtered out. This is the desired behaviour (offline customers can’t authenticate via JWT, so they never reach this endpoint).

The e2e test for AC-1 (cross-user isolation, T-6 in §7 of the spec) is non-negotiable to lock this behaviour.

A1 — Mount /me/bookings on the existing BookingsClientController

Section titled “A1 — Mount /me/bookings on the existing BookingsClientController”

Rejected. That controller is @Controller('companies/:companyId') and hosts only the create/pay/cancel mutations. Refactoring it to also host a global-scope GET requires either splitting its prefix or adding a sibling controller anyway — the latter is what D1 does, just in a new file. Co-location with the per-company list endpoint (ActivitiesClientController) was considered but rejected for the same reason: it carries the companies/:companyId prefix.

A2 — Extract a shared _findMyBookingsBase(filters) private helper now

Section titled “A2 — Extract a shared _findMyBookingsBase(filters) private helper now”

Deferred (see D2). The two methods share ~80% of the query, but the WHERE-clause shape differs (per-company uses bookings.customerId = resolvedCustomer.id, global uses companyCustomers.userId = userId through the join). Refactoring into a helper before both work risks hiding a JOIN-cardinality bug behind an abstraction. The follow-up refactor is a 30-line diff once both versions are green.

A3 — Separate response DTO for the global endpoint

Section titled “A3 — Separate response DTO for the global endpoint”

Rejected. Adding company only to a new GlobalCustomerBookingClientResponseDto keeps the per-company DTO unchanged, but doubles the maintenance surface (every new field added to one must be considered for the other) and breaks the “single DTO for one logical entity” rule used elsewhere in the codebase (the per-activity and per-company variants already share CustomerBookingClientResponseDto). Per OQ-1, the redundant company field on per-company responses is harmless because the gym_app consumer reads via fromJson which ignores nothing.

A4 — Derive sphere icon from targetApp (server-side)

Section titled “A4 — Derive sphere icon from targetApp (server-side)”

Rejected per spec §5 Option SB. The targetApp is a per-app routing hint, not a per-activity sphere classification. Adding sphereCode to the DTO is more truthful and unlocks future sphere-based filters in the cross-company list (out of scope here).

Surfaces affected: client only. Business and super-admin contracts are not touched.

GET /api/client/me/bookings
operationId: bookingsClientListMineGlobal
tags: [Bookings]
security: [bearerAuth]
parameters:
- upcoming?: boolean (query)
- page?: integer (query, default 1)
- limit?: integer (query, default 20, max 50)
responses:
200:
schema:
type: object
properties:
items: { type: array, items: { $ref: CustomerBookingClientResponseDto } }
total: { type: integer }
page: { type: integer }
limit: { type: integer }
required: [items, total, page, limit]

Two existing schemas grow new properties (non-breaking widening):

BookingActivityDto (existing, at line 247):

properties:
# ... existing id / title / slug / posterUrl / refundable
sphereCode:
type: string
description: Sphere code (e.g. SPORT, EVENTS, SERVICES) for client-side icon lookup.
required:
- id
- title
- slug
- refundable
- sphereCode # added

CustomerBookingClientResponseDto (existing, at line 728):

properties:
# ... existing id / status / price / createdAt / paymentMethod / session / activity
company:
$ref: '#/components/schemas/BookingCompanyDto'
required:
- id
- status
- price
- createdAt
- paymentMethod
- session
- activity
- company # added

BookingCompanyDto (new top-level schema):

BookingCompanyDto:
type: object
properties:
id: { type: string }
name: { type: string }
logoUrl: { type: string, nullable: true }
required: [id, name, logoUrl]

Surface impact — field visibility justification

Section titled “Surface impact — field visibility justification”
  • Client surface (/api/client/*) — gets all the new fields. No internal-only data exposed: company.id / company.name / company.logoUrl are all already on the public company profile (UpcomingActivityGroupDto.company mirrors the same shape per spec §2). sphereCode is public taxonomy.
  • Business surface (/api/business/*) — UNCHANGED. The per-company endpoint here is a different family (bookings-admin.controller.ts) and uses a different response DTO (BookingAdminResponseDto). No spillover.
  • Super-admin surface (/api/superadmin/*) — UNCHANGED. No bookings endpoints on this surface today.

The same CustomerBookingClientResponseDto is used by all three /api/client/* booking-list endpoints (per-company, per-activity, global). All three return the extended shape — consumers regenerate once.

No DB migration required. Every column read is already present:

  • bookings.id, bookings.status, bookings.price, bookings.createdAt, bookings.customerId, bookings.walletDebited, bookings.bonusDebited, bookings.customerEntitlementId — existing.
  • sessions.id, sessions.startsAt, sessions.endsAt, sessions.activityId — existing.
  • activities.id, activities.title, activities.slug, activities.posterUrl, activities.refundable, activities.companyId — existing.
  • activities.sphereId: uuid exists at activities.schema.ts:243 (the code column at activities.schema.ts:80 is on the spheres TABLE, not activities). To project sphereCode: string per D3, the global service method MUST innerJoin(spheres, eq(spheres.id, activities.sphereId)) and select spheres.code. The per-company method (findMyBookings) must be widened identically since they share the DTO. Still no migration — one extra JOIN in the SELECT list.
  • spheres.id, spheres.code — existing (activities.schema.ts:80 code: text('code').notNull().unique()).
  • companies.id, companies.name, companies.logoUrl — existing.
  • companyCustomers.userId — existing (companies.schema.ts:117).

Indexes: the existing JOIN chain hits primary keys + FKs only. No new indexes needed. Pagination cost is O(limit) for the page query

  • O(N) for the total count where N = bookings for the user across all companies (typically <500). If a perf hotspot emerges, the index bookings (customer_id) plus existing company_customer (company_id, user_id) cover the predicate.

Library: libs/features/activities/ (existing — same feature lib that hosts the per-company list).

Files touched:

  • NEW libs/features/activities/src/lib/controllers/bookings-global-client.controller.ts — single-endpoint controller, ~30 lines.
  • EDIT libs/features/activities/src/lib/controllers/activities-client.controller.ts (line 47) — extend @ApiExtraModels(BookingSessionDto, BookingActivityDto) to include BookingCompanyDto (per D3 step 4).
  • EDIT libs/features/activities/src/lib/services/activities-client.service.ts — add findMyBookingsGlobal(); extend existing findMyBookings()’s projection with company { id, name, logoUrl } and (per D3) activity.sphereCode via the spheres JOIN.
  • EDIT libs/features/activities/src/lib/dto/booking-client.response.dto.ts — add BookingCompanyDto; extend CustomerBookingClientResponseDto and BookingActivityDto.
  • EDIT libs/features/activities/src/lib/activities-client.module.ts — register the new controller in the controllers: [...] array (no new providers; reuses the existing ActivitiesClientService).
  • NEW libs/features/activities/src/lib/services/activities-client.service.global-bookings.spec.ts — e2e tests T-1..T-6 per spec §7.
  • REGEN _workflow/contracts/client.openapi.yaml via npm run sync:contracts in tktspace-backend (NOT hand-edited).

No new shared utilities in libs/shared/. No changes to other feature libs.

The business contract is not touched. No regeneration needed.

tktspace-web — UNCHANGED for this ticket

Section titled “tktspace-web — UNCHANGED for this ticket”

The web app does not consume /me/bookings today. The regenerated client.openapi.yaml will percolate to web on its next npm run generate cycle — no source edits required.

Static site; no API consumption affected.

App: apps/gym_app only. apps/tickets_app is out of scope per spec §9 (“picks up the same shape when it boots”).

Shared packages touched:

  • packages/api — regenerated DTOs (CustomerBookingClientResponseDto
    • BookingActivityDto.sphereCode + new BookingCompanyDto) via melos run sync:spec && melos run generate:api. Per D5/NIT-2: new shared helper lib/src/paginated_bookings_response.dart; existing lib/src/my_bookings_paginated.dart collapses to a 3-line URI builder; new lib/src/my_bookings_global_paginated.dart mirrors it for the global URI.

Files touched in apps/gym_app:

  • NEW apps/gym_app/lib/pages/my_bookings/my_bookings_global_page.dart — top-level cross-company screen with 2 tabs + infinite scroll.
  • EDIT apps/gym_app/lib/router/app_router.dart — add /my-bookings GoRoute (top-level, alongside the existing drilldown), and add ProfileMenuItem to _profileConfig between favorites and settings.
  • NEW apps/gym_app/integration_test/my_bookings_global_test.dart — integration test per spec §7 mobile section.

Offline / state considerations:

  • No offline caching required (matches per-activity screen — fresh fetch on enter).
  • No deep-linking changes — /my-bookings is a flat path; tap-through uses the existing /my-bookings/:companyId/:activityId drilldown.
  • i18n: 6 new keys per OQ-4 (deferred to Phase C): bookings#global_title, bookings#tab_upcoming, bookings#tab_past, bookings#global_empty_upcoming, bookings#global_empty_past, bookings#global_menu. CSV emitted at end of Phase C per workspace memory feedback_i18n_csv_output.

No new app (not adding a brand/flavor). No package dependency changes in pubspec.yaml.

  • R1 — Per-company DTO widening for an unrelated consumer. Adding required company + sphereCode to CustomerBookingClientResponseDto changes the response shape of the existing /api/client/companies/:companyId/my-bookings endpoint used by my_bookings_page.dart. Mitigation: the existing consumer reads via CustomerBookingClientResponseDto.fromJson(...) in the hand-wrapper, and json_serializable ignores extra fields silently. Verified the wrapper path (lines 58–62 of my_bookings_paginated.dart) — no field-by-field destructuring, just .fromJson(e). Risk is low but worth a sanity-check by mobile dev in Phase A.

  • R2 — Cross-user data leak via wrong filter column. If the SQL filter uses companyCustomers.email = userEmail (legacy pre-D5 identity model) instead of companyCustomers.userId = userId, a user with the same email but a different users.id could see another user’s bookings. Mitigation: D6 codifies the SQL fragment; AC-5 + T-6 in spec §7 mandates an e2e isolation test. Backend dev must add T-6 in Phase B, no exceptions.

  • R3 — Pagination total semantics under upcoming/past split. The total must reflect the filtered count (upcoming-only or past-only), not the unfiltered union — otherwise hasMore = page * limit < total miscomputes on the first page when the user has bookings in both buckets. Mitigation: the count query in D2 uses the same WHERE clause as the page query (matches the existing findMyBookings() pattern at lines 624–629). Document in service comment; T-2 + T-4 in spec §7 cover both halves.

Risks beyond the three listed (for completeness):

  • R4 — Hand-wrapper drift on contract refresh. If the canonical contract response shape changes (e.g. adds cursor for keyset pagination), the hand-wrapper silently parses the old shape and drops the new field. Acceptable for now (matches the existing wrapper’s risk profile); the follow-up “lift PaginatedBookings to named DTO” ticket retires both wrappers. Follow-up ticket stub: lifting the shared paginator to a named server-side DTO is out of scope for this MR — it touches the per-company endpoint too and is a separate refactor concern best reviewed in isolation.
  • R5 — Soft-delete future-proofing for companies INNER JOIN. If companies ever grows a deletedAt column, the current INNER JOIN becomes a hidden filter (bookings under soft-deleted companies vanish from the user’s history). At that point: switch to LEFT JOIN + post-filter, or document the exclusion as intentional. Not actionable today since companies has no soft-delete column.
  • Feature flag: none. Additive endpoint + additive (non-breaking) DTO fields. Mobile gates the route entry behind a profile-menu item; shipping the entry and the screen together gates user discovery.
  • Phased rollout: none. Single MR per consumer (backend + mobile), contract regenerated atomically in the backend MR.
  • Backfill: none. No data model changes.
  • Rollback: git revert on the backend MR removes the endpoint and reverts the DTO widening; git revert on the mobile MR hides the profile menu entry. The extra company / sphereCode fields in older client builds (post-revert) are harmless — they simply stop appearing in responses. Mobile builds released between ship-and-revert keep working (extras ignored).

Add libs/features/activities/src/lib/services/activities-client.service.global-bookings.spec.ts:

  • T-1 — zero bookings → { items: [], total: 0, page: 1, limit: 20 }.
  • T-2 — single company, 3 bookings, upcoming/past split. Assert ASC ordering on upcoming=true (earliest sessions.startsAt first) and DESC ordering on upcoming=false (most-recent sessions.endsAt first), per D2 time-window filter.
  • T-3 — two companies mixed, company.name correct per item.
  • T-4 — pagination correctness (25 bookings, page=2 limit=10).
  • T-5 — status filter (CANCELLED + REFUNDED excluded).
  • T-6 — cross-user isolation (R2 mitigation).

Run via:

Terminal window
# In tktspace-backend
pnpm exec nx test activities --testPathPatterns='global-bookings'

Plus the existing sync:contracts:check gate in CI catches contract drift if the YAML isn’t refreshed (AC-9).

Add apps/gym_app/integration_test/my_bookings_global_test.dart:

  • Mock ApiClient (or the hand-wrapper) to return a 2-item page with distinct company.name.
  • Verify both tabs render, default is Upcoming, card shows company name + activity title + payment badge.
  • Verify tap pushes /my-bookings/:companyId/:activityId.

Run via:

Terminal window
# In tktspace-mobile-app
flutter test apps/gym_app/integration_test/my_bookings_global_test.dart

Plus widget tests for the empty state and the loading skeleton.

libs/features/activities/src/lib/
controllers/bookings-global-client.controller.ts NEW
services/activities-client.service.ts EDIT (add findMyBookingsGlobal, widen findMyBookings projection)
services/activities-client.service.global-bookings.spec.ts NEW
dto/booking-client.response.dto.ts EDIT (BookingCompanyDto, +company, +sphereCode)
activities-client.module.ts EDIT (register controller)
# Then in tktspace-backend root:
npm run sync:contracts REGEN _workflow/contracts/client.openapi.yaml
packages/api/lib/src/paginated_bookings_response.dart NEW (shared helper + MyBookingsPageResponse, per D5/NIT-2)
packages/api/lib/src/my_bookings_paginated.dart EDIT (collapse to 3-line URI builder composing the helper)
packages/api/lib/src/my_bookings_global_paginated.dart NEW (3-line URI builder composing the helper, per D5)
apps/gym_app/lib/pages/my_bookings/my_bookings_global_page.dart NEW
apps/gym_app/lib/router/app_router.dart EDIT (ProfileMenuItem + GoRoute)
apps/gym_app/integration_test/my_bookings_global_test.dart NEW
# Then in tktspace-mobile-app root:
melos run sync:spec && melos run generate:api REGEN packages/api/lib/src/generated/
contracts/client.openapi.yaml REGEN (via sync:contracts, not hand-edited)
adrs/cross-company-me-bookings.md THIS FILE

STATUS: READY_FOR_REVIEW