88 lines
3.3 KiB
TypeScript
88 lines
3.3 KiB
TypeScript
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<Cart> {
|
|
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<Order> {
|
|
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<Order> {
|
|
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<Order> {
|
|
return this.orders.getById(id);
|
|
}
|
|
}
|