Skip to content

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

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 slim BOOKING_TICKET job into the notifications-email BullMQ queue. Recipient is resolved from companyCustomers.email first and then from users.email via companyCustomers.userId (X1 D10 — “Recipient resolution”) — so a companyCustomer with userId = NULL and a real email already 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 constraint unqEmail on (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 is resolveCustomer(userId, companyId) which find-or-creates by userId. 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, clientId are all derived server-side or set to NULL.
  • 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 verifyToken is new for this path. The authenticated flow returns no token in the create response — it issues one on demand at GET /me/bookings/:id/verify-token. Guests have no JWT, no me/bookings they 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 extends CreateBookingResponseDto with an optional verifyToken (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.

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:

  1. 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.
  2. Reuses BookingsService.resolveCustomerId for the find-or-create (X2 D3) and BookingsClientService payment branches for everything downstream (X2 D5).
  3. Issues a 5-minute verifyToken inline on the response when the booking is in CONFIRMED status after createBooking returns (X2 D7). The authenticated 30 s default is untouched.
  4. Relies on BookingConfirmationEmailService.dispatchConfirmedBookingTicket (X1) for the PDF email — no new email plumbing.
  5. 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.

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

Mounted 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/:companyId
export 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 on request.user inside the service. The two flows share the service layer (resolveCustomerId, createBooking internals) — that is where the reuse belongs, not at the HTTP edge.
  • Reuse BookingsClientController with 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:

FieldSourceNotes
userId / authenticated userNone.No JWT, no @ActiveUser(). The service never reads request.user.
customerIdNone.Not in the DTO. The DTO is the only request-side input the controller forwards.
clientIdNone.Not in the DTO.
emailDTO (required)Lower-cased + trimmed before any lookup or insert (see D3). Validated by class-validator @IsEmail().
name, phoneDTO (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, sessionIdURL pathValidated by the same path-level constraints the authenticated flow uses (@Param('companyId')). The service performs the existing tenancy checks via resolveSessionPrice.
paymentMethodDTO (required)Restricted via the GuestPaymentMethod enum at the DTO level. The service additionally re-validates against the session’s allowedPaymentMethods (see D5).
resultUrlDTO (required when paymentMethod = LIQPAY)Identical validation to CreateClientBookingDto.resultUrl (@IsUrl()).
extrasDTO (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 global JwtAuthGuard short-circuits — request.user is undefined.)
  • Accept customerId, userId, clientId, walletId, or any internal ID in the request body. The DTO class does not declare these fields; class-transformer’s whitelist: true (already wired globally) drops unknown fields.
  • Update an existing companyCustomer row’s name / phone from the request payload (see D3 — the existing row’s fields are authoritative; only a future AuthSyncService.validateClient link sets userId).

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 at bookings.service.ts:327). The guest service calls this.bookingsService.resolveCustomerId(...) — this will not compile while the method is private. The implementation MR MUST widen the modifier to public 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” — resolveCustomerId visibility change row).

The helper’s semantics today match AC-3:

  1. Selects companyCustomers rows where companyId = ? AND (phone = dto.phone OR email = dto.email), limited to 1.
  2. If found and not BANNED — returns the existing id. Both bookings end up attached to the same companyCustomer. AC-3 satisfied with zero behavioural change.
  3. If not found — inserts a row with name?, phone?, email? and userId = 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 SELECT queries return empty. Both attempt INSERT. The second INSERT raises unique_violation (Postgres 23505).

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.email values 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 with email = '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.validateClient matches by users.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.ts
export 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.md authoring rule “every named enum carries enumName: 'X'”).
  • A typed value at every call site — the mapping GuestPaymentMethod → BookingPaymentMethod is a 1-liner in the service.
  • 400 validation errors when a client sends WALLET, PASS, DEFER, or BONUS — 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:

  1. (Chosen) Refactor createBooking to take a resolved customerId argument. 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. createBookingForCustomer MUST live as a method on BookingsClientService itself — NOT on BookingsGuestService. It is the orchestrator for the entire payment pipeline and calls private helpers resolveSessionPrice (bookings-client.service.ts:804), bookOnSite (bookings-client.service.ts:1087), bookWithLiqpay (verified to be private in bookings-client.service.ts), validatePaymentMethod, resolveExtras, bookWithPass, bookDeferred, and fireBookingNotifications — all currently private. 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 = false for guests — rationale (corrected). isNewCustomer only gates the WALLET branch at bookings-client.service.ts:747 (if (method === BookingPaymentMethod.WALLET && isNewCustomer) throw...). Guests cannot select WALLET — it is blocked by the GuestPaymentMethod enum at the DTO layer (D4). Passing isNew = false is therefore harmless: the only branch that reads the flag is unreachable on the guest path. We pass false for completeness and stable signatures; no behavioural effect either way. (Earlier draft incorrectly framed this as a “trial period” concern — corrected.)

  2. Rejected. Add an isGuest boolean to createBooking and branch internally — pollutes the existing method.

  3. 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 on userId.

    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 userProfiles by email — userId is 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-email

Auth: 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: BookingsClientService is currently declared in providers: but the module has no exports: array at all (verified — read of the entire file). The X2 MR MUST add it. BookingsGuestService lives inside the same module, so it can inject BookingsClientService directly (no export needed for intra-module DI). However we still add the exports because future cross-module consumers (e.g. a hypothetical BookingsAdminAssistedService) will need it, and exporting a provider that’s already used intra-module is zero-cost. BookingsService is provided by ActivitiesModule (imported above) — its export is ActivitiesModule’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 D8
createGuestBooking(@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).

  1. Client (mobile or web) POSTs POST /api/client/guest/companies/{cid}/sessions/{sid}/bookings with paymentMethod: LIQPAY + resultUrl.
  2. BookingsGuestController.createGuestBookingBookingsGuestService.createGuestBookingBookingsClientService.createBookingForCustomer(customerId, ...)bookWithLiqpay(sessionId, customerId, companyId, totalPrice, session, resultUrl) (bookings-client.service.ts).
  3. bookWithLiqpay (existing code) creates a bookings row with status: 'PENDING_PAYMENT' and a payments row with sourceType: 'BOOKING' + sourceId: <bookingId>. Returns { booking, payment: { data, signature, paymentUrl } }.
  4. Mobile/web redirects user to LiqPay using payment.data + payment.signature (or paymentUrl for hosted flow).
  5. LiqPay POSTs POST /api/client/payments/webhookPaymentsClientController.processWebhook (payments-client.controller.ts:26-32). Already decorated @Public() — no JWT required.
  6. PaymentsService.processWebhook (payments.service.ts:163-209) verifies LiqPay signature, looks up payments by order_id, marks the payment row paid, then finds the booking via payment.sourceId and updates bookings.status → CONFIRMED.
  7. 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 from companyCustomers.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 to BookingVerifyTokenService. The authenticated flow’s sign({ 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-updated is 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:

  1. 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 CONFIRMED status), so emitting it is misleading.
  2. UX surface area. AC-13 (“guest ticket view showing the QR”) is gated client-side on verifyToken being 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.0 is already in tktspace-backend/package.json (verified).
  • ThrottlerModule.forRoot([{ ttl: 900_000, limit: 10 }]) is already wired globally in apps/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 assertCustomersLimit enforces 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/throttler import, 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 neutralisationcustomer_banned and already_exists are collapsed into errors.booking.unavailable on the guest surface to prevent email enumeration (D5; CONCERN 9).
  • Subscription limits already enforced. assertCustomersLimit (called by resolveCustomerId) + assertBookingsLimit (called by BookingsClientService) remain in the call graph and provide per-tenant ceilings.
  • GUEST_CHECKOUT_ENABLED kill-switch (SUGGESTION 11) — if abuse spikes, ops flip the env var to false and the controller throws 404 without a deploy.

Deferred (NOT shipped in X2):

  • Throwaway-email blacklists. Moving target, high false-positive rate on +suffix emails. Skipped.
  • Per-IP × per-companyId shaping. 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 with status = PENDING_PAYMENT and a 5-minute reservation hold; promote to CONFIRMED on 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-prompt

When 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’s allowedPaymentMethods intersects {ON_SITE, LIQPAY}.
  • Web (src/app/pages/.../checkout — TBD by web-dev in Phase B). Mirror the same condition. Existing SignupPage extended (AC-17) to read ?email from ActivatedRoute.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)”
TokenTTLWhere it livesUsed for
PDF token (long-lived, X1)session.endsAt + 30 min grace (or session.startsAt + 240 min when endsAt is null)Email link inside the PDFCustomer’s durable proof; scanners verify it for hours after the session
verifyToken — authenticated30 s — BookingVerifyTokenService.DEFAULT_VERIFY_TOKEN_TTL_SEC (UNCHANGED)Returned by GET /me/bookings/:id/verify-tokenIn-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_banned400 errors.booking.unavailable
400 errors.booking.already_exists400 errors.booking.unavailable
404 errors.session.not_found404 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 errorsValidation 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/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 (enum GuestPaymentMethod: ON_SITE | LIQPAY, required).
    • resultUrl (string URL, required iff paymentMethod = LIQPAY).
    • extras (array of BookingExtraItemDto, 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.
    • paymentMethod outside enum → errors.validation.paymentMethod.
    • resultUrl missing when LIQPAY → errors.validation.resultUrl.
    • Activity rejects the chosen method → errors.booking.payment_method_not_allowed (same as authenticated path).
  • Business errors (guest surface — neutralised per D11):
    • 400 errors.booking.unavailable — collapses two underlying conditions (customer_banned OR already_exists) into a single neutral code to prevent enumeration of (companyId, email) relationships. The admin surface keeps the granular codes.
    • 404 errors.session.not_foundsessionId does not belong to companyId. 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.
ContractTouched?Why
contracts/client.openapi.yamlYesNew bookingsGuestCreate endpoint, CreateGuestBookingDto, GuestPaymentMethod enum, GuestCreateBookingResponseDto.
contracts/business.openapi.yamlNoGuest 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.yamlNoNot affected.

Field-level differences vs. authenticated booking-create.

FieldAuthenticated (CreateBookingResponseDto)Guest (GuestCreateBookingResponseDto)Why
bookingrequiredrequiredSame; primary artefact.
paymentoptional (LIQPAY only)optional (LIQPAY only)Same; identical LiqPay payload.
insufficientBalance / required / availableoptional (WALLET only)absentGuests can’t use WALLET — these fields are meaningless.
verifyTokenabsentoptional (ON_SITE only) — 5-min TTLAuthenticated 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.

No schema changes.

  • companyCustomers table already has email TEXT (nullable), userId UUID (nullable, FK to users.id), and UNIQUE(companyId, email) (companies.schema.ts:122, 117, 133).
  • bookings.customerId already accepts any companyCustomers.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.

ConcernLib / file
Guest controllerlibs/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 servicelibs/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 wiringlibs/features/activities/src/lib/activities-client.module.ts (EDIT) — register BookingsGuestController in controllers:, BookingsGuestService in providers:. No new module file (SUGGESTION 12 applied).
ClientApiModule importNo change. ClientApiModule already imports ActivitiesClientModule.
BLOCKER 1 — resolveCustomerId visibilitylibs/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 extractionlibs/features/activities/src/lib/services/bookings-client.service.tson 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 fireBookingNotificationslibs/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 exportslibs/features/activities/src/lib/activities-client.module.tsCONFIRMED 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 + enumlibs/features/activities/src/lib/dto/create-guest-booking.dto.ts (NEW) — CreateGuestBookingDto, GuestPaymentMethod.
Guest response DTOlibs/features/activities/src/lib/dto/guest-booking.response.dto.ts (NEW) — GuestCreateBookingResponseDto. (Reuses BookingRecordDto, LiqpayPaymentDto, BookingVerifyTokenClientResponseDto.)
Postgres unique-violation helperlibs/features/activities/src/lib/services/bookings-guest.service.ts — local helper isPostgresUniqueViolation(err): err is PgError. (Postgres error code '23505'.)
Rate-limiter wiringNo 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 vartktspace-backend/.env.example — add GUEST_CHECKOUT_ENABLED=true (kill-switch — SUGGESTION 11).
i18n stringsMobile + web only (see Frontend implications). Backend introduces one new error code: errors.booking.unavailable (per D11).
Testslibs/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.

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’s allowedPaymentMethods intersect {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 — read email query param via ActivatedRoute.queryParamMap and call this.form.controls.email.setValue(param) in ngOnInit. Existing returnUrl behavior is unchanged.
  • src/app/pages/.../guest-ticket (NEW) — guest ticket view showing booking summary, QR rendered from verifyToken.token (single-issue 5-min token — no refresh polling; guests cannot hit /me/bookings/:id/verify-token without 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 generate after the contract lands. The new bookingsGuestCreate fn-function appears in core/api/. Call sites use api.invoke(bookingsGuestCreate, {...}).

Static marketing copy. Does not call /api/client/*.

  • State machine. D9 above. Three new pages / states:
    • GuestCheckoutSummaryPage (or a state inside the existing checkout) — email + name? + phone? form (AC-8).
    • PostPurchaseSignupNudge modal (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’s companyCustomer.userId automatically — the app does NOT need to call a “claim” endpoint. After signup, navigate to MyBookingsPage and 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_url points to the existing incust://booking/:id deep link. Implementation maps the bookingId query 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.

  • packages/api — regenerated by melos run sync:spec && melos run generate:api. New methods on ApiClient: createGuestBooking(...) (Chopper-generated from bookingsGuestCreate). No hand-written HTTP.
  • packages/ui — two new widgets:
    • GuestCheckoutSummary — Material form widget (TextFormField for email/name/phone, plus a payment-method Wrap of chips).
    • PostPurchaseSignupNudgeAlertDialog or Modal with 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.
RiskLikelihoodImpactMitigation
Race on simultaneous guest POSTs with same email → unique violationMediumOne of the two POSTs would fail with an unhandled 500 if not caughtD3 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 companyHigh (without mitigation) → Low (with D8)DB row growth; subscription-cap exhaustion on legitimate companies; trivial DoSShipped 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 freeMedium (without further mitigation)Legitimate customers blocked at full sessions; staff has to manually triage / cancelD8’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 frictionMediumCustomers can’t be reliably reached for follow-upOut 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 lookupLowDuplicate 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 userIdHigh if missedCrash on every guest bookingD5 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 retriesLowGuest has no in-app surface to re-trigger (no JWT → no /me/*); they’re stuck without proof of purchaseRecovery 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 201MediumConfusing UX; guest may think the booking failed; loses the original verifyToken + booking refFuture 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 customerLow (irrelevant in practice)Would block guests on the WALLET branchMoot. 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 ActivitiesClientModuleCONFIRMED — required changeWithout the export, future cross-module callers can’t injectBLOCKER 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 viewHigh at 30sGuest taps “Continue without account” and lands on a QR that 401sD7 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)LowCustomer doesn’t get the PDFX1 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 volumeLowPDF emails delayedBullMQ 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 emailLowTwo companyCustomer rows for the same human; bookings split across themAcceptable — 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 indefinitelyPersistentGDPR exposure; no automated erase path for guests who never sign upAcknowledged. 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 vectorVery lowNone — Angular bindings escape by defaultRead 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)LowPDF mailed for a CANCELLED bookingAcceptable — 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() surfaceMedium (without mitigation)Attacker iterates (companyId, email) pairs to enumerate company-customer relationshipsMitigated in D11. customer_banned and already_exists collapse into neutral errors.booking.unavailable on the guest surface. Admin surface keeps granular codes.

Phased.

  1. Phase A (this ADR + contract patch). Land the ADR, OpenAPI patch, and the drafts/migration-*.sql no-op stub. Run the spec-vs-contract CI check.
  2. Phase B (backend). Land the backend changes: bookings-guest.controller.ts, bookings-guest.service.ts, register both in ActivitiesClientModule (no new module file — see D5 / SUGGESTION 12), the new DTOs, the refactor of BookingsClientService.createBooking into a thin wrapper around createBookingForCustomer, the fireBookingNotifications null- 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).
  3. Phase C (mobile — gym_app). Regenerate packages/api (melos run sync:spec && melos run generate:api). Build GuestCheckoutSummary + 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.
  4. Phase D (web). Regenerate the API client (npm run generate). Extend SignupPage to read ?email. Build the same three UI surfaces in Angular. Mirror the mobile end-to-end tests.
  5. Phase E (production). Deploy backend + mobile + web in order. Monitor:
    • companyCustomer rows with userId 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.