import { Body, Controller, Get, Inject, Param, Post, Query, Req, Res } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Request, Response } from 'express'; import type { Cart, Locale, Order } from '@sport/types'; import { placeOrderSchema, type PlaceOrderInput } from '@sport/validation'; import { Public } from '@/common/decorators/public.decorator'; import { RequestLocale } from '@/common/i18n/locale.decorator'; import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe'; import { APP_CONFIG } from '@/config/app-config.module'; import type { AppConfig } from '@/config/configuration'; import { clearCartCookie, resolveCartToken, setCartCookie } from '@/modules/carts/public'; import { OrdersService } from '@/modules/orders/public'; import { CheckoutService } from './checkout.service'; /** * Public because guest checkout is the default. Customer accounts arrive in M8 * and will attach an order to a customer, not gate the ability to place one. */ @ApiTags('checkout') @Public() @Controller('checkout') export class CheckoutController { constructor( private readonly service: CheckoutService, private readonly orders: OrdersService, @Inject(APP_CONFIG) private readonly config: AppConfig, ) {} @Get('quote') @ApiOperation({ summary: 'The bag as it will be charged' }) quote( @Req() request: Request, @Res({ passthrough: true }) response: Response, @RequestLocale() locale: Locale, ): Promise { const { token } = resolveCartToken(request); setCartCookie(response, token, this.config.app.isProduction); return this.service.quote(token, locale); } @Post('orders') @ApiOperation({ summary: 'Place the order and reserve stock' }) async placeOrder( @Body(new ZodValidationPipe(placeOrderSchema)) body: PlaceOrderInput, @Req() request: Request, @Res({ passthrough: true }) response: Response, @RequestLocale() locale: Locale, ): Promise { const { token } = resolveCartToken(request); const order = await this.service.placeOrder(token, body, locale); // The bag is gone, so the cookie naming it should go too — otherwise the // next visit reads an empty cart under a stale token forever. clearCartCookie(response, this.config.app.isProduction); return order; } @Get('orders/lookup') @ApiOperation({ summary: 'Find a placed order by number and email' }) lookup(@Query('orderNumber') orderNumber: string, @Query('email') email: string): Promise { return this.orders.lookup(orderNumber ?? '', email ?? ''); } /** * Confirmation lookup by id — a capability URL. * * The id is a UUIDv7: not sequential, not enumerable, and not derivable from * the order number. Holding it is the authorisation, which is what lets a * guest see their own order without an account. * * It exists so the confirmation page need not carry an email address in its * query string, where it would sit in browser history and ride along in the * `Referer` of every outbound request the page makes. * * Declared after `orders/lookup` so that literal path never matches here. */ @Get('orders/:id') @ApiOperation({ summary: 'A placed order, by its unguessable id' }) getById(@Param('id') id: string): Promise { return this.orders.getById(id); } }