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)”1. Context
Section titled “1. Context”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-bookingslives onActivitiesClientController(libs/features/activities/src/lib/controllers/activities-client.controller.ts:108),operationId: 'bookingsClientListMine'. Note: it does NOT live onbookings-client.controller.ts(which only handles create/pay/cancel). - Service:
ActivitiesClientService.findMyBookings(companyId, userId, opts)atservices/activities-client.service.ts:547. HelperresolveCustomer(companyId, userId)at:833enforces thecompanyCustomers.userId = $userIdscope. - DTO:
CustomerBookingClientResponseDto(dto/booking-client.response.dto.ts:20) currently has nocompanyfield. SiblingBookingActivityDto(:11) has nosphereCode. /me/*precedent:WalletClientControlleris@Controller('me/wallet')withoperationIdfamilywalletClient*. Same auth pattern (@UseGuards(ClientJwtGuard)+@ActiveUser('id') userId).- Mobile per-activity screen at
apps/gym_app/lib/pages/my_bookings/my_bookings_page.dartstays as drilldown. Hand-wrapperpackages/api/lib/src/my_bookings_paginated.dartbridges the untyped paginated response. - Router:
apps/gym_app/lib/router/app_router.dart:34defines_profileConfigwith itemsedit_public_profile → favorites → settings → logout. The drilldown route/my-bookings/:companyId/:activityIdis registered at:250.
2. Decisions
Section titled “2. Decisions”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
FindMyBookingsDtofor query params (upcoming,page,limit;dateFrom/dateToare 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*.
D2 — Service layer split
Section titled “D2 — Service layer split”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 → companiesplusactivities → spheres(the existing method joinsbookings → sessions → activitiesonly; the global variant must addinnerJoin(companies, eq(companies.id, activities.companyId))to projectcompany { id, name, logoUrl }, andinnerJoin(spheres, eq(spheres.id, activities.sphereId))to projectactivity.sphereCodeviaspheres.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=true→gt(sessions.startsAt, new Date()), ORDER ASC.upcoming=false→lte(sessions.endsAt, new Date()), ORDER DESC.
- Pagination: page default 1, limit default 20, capped at 50.
paymentMethodderivation viaderivePaymentMethod().
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:
- Add a new
BookingCompanyDto:id: string(@ApiProperty()).name: string(@ApiProperty()).logoUrl: string | null(@ApiProperty({ nullable: true, type: String })).
- Extend
CustomerBookingClientResponseDto:- Add required
company: BookingCompanyDto(@ApiProperty({ type: BookingCompanyDto })).
- Add required
- Extend
BookingActivityDto:- Add
sphereCode: string(@ApiProperty({ description: 'Sphere code (SPORT | EVENTS | SERVICES)' })). - Not an enum on the DTO — the DB column is
textnot enum (activities.schema.ts:80code: 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 plainstring— client maps to icon viaSPHERE_FALLBACK_ICONSmap (Mobile D4).
- Add
- Register
BookingCompanyDtoon both controllers via@ApiExtraModelsso consumer codegens (web, mobile) emitBookingCompanyDtoas a top-level named schema rather than inlining it. Without this, the existing per-company endpoint’s generated client would inlinecompanyanonymously, breaking consumer type-naming.- NEW
BookingsGlobalClientController—@ApiExtraModels(BookingSessionDto, BookingActivityDto, BookingCompanyDto, CustomerBookingClientResponseDto). - EXISTING
ActivitiesClientController(activities-client.controller.ts:47) — extend the existing decorator:BookingSessionDto, BookingActivityDto→BookingSessionDto, BookingActivityDto, BookingCompanyDto.
- NEW
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.
D4 — Mobile screen structure (gym_app)
Section titled “D4 — Mobile screen structure (gym_app)”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 aScaffoldwithTabBar(Upcoming,Past) above aTabBarView. Note:my_passes_page.dartuses 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(mirrorsmy_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 atmaxScrollExtent - 200. - Card layout (single row):
- Leading: company logo (32×32, circular, fallback to
Icons.business_outlinedwhencompany.logoUrlis 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 viapackage:intl). - Trailing: payment method badge (existing widget in the per-activity page — extract to shared if not already).
- Leading: company logo (32×32, circular, fallback to
- 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.dartexposing:class MyBookingsPageResponse— moved frommy_bookings_paginated.dart(already non-company-scoped despite living there).Future<MyBookingsPageResponse> fetchPaginatedBookings(Uri uri)— private/library-internal helper performing the Chopperclient.send<dynamic, dynamic>+jsonDecode+ manualCustomerBookingClientResponseDto.fromJsonmapping. Extras (company,activity.sphereCode) flow throughfromJsononce 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-companycompanyIdpath segment. - NEW
packages/api/lib/src/my_bookings_global_paginated.dart— 3-line URI builder composing the helper. URI:'$base/api/client/me/bookings'(nocompanyIdpath segment). Signature dropscompanyId: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.
D6 — Cross-user isolation SQL fragment
Section titled “D6 — Cross-user isolation SQL fragment”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.
3. Considered alternatives
Section titled “3. Considered alternatives”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).
4. API design (per surface)
Section titled “4. API design (per surface)”Surfaces affected: client only. Business and super-admin
contracts are not touched.
client.openapi.yaml — new endpoint
Section titled “client.openapi.yaml — new endpoint”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]client.openapi.yaml — DTO patches
Section titled “client.openapi.yaml — DTO patches”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 # addedCustomerBookingClientResponseDto (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 # addedBookingCompanyDto (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.logoUrlare all already on the public company profile (UpcomingActivityGroupDto.companymirrors the same shape per spec §2).sphereCodeis 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.
5. Data model
Section titled “5. Data model”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: uuidexists atactivities.schema.ts:243(thecodecolumn atactivities.schema.ts:80is on thespheresTABLE, notactivities). To projectsphereCode: stringper D3, the global service method MUSTinnerJoin(spheres, eq(spheres.id, activities.sphereId))and selectspheres.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:80code: 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 indexbookings (customer_id)plus existingcompany_customer (company_id, user_id)cover the predicate.
6. Backend module placement
Section titled “6. Backend module placement”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 includeBookingCompanyDto(per D3 step 4). - EDIT
libs/features/activities/src/lib/services/activities-client.service.ts— addfindMyBookingsGlobal(); extend existingfindMyBookings()’s projection withcompany { id, name, logoUrl }and (per D3)activity.sphereCodevia thespheresJOIN. - EDIT
libs/features/activities/src/lib/dto/booking-client.response.dto.ts— addBookingCompanyDto; extendCustomerBookingClientResponseDtoandBookingActivityDto. - EDIT
libs/features/activities/src/lib/activities-client.module.ts— register the new controller in thecontrollers: [...]array (no new providers; reuses the existingActivitiesClientService). - 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.yamlvianpm run sync:contractsintktspace-backend(NOT hand-edited).
No new shared utilities in libs/shared/. No changes to other feature
libs.
7. Frontend implications (per app)
Section titled “7. Frontend implications (per app)”tktspace-business — UNCHANGED
Section titled “tktspace-business — UNCHANGED”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.
tktspace-landing — UNCHANGED
Section titled “tktspace-landing — UNCHANGED”Static site; no API consumption affected.
8. Mobile implications
Section titled “8. Mobile implications”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 (CustomerBookingClientResponseDtoBookingActivityDto.sphereCode+ newBookingCompanyDto) viamelos run sync:spec && melos run generate:api. Per D5/NIT-2: new shared helperlib/src/paginated_bookings_response.dart; existinglib/src/my_bookings_paginated.dartcollapses to a 3-line URI builder; newlib/src/my_bookings_global_paginated.dartmirrors 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-bookingsGoRoute(top-level, alongside the existing drilldown), and addProfileMenuItemto_profileConfigbetweenfavoritesandsettings. - 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-bookingsis a flat path; tap-through uses the existing/my-bookings/:companyId/:activityIddrilldown. - 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 memoryfeedback_i18n_csv_output.
No new app (not adding a brand/flavor). No package dependency changes
in pubspec.yaml.
9. Risks
Section titled “9. Risks”-
R1 — Per-company DTO widening for an unrelated consumer. Adding required
company+sphereCodetoCustomerBookingClientResponseDtochanges the response shape of the existing/api/client/companies/:companyId/my-bookingsendpoint used bymy_bookings_page.dart. Mitigation: the existing consumer reads viaCustomerBookingClientResponseDto.fromJson(...)in the hand-wrapper, andjson_serializableignores extra fields silently. Verified the wrapper path (lines 58–62 ofmy_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 ofcompanyCustomers.userId = userId, a user with the same email but a differentusers.idcould 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
totalmust reflect the filtered count (upcoming-only or past-only), not the unfiltered union — otherwisehasMore = page * limit < totalmiscomputes 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 existingfindMyBookings()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
cursorfor 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
companiesINNER JOIN. Ifcompaniesever grows adeletedAtcolumn, 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 sincecompanieshas no soft-delete column.
10. Rollout plan
Section titled “10. Rollout plan”- 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 reverton the backend MR removes the endpoint and reverts the DTO widening;git reverton the mobile MR hides the profile menu entry. The extracompany/sphereCodefields 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).
11. Test plan
Section titled “11. Test plan”Backend (Drizzle e2e via Jest + pg)
Section titled “Backend (Drizzle e2e via Jest + pg)”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(earliestsessions.startsAtfirst) and DESC ordering onupcoming=false(most-recentsessions.endsAtfirst), per D2 time-window filter. - T-3 — two companies mixed,
company.namecorrect per item. - T-4 — pagination correctness (25 bookings,
page=2 limit=10). - T-5 — status filter (
CANCELLED+REFUNDEDexcluded). - T-6 — cross-user isolation (R2 mitigation).
Run via:
# In tktspace-backendpnpm 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).
Mobile (Flutter integration test)
Section titled “Mobile (Flutter integration test)”Add apps/gym_app/integration_test/my_bookings_global_test.dart:
- Mock
ApiClient(or the hand-wrapper) to return a 2-item page with distinctcompany.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:
# In tktspace-mobile-appflutter test apps/gym_app/integration_test/my_bookings_global_test.dartPlus widget tests for the empty state and the loading skeleton.
12. Files to touch (summary)
Section titled “12. Files to touch (summary)”tktspace-backend
Section titled “tktspace-backend”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.yamltktspace-mobile-app
Section titled “tktspace-mobile-app”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 NEWapps/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/_workflow
Section titled “_workflow”contracts/client.openapi.yaml REGEN (via sync:contracts, not hand-edited)adrs/cross-company-me-bookings.md THIS FILESTATUS: READY_FOR_REVIEW