ADR: Guest checkout via email + post-purchase signup nudge (X2)
ADR: Guest checkout via email + post-purchase signup nudge (X2)
Section titled “ADR: Guest checkout via email + post-purchase signup nudge (X2)”Context
Section titled “Context”The current booking surface requires a JWT — every buyer must already
have a Supabase account before they can purchase. The spec
(specs/guest-checkout-via-email-and-signup-nudge.md) lifts that
constraint for a narrow, public-by-design path: a single
@Public() endpoint that accepts { email, name?, phone?, paymentMethod, resultUrl?, extras? } and produces a confirmed (or
PENDING_PAYMENT) booking plus a PDF ticket email plus a short-lived
in-app QR token.
X1 (869dt9bxd, long-lived-signed-token-pdf-ticket-via-email)
landed the moving parts this ticket depends on:
BookingConfirmationEmailService.dispatchConfirmedBookingTicket(booking, session)(libs/features/booking-confirmation-email) — fire-and-forget producer that enqueues a slimBOOKING_TICKETjob into thenotifications-emailBullMQ queue. Recipient is resolved fromcompanyCustomers.emailfirst and then fromusers.emailviacompanyCustomers.userId(X1 D10 — “Recipient resolution”) — so acompanyCustomerwithuserId = NULLand a realemailalready works without modification.- Long-lived PDF token + worker pipeline (X1 D4 / D10) — same machinery, unchanged.
Two existing helpers cover most of the implementation cost:
BookingsService.resolveCustomerId({ customerId?, email?, phone?, name? }, companyId)(bookings.service.ts:327) — find-or-create by(companyId, phone OR email), protected by the DB unique constraintunqEmailon(company_customer.company_id, company_customer.email)(companies.schema.ts:133). This is exactly the semantics AC-3 asks for.BookingsClientService.createBooking(userId, companyId, sessionId, dto)(bookings-client.service.ts:76) — the existing authenticated booking pipeline (validation, price calc, six payment paths, BullMQ dispatch). Its first step isresolveCustomer(userId, companyId)which find-or-creates byuserId. For guests we need to swap that step for a(companyId, email)find-or-create, leave everything else intact, and gate the payment-method enum to[ON_SITE, LIQPAY].
AC-6 also notes that LiqPay’s webhook is already @Public() and
resolves bookings purely by order_id → payment.id → payment.sourceId → booking.id (payments.service.ts:163-209); no callback-side change
is required for guests.
The architecture has to walk a narrow line:
- Public, but not abusable. Anyone can POST. The endpoint must
refuse to trust any client-supplied identity beyond the email
triple
{ email, name?, phone? }.userId,customerId,clientIdare all derived server-side or set toNULL. - Same business semantics as authenticated. Same validation
errors, same response envelope (
CreateBookingResponseDto), same LiqPay/wallet/extras code paths — guest is a route adapter, not a parallel domain. - The inline
verifyTokenis new for this path. The authenticated flow returns no token in the create response — it issues one on demand atGET /me/bookings/:id/verify-token. Guests have no JWT, nome/bookingsthey can poll, and need a QR for the immediate in-app receipt screen (AC-13). So the guest response is deliberately wider than the authenticated one: it extendsCreateBookingResponseDtowith an optionalverifyToken(token + expiresAt + refreshIn) — emitted only on terminal-CONFIRMED paths (ON_SITE; LIQPAY is PENDING_PAYMENT until the webhook fires, so the in-app QR is not available yet — the user receives the PDF link by email after webhook). The guest token uses a 5-minute TTL (vs. the 30 s authenticated default) because the guest has no refresh endpoint — see D7.
Decision
Section titled “Decision”Ship X2 as a thin route adapter on top of the existing booking pipeline, with one new public controller, one new service method, one new DTO + enum, and zero schema changes. The route adapter:
- Mounts under a separate
/api/client/guest/*URL prefix to make the public boundary visually obvious in logs, traces, and the OpenAPI doc — and to let us scope a guard/throttler to the prefix later without affecting authenticated routes. - Reuses
BookingsService.resolveCustomerIdfor the find-or-create (X2 D3) andBookingsClientServicepayment branches for everything downstream (X2 D5). - Issues a 5-minute
verifyTokeninline on the response when the booking is inCONFIRMEDstatus aftercreateBookingreturns (X2 D7). The authenticated 30 s default is untouched. - Relies on
BookingConfirmationEmailService.dispatchConfirmedBookingTicket(X1) for the PDF email — no new email plumbing. - Relies on the existing LiqPay
@Public()webhook for the PENDING_PAYMENT → CONFIRMED path (X1 + LiqPay payment service — no change in either).
The numbered decisions below pin each AC to a concrete code surface.
Decisions
Section titled “Decisions”D1 — Endpoint shape and @Public() boundary
Section titled “D1 — Endpoint shape and @Public() boundary”Decision. New controller:
libs/features/activities/src/lib/controllers/bookings-guest.controller.tsMounted at the route prefix guest/ so the final URL is
POST /api/client/guest/companies/{companyId}/sessions/{sessionId}/bookings(matching AC-1 verbatim). The controller carries one route with
@Public() at the method level. No @UseGuards(ClientJwtGuard)
at the controller level — the entire controller is intentionally
guard-free.
@ApiTags('Guest Bookings')@Controller('guest/companies/:companyId') // resolves to /api/client/guest/companies/:companyIdexport class BookingsGuestController { constructor(private readonly service: BookingsGuestService) {}
@Public() @Post('sessions/:sessionId/bookings') @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'Book a session as a guest (no JWT). Email is required; payment limited to ON_SITE / LIQPAY.', operationId: 'bookingsGuestCreate', }) @ApiResponse({ status: 201, type: GuestCreateBookingResponseDto }) createGuestBooking( @Param('companyId') companyId: string, @Param('sessionId') sessionId: string, @Body() dto: CreateGuestBookingDto, ): Promise<GuestCreateBookingResponseDto> { return this.service.createGuestBooking(companyId, sessionId, dto); }}Why a separate controller (not a second method on
BookingsClientController). BookingsClientController carries a
class-level @UseGuards(ClientJwtGuard) — every method on it is
authenticated by default. Adding a single @Public() method works
in NestJS but invites accidental copy-paste regressions (forget
@Public() and the endpoint silently 401s). A dedicated controller
makes the public boundary structural, not annotation-deep.
Why the /guest/ prefix (not just a different path under
/api/client/companies/:cid/). The spec already mandates
/api/client/guest/... (AC-1). Beyond that:
- Observability. Access logs, traces, and Prometheus labels
split cleanly on
route ~= '^/api/client/guest/'for monitoring guest traffic and abuse signals. - Future throttling. We can mount a global throttler scoped to
/api/client/guest/*without scanning method-level metadata. - Operator clarity. It is unambiguous in a config or a SQL log that a row was created via the unauthenticated route.
Considered alternatives.
- Single endpoint with optional
@Public({ tryAuth: true })— rejected. Conflates the auth contract; would require runtime branching onrequest.userinside the service. The two flows share the service layer (resolveCustomerId, createBooking internals) — that is where the reuse belongs, not at the HTTP edge. - Reuse
BookingsClientControllerwith method-level@Public()— see “Why a separate controller” above. Rejected.
Grounding. AC-1.
D2 — Auth boundary: what the endpoint MUST NOT trust
Section titled “D2 — Auth boundary: what the endpoint MUST NOT trust”Decision. The endpoint MUST treat every identity-related field in the request as non-existent. The DTO is intentionally narrow (see D4) and the service derives or generates everything else server-side. Concretely:
| Field | Source | Notes |
|---|---|---|
userId / authenticated user | None. | No JWT, no @ActiveUser(). The service never reads request.user. |
customerId | None. | Not in the DTO. The DTO is the only request-side input the controller forwards. |
clientId | None. | Not in the DTO. |
email | DTO (required) | Lower-cased + trimmed before any lookup or insert (see D3). Validated by class-validator @IsEmail(). |
name, phone | DTO (optional) | Trimmed, persisted only if companyCustomer row is newly created (see D3). On an existing row, name/phone are not updated — see “Idempotency” in D3. |
companyId, sessionId | URL path | Validated by the same path-level constraints the authenticated flow uses (@Param('companyId')). The service performs the existing tenancy checks via resolveSessionPrice. |
paymentMethod | DTO (required) | Restricted via the GuestPaymentMethod enum at the DTO level. The service additionally re-validates against the session’s allowedPaymentMethods (see D5). |
resultUrl | DTO (required when paymentMethod = LIQPAY) | Identical validation to CreateClientBookingDto.resultUrl (@IsUrl()). |
extras | DTO (optional) | Same shape and validation as the authenticated path (BookingExtraItemDto[]). |
The endpoint MUST NOT:
- Read
Authorization,Cookie, or any session header to derive identity. (The route is@Public(); the globalJwtAuthGuardshort-circuits —request.userisundefined.) - Accept
customerId,userId,clientId,walletId, or any internal ID in the request body. The DTO class does not declare these fields; class-transformer’swhitelist: true(already wired globally) drops unknown fields. - Update an existing
companyCustomerrow’sname/phonefrom the request payload (see D3 — the existing row’s fields are authoritative; only a futureAuthSyncService.validateClientlink setsuserId).
Grounding. AC-1, AC-2 (validation), AC-3 (single-row-per-email).
D3 — Customer resolution and idempotency
Section titled “D3 — Customer resolution and idempotency”Decision. Promote BookingsService.resolveCustomerId(dto, companyId)
(bookings.service.ts:327) from private to public and call it from
the guest service. The visibility change is the minimum-viable refactor;
the method body is unchanged.
Visibility change is required, not optional. The method is currently declared
private async resolveCustomerId(...)(verified atbookings.service.ts:327). The guest service callsthis.bookingsService.resolveCustomerId(...)— this will not compile while the method is private. The implementation MR MUST widen the modifier topublic async. We do NOT duplicate the find-or-create lookup; the helper’s semantics ((companyId, phone OR email), BANNED check,assertCustomersLimit, insert) are precisely what AC-3 specifies and we want a single source of truth. This is part of the X2 backend refactor surface (see “Backend module placement” —resolveCustomerIdvisibility change row).
The helper’s semantics today match AC-3:
- Selects
companyCustomersrows wherecompanyId = ?AND (phone = dto.phoneORemail = dto.email), limited to 1. - If found and not
BANNED— returns the existingid. Both bookings end up attached to the samecompanyCustomer. AC-3 satisfied with zero behavioural change. - If not found — inserts a row with
name?, phone?, email?anduserId = NULL. AC-3 satisfied.
The companyCustomers table has a unique constraint
unq_email on (companyId, email) (companies.schema.ts:133).
This is the database-level safety net for the find-or-create race:
Race scenario. Two simultaneous POSTs from the same guest email arrive within milliseconds. Both
SELECTqueries return empty. Both attemptINSERT. The secondINSERTraisesunique_violation(Postgres23505).
Race-handling in the guest service. The guest service wraps
resolveCustomerId in a single retry-on-unique-violation:
async resolveGuestCustomerId(companyId: string, email: string, name?: string, phone?: string): Promise<string> { const trimmedEmail = email.trim().toLowerCase(); try { return await this.bookingsService.resolveCustomerId( { email: trimmedEmail, name, phone }, companyId, ); } catch (err) { if (!isPostgresUniqueViolation(err)) throw err; // Lost the race — re-query and return the now-existing row. const [existing] = await this.drizzle.db .select({ id: companyCustomers.id, status: companyCustomers.status }) .from(companyCustomers) .where( and( eq(companyCustomers.companyId, companyId), eq(companyCustomers.email, trimmedEmail), ), ) .limit(1); if (!existing) throw err; // Truly impossible state; re-throw. if (existing.status === 'BANNED') throw new BadRequestException('errors.booking.customer_banned'); return existing.id; }}The isPostgresUniqueViolation helper inspects err.code === '23505'
and err.constraint === 'unq_email' (or err.detail if Drizzle
doesn’t propagate constraint reliably). The helper lives next to
the new service.
Why retry once, not many times. The unique constraint
guarantees that after one failed INSERT, the row exists. A second
SELECT is sufficient to recover. No backoff loop is needed.
Why lowercase the email. Postgres text equality is
case-sensitive; “Bob@x.com” and “bob@x.com” would create two rows
without normalization. The authenticated flow doesn’t lowercase
because Supabase already does so before issuing the JWT and writing
users.email. Guests have no Supabase to normalize for them —
we do it explicitly in the guest service.
Migration note. Existing
companyCustomers.emailvalues are mixed-case (offline-created rows + Supabase-linked rows). We do NOT backfill / lowercase historical data — that’s out of scope. The guest lookup is “best-effort” against lowercased rows; if a historical row exists withemail = 'Bob@x.com'and a guest POSTs'bob@x.com', a new row will be created. AC-7 still works on the new row when the user signs up later (Supabase normalizes,AuthSyncService.validateClientmatches byusers.email==companyCustomers.email— both sides lowercase, links the row).
Grounding. AC-3, AC-7.
D4 — Request DTO + GuestPaymentMethod enum
Section titled “D4 — Request DTO + GuestPaymentMethod enum”Decision. New DTO + enum in:
libs/features/activities/src/lib/dto/create-guest-booking.dto.tsexport enum GuestPaymentMethod { ON_SITE = 'ON_SITE', LIQPAY = 'LIQPAY',}
export class CreateGuestBookingDto { @ApiProperty({ format: 'email' }) @IsEmail() @IsNotEmpty() email!: string;
@ApiPropertyOptional({ maxLength: 200 }) @IsOptional() @IsString() @MaxLength(200) name?: string;
@ApiPropertyOptional({ maxLength: 32 }) @IsOptional() @IsString() @MaxLength(32) phone?: string;
@ApiProperty({ enum: GuestPaymentMethod, enumName: 'GuestPaymentMethod' }) @IsEnum(GuestPaymentMethod) paymentMethod!: GuestPaymentMethod;
@ApiPropertyOptional({ description: 'Required for LIQPAY: URL to redirect after payment' }) @ValidateIf((o) => o.paymentMethod === GuestPaymentMethod.LIQPAY) @IsUrl() resultUrl?: string;
@ApiPropertyOptional({ type: [BookingExtraItemDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => BookingExtraItemDto) extras?: BookingExtraItemDto[];}GuestPaymentMethod is a separate enum, not a subset of
BookingPaymentMethod. TypeScript can’t express “subset of enum”
at the type level cleanly; making it a separate enum gives us:
- A first-class named schema in the OpenAPI doc (per
_workflow/CLAUDE.mdauthoring rule “every named enum carriesenumName: 'X'”). - A typed value at every call site — the mapping
GuestPaymentMethod → BookingPaymentMethodis a 1-liner in the service. 400validation errors when a client sendsWALLET,PASS,DEFER, orBONUS— class-validator rejects before any business logic runs (AC-2).
No customerEntitlementId. Pass payments are out of scope for
guests — guests have no entitlements. The DTO simply doesn’t
declare the field; whitelist-validation drops it if a client sends
it.
No extrasPaymentMethod. Pass-based mixed coverage is also
out of scope — the field is omitted from the DTO.
Why no IsNotEmpty() on name / phone. Optional fields are
optional. Empty strings get trimmed to '' and treated as absent
(see D2 normalisation).
Where the file lives. Mirrors create-client-booking.dto.ts.
The reused BookingExtraItemDto is already exported from that
same file — single import.
Grounding. AC-1, AC-2.
D5 — Service layer: where the guest logic lives + call graph
Section titled “D5 — Service layer: where the guest logic lives + call graph”Decision. New service:
libs/features/activities/src/lib/services/bookings-guest.service.ts@Injectable()export class BookingsGuestService { private readonly logger = new Logger(BookingsGuestService.name); private static readonly GUEST_TOKEN_TTL_SEC = 300; // 5 minutes — see D7
constructor( private readonly drizzle: DrizzleService, private readonly bookingsService: BookingsService, // resolveCustomerId (now public) private readonly bookingsClient: BookingsClientService, // createBookingForCustomer private readonly verifyToken: BookingVerifyTokenService, // 5-min in-app token ) {}
async createGuestBooking( companyId: string, sessionId: string, dto: CreateGuestBookingDto, ): Promise<GuestCreateBookingResponseDto> { let customerId: string; try { customerId = await this.resolveGuestCustomerId( companyId, dto.email, dto.name, dto.phone, ); } catch (err) { // Error-message enumeration hardening (D11 / CONCERN 9): on the // guest surface, collapse customer-existence-implying errors into // a single neutral code. Admin surface preserves the granular ones. if ( err instanceof BadRequestException && (err.message === 'errors.booking.customer_banned' || err.message === 'errors.booking.already_exists') ) { throw new BadRequestException('errors.booking.unavailable'); } throw err; }
// Map DTO into the authenticated DTO shape, swapping the enum. const innerDto: CreateClientBookingDto = { paymentMethod: dto.paymentMethod === GuestPaymentMethod.LIQPAY ? BookingPaymentMethod.LIQPAY : BookingPaymentMethod.ON_SITE, resultUrl: dto.resultUrl, extras: dto.extras, };
let result; try { result = await this.bookingsClient.createBookingForCustomer( customerId, companyId, sessionId, innerDto, { isNew: false, actorUserId: null }, ); } catch (err) { if ( err instanceof BadRequestException && (err.message === 'errors.booking.customer_banned' || err.message === 'errors.booking.already_exists') ) { throw new BadRequestException('errors.booking.unavailable'); } throw err; }
// Issue the 5-minute verifyToken inline when terminal-CONFIRMED (D7). const verifyToken = result.booking.status === 'CONFIRMED' ? this.issueGuestToken(result.booking.id) : undefined;
return { ...result, verifyToken }; }
private issueGuestToken(bookingId: string): BookingVerifyTokenClientResponseDto { // 5-min TTL — uses the existing `sign({ bookingId, ttlSec })` // overload (`booking-verify-token.service.ts:129`) so authenticated // flows (which call `sign({ bookingId })` with no override) still // get the 30s `DEFAULT_VERIFY_TOKEN_TTL_SEC` — UNTOUCHED. const { token, exp } = this.verifyToken.sign({ bookingId, ttlSec: BookingsGuestService.GUEST_TOKEN_TTL_SEC, }); const expiresAtMs = exp * 1000; return { token, expiresAt: new Date(expiresAtMs).toISOString(), refreshIn: Math.max(5_000, expiresAtMs - Date.now() - 5_000), }; }}Why a createBookingForCustomer(customerId, ...) variant of
BookingsClientService.createBooking(userId, ...) rather than
calling createBooking directly. The existing method’s first
step is resolveCustomer(userId, companyId) (bookings-client.service.ts:693),
which find-or-creates by userId. Guests have no userId. So we
either:
-
(Chosen) Refactor
createBookingto take a resolvedcustomerIdargument. The existing public method becomes:async createBooking(userId: string, companyId: string, sessionId: string, dto: CreateClientBookingDto) {const { customer, isNew } = await this.resolveCustomer(userId, companyId);return this.createBookingForCustomer(customer.id, companyId, sessionId, dto, { isNew, actorUserId: userId });}And the new method
createBookingForCustomer(customerId, companyId, sessionId, dto, opts?)carries the rest of the pipeline (subscription assertion, duplicate check, price calc, payment switch, extras insert, session sync, BOOKING_CREATED notification, PDF email dispatch).Critical placement constraint.
createBookingForCustomerMUST live as a method onBookingsClientServiceitself — NOT onBookingsGuestService. It is the orchestrator for the entire payment pipeline and calls private helpersresolveSessionPrice(bookings-client.service.ts:804),bookOnSite(bookings-client.service.ts:1087),bookWithLiqpay(verified to be private inbookings-client.service.ts),validatePaymentMethod,resolveExtras,bookWithPass,bookDeferred, andfireBookingNotifications— all currentlyprivate. Moving the orchestrator out of this class would force widening all of those, polluting the surface. The guest service stays a 1-method adapter: resolveGuestCustomerId → delegate → optionally issue token.opts.isNew = falsefor guests — rationale (corrected).isNewCustomeronly gates the WALLET branch atbookings-client.service.ts:747(if (method === BookingPaymentMethod.WALLET && isNewCustomer) throw...). Guests cannot select WALLET — it is blocked by theGuestPaymentMethodenum at the DTO layer (D4). PassingisNew = falseis therefore harmless: the only branch that reads the flag is unreachable on the guest path. We passfalsefor completeness and stable signatures; no behavioural effect either way. (Earlier draft incorrectly framed this as a “trial period” concern — corrected.) -
Rejected. Add an
isGuestboolean tocreateBookingand branch internally — pollutes the existing method. -
Rejected. Duplicate the createBooking pipeline in
BookingsGuestService. ~250 LoC duplicated; impossible to keep in sync with future booking changes. Also impossible without widening all the private helpers (bookOnSite,bookWithLiqpay,resolveSessionPrice, …), defeating the purpose.
Critical: fireBookingNotifications requires userId. The
existing fireBookingNotifications(userId, companyId, ...) call
(bookings-client.service.ts:199) calls
this.notifications.notify({ userId, ... }) for the customer push.
Guests have no userId — the customer push has no target. Two
options inside createBookingForCustomer:
-
(Chosen) When called with
actorUserId = null(guest path), skip the customer notification but still notify business members (OWNER / ADMIN / COACH). The business side benefits from learning about new bookings regardless of who placed them. PDF email (X1 —dispatchConfirmedBookingTicket) is the customer’s notification surface; it does not depend onuserId.Observability. When the userId-null guard fires, log a structured info-level entry so support has a debug trail for “why didn’t this guest get a push”:
// Inside fireBookingNotifications, immediately after the null guard:if (actorUserId === null) {this.logger.log({ bookingId: booking.id, customerId, companyId },'skipped customer push (guest booking)',);// … continue to business-side notifications.}The signature becomes:
// In the refactor:// - `createBooking(userId, ...)` calls `createBookingForCustomer(customer.id, ..., { actorUserId: userId })`.// - Guest service calls `createBookingForCustomer(customerId, ..., { actorUserId: null })`.// - `fireBookingNotifications(actorUserId | null, ...)` skips the customer push when null AND logs the skip. -
Rejected. Try to look up
userProfilesby email —userIdis exactly what we don’t have for guests.
PDF email failure recovery — future ticket. If the BullMQ
BOOKING_TICKET job fails after all retries, the guest has no
in-app surface to re-trigger the dispatch (no JWT → no
/me/bookings/:id/verify-token). The recovery path is a backend-
only admin endpoint:
POST /api/business/bookings/:id/resend-confirmation-emailAuth: BusinessJwtGuard + role-restricted to OWNER/ADMIN of the
booking’s company. Calls
BookingConfirmationEmailService.dispatchConfirmedBookingTicket(booking, session)
re-using the existing X1 producer. This is not implemented in
X2 — it’s a tracked follow-up ticket. Linked from Risks below.
Why BookingsGuestService lives in libs/features/activities,
not libs/features/guest. The service is a thin orchestrator
that calls into the existing activities feature’s BookingsService
and BookingsClientService. Creating a sibling libs/features/guest
forces a circular dependency (guest → activities, and any future
guest-aware code in activities would need to import guest).
Co-locating with the rest of the booking domain keeps the dependency
graph one-directional and matches the surface pattern
(“<feature>-client/<feature>-admin/…” controllers in the same
lib).
Module wiring — fold into ActivitiesClientModule (no new module
file). The guest endpoint is part of the /api/client/* surface,
exactly like every other route declared in
activities-client.module.ts. Adding a sibling
activities-guest.module.ts would split one logical surface across
two NestJS modules with identical imports arrays — duplication
without isolation benefit (the controller carries @Public() at the
method level; that is the structural boundary, not the module).
Concretely, edit
libs/features/activities/src/lib/activities-client.module.ts:
@Module({ imports: [ DataAccessDbModule, ActivitiesModule, // already provides BookingsService WalletModule, PassesModule, PaymentsModule, NotificationsFeatureModule, BookingVerifyTokenModule.forRoot({ signingSecret: process.env['BOOKING_VERIFY_SIGNING_SECRET'] ?? '', }), BookingConfirmationEmailModule, ], controllers: [ ActivitiesClientController, ActivitiesGlobalClientController, BookingsClientController, BookingsGuestController, // NEW (X2) BookingsGlobalClientController, CategoriesClientController, FavoritesClientController, ContributorReviewsClientController, ContributorRatingsPublicController, ReviewsEligibilityClientController, ContributorsClientController, ], providers: [ SubscriptionService, ActivitiesClientService, BookingsClientService, BookingsGuestService, // NEW (X2) FavoritesClientService, ContributorReviewsService, ContributorsListService, ], exports: [ BookingsClientService, // NEW (X2): see "BookingsClientService export" below ],})export class ActivitiesClientModule {}BookingsClientService export — confirmed REQUIRED, not “verify later”. Verified at
libs/features/activities/src/lib/activities-client.module.ts:60-67:BookingsClientServiceis currently declared inproviders:but the module has noexports:array at all (verified — read of the entire file). The X2 MR MUST add it.BookingsGuestServicelives inside the same module, so it can injectBookingsClientServicedirectly (no export needed for intra-module DI). However we still add theexportsbecause future cross-module consumers (e.g. a hypotheticalBookingsAdminAssistedService) will need it, and exporting a provider that’s already used intra-module is zero-cost.BookingsServiceis provided byActivitiesModule(imported above) — its export isActivitiesModule’s concern (verify in Phase B; if it isn’t exported there either, add it).
ClientApiModule (apps/api/src/app/modules/client-api/client-api.module.ts)
already imports ActivitiesClientModule, so the new
BookingsGuestController is mounted automatically under
/api/client/*. No edit to ClientApiModule is required.
Backend kill-switch (SUGGESTION 11). Add a
GUEST_CHECKOUT_ENABLED env var (default 'true'). When set to
'false', the controller short-circuits with 404 so the endpoint
appears non-existent (do NOT throw 503; we don’t want to advertise
the feature’s existence in an off state):
@Public()@Post('sessions/:sessionId/bookings')@HttpCode(HttpStatus.CREATED)@Throttle({ default: { limit: 10, ttl: 60_000 } }) // see D8createGuestBooking(@Param(...) ...): Promise<GuestCreateBookingResponseDto> { if (process.env['GUEST_CHECKOUT_ENABLED'] === 'false') { throw new NotFoundException(); } return this.service.createGuestBooking(companyId, sessionId, dto);}Document the env var in tktspace-backend/.env.example.
Grounding. AC-1, AC-2, AC-3, AC-4, AC-5, AC-6.
D6 — LiqPay round-trip: zero callback-side changes
Section titled “D6 — LiqPay round-trip: zero callback-side changes”Decision. No code change in
libs/features/payments/src/lib/services/payments.service.ts or the
payments controller. The webhook flow already works for guest
bookings end-to-end.
Call trace (verified against existing code).
- Client (mobile or web) POSTs
POST /api/client/guest/companies/{cid}/sessions/{sid}/bookingswithpaymentMethod: LIQPAY+resultUrl. BookingsGuestController.createGuestBooking→BookingsGuestService.createGuestBooking→BookingsClientService.createBookingForCustomer(customerId, ...)→bookWithLiqpay(sessionId, customerId, companyId, totalPrice, session, resultUrl)(bookings-client.service.ts).bookWithLiqpay(existing code) creates abookingsrow withstatus: 'PENDING_PAYMENT'and apaymentsrow withsourceType: 'BOOKING'+sourceId: <bookingId>. Returns{ booking, payment: { data, signature, paymentUrl } }.- Mobile/web redirects user to LiqPay using
payment.data+payment.signature(orpaymentUrlfor hosted flow). - LiqPay POSTs
POST /api/client/payments/webhook→PaymentsClientController.processWebhook(payments-client.controller.ts:26-32). Already decorated@Public()— no JWT required. PaymentsService.processWebhook(payments.service.ts:163-209) verifies LiqPay signature, looks uppaymentsbyorder_id, marks the payment row paid, then finds the booking viapayment.sourceIdand updatesbookings.status → CONFIRMED.- Same code path calls
dispatchConfirmedBookingTicket(booking, session)(X1 — already wired into the LiqPay webhook per X1 D7’s five-call-site list). Recipient is resolved fromcompanyCustomers.email(the guest’s email). PDF email lands.
What this means for X2.
- No changes to the webhook controller.
- No changes to
PaymentsService.processWebhook. - No changes to LiqPay signing or signature verification.
- X1’s PDF dispatch hook already runs on the webhook path.
The only X2 concern in the LiqPay flow. The guest’s
GuestCreateBookingResponseDto.booking.status is 'PENDING_PAYMENT',
not 'CONFIRMED', when the LiqPay flow is selected. The
verifyToken is NOT issued in the LIQPAY response (see D7) —
the guest has no confirmed booking to verify yet. The customer
sees the PDF link in their email once LiqPay confirms (5–60 s
typical latency). AC-13 (“in-app QR view”) therefore only applies
to ON_SITE (terminal-CONFIRMED at create); for LIQPAY guests, the
post-purchase flow leads to the email-only state.
Grounding. AC-6, AC-13.
D7 — 5-minute verifyToken on the guest response: ON_SITE only
Section titled “D7 — 5-minute verifyToken on the guest response: ON_SITE only”Decision. Emit verifyToken in the response only when
result.booking.status === 'CONFIRMED'. Concretely: ON_SITE
returns it; LIQPAY does not. The guest token TTL is 5 minutes
(300 s), not the 30-second default used by authenticated callers.
Why 5 min for guests (spec amendment). The 30 s
DEFAULT_VERIFY_TOKEN_TTL_SEC is tuned for the authenticated
in-app QR loop, which re-fetches via GET /me/bookings/:id/verify-token
every ~25 s (the refreshIn value the existing endpoint returns).
Guests cannot call /me/* — no JWT. The post-purchase nudge
modal + “Continue without account” + “Guest ticket view”
state-machine transition (D9) can plausibly take 30+ seconds
(modal-read time, dismissal animation, navigation). A 30-second
token would expire mid-flow on every slow user, producing a QR
that 401s before the guest ever shows it. The 5-minute window:
- Gives the guest enough time to traverse the entire post-purchase flow and present the QR at the venue counter.
- Stays short enough that the token remains a “now” credential, not a durable proof — the PDF email is the durable surface (X1).
- Stays well under the long-lived PDF TTL (
session.endsAt + 30 min, typically hours), so the two tokens don’t collide in meaning. - Reuses the existing
sign({ bookingId, ttlSec })overload — no new method, no API surface change toBookingVerifyTokenService. The authenticated flow’ssign({ bookingId })(no override) is untouched and keeps the 30 s default.
Spec amendment. This widens the spec’s “Token TTL Clarification” table (which previously listed only “30 seconds”) to distinguish guest (5 min) from authenticated (30 s). The spec’s frontmatter
last-updatedis bumped to reflect the amendment. This is within the spec’s stated scope — the spec explicitly framed the verifyToken TTL as a tunable parameter, not a hard contract.
Why not always. Two reasons:
- Eligibility. The token is a short-lived
present-yourself-at-the-venue artefact. For a PENDING_PAYMENT
booking, the customer hasn’t paid yet — no scanner should
accept the token. The token wouldn’t validate at the scanner
anyway (the verify service requires the booking to be in
CONFIRMEDstatus), so emitting it is misleading. - UX surface area. AC-13 (“guest ticket view showing the QR”)
is gated client-side on
verifyTokenbeing present in the response (mobile + web — see D9). LIQPAY clients should route to a “payment in progress” screen instead.
Why a separate response shape (GuestCreateBookingResponseDto)
rather than extending CreateBookingResponseDto. Two surfaces
share the booking-create envelope: the authenticated path and the
guest path. They have different field visibility by design:
- Authenticated:
booking, insufficientBalance?, required?, available?, payment? - Guest:
booking, payment?, verifyToken?
insufficientBalance / required / available are wallet-method
exclusives. Guests can’t pay via wallet, so those fields are
permanently absent from the guest response. Folding them all into
one DTO would surface meaningless fields on a public-API contract.
The guest DTO inherits booking and payment (LiqPay data)
from the authenticated shape — same JSON structure, same field
semantics. The only delta is the optional verifyToken block.
export class GuestCreateBookingResponseDto { @ApiProperty({ type: BookingRecordDto }) booking!: BookingRecordDto;
@ApiPropertyOptional({ type: LiqpayPaymentDto, description: 'LiqPay checkout data (LIQPAY method only)' }) payment?: LiqpayPaymentDto;
@ApiPropertyOptional({ type: BookingVerifyTokenClientResponseDto, description: '5-minute verify token for in-app guest QR rendering. Only emitted on terminal-CONFIRMED ' + 'bookings (ON_SITE). For LIQPAY the guest sees the PDF link in their email after ' + 'webhook-confirmation; no in-app QR is available pre-payment.', }) verifyToken?: BookingVerifyTokenClientResponseDto;}Reuses BookingVerifyTokenClientResponseDto (the same shape
GET /me/bookings/:id/verify-token returns — token + expiresAt +
refreshIn). Single source of truth for verify-token semantics
across the client surface.
Grounding. AC-4, AC-13.
D8 — Rate limiting (HARD REQUIREMENT) + abuse posture
Section titled “D8 — Rate limiting (HARD REQUIREMENT) + abuse posture”Decision. Ship per-IP rate-limiting in Phase B as part of this ticket. Non-negotiable. Per-company subscription quotas do NOT protect against the obvious email-rotation attack from a single IP — the attacker burns legitimate companies’ customer caps and trivially griefs ON_SITE schedules (no payment required for ON_SITE to reach CONFIRMED).
Mandatory mitigation — applied at the controller method level in Phase B:
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
@Controller('guest/companies/:companyId')export class BookingsGuestController { @Public() @UseGuards(ThrottlerGuard) @Throttle({ default: { limit: 10, ttl: 60_000 } }) // 10 req / 60s / IP @Post('sessions/:sessionId/bookings') @HttpCode(HttpStatus.CREATED) @ApiOperation({ ..., operationId: 'bookingsGuestCreate' }) @ApiResponse({ status: 201, type: GuestCreateBookingResponseDto }) @ApiResponse({ status: 429, description: 'Rate limit exceeded (10 req / 60s / IP)' }) createGuestBooking(...) { ... }}Dependency / wiring status — already in place:
@nestjs/throttler@^6.5.0is already intktspace-backend/package.json(verified).ThrottlerModule.forRoot([{ ttl: 900_000, limit: 10 }])is already wired globally inapps/api/src/app/app.module.ts:94(verified). Method-level@Throttle({...})overrides the global config for this route.- No install, no module changes required.
Why 10 req / 60 s / IP.
- A legitimate guest checkout traverses the endpoint once (occasionally twice on retries). 10 requests / minute is 50× the legitimate throughput — no legitimate user hits this ceiling.
- Caps the per-IP customer-row creation rate at 600/hour. A scripted
attacker would need a botnet to scale meaningfully — at which point
the existing per-company
assertCustomersLimitenforces the per-tenant ceiling, and the operations team has actionable signals (route ~= '^/api/client/guest/'+ IP-based anomaly detection). - Matches the pattern already established by
ScannerLoginThrottlerGuard(libs/features/auth/src/lib/scanner-auth/scanner-login-throttler.guard.ts:16) — same@nestjs/throttlerimport, same shape, drop-in style.
Why the off-the-shelf ThrottlerGuard rather than a custom
subclass. The scanner-login case uses a custom subclass to key on
${ip}|${email} because scanner-login is a brute-force credential
attack — you want to throttle per-(IP × identity) tuple. Guest
checkout is the opposite problem: an attacker rotates emails on
purpose, so adding email to the key DEFEATS the throttle. Plain
IP-keying is the right policy.
Additional in-Phase-B abuse defences (already covered elsewhere in this ADR).
@IsEmail()validation — rejects malformed emails (D4).email.trim().toLowerCase()— prevents trivial duplication (D3).- Error-message neutralisation —
customer_bannedandalready_existsare collapsed intoerrors.booking.unavailableon the guest surface to prevent email enumeration (D5; CONCERN 9). - Subscription limits already enforced.
assertCustomersLimit(called byresolveCustomerId) +assertBookingsLimit(called byBookingsClientService) remain in the call graph and provide per-tenant ceilings. GUEST_CHECKOUT_ENABLEDkill-switch (SUGGESTION 11) — if abuse spikes, ops flip the env var tofalseand the controller throws 404 without a deploy.
Deferred (NOT shipped in X2):
- Throwaway-email blacklists. Moving target, high false-positive
rate on
+suffixemails. Skipped. - Per-IP × per-
companyIdshaping. Useful if attackers target one company at a time; the basic IP-only limit is sufficient for v1. Add later as a custom subclass if attack patterns warrant it. - Distributed-attacker / ON_SITE seat-flooding mitigation (CONCERN 7).
See Risks. Future improvement: when
paymentMethod = ON_SITE, create the booking withstatus = PENDING_PAYMENTand a 5-minute reservation hold; promote toCONFIRMEDon first scan; auto-release on timeout. Not in scope for X2.
Grounding. AC-1 (must accept any unauthenticated request) +
implicit security posture for @Public() endpoints.
D9 — Frontend state machine (mobile + web)
Section titled “D9 — Frontend state machine (mobile + web)”Decision. Three discrete UI states keyed on auth + checkout
outcome. The state machine is identical in gym_app and tktspace-web;
the implementation differs (Flutter widgets vs. Angular components),
the state graph does not.
┌─ user is signed in ─────► Existing authenticated flow (unchanged). │[Checkout summary step] ────┤ │ ┌─ allowedPaymentMethods ∩ {ON_SITE,LIQPAY} = ∅ │ │ → "Sign in to book" CTA (AC-10) → opens auth flow └─ user is NOT signed in ──┤ └─ allowedPaymentMethods ∩ {ON_SITE,LIQPAY} ≠ ∅ → Render guest fields: email (required), name?, phone? → Payment-method selector restricted to the intersection (AC-9) → "Book" CTA → POST guest endpoint │ ├─ paymentMethod = ON_SITE │ → 201 with verifyToken → state: "guest CONFIRMED" └─ paymentMethod = LIQPAY → 201 with payment.data/signature → redirect to LiqPay → on return from LiqPay → state: "guest LIQPAY pending"
[Guest CONFIRMED state] → Post-purchase modal (AC-11): - Primary: "Create account with this email" → AC-12 path - Secondary: "Continue without account" → AC-13 path
[AC-12 path: "Create account"] gym_app: open Supabase signup flow with email pre-filled on success → AuthSyncService runs server-side → navigate to "My Bookings" web: navigate to /auth/signup?email=<guestEmail>&returnUrl=/my-bookings SignupPage reads ?email and pre-fills the email FormControl on signup success → existing /my-bookings page shows the linked booking
[AC-13 path: "Continue without account"] navigate to guest-ticket view showing: - booking summary (activity title, session date/time, price) - QR rendered from verifyToken.token (single-issue 5-min token — no refresh polling; guests have no /me/* refresh endpoint) - "Register to save your tickets" banner
[Guest LIQPAY pending state] (no in-app QR — the verifyToken is not in the response by D7) Show "Payment in progress" screen. On return from LiqPay redirect (success or cancel): - on success → "Check your email" screen (PDF arrives via X1 webhook hook within ~15s) - on failure → re-promptWhen does “guest mode” UI activate. The mobile and web checkout pages already differentiate signed-in vs. not. When not signed-in, the existing flow today blocks at “Sign in to book”. The new flow unblocks this:
- Mobile (
gym_app/lib/.../checkout— exact path TBD by mobile-dev in Phase B). Inject the guest summary widget when not signed in AND the activity’sallowedPaymentMethodsintersects{ON_SITE, LIQPAY}. - Web (
src/app/pages/.../checkout— TBD by web-dev in Phase B). Mirror the same condition. ExistingSignupPageextended (AC-17) to read?emailfromActivatedRoute.queryParamMap.
State persistence across LiqPay redirect. Mobile uses deep-link
return; web uses the resultUrl query param. Both pass back enough
context (bookingId query param on the resultUrl) to re-display the
“check your email” screen.
packages/api regeneration. After the contract patch lands and
sync:contracts is run, mobile runs melos run generate:api. The
new bookingsGuestCreate operation appears on the generated
ApiClient. No hand-written HTTP needed.
packages/ui additions. Two new shared widgets:
GuestCheckoutSummary— email/name/phone form (UI-only).PostPurchaseSignupNudge— modal with two CTAs (UI-only).
Both are pure presentation; state lives in the calling page.
tickets_app. Explicitly out of scope per the spec — only
gym_app ships guest checkout in this ticket.
Grounding. AC-8 through AC-13 (mobile), AC-14 through AC-18 (web).
D10 — Token TTLs (recap from spec — amended)
Section titled “D10 — Token TTLs (recap from spec — amended)”| Token | TTL | Where it lives | Used for |
|---|---|---|---|
| PDF token (long-lived, X1) | session.endsAt + 30 min grace (or session.startsAt + 240 min when endsAt is null) | Email link inside the PDF | Customer’s durable proof; scanners verify it for hours after the session |
verifyToken — authenticated | 30 s — BookingVerifyTokenService.DEFAULT_VERIFY_TOKEN_TTL_SEC (UNCHANGED) | Returned by GET /me/bookings/:id/verify-token | In-app QR refresh on the authenticated booking-detail screen |
verifyToken — guest (this ADR D7) | 5 min (300 s) — passed explicitly via sign({ bookingId, ttlSec: 300 }) | Inline in the guest response (CONFIRMED only) | In-app QR on the guest-ticket view (AC-13). Single-issue token; no refresh endpoint on the public surface |
Same HMAC key, same payload shape { bid, iat, exp }. The scanner
treats all three identically — TTL ceiling is not enforced
server-side (X1 D8). They differ only by their exp claim.
Spec amendment. Spec frontmatter last-updated bumped, and the
“Token TTL Clarification” table in the spec gets a new row for the
guest 5-min token. The 30-second authenticated default is
UNTOUCHED.
Grounding. AC-4, “Token TTL Clarification” in the spec.
D11 — Error-code neutralisation on the public surface
Section titled “D11 — Error-code neutralisation on the public surface”Decision. On the @Public() guest endpoint, collapse two
emit-conditional error codes that today reveal information about
the companyCustomer row’s prior existence:
| Original (admin / authenticated surface — UNCHANGED) | Guest surface (NEW) |
|---|---|
400 errors.booking.customer_banned | 400 errors.booking.unavailable |
400 errors.booking.already_exists | 400 errors.booking.unavailable |
404 errors.session.not_found | 404 errors.session.not_found (UNCHANGED — sessionId is in the URL path, attacker already knows it; not enumerable) |
403 errors.subscription.* | 403 errors.subscription.* (UNCHANGED — leaks tenant state, not user state) |
| Validation errors | Validation errors (UNCHANGED — caller’s input, not user state) |
Why collapse only those two. An attacker can probe
(companyId, email) pairs and use the differentiation between
customer_banned (email exists for this company AND is banned)
and already_exists (email exists for this company AND has a
booking for this session) and the absence of either (no prior
relationship) to enumerate company-customer relationships. The
guest surface MUST NOT reveal this. The collapsed
errors.booking.unavailable reads as a neutral “this combination
won’t work, try a different one”.
Why keep session_not_found and subscription.*. Neither
discloses anything about the guest’s identity — sessionId is in
the URL the attacker just typed, and subscription state is about
the company, not about whether the guest’s email is known.
Implementation. Two narrow catch-and-rethrow blocks in
BookingsGuestService.createGuestBooking (see D5 code snippet).
The admin / authenticated surface paths in BookingsClientService
and BookingsService are NOT modified — they keep granular codes.
OpenAPI. The guest endpoint’s 400 response example in
contracts/client.openapi.yaml is updated to reference
errors.booking.unavailable (see “Contract patches” below).
Grounding. Implicit AC-1 hardening; no AC explicitly mandates this, but the public boundary requires it.
API design (per surface)
Section titled “API design (per surface)”/api/client (client surface — the only affected surface)
Section titled “/api/client (client surface — the only affected surface)”New endpoint.
POST /api/client/guest/companies/{companyId}/sessions/{sessionId}/bookings- Auth: None —
@Public(). - operationId:
bookingsGuestCreate - Request body (
CreateGuestBookingDto):email(string, required, email) — guest email; lowercased server-side.name(string, optional, ≤200).phone(string, optional, ≤32).paymentMethod(enumGuestPaymentMethod: ON_SITE | LIQPAY, required).resultUrl(string URL, required iffpaymentMethod = LIQPAY).extras(array ofBookingExtraItemDto, optional).
- Response:
201 GuestCreateBookingResponseDto.booking(BookingRecordDto, required).payment(LiqpayPaymentDto, optional — LIQPAY only).verifyToken(BookingVerifyTokenClientResponseDto, optional — ON_SITE only).
- Validation errors (400):
- Invalid email →
errors.validation.email. paymentMethodoutside enum →errors.validation.paymentMethod.resultUrlmissing when LIQPAY →errors.validation.resultUrl.- Activity rejects the chosen method →
errors.booking.payment_method_not_allowed(same as authenticated path).
- Invalid email →
- Business errors (guest surface — neutralised per D11):
400 errors.booking.unavailable— collapses two underlying conditions (customer_bannedORalready_exists) into a single neutral code to prevent enumeration of(companyId, email)relationships. The admin surface keeps the granular codes.404 errors.session.not_found—sessionIddoes not belong tocompanyId. Not enumerable (sessionId is in the path).403 errors.subscription.bookings_limit/errors.subscription.customers_limit— company at its subscription cap. Leaks tenant state, not user state — fine to expose.429 Too Many Requests— IP throttle (D8): 10 req / 60s / IP.
Surface impact
Section titled “Surface impact”| Contract | Touched? | Why |
|---|---|---|
contracts/client.openapi.yaml | Yes | New bookingsGuestCreate endpoint, CreateGuestBookingDto, GuestPaymentMethod enum, GuestCreateBookingResponseDto. |
contracts/business.openapi.yaml | No | Guest bookings appear in the existing booking list — no new admin surface or DTO. The existing BookingRecordDto on the business contract already exposes customerId and joins to companyCustomer; the guest case is data-only (“rows with userId IS NULL”), not schema-level. |
contracts/super-admin.openapi.yaml | No | Not affected. |
Field-level differences vs. authenticated booking-create.
| Field | Authenticated (CreateBookingResponseDto) | Guest (GuestCreateBookingResponseDto) | Why |
|---|---|---|---|
booking | required | required | Same; primary artefact. |
payment | optional (LIQPAY only) | optional (LIQPAY only) | Same; identical LiqPay payload. |
insufficientBalance / required / available | optional (WALLET only) | absent | Guests can’t use WALLET — these fields are meaningless. |
verifyToken | absent | optional (ON_SITE only) — 5-min TTL | Authenticated users issue their own 30s token via GET /me/bookings/:id/verify-token; guests need it inline (with extended TTL — D7) because they have no JWT for the me/* route. |
Data model
Section titled “Data model”No schema changes.
companyCustomerstable already hasemail TEXT(nullable),userId UUID(nullable, FK tousers.id), andUNIQUE(companyId, email)(companies.schema.ts:122, 117, 133).bookings.customerIdalready accepts anycompanyCustomers.id, no type discrimination by guest vs. authenticated.- Tokens are stateless HMAC — no persistence.
- No new tables, no new columns, no new indexes.
drafts/migration-guest-checkout-via-email-and-signup-nudge.sql is
a no-op stub documenting this — see “Migration outline” below.
Backend module placement
Section titled “Backend module placement”| Concern | Lib / file |
|---|---|
| Guest controller | libs/features/activities/src/lib/controllers/bookings-guest.controller.ts (NEW) — mounted at /api/client/guest/companies/:companyId. Carries @Public() + @UseGuards(ThrottlerGuard) + @Throttle({ default: { limit: 10, ttl: 60_000 } }) per D8. |
| Guest service | libs/features/activities/src/lib/services/bookings-guest.service.ts (NEW) — orchestrates resolveGuestCustomerId → delegate to BookingsClientService.createBookingForCustomer → 5-min token issuance. Includes the D11 error-collapsing wrapper. |
| Module wiring | libs/features/activities/src/lib/activities-client.module.ts (EDIT) — register BookingsGuestController in controllers:, BookingsGuestService in providers:. No new module file (SUGGESTION 12 applied). |
ClientApiModule import | No change. ClientApiModule already imports ActivitiesClientModule. |
BLOCKER 1 — resolveCustomerId visibility | libs/features/activities/src/lib/services/bookings.service.ts:327 — change private async resolveCustomerId to public async resolveCustomerId. Hard requirement; the guest service won’t compile otherwise. No body change. |
BLOCKER 1 — createBookingForCustomer extraction | libs/features/activities/src/lib/services/bookings-client.service.ts — on BookingsClientService itself (NOT on BookingsGuestService), extract a new public method createBookingForCustomer(customerId: string, companyId: string, sessionId: string, dto: CreateClientBookingDto, opts: { isNew: boolean; actorUserId: string | null }). The existing public createBooking(userId, ...) becomes a thin wrapper that calls resolveCustomer(userId, ...) then delegates. Required because the orchestrator calls private helpers resolveSessionPrice (:804), bookOnSite (:1087), bookWithLiqpay, bookWithPass, bookDeferred, validatePaymentMethod, resolveExtras, fireBookingNotifications — moving the orchestrator out of this class would force widening all of them. No behaviour change for authenticated callers. |
Refactor on fireBookingNotifications | libs/features/activities/src/lib/services/bookings-client.service.ts:199 — accept actorUserId: string | null; when null, log logger.log({ bookingId, customerId, companyId }, 'skipped customer push (guest booking)') and skip the customer push. Business pushes still fire. |
BLOCKER 2 — ActivitiesClientModule exports | libs/features/activities/src/lib/activities-client.module.ts — CONFIRMED REQUIRED (verified at :60-67 — no exports: array exists today). Add exports: [BookingsClientService]. BookingsGuestService is intra-module so it doesn’t strictly need it, but exporting now lets future cross-module consumers (e.g. an admin “assisted booking” feature) inject without another module edit. |
| Guest DTO + enum | libs/features/activities/src/lib/dto/create-guest-booking.dto.ts (NEW) — CreateGuestBookingDto, GuestPaymentMethod. |
| Guest response DTO | libs/features/activities/src/lib/dto/guest-booking.response.dto.ts (NEW) — GuestCreateBookingResponseDto. (Reuses BookingRecordDto, LiqpayPaymentDto, BookingVerifyTokenClientResponseDto.) |
| Postgres unique-violation helper | libs/features/activities/src/lib/services/bookings-guest.service.ts — local helper isPostgresUniqueViolation(err): err is PgError. (Postgres error code '23505'.) |
| Rate-limiter wiring | No backend module changes. @nestjs/throttler@^6.5.0 is already a dep (verified in tktspace-backend/package.json:36) and ThrottlerModule.forRoot([{ ttl: 900_000, limit: 10 }]) is already globally wired (apps/api/src/app/app.module.ts:94). The controller-method-level @Throttle({...}) overrides global config for the guest route. |
| Env var | tktspace-backend/.env.example — add GUEST_CHECKOUT_ENABLED=true (kill-switch — SUGGESTION 11). |
| i18n strings | Mobile + web only (see Frontend implications). Backend introduces one new error code: errors.booking.unavailable (per D11). |
| Tests | libs/features/activities/src/lib/services/__tests__/bookings-guest.service.spec.ts (unit — including D11 error neutralisation, D8 throttle behaviour) + apps/api/src/test/guest-booking.e2e.spec.ts (integration — ON_SITE happy path, LIQPAY round-trip via stub, race-on-unique-violation, 429 enforcement, GUEST_CHECKOUT_ENABLED=false returns 404). |
No cycle. All new files live in libs/features/activities,
which already depends on the same libs the existing booking
pipeline uses. libs/features/activities already depends on
libs/features/booking-confirmation-email (X1). No new
inter-lib edges.
Frontend implications
Section titled “Frontend implications”tktspace-business — no changes
Section titled “tktspace-business — no changes”Guest bookings appear in the existing booking list with customerId
joining to a companyCustomer row whose userId IS NULL. The
business UI already renders that row’s name / email /
phone — guests look identical to offline-created customers
(which is what they are, from the schema’s perspective).
tktspace-web — checkout page + signup pre-fill
Section titled “tktspace-web — checkout page + signup pre-fill”src/app/pages/.../checkout(exact page TBD by web-dev) — inject<app-guest-checkout-summary>component when not authenticated and the activity’sallowedPaymentMethodsintersect{ON_SITE, LIQPAY}. Filter the payment-method selector to the intersection (AC-15).src/app/pages/.../checkout— render the<app-post-purchase-signup-nudge>modal after a 201 response on the guest endpoint (AC-16). Primary CTA navigates to/auth/signup?email=<guestEmail>&returnUrl=/my-bookings(AC-17).src/app/pages/auth/signup/signup.page.ts— reademailquery param viaActivatedRoute.queryParamMapand callthis.form.controls.email.setValue(param)inngOnInit. ExistingreturnUrlbehavior is unchanged.src/app/pages/.../guest-ticket(NEW) — guest ticket view showing booking summary, QR rendered fromverifyToken.token(single-issue 5-min token — no refresh polling; guests cannot hit/me/bookings/:id/verify-tokenwithout a JWT), and the “Register to save your tickets” banner (AC-18). When the token expires, show a “Token expired — open your email for the PDF ticket” empty-state.- API client regeneration. Run
npm run generateafter the contract lands. The newbookingsGuestCreatefn-function appears incore/api/. Call sites useapi.invoke(bookingsGuestCreate, {...}).
tktspace-landing — no changes
Section titled “tktspace-landing — no changes”Static marketing copy. Does not call /api/client/*.
Mobile implications
Section titled “Mobile implications”apps/gym_app — affected
Section titled “apps/gym_app — affected”- State machine. D9 above. Three new pages / states:
GuestCheckoutSummaryPage(or a state inside the existing checkout) —email+name?+phone?form (AC-8).PostPurchaseSignupNudgemodal (AC-11).GuestTicketPage— booking summary + 5-min QR + register banner (AC-13). No refresh polling (single-issue token).
- Auth interaction. “Create account” CTA opens the existing
Supabase signup flow with the email field pre-filled. On signup
success,
AuthSyncService.validateClient(backend) links the guest’scompanyCustomer.userIdautomatically — the app does NOT need to call a “claim” endpoint. After signup, navigate toMyBookingsPageand the linked booking is visible. - Offline considerations. Guest checkout fundamentally requires network (need the LiqPay redirect, the inline QR token, the PDF email dispatch). No offline support.
- Deep-linking. LiqPay return URL — gym_app uses universal
links; the LiqPay
result_urlpoints to the existingincust://booking/:iddeep link. Implementation maps thebookingIdquery param onto the existing booking-detail route.
apps/tickets_app — explicitly out of scope
Section titled “apps/tickets_app — explicitly out of scope”Per spec; no changes.
Shared packages
Section titled “Shared packages”packages/api— regenerated bymelos run sync:spec && melos run generate:api. New methods onApiClient:createGuestBooking(...)(Chopper-generated frombookingsGuestCreate). No hand-written HTTP.packages/ui— two new widgets:GuestCheckoutSummary— Material form widget (TextFormFieldfor email/name/phone, plus a payment-methodWrapof chips).PostPurchaseSignupNudge—AlertDialogorModalwith two actions. Both purely presentational; state lives in the calling page.
packages/auth— no changes. Supabase signup already accepts pre-filled email via constructor argument or?initialEmail=...on the existing signup widget (mobile-dev to verify in Phase B).packages/checkout— minor extension to expose a “isGuestMode” branch in the checkout state machine and route to the new API method. No backend coupling beyond the regenerated client.
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Race on simultaneous guest POSTs with same email → unique violation | Medium | One of the two POSTs would fail with an unhandled 500 if not caught | D3 retry-on-23505 helper. Tested by the integration suite via two concurrent calls. |
High-volume guest-spam attack (email rotation from single IP) inflates companyCustomers rows for a target company | High (without mitigation) → Low (with D8) | DB row growth; subscription-cap exhaustion on legitimate companies; trivial DoS | Shipped in Phase B: @Throttle({ default: { limit: 10, ttl: 60_000 } }) per IP on the controller method (D8, BLOCKER 3). Plus assertCustomersLimit per-tenant ceiling. Plus GUEST_CHECKOUT_ENABLED=false kill-switch. |
ON_SITE seat-flooding (CONCERN 7) — distributed attacker rotates IPs, makes paymentMethod=ON_SITE bookings to fill venue capacity for free | Medium (without further mitigation) | Legitimate customers blocked at full sessions; staff has to manually triage / cancel | D8’s 10/min/IP limit defeats trivial single-attacker case. Future improvement (NOT shipped in X2, tracked): when paymentMethod = ON_SITE, create booking as PENDING_PAYMENT with a 5-min reservation hold; promote to CONFIRMED on first scan or expire-and-release on timeout. |
| Throwaway emails (10minutemail, etc.) used to bypass cancellation friction | Medium | Customers can’t be reliably reached for follow-up | Out of scope per D8. If product wants disposable-email blocking later, add a class-validator rule at the DTO level. |
| Existing rows with mixed-case email don’t match guest’s lowercased lookup | Low | Duplicate companyCustomer rows; AC-7 link still works at signup time (Supabase + AuthSyncService both lowercase via email == match) | Documented in D3. No backfill in this ticket. |
fireBookingNotifications blindly accesses userId | High if missed | Crash on every guest booking | D5 mandates the refactor to accept actorUserId: string | null. When null, structured-log the skip and continue. Covered by unit + integration tests on the guest path. |
| PDF email dispatch fails after all BullMQ retries | Low | Guest has no in-app surface to re-trigger (no JWT → no /me/*); they’re stuck without proof of purchase | Recovery path (NOT shipped in X2, follow-up ticket): backend-only admin endpoint POST /api/business/bookings/:id/resend-confirmation-email (BusinessJwtGuard + OWNER/ADMIN role). Calls the existing X1 producer. See D5. Support can trigger from the business panel. |
Double-tap idempotency (CONCERN 8) — guest accidentally double-taps “Book”; second tap returns 400 booking.unavailable (collapsed from already_exists) instead of replaying the original 201 | Medium | Confusing UX; guest may think the booking failed; loses the original verifyToken + booking ref | Future improvement (NOT shipped in X2, tracked): support an Idempotency-Key header — server caches the original 201 response by key and replays it on retry. This gap also affects the authenticated createBooking path; addressing both at once would be the right scope. |
validatePaymentMethod treats isNew = true as a WALLET-gated customer | Low (irrelevant in practice) | Would block guests on the WALLET branch | Moot. WALLET is unreachable from the guest endpoint (blocked by GuestPaymentMethod enum at the DTO layer — D4). D5 passes isNew = false for completeness; no behavioural effect. Rationale corrected from earlier draft (no “trial period” gating exists; the only branch that reads isNewCustomer is WALLET — see bookings-client.service.ts:747). |
BookingsClientService not exported from ActivitiesClientModule | CONFIRMED — required change | Without the export, future cross-module callers can’t inject | BLOCKER 2 resolved. Verified at activities-client.module.ts:60-67: no exports: array. X2 MR adds exports: [BookingsClientService]. Intra-module DI (the new BookingsGuestService) works regardless; the export covers future consumers. |
verifyToken UX cliff (CONCERN 6) — token expires while guest navigates post-purchase modal → guest-ticket view | High at 30s | Guest taps “Continue without account” and lands on a QR that 401s | D7 chooses 5-min TTL (300s) for the guest token via existing sign({ bookingId, ttlSec: 300 }) overload. Authenticated 30s default UNTOUCHED. Spec amendment — TTL table now distinguishes guest vs. authenticated. No new endpoint; no contract change. |
| LiqPay webhook arrives before the BullMQ job for the PDF is scheduled (race in the X1 dispatch hook) | Low | Customer doesn’t get the PDF | X1 dispatch is fire-and-forget but synchronous to the webhook handler — the enqueue completes before the handler returns 200 to LiqPay. No race. |
| Resend rate-limits at high guest volume | Low | PDF emails delayed | BullMQ retry/backoff in X1’s email queue handles this. Resend’s per-project quota is far above expected guest volume. |
companyCustomer row created with email but no userId; later the same person signs up with a DIFFERENT email | Low | Two companyCustomer rows for the same human; bookings split across them | Acceptable — outside the linkage contract. Documented as a “known limitation; no merge UI”. |
PII at rest, no expiry (CONCERN 10) — guest emails/names/phones persist in companyCustomers indefinitely | Persistent | GDPR exposure; no automated erase path for guests who never sign up | Acknowledged. GDPR-erase path is out of scope for X2 and tracked separately. Support can manually delete rows via direct DB access on request. |
Web SignupPage extension to read ?email query param introduces an XSS vector | Very low | None — Angular bindings escape by default | Read via ActivatedRoute.queryParamMap.get('email'); pass through IsEmail-pattern validator before setting the form value. |
| Guest booking is cancelled before BullMQ picks up the PDF job (race) | Low | PDF mailed for a CANCELLED booking | Acceptable — X1 risk register already covers this (bookings-verify.service.ts:128 returns 400 not_verifiable_status at scan time). Log only. |
Error-message enumeration on @Public() surface | Medium (without mitigation) | Attacker iterates (companyId, email) pairs to enumerate company-customer relationships | Mitigated in D11. customer_banned and already_exists collapse into neutral errors.booking.unavailable on the guest surface. Admin surface keeps granular codes. |
Rollout plan
Section titled “Rollout plan”Phased.
- Phase A (this ADR + contract patch). Land the ADR, OpenAPI
patch, and the
drafts/migration-*.sqlno-op stub. Run the spec-vs-contract CI check. - Phase B (backend). Land the backend changes:
bookings-guest.controller.ts,bookings-guest.service.ts, register both inActivitiesClientModule(no new module file — see D5 / SUGGESTION 12), the new DTOs, the refactor ofBookingsClientService.createBookinginto a thin wrapper aroundcreateBookingForCustomer, thefireBookingNotificationsnull- safety, and the unit + integration tests. Sync contracts (npm run sync:contracts) and verify the YAML diff matches the patch from Phase A. Boot the API locally; smoke-test the new endpoint with curl (ON_SITE happy path + LIQPAY redirect + duplicate-email race). - Phase C (mobile — gym_app). Regenerate
packages/api(melos run sync:spec && melos run generate:api). BuildGuestCheckoutSummary+PostPurchaseSignupNudge+GuestTicketPage. Wire into the existing checkout state machine. Test ON_SITE end-to-end against the dev backend; test LIQPAY round-trip against LiqPay sandbox. - Phase D (web). Regenerate the API client (
npm run generate). ExtendSignupPageto read?email. Build the same three UI surfaces in Angular. Mirror the mobile end-to-end tests. - Phase E (production). Deploy backend + mobile + web in
order. Monitor:
companyCustomerrows withuserId IS NULL— daily growth.- LiqPay webhook latency for guest bookings.
- Post-purchase modal CTA click-through rate (mobile + web analytics).
- Resend bounce rate on guest-domain emails.
Feature flag. None at the API level — the endpoint either
exists or doesn’t. Hide-it-client-side flag (enableGuestCheckout)
recommended on both mobile and web for the first 48 h of production
to allow a fast UX rollback without a backend redeploy. Implementation
detail; not blocking the ADR.
Backfill? Not applicable.