From 624a6402bfa7da34d197116feb04a918b1befe7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=B4ng=20=C4=90=E1=BB=A9c=20Huy?= Date: Wed, 12 Aug 2026 21:38:04 +0700 Subject: [PATCH] Stage M5 and Stage M6 --- README.md | 41 +- .../src/app/(dashboard)/orders/[id]/page.tsx | 19 + .../admin/src/app/(dashboard)/orders/page.tsx | 19 +- .../src/features/orders/order-detail.tsx | 233 ++++++++++++ .../src/features/orders/orders-table.tsx | 169 +++++++++ .../src/features/products/products-table.tsx | 21 +- apps/admin/src/lib/format.ts | 32 ++ apps/admin/src/messages/en.json | 55 +++ apps/admin/src/messages/vi.json | 55 +++ .../20260812085629_add_orders/migration.sql | 88 +++++ .../migration.sql | 64 ++++ apps/api/prisma/schema.prisma | 167 +++++++++ apps/api/prisma/seed/catalog.ts | 5 + apps/api/src/modules/carts/cart-cookie.ts | 50 +++ .../api/src/modules/carts/carts.controller.ts | 103 +++++ apps/api/src/modules/carts/carts.module.ts | 29 +- apps/api/src/modules/carts/carts.service.ts | 353 ++++++++++++++++++ apps/api/src/modules/carts/public/index.ts | 3 +- .../modules/checkout/checkout.controller.ts | 87 +++++ .../src/modules/checkout/checkout.module.ts | 29 +- .../src/modules/checkout/checkout.service.ts | 144 +++++++ apps/api/src/modules/checkout/public/index.ts | 2 +- .../modules/orders/order-transitions.spec.ts | 46 +++ .../src/modules/orders/orders.controller.ts | 61 +++ .../src/modules/orders/orders.mapper.spec.ts | 39 ++ apps/api/src/modules/orders/orders.mapper.ts | 103 +++++ apps/api/src/modules/orders/orders.module.ts | 32 +- .../src/modules/orders/orders.repository.ts | 100 +++++ apps/api/src/modules/orders/orders.service.ts | 253 +++++++++++++ apps/api/src/modules/orders/public/index.ts | 3 +- .../products/products-admin.service.ts | 17 + .../modules/products/products.repository.ts | 38 ++ .../src/modules/products/products.service.ts | 93 ++++- .../search/postgres-search.provider.ts | 146 ++++++++ apps/api/src/modules/search/public/index.ts | 3 +- .../modules/search/search-index.subscriber.ts | 52 +++ .../modules/search/search-indexer.service.ts | 144 +++++++ .../src/modules/search/search.controller.ts | 74 ++++ apps/api/src/modules/search/search.module.ts | 44 ++- .../api/src/modules/search/search.provider.ts | 41 ++ apps/api/src/modules/search/search.service.ts | 58 +++ .../app/[locale]/(account)/account/layout.tsx | 2 +- .../app/[locale]/(checkout)/checkout/page.tsx | 15 +- .../src/app/[locale]/(checkout)/layout.tsx | 2 +- .../(checkout)/order-confirmation/page.tsx | 29 ++ .../src/app/[locale]/(shop)/cart/page.tsx | 14 +- .../src/app/[locale]/(shop)/layout.tsx | 2 +- .../src/app/[locale]/(shop)/search/page.tsx | 7 +- apps/storefront/src/app/[locale]/layout.tsx | 7 +- .../src/components/commerce/add-to-bag.tsx | 69 ++++ .../src/components/commerce/cart-badge.tsx | 37 ++ .../src/components/commerce/cart-view.tsx | 239 ++++++++++++ .../src/components/commerce/checkout-form.tsx | 278 ++++++++++++++ .../src/components/commerce/load-more.tsx | 3 +- .../commerce/order-confirmation.tsx | 141 +++++++ .../components/commerce/product-detail.tsx | 25 +- .../components/commerce/product-listing.tsx | 13 +- .../src/components/commerce/search-dialog.tsx | 160 ++++++++ .../src/components/commerce/site-header.tsx | 22 +- .../src/features/cart/cart-provider.tsx | 122 ++++++ apps/storefront/src/lib/catalog/index.ts | 22 ++ apps/storefront/src/lib/routes.ts | 1 + apps/storefront/src/messages/en.json | 74 +++- apps/storefront/src/messages/vi.json | 74 +++- ...orders-snapshot-everything-they-display.md | 78 ++++ docs/adr/README.md | 1 + docs/architecture.md | 68 +++- packages/api-client/src/create-client.ts | 10 + packages/api-client/src/index.ts | 6 + packages/api-client/src/resources/catalog.ts | 20 + packages/api-client/src/resources/commerce.ts | 150 ++++++++ packages/types/src/catalog/search.ts | 13 + packages/types/src/commerce/cart.ts | 64 ++++ packages/types/src/commerce/order.ts | 101 +++++ packages/types/src/index.ts | 3 + packages/validation/src/commerce.ts | 67 ++++ packages/validation/src/index.ts | 1 + 77 files changed, 4879 insertions(+), 176 deletions(-) create mode 100644 apps/admin/src/app/(dashboard)/orders/[id]/page.tsx create mode 100644 apps/admin/src/features/orders/order-detail.tsx create mode 100644 apps/admin/src/features/orders/orders-table.tsx create mode 100644 apps/admin/src/lib/format.ts create mode 100644 apps/api/prisma/migrations/20260812085629_add_orders/migration.sql create mode 100644 apps/api/prisma/migrations/20260812130938_add_search_documents/migration.sql create mode 100644 apps/api/src/modules/carts/cart-cookie.ts create mode 100644 apps/api/src/modules/carts/carts.controller.ts create mode 100644 apps/api/src/modules/carts/carts.service.ts create mode 100644 apps/api/src/modules/checkout/checkout.controller.ts create mode 100644 apps/api/src/modules/checkout/checkout.service.ts create mode 100644 apps/api/src/modules/orders/order-transitions.spec.ts create mode 100644 apps/api/src/modules/orders/orders.controller.ts create mode 100644 apps/api/src/modules/orders/orders.mapper.spec.ts create mode 100644 apps/api/src/modules/orders/orders.mapper.ts create mode 100644 apps/api/src/modules/orders/orders.repository.ts create mode 100644 apps/api/src/modules/orders/orders.service.ts create mode 100644 apps/api/src/modules/search/postgres-search.provider.ts create mode 100644 apps/api/src/modules/search/search-index.subscriber.ts create mode 100644 apps/api/src/modules/search/search-indexer.service.ts create mode 100644 apps/api/src/modules/search/search.controller.ts create mode 100644 apps/api/src/modules/search/search.provider.ts create mode 100644 apps/api/src/modules/search/search.service.ts create mode 100644 apps/storefront/src/app/[locale]/(checkout)/order-confirmation/page.tsx create mode 100644 apps/storefront/src/components/commerce/add-to-bag.tsx create mode 100644 apps/storefront/src/components/commerce/cart-badge.tsx create mode 100644 apps/storefront/src/components/commerce/cart-view.tsx create mode 100644 apps/storefront/src/components/commerce/checkout-form.tsx create mode 100644 apps/storefront/src/components/commerce/order-confirmation.tsx create mode 100644 apps/storefront/src/components/commerce/search-dialog.tsx create mode 100644 apps/storefront/src/features/cart/cart-provider.tsx create mode 100644 docs/adr/0018-orders-snapshot-everything-they-display.md create mode 100644 packages/api-client/src/resources/commerce.ts create mode 100644 packages/types/src/catalog/search.ts create mode 100644 packages/types/src/commerce/cart.ts create mode 100644 packages/types/src/commerce/order.ts create mode 100644 packages/validation/src/commerce.ts diff --git a/README.md b/README.md index 7f76fc8..cb141bf 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ sport-store/ │ ├── common/ decorators · filters · guards · interceptors │ │ middleware · pipes · errors │ ├── infrastructure/ prisma · redis · storage · events · logging -│ └── modules/ 20 bounded contexts +│ └── modules/ 21 bounded contexts │ ├── packages/ │ ├── types/ Framework-free domain + API contracts (zero deps) @@ -158,7 +158,7 @@ sport-store/ │ ├── docs/ │ ├── architecture.md Boundaries, conventions, risks — read this first -│ └── adr/ 17 decision records +│ └── adr/ 18 decision records │ ├── docker-compose.yml Backing services; `--profile full` runs everything ├── turbo.json pnpm-workspace.yaml package.json @@ -251,17 +251,16 @@ locale-in-path would buy nothing. | **M2** ✅ | Auth: login, refresh rotation with reuse detection, RBAC admin, user & role management | | **M3** ✅ | Admin catalog: write API, variant matrix, media uploads, inventory ledger, product editor (option builder + per-locale tabs) | | **M4** ✅ | Storefront catalog — listings, PDP, variant selector, filters, sort control, load-more pagination and a mobile filter sheet | -| **M5** | Cart, checkout, orders | -| **M6** | Search + faceting | +| **M5** ✅ | Cart (Redis), guest checkout, orders with stock reservation and an admin order lifecycle | +| **M6** ✅ | Search: PostgreSQL full-text + trigram, diacritic-folded, ranked, with type-ahead and refinable results | | **M7** | Promotions, coupons, reviews, CMS | | **M8** | Customer account | | **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | -**Recommended next step: M5 (cart & checkout).** M3 now closes the loop end to end: an operator -creates a product with per-locale content, defines the option axes, gets a generated variant matrix, -prices it, attaches imagery per colourway, receives stock through the ledger and publishes — and the -result renders on the storefront in both languages. Cart and checkout are the first flows that put -the variant model under real concurrency. +**Recommended next step: M7 (promotions, coupons, reviews, CMS) or M9 (payments).** The store can +now be browsed, searched, filled into a bag and checked out, and every order moves stock through a +ledger. What it still cannot do is take money — which is the one gap between this and a shop that +trades. --- @@ -271,11 +270,12 @@ Everything below was run, not assumed: - `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass; `pnpm format:check` clean -- 6 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, +- 7 migrations, 35 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, 3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts -- **48 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the +- **57 tests** — RBAC guards, password hashing, translation fallback, `Accept-Language`, the variant matrix planner, the HTTP client's fetch receiver and retry recursion, the inventory - list's variant-driven projection, and the two admin-schema defects that caused silent data loss + list's variant-driven projection, the two admin-schema defects that caused silent data loss, and + order-number round-tripping - Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in both `vi` and `en`; money formats per locale from one integer (`690.000 ₫` / `₫690,000`) - Storefront: every route 200 in both locales; `/en/products/` → 307 → @@ -303,6 +303,23 @@ through Chrome with the console and network panel open: media library upload driven from the browser (presign → PUT to MinIO → register, 400×500 PNG landed at 10,962 bytes with a date-partitioned UUID key); inventory adjustment from the table wrote a ledger entry and the storefront went `OUT_OF_STOCK` → `IN_STOCK` on the next request +- **Search (M6):** `nocturne` and `running jacket` match exactly; `ao chay bo` finds _Áo Chạy Bộ + Aero_ without diacritics; `nocturn`, `jaket` and `runing` survive their typos; `velocity` + matches by brand, `crimson` by colourway, `VEL-NOC` by SKU; `zzzzqqq` correctly finds nothing. + Type-ahead suggests from the product name only, and results stay refinable by the same facets + as any listing with an honest sort control +- **Concurrency:** three simultaneous checkouts for a single unit produce exactly one order and + two clean rejections, with `reserved` landing on 1 — the read-then-write version created two + orders and lost a reservation +- **The purchase flow, end to end in a browser (M5):** added to bag from the PDP (badge updates), + changed quantity in the bag, checked out as a guest and placed order **SP-000003**; the + confirmation page is reachable from its bookmarkable URL; the admin confirmed then fulfilled it, + which moved `onHand` 15→12, released `reserved` 3→0 and wrote a `SALE -3` ledger entry alongside + `order.confirmed` / `order.fulfilled` audit records +- **Commerce guard rails:** reserving does not touch `onHand`; cancelling returns the reservation; + `PENDING→COMPLETED` is refused; cancelling without a reason is refused; a 20-unit request caps to + available stock with a `QUANTITY_REDUCED` notice; guest order lookup needs number _and_ email and + answers a wrong email with the same `NOT_FOUND` as a wrong number - **Catalog write correctness**, each reproduced before the fix and re-run after: a product authored in one language is accepted; renaming a product no longer clears its gender/sport targeting, collections or attributes; adding a size inherits the sibling price and the stored diff --git a/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx b/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx new file mode 100644 index 0000000..9e083ca --- /dev/null +++ b/apps/admin/src/app/(dashboard)/orders/[id]/page.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import { getTranslations } from 'next-intl/server'; + +import { OrderDetail } from '@/features/orders/order-detail'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('orders'); + return { title: t('detailTitle') }; +} + +export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + return ( +
+ +
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/orders/page.tsx b/apps/admin/src/app/(dashboard)/orders/page.tsx index 9514dc6..e3127f4 100644 --- a/apps/admin/src/app/(dashboard)/orders/page.tsx +++ b/apps/admin/src/app/(dashboard)/orders/page.tsx @@ -1,22 +1,23 @@ import type { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; -import { PageScaffold } from '@/components/layout/page-scaffold'; +import { OrdersTable } from '@/features/orders/orders-table'; export async function generateMetadata(): Promise { - const t = await getTranslations('pages.orders'); + const t = await getTranslations('orders'); return { title: t('title') }; } export default async function OrdersPage() { - const t = await getTranslations('pages.orders'); + const t = await getTranslations('orders'); return ( - +
+
+

{t('title')}

+

{t('description')}

+
+ +
); } diff --git a/apps/admin/src/features/orders/order-detail.tsx b/apps/admin/src/features/orders/order-detail.tsx new file mode 100644 index 0000000..490a6c0 --- /dev/null +++ b/apps/admin/src/features/orders/order-detail.tsx @@ -0,0 +1,233 @@ +'use client'; + +import Link from 'next/link'; +import { useFormatter, useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; + +import { isApiClientError } from '@sport/api-client'; +import { PERMISSIONS, type Order, type OrderStatus } from '@sport/types'; +import { Badge, Button, Input, Skeleton } from '@sport/ui'; + +import { useSession } from '@/features/auth/session-provider'; +import { browserApi } from '@/lib/api'; +import { formatDateTime, formatMoney } from '@/lib/format'; + +import { STATUS_VARIANT } from './orders-table'; + +/** + * Which actions to offer, mirroring the server's transition table. + * + * Duplicated deliberately rather than fetched: the server is the authority and + * rejects anything illegal, so this list only decides which buttons are worth + * showing. Getting it out of step shows a button that fails, never a transition + * that should not happen. + */ +const NEXT_STATUSES: Record = { + PENDING: ['CONFIRMED', 'CANCELLED'], + CONFIRMED: ['FULFILLED', 'CANCELLED'], + FULFILLED: ['COMPLETED'], + COMPLETED: [], + CANCELLED: [], +}; + +export function OrderDetail({ orderId }: { orderId: string }) { + const t = useTranslations('orders'); + const format = useFormatter(); + const { can } = useSession(); + + const [order, setOrder] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [reason, setReason] = useState(''); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const result = await browserApi.ordersAdmin.getOrder(orderId); + if (!cancelled) setOrder(result); + } catch (caught) { + if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed')); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [orderId, t]); + + async function move(status: OrderStatus) { + setBusy(true); + setError(null); + + try { + setOrder(await browserApi.ordersAdmin.updateOrderStatus(orderId, status, reason || null)); + setReason(''); + } catch (caught) { + setError(isApiClientError(caught) ? caught.message : t('updateFailed')); + } finally { + setBusy(false); + } + } + + if (error && !order) { + return ( +

+ {error} +

+ ); + } + + if (!order) { + return ( +
+ + +
+ ); + } + + const next = NEXT_STATUSES[order.status]; + const money = (amount: number) => formatMoney({ amount, currency: order.currency }, format); + + return ( +
+
+ + ← {t('backToList')} + +

{order.orderNumber}

+ {t(`status.${order.status}`)} + {t(`payment.${order.paymentStatus}`)} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {can(PERMISSIONS.ORDER_UPDATE) && next.length > 0 ? ( +
+

{t('actions')}

+ + {next.includes('CANCELLED') ? ( + setReason(event.target.value)} + placeholder={t('reasonPlaceholder')} + className="max-w-md" + /> + ) : null} + +
+ {next.map((status) => ( + + ))} +
+ + {next.includes('FULFILLED') ? ( +

{t('fulfilHint')}

+ ) : null} +
+ ) : null} + +
+
+

+ {t('items')} +

+ + + {order.lines.map((line) => ( + + + + + + ))} + +
+

{line.productName}

+

{line.variantTitle}

+

{line.sku}

+
+ {money(line.unitPrice.amount)} × {line.quantity} + + {money(line.lineTotal.amount)} +
+
+ + + +
+
{t('total')}
+
{money(order.total.amount)}
+
+
+
+ + +
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/admin/src/features/orders/orders-table.tsx b/apps/admin/src/features/orders/orders-table.tsx new file mode 100644 index 0000000..f456bee --- /dev/null +++ b/apps/admin/src/features/orders/orders-table.tsx @@ -0,0 +1,169 @@ +'use client'; + +import Link from 'next/link'; +import { useFormatter, useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; + +import { isApiClientError } from '@sport/api-client'; +import { PERMISSIONS, type OrderListItem, type OrderStatus } from '@sport/types'; +import { Badge, Button, Input, Skeleton, cn } from '@sport/ui'; + +import { useSession } from '@/features/auth/session-provider'; +import { browserApi } from '@/lib/api'; +import { formatDateTime, formatMoney } from '@/lib/format'; + +const STATUS_FILTERS = [ + undefined, + 'PENDING', + 'CONFIRMED', + 'FULFILLED', + 'COMPLETED', + 'CANCELLED', +] as const; + +export const STATUS_VARIANT: Record = { + PENDING: 'warning', + CONFIRMED: 'solid', + FULFILLED: 'success', + COMPLETED: 'success', + CANCELLED: 'neutral', +}; + +export function OrdersTable() { + const t = useTranslations('orders'); + const format = useFormatter(); + const { can } = useSession(); + + const [orders, setOrders] = useState(null); + const [total, setTotal] = useState(0); + const [status, setStatus] = useState(undefined); + const [query, setQuery] = useState(''); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function run() { + try { + const result = await browserApi.ordersAdmin.listOrders({ + status, + q: query || undefined, + perPage: 50, + }); + if (cancelled) return; + setOrders([...result.items]); + setTotal(result.pageInfo.totalItems); + } catch (caught) { + if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('loadFailed')); + } + } + + // Debounced so typing an order number does not fire a request per keystroke. + const timer = setTimeout(() => void run(), query ? 300 : 0); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [status, query, t]); + + if (!can(PERMISSIONS.ORDER_READ)) { + return

{t('noPermission')}

; + } + + return ( +
+
+ setQuery(event.target.value)} + placeholder={t('searchPlaceholder')} + className="max-w-xs" + /> + +
+ {STATUS_FILTERS.map((value) => ( + + ))} +
+ + {t('count', { count: total })} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {!orders ? ( +
+ + + +
+ ) : orders.length === 0 ? ( +

{t('empty')}

+ ) : ( +
+ + + + + + + + + + + + + {orders.map((order) => ( + + + + + + + + + + ))} + +
{t('column.order')}{t('column.customer')}{t('column.status')}{t('column.items')}{t('column.total')}{t('column.placed')} +
+ + {order.orderNumber} + + +

{order.customerName}

+

{order.email}

+
+ + {t(`status.${order.status}`)} + + {order.itemCount}{formatMoney(order.total, format)} + {formatDateTime(order.placedAt, format)} + + +
+
+ )} +
+ ); +} diff --git a/apps/admin/src/features/products/products-table.tsx b/apps/admin/src/features/products/products-table.tsx index 3af3f0c..f13fd99 100644 --- a/apps/admin/src/features/products/products-table.tsx +++ b/apps/admin/src/features/products/products-table.tsx @@ -2,15 +2,16 @@ import Image from 'next/image'; import Link from 'next/link'; -import { useTranslations } from 'next-intl'; +import { useFormatter, useTranslations } from 'next-intl'; import { useCallback, useEffect, useState } from 'react'; import { isApiClientError } from '@sport/api-client'; -import { MINOR_UNIT_SCALE, PERMISSIONS, type AdminProductListItem, type Money } from '@sport/types'; +import { PERMISSIONS, type AdminProductListItem } from '@sport/types'; import { Badge, Button, Input, Skeleton, cn } from '@sport/ui'; import { useSession } from '@/features/auth/session-provider'; import { browserApi } from '@/lib/api'; +import { formatMoney } from '@/lib/format'; const STATUS_VARIANT = { ACTIVE: 'success', @@ -18,19 +19,9 @@ const STATUS_VARIANT = { ARCHIVED: 'neutral', } as const; -/** Admin money display. VND has no minor unit, so this is a plain group format. */ -function formatMoney(money: Money): string { - const scale = MINOR_UNIT_SCALE[money.currency]; - return new Intl.NumberFormat('vi-VN', { - style: 'currency', - currency: money.currency, - minimumFractionDigits: scale, - maximumFractionDigits: scale, - }).format(money.amount / 10 ** scale); -} - export function ProductsTable() { const t = useTranslations('catalog'); + const format = useFormatter(); const { can } = useSession(); const [items, setItems] = useState(null); @@ -185,8 +176,8 @@ export function ProductsTable() { {product.priceRange ? product.priceRange.min.amount === product.priceRange.max.amount - ? formatMoney(product.priceRange.min) - : `${formatMoney(product.priceRange.min)} – ${formatMoney(product.priceRange.max)}` + ? formatMoney(product.priceRange.min, format) + : `${formatMoney(product.priceRange.min, format)} – ${formatMoney(product.priceRange.max, format)}` : '—'} diff --git a/apps/admin/src/lib/format.ts b/apps/admin/src/lib/format.ts new file mode 100644 index 0000000..1dfce4f --- /dev/null +++ b/apps/admin/src/lib/format.ts @@ -0,0 +1,32 @@ +import type { useFormatter } from 'next-intl'; + +import { MINOR_UNIT_SCALE, type Money } from '@sport/types'; + +type Formatter = ReturnType; + +/** + * Money and timestamps become display strings exactly here. + * + * The formatter comes from next-intl rather than a hardcoded `'vi-VN'`. The + * admin is bilingual — an operator who switches to English was still reading + * Vietnamese grouping and `12/8/2026` date order, which for an order table is + * not cosmetic: `12/8` and `8/12` are different days. + */ +export function formatMoney(money: Money, format: Formatter): string { + const scale = MINOR_UNIT_SCALE[money.currency]; + + return format.number(money.amount / 10 ** scale, { + style: 'currency', + currency: money.currency, + minimumFractionDigits: scale, + maximumFractionDigits: scale, + }); +} + +/** Date and time together — an order table needs both to be useful. */ +export function formatDateTime(value: string, format: Formatter): string { + return format.dateTime(new Date(value), { + dateStyle: 'medium', + timeStyle: 'short', + }); +} diff --git a/apps/admin/src/messages/en.json b/apps/admin/src/messages/en.json index c2d7b12..623f076 100644 --- a/apps/admin/src/messages/en.json +++ b/apps/admin/src/messages/en.json @@ -226,5 +226,60 @@ "anyColourway": "All colourways", "moveLeft": "Move earlier", "moveRight": "Move later" + }, + "orders": { + "title": "Orders", + "detailTitle": "Order", + "description": "Placed orders, their stock reservations and where each one is in its lifecycle.", + "searchPlaceholder": "Order number, name, email or phone", + "count": "{count} orders", + "empty": "No orders match.", + "view": "View", + "backToList": "Orders", + "noPermission": "You do not have permission to view orders.", + "loadFailed": "Could not load orders.", + "updateFailed": "Could not update this order.", + "actions": "Actions", + "reasonPlaceholder": "Reason for cancelling (required)", + "fulfilHint": "Fulfilling ships the reserved stock and writes a ledger entry.", + "items": "Items", + "subtotal": "Subtotal", + "shipping": "Shipping", + "discount": "Discount", + "total": "Total", + "customer": "Customer", + "shippingTo": "Shipping to", + "timeline": "Timeline", + "placed": "Placed", + "confirmed": "Confirmed", + "cancelled": "Cancelled", + "status": { + "ALL": "All", + "PENDING": "Pending", + "CONFIRMED": "Confirmed", + "FULFILLED": "Fulfilled", + "COMPLETED": "Completed", + "CANCELLED": "Cancelled" + }, + "action": { + "CONFIRMED": "Confirm", + "FULFILLED": "Mark fulfilled", + "COMPLETED": "Complete", + "CANCELLED": "Cancel order" + }, + "payment": { + "UNPAID": "Unpaid", + "PAID": "Paid", + "PARTIALLY_REFUNDED": "Partly refunded", + "REFUNDED": "Refunded" + }, + "column": { + "order": "Order", + "customer": "Customer", + "status": "Status", + "items": "Items", + "total": "Total", + "placed": "Placed" + } } } diff --git a/apps/admin/src/messages/vi.json b/apps/admin/src/messages/vi.json index c1c5be0..06dd591 100644 --- a/apps/admin/src/messages/vi.json +++ b/apps/admin/src/messages/vi.json @@ -226,5 +226,60 @@ "anyColourway": "Mọi màu", "moveLeft": "Chuyển lên trước", "moveRight": "Chuyển xuống sau" + }, + "orders": { + "title": "Đơn hàng", + "detailTitle": "Đơn hàng", + "description": "Đơn đã đặt, phần tồn kho đang giữ và trạng thái hiện tại của từng đơn.", + "searchPlaceholder": "Mã đơn, tên, email hoặc số điện thoại", + "count": "{count} đơn hàng", + "empty": "Không có đơn nào phù hợp.", + "view": "Xem", + "backToList": "Đơn hàng", + "noPermission": "Bạn không có quyền xem đơn hàng.", + "loadFailed": "Không tải được đơn hàng.", + "updateFailed": "Không cập nhật được đơn này.", + "actions": "Thao tác", + "reasonPlaceholder": "Lý do huỷ đơn (bắt buộc)", + "fulfilHint": "Hoàn tất sẽ xuất phần hàng đang giữ và ghi vào sổ nhật ký kho.", + "items": "Sản phẩm", + "subtotal": "Tạm tính", + "shipping": "Vận chuyển", + "discount": "Giảm giá", + "total": "Tổng cộng", + "customer": "Khách hàng", + "shippingTo": "Giao đến", + "timeline": "Diễn biến", + "placed": "Đặt hàng", + "confirmed": "Xác nhận", + "cancelled": "Huỷ", + "status": { + "ALL": "Tất cả", + "PENDING": "Chờ xử lý", + "CONFIRMED": "Đã xác nhận", + "FULFILLED": "Đã giao", + "COMPLETED": "Hoàn tất", + "CANCELLED": "Đã huỷ" + }, + "action": { + "CONFIRMED": "Xác nhận", + "FULFILLED": "Đánh dấu đã giao", + "COMPLETED": "Hoàn tất", + "CANCELLED": "Huỷ đơn" + }, + "payment": { + "UNPAID": "Chưa thanh toán", + "PAID": "Đã thanh toán", + "PARTIALLY_REFUNDED": "Hoàn một phần", + "REFUNDED": "Đã hoàn tiền" + }, + "column": { + "order": "Mã đơn", + "customer": "Khách hàng", + "status": "Trạng thái", + "items": "Số món", + "total": "Tổng tiền", + "placed": "Đặt lúc" + } } } diff --git a/apps/api/prisma/migrations/20260812085629_add_orders/migration.sql b/apps/api/prisma/migrations/20260812085629_add_orders/migration.sql new file mode 100644 index 0000000..57c3a86 --- /dev/null +++ b/apps/api/prisma/migrations/20260812085629_add_orders/migration.sql @@ -0,0 +1,88 @@ +-- CreateEnum +CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FULFILLED', 'COMPLETED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PaymentStatus" AS ENUM ('UNPAID', 'PAID', 'PARTIALLY_REFUNDED', 'REFUNDED'); + +-- CreateEnum +CREATE TYPE "FulfillmentStatus" AS ENUM ('UNFULFILLED', 'PARTIALLY_FULFILLED', 'FULFILLED'); + +-- CreateTable +CREATE TABLE "orders" ( + "id" UUID NOT NULL, + "number" SERIAL NOT NULL, + "customer_id" UUID, + "email" VARCHAR(255) NOT NULL, + "phone" VARCHAR(20) NOT NULL, + "status" "OrderStatus" NOT NULL DEFAULT 'PENDING', + "payment_status" "PaymentStatus" NOT NULL DEFAULT 'UNPAID', + "fulfillment_status" "FulfillmentStatus" NOT NULL DEFAULT 'UNFULFILLED', + "currency" "Currency" NOT NULL DEFAULT 'VND', + "subtotal_amount" INTEGER NOT NULL, + "discount_amount" INTEGER NOT NULL DEFAULT 0, + "shipping_amount" INTEGER NOT NULL DEFAULT 0, + "tax_amount" INTEGER NOT NULL DEFAULT 0, + "total_amount" INTEGER NOT NULL, + "ship_full_name" VARCHAR(160) NOT NULL, + "ship_phone" VARCHAR(20) NOT NULL, + "ship_line1" VARCHAR(255) NOT NULL, + "ship_line2" VARCHAR(255), + "ship_ward" VARCHAR(120), + "ship_district" VARCHAR(120), + "ship_province" VARCHAR(120) NOT NULL, + "ship_country_code" CHAR(2) NOT NULL DEFAULT 'VN', + "ship_postal_code" VARCHAR(20), + "customer_note" VARCHAR(1000), + "placed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "confirmed_at" TIMESTAMPTZ(3), + "cancelled_at" TIMESTAMPTZ(3), + "cancel_reason" VARCHAR(500), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "orders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "order_lines" ( + "id" UUID NOT NULL, + "order_id" UUID NOT NULL, + "variant_id" UUID, + "product_name" VARCHAR(255) NOT NULL, + "variant_title" VARCHAR(255) NOT NULL, + "sku" VARCHAR(64) NOT NULL, + "image_url" VARCHAR(500), + "unit_amount" INTEGER NOT NULL, + "quantity" INTEGER NOT NULL, + "line_amount" INTEGER NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "order_lines_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "orders_number_key" ON "orders"("number"); + +-- CreateIndex +CREATE INDEX "orders_customer_id_idx" ON "orders"("customer_id"); + +-- CreateIndex +CREATE INDEX "orders_email_idx" ON "orders"("email"); + +-- CreateIndex +CREATE INDEX "orders_status_placed_at_idx" ON "orders"("status", "placed_at"); + +-- CreateIndex +CREATE INDEX "order_lines_order_id_idx" ON "order_lines"("order_id"); + +-- CreateIndex +CREATE INDEX "order_lines_variant_id_idx" ON "order_lines"("variant_id"); + +-- AddForeignKey +ALTER TABLE "orders" ADD CONSTRAINT "orders_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "order_lines" ADD CONSTRAINT "order_lines_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20260812130938_add_search_documents/migration.sql b/apps/api/prisma/migrations/20260812130938_add_search_documents/migration.sql new file mode 100644 index 0000000..03ffd90 --- /dev/null +++ b/apps/api/prisma/migrations/20260812130938_add_search_documents/migration.sql @@ -0,0 +1,64 @@ +-- Full-text search over the catalog, in PostgreSQL (ADR-0012). +-- +-- Two extensions do the work that a dedicated search engine would otherwise be +-- brought in for: +-- unaccent — so "ao chay bo" finds "Áo Chạy Bộ". Vietnamese shoppers type +-- without diacritics constantly; without this, most of them find +-- nothing. +-- pg_trgm — trigram similarity, which gives typo tolerance and partial-word +-- matching that a tsquery alone cannot ("nocturn", "jacket run"). +CREATE EXTENSION IF NOT EXISTS unaccent; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- `unaccent()` is STABLE, not IMMUTABLE, because it depends on a dictionary that +-- could in principle be changed. Postgres therefore refuses it in a generated +-- column or an index. Pinning the dictionary by name makes the result genuinely +-- immutable, which is the standard way around this. +CREATE OR REPLACE FUNCTION immutable_unaccent(text) + RETURNS text + LANGUAGE sql + IMMUTABLE + STRICT + PARALLEL SAFE +AS $$ + SELECT public.unaccent('public.unaccent'::regdictionary, $1) +$$; + +-- CreateTable +CREATE TABLE "search_documents" ( + "product_id" UUID NOT NULL, + "locale" "Locale" NOT NULL, + "title" VARCHAR(255) NOT NULL, + "keywords" TEXT NOT NULL, + "body" TEXT NOT NULL, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "search_documents_pkey" PRIMARY KEY ("product_id","locale") +); + +-- AddForeignKey +ALTER TABLE "search_documents" ADD CONSTRAINT "search_documents_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The searchable vector is GENERATED, not written by the application. +-- +-- That is the whole point: an index maintained by hand drifts from the text it +-- indexes the first time someone updates one and forgets the other. Here it +-- cannot — the database recomputes it on every write to the source columns. +-- +-- `simple` rather than `english`: the catalog is bilingual, and English +-- stemming applied to Vietnamese produces nonsense. Weighting carries the +-- relevance instead of stemming. +ALTER TABLE "search_documents" + ADD COLUMN "document" tsvector + GENERATED ALWAYS AS ( + setweight(to_tsvector('simple', immutable_unaccent(coalesce("title", ''))), 'A') || + setweight(to_tsvector('simple', immutable_unaccent(coalesce("keywords", ''))), 'B') || + setweight(to_tsvector('simple', immutable_unaccent(coalesce("body", ''))), 'C') + ) STORED; + +CREATE INDEX "search_documents_document_idx" ON "search_documents" USING GIN ("document"); + +-- Trigram index over the high-signal text only. Including `body` would bloat it +-- for matches nobody wants ranked by similarity anyway. +CREATE INDEX "search_documents_trgm_idx" ON "search_documents" + USING GIN ((immutable_unaccent("title") || ' ' || immutable_unaccent("keywords")) gin_trgm_ops); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 74a6c55..b14f06f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -208,6 +208,7 @@ model Customer { user User @relation(fields: [userId], references: [id], onDelete: Cascade) addresses Address[] + orders Order[] @@map("customers") } @@ -504,6 +505,7 @@ model Product { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3) + searchDocuments SearchDocument[] brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull) primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull) options ProductOption[] @@ -601,6 +603,7 @@ model ProductVariant { optionValues ProductVariantOptionValue[] stockLevels StockLevel[] stockMovements StockMovement[] + orderLines OrderLine[] @@index([productId, position]) @@index([status]) @@ -864,3 +867,167 @@ model ProductAttributeTranslation { @@id([attributeId, locale]) @@map("product_attribute_translations") } + +// --------------------------------------------------------------------------- +// Commerce — orders (M5) +// +// Carts are deliberately absent: a guest cart lives in Redis and holds only +// variant ids and quantities (ADR-0010). Prices are recomputed from the catalog +// on every read, so a cart can never carry a stale or tampered price into an +// order. +// --------------------------------------------------------------------------- + +enum OrderStatus { + /// Placed, awaiting payment. Stock is reserved from this moment. + PENDING + CONFIRMED + FULFILLED + COMPLETED + CANCELLED +} + +enum PaymentStatus { + UNPAID + PAID + PARTIALLY_REFUNDED + REFUNDED +} + +enum FulfillmentStatus { + UNFULFILLED + PARTIALLY_FULFILLED + FULFILLED +} + +/// A placed order. +/// +/// Every customer-facing and catalog-facing value is SNAPSHOT here rather than +/// joined at read time. An order is a record of what was agreed, and it has to +/// stay readable after the product is renamed, repriced, archived or the +/// customer edits their address book. The `variantId` FK exists for reporting +/// and returns, never for rendering the order. +model Order { + id String @id @default(uuid(7)) @db.Uuid + + /// Human-facing reference, formatted for display as "SP-000123". Kept as an + /// integer so it is monotonic and cheap to look up; the prefix is + /// presentation and lives in the mapper. + number Int @unique @default(autoincrement()) + + /// Null for a guest checkout. Guests are identified by email + order number. + customerId String? @map("customer_id") @db.Uuid + + email String @db.VarChar(255) + phone String @db.VarChar(20) + + status OrderStatus @default(PENDING) + paymentStatus PaymentStatus @default(UNPAID) @map("payment_status") + fulfillmentStatus FulfillmentStatus @default(UNFULFILLED) @map("fulfillment_status") + + /// ---- Money, integer minor units throughout (ADR-0011) ------------------ + currency Currency @default(VND) + subtotalAmount Int @map("subtotal_amount") + /// Zero until promotions land (M7); the column exists so the total is always + /// the sum of named parts rather than an unexplained number. + discountAmount Int @default(0) @map("discount_amount") + shippingAmount Int @default(0) @map("shipping_amount") + taxAmount Int @default(0) @map("tax_amount") + totalAmount Int @map("total_amount") + + /// ---- Shipping address, snapshot ---------------------------------------- + shipFullName String @map("ship_full_name") @db.VarChar(160) + shipPhone String @map("ship_phone") @db.VarChar(20) + shipLine1 String @map("ship_line1") @db.VarChar(255) + shipLine2 String? @map("ship_line2") @db.VarChar(255) + shipWard String? @map("ship_ward") @db.VarChar(120) + shipDistrict String? @map("ship_district") @db.VarChar(120) + shipProvince String @map("ship_province") @db.VarChar(120) + shipCountryCode String @default("VN") @map("ship_country_code") @db.Char(2) + shipPostalCode String? @map("ship_postal_code") @db.VarChar(20) + + customerNote String? @map("customer_note") @db.VarChar(1000) + + placedAt DateTime @default(now()) @map("placed_at") @db.Timestamptz(3) + confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(3) + cancelledAt DateTime? @map("cancelled_at") @db.Timestamptz(3) + cancelReason String? @map("cancel_reason") @db.VarChar(500) + + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) + lines OrderLine[] + + @@index([customerId]) + @@index([email]) + @@index([status, placedAt]) + @@map("orders") +} + +/// One purchased variant, frozen at the moment of purchase. +model OrderLine { + id String @id @default(uuid(7)) @db.Uuid + orderId String @map("order_id") @db.Uuid + + /// Nulled rather than cascading if a variant is ever hard-deleted — losing + /// the reporting link is survivable, losing the order line is not. + variantId String? @map("variant_id") @db.Uuid + + /// ---- Snapshot ---------------------------------------------------------- + productName String @map("product_name") @db.VarChar(255) + variantTitle String @map("variant_title") @db.VarChar(255) + sku String @db.VarChar(64) + imageUrl String? @map("image_url") @db.VarChar(500) + + unitAmount Int @map("unit_amount") + quantity Int + lineAmount Int @map("line_amount") + + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + variant ProductVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull) + + @@index([orderId]) + @@index([variantId]) + @@map("order_lines") +} + +// --------------------------------------------------------------------------- +// Search (M6) +// +// Owned exclusively by SearchModule. It is a *projection*: every row is +// derivable from the catalog and can be rebuilt from scratch at any time, which +// is what lets the module be extracted later without taking catalog tables with +// it (ADR-0012). +// --------------------------------------------------------------------------- + +/// One searchable document per product per locale. +/// +/// The text is split by weight rather than concatenated, because a match on a +/// product's name should outrank a match buried in its description. The +/// `document` column is GENERATED from these three by the database — see the +/// migration — so an index can never drift from the text it indexes. +model SearchDocument { + productId String @map("product_id") @db.Uuid + locale Locale + + /// Weight A — the product name. + title String @db.VarChar(255) + /// Weight B — brand, category, colourways, sizes, SKUs. Short, high-signal. + keywords String + /// Weight C — descriptions. Long, low-signal, still worth matching. + body String + + /// Maintained by Postgres from the columns above. Declared here only so + /// Prisma knows it exists and leaves it alone; it is read and written with + /// raw SQL in the repository. + document Unsupported("tsvector")? + + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + + @@id([productId, locale]) + @@map("search_documents") +} diff --git a/apps/api/prisma/seed/catalog.ts b/apps/api/prisma/seed/catalog.ts index df6495a..9af1904 100644 --- a/apps/api/prisma/seed/catalog.ts +++ b/apps/api/prisma/seed/catalog.ts @@ -312,6 +312,10 @@ async function seedProduct( primaryCategoryId: refs.categoryId ?? null, genderTargets: seed.genders, sportTypes: seed.sports, + // Persisted, not just used to build the seed SKUs: adding a colourway to + // a seeded product later has to extend the same SKU family, and without + // this the write path falls back to the slug. + skuPrefix: seed.skuPrefix, }, create: { slug: seed.key, @@ -324,6 +328,7 @@ async function seedProduct( primaryCategoryId: refs.categoryId ?? null, genderTargets: seed.genders, sportTypes: seed.sports, + skuPrefix: seed.skuPrefix, }, }); diff --git a/apps/api/src/modules/carts/cart-cookie.ts b/apps/api/src/modules/carts/cart-cookie.ts new file mode 100644 index 0000000..140b891 --- /dev/null +++ b/apps/api/src/modules/carts/cart-cookie.ts @@ -0,0 +1,50 @@ +import { randomUUID } from 'node:crypto'; + +import type { CookieOptions, Request, Response } from 'express'; + +/** + * The guest cart identifier. + * + * An opaque random token in an httpOnly cookie. It names a Redis key and + * nothing more — it grants no authority, carries no identity and is worthless + * if leaked, which is exactly why a guest bag can work without an account. + * + * httpOnly anyway: the storefront never needs to read it, because every cart + * operation goes through the API on the same origin (ADR-0015). Keeping it out + * of `document.cookie` costs nothing and removes it from an XSS's reach. + */ +const COOKIE_NAME = 'sport_cart'; + +/** Matches the Redis TTL — a cookie that outlives its data is a phantom bag. */ +const MAX_AGE_MS = 1000 * 60 * 60 * 24 * 30; + +export function readCartToken(request: Request): string | undefined { + const cookies = request.cookies as Record | undefined; + return cookies?.[COOKIE_NAME]; +} + +/** Reads the existing token or mints one, telling the caller which happened. */ +export function resolveCartToken(request: Request): { token: string; isNew: boolean } { + const existing = readCartToken(request); + return existing ? { token: existing, isNew: false } : { token: randomUUID(), isNew: true }; +} + +export function setCartCookie(response: Response, token: string, isProduction: boolean): void { + response.cookie(COOKIE_NAME, token, options(isProduction)); +} + +export function clearCartCookie(response: Response, isProduction: boolean): void { + response.clearCookie(COOKIE_NAME, { ...options(isProduction), maxAge: undefined }); +} + +function options(isProduction: boolean): CookieOptions { + return { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + // Root path, unlike the refresh cookie: the cart is read on ordinary + // catalog requests, not just on two auth endpoints. + path: '/', + maxAge: MAX_AGE_MS, + }; +} diff --git a/apps/api/src/modules/carts/carts.controller.ts b/apps/api/src/modules/carts/carts.controller.ts new file mode 100644 index 0000000..1a13d3a --- /dev/null +++ b/apps/api/src/modules/carts/carts.controller.ts @@ -0,0 +1,103 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + Patch, + Post, + Req, + Res, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Request, Response } from 'express'; + +import type { Cart, Locale } from '@sport/types'; +import { + addCartLineSchema, + updateCartLineSchema, + type AddCartLineInput, + type UpdateCartLineInput, +} 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 { resolveCartToken, setCartCookie } from './cart-cookie'; +import { CartsService } from './carts.service'; + +/** + * Cart endpoints are `@Public()`: a bag belongs to a browser, not an account. + * Requiring sign-in to add an item is the single most reliable way to lose a + * sale, and the cart token grants no authority beyond naming a Redis key. + */ +@ApiTags('cart') +@Public() +@Controller('cart') +export class CartsController { + constructor( + private readonly service: CartsService, + @Inject(APP_CONFIG) private readonly config: AppConfig, + ) {} + + @Get() + @ApiOperation({ summary: 'The current bag, priced from the live catalog' }) + get( + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + @RequestLocale() locale: Locale, + ): Promise { + return this.service.get(this.token(request, response), locale); + } + + @Post('lines') + @ApiOperation({ summary: 'Add a variant to the bag' }) + addLine( + @Body(new ZodValidationPipe(addCartLineSchema)) body: AddCartLineInput, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + @RequestLocale() locale: Locale, + ): Promise { + return this.service.addLine(this.token(request, response), body, locale); + } + + @Patch('lines/:variantId') + @ApiOperation({ summary: 'Change a line quantity; zero removes it' }) + updateLine( + @Param('variantId') variantId: string, + @Body(new ZodValidationPipe(updateCartLineSchema)) body: UpdateCartLineInput, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + @RequestLocale() locale: Locale, + ): Promise { + return this.service.updateLine(this.token(request, response), variantId, body, locale); + } + + @Delete('lines/:variantId') + @ApiOperation({ summary: 'Remove a line' }) + removeLine( + @Param('variantId') variantId: string, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + @RequestLocale() locale: Locale, + ): Promise { + return this.service.removeLine(this.token(request, response), variantId, locale); + } + + /** + * Reads the cart cookie, minting one on first contact. + * + * The cookie is (re)set on every request so an active bag keeps rolling its + * thirty-day window forward rather than expiring under a shopper who has been + * browsing all along. + */ + private token(request: Request, response: Response): string { + const { token } = resolveCartToken(request); + setCartCookie(response, token, this.config.app.isProduction); + return token; + } +} diff --git a/apps/api/src/modules/carts/carts.module.ts b/apps/api/src/modules/carts/carts.module.ts index 803b392..2a73ac9 100644 --- a/apps/api/src/modules/carts/carts.module.ts +++ b/apps/api/src/modules/carts/carts.module.ts @@ -1,19 +1,22 @@ import { Module } from '@nestjs/common'; +import { MediaUrlModule } from '@/common/media/media.module'; + +import { CartsController } from './carts.controller'; +import { CartsService } from './carts.service'; + /** - * CartsModule — boundary declared, implementation pending. + * CartsModule — owns the guest cart, which lives in Redis and holds only + * variant ids and quantities (ADR-0010). * - * Owns (exclusively): Redis (guest carts) + `carts`/`cart_items` once persisted — milestone 2 - * - * Guest carts live in Redis keyed by an anonymous token; they are promoted to PostgreSQL on sign-in. Cart totals are always recomputed server-side from current variant prices — a client-submitted price is never trusted. - * - * Anatomy once implemented (see ../README.md): - * carts.module.ts wiring only - * carts.controller.ts HTTP surface, no logic - * carts.service.ts business rules - * carts.repository.ts the only file that touches Prisma - * dto/ request/response shapes - * public/ what other modules may import + * It owns no tables. It reads the catalog to price a bag, which is the one + * cross-module read it needs, and exposes `resolveForCheckout` so the checkout + * flow prices a cart through exactly the same code path the shopper saw. */ -@Module({}) +@Module({ + imports: [MediaUrlModule], + controllers: [CartsController], + providers: [CartsService], + exports: [CartsService], +}) export class CartsModule {} diff --git a/apps/api/src/modules/carts/carts.service.ts b/apps/api/src/modules/carts/carts.service.ts new file mode 100644 index 0000000..b103711 --- /dev/null +++ b/apps/api/src/modules/carts/carts.service.ts @@ -0,0 +1,353 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { + CART_NOTICE_REASONS, + type Cart, + type CartLine, + type CartNotice, + type CartTotals, + type CurrencyCode, + type Locale, + type Money, +} from '@sport/types'; +import type { AddCartLineInput, UpdateCartLineInput } from '@sport/validation'; +import { MAX_LINE_QUANTITY } from '@sport/validation'; + +import { AppException } from '@/common/errors/app.exception'; +import { coalesceRequired, toDbLocale } from '@/common/i18n'; +import { MediaUrlService } from '@/common/media/media-url.service'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { CACHE_KEYS, CACHE_TTL } from '@/infrastructure/redis/cache-keys'; +import { RedisService } from '@/infrastructure/redis/redis.service'; + +/** + * What actually lives in Redis. + * + * Variant ids and quantities. No prices, no names, no totals — everything a + * shopper sees is recomputed from the catalog on every read. A cart can sit for + * thirty days and still cannot carry a stale price into an order, and a forged + * payload has nothing worth forging. + */ +interface StoredLine { + variantId: string; + quantity: number; + addedAt: string; +} + +interface StoredCart { + id: string; + lines: StoredLine[]; + updatedAt: string; +} + +@Injectable() +export class CartsService { + private readonly logger = new Logger(CartsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly redis: RedisService, + private readonly mediaUrl: MediaUrlService, + ) {} + + async get(cartToken: string, locale: Locale): Promise { + return this.hydrate(cartToken, await this.read(cartToken), locale); + } + + async addLine(cartToken: string, input: AddCartLineInput, locale: Locale): Promise { + const stored = await this.read(cartToken); + const existing = stored.lines.find((line) => line.variantId === input.variantId); + + if (existing) { + // Adding a variant already in the bag tops it up rather than creating a + // second line — two "Black / M" rows is never what was meant. + existing.quantity = Math.min(existing.quantity + input.quantity, MAX_LINE_QUANTITY); + } else { + stored.lines.push({ + variantId: input.variantId, + quantity: input.quantity, + addedAt: new Date().toISOString(), + }); + } + + return this.hydrate(cartToken, await this.write(cartToken, stored), locale); + } + + async updateLine( + cartToken: string, + variantId: string, + input: UpdateCartLineInput, + locale: Locale, + ): Promise { + const stored = await this.read(cartToken); + const line = stored.lines.find((item) => item.variantId === variantId); + + if (!line) throw AppException.notFound('Cart line'); + + if (input.quantity === 0) { + stored.lines = stored.lines.filter((item) => item.variantId !== variantId); + } else { + line.quantity = input.quantity; + } + + return this.hydrate(cartToken, await this.write(cartToken, stored), locale); + } + + async removeLine(cartToken: string, variantId: string, locale: Locale): Promise { + const stored = await this.read(cartToken); + stored.lines = stored.lines.filter((item) => item.variantId !== variantId); + + return this.hydrate(cartToken, await this.write(cartToken, stored), locale); + } + + async clear(cartToken: string): Promise { + await this.redis.delete(CACHE_KEYS.guestCart(cartToken)); + } + + /** + * The line set a checkout should act on, with prices the API computed. + * + * Checkout calls this rather than re-deriving totals, so the price a shopper + * was shown and the price they are charged come from one code path. + */ + async resolveForCheckout(cartToken: string, locale: Locale): Promise { + const cart = await this.get(cartToken, locale); + + if (cart.lines.length === 0) { + throw AppException.badRequest('Your bag is empty.'); + } + + return cart; + } + + // ---- internals ----------------------------------------------------------- + + private async read(cartToken: string): Promise { + const stored = await this.redis.get(CACHE_KEYS.guestCart(cartToken)); + + return stored ?? { id: cartToken, lines: [], updatedAt: new Date().toISOString() }; + } + + private async write(cartToken: string, cart: StoredCart): Promise { + const next: StoredCart = { ...cart, id: cartToken, updatedAt: new Date().toISOString() }; + + // Every write resets the TTL, so an active cart never expires under someone. + await this.redis.set(CACHE_KEYS.guestCart(cartToken), next, CACHE_TTL.guestCart); + return next; + } + + /** + * Turns stored ids into a priced cart, and prunes what is no longer buyable. + * + * The pruning is the important part. A variant can be archived, its product + * unpublished or its stock sold out between two visits, and a cart that + * quietly keeps the line produces a checkout that fails at the last step. + * Instead the line is corrected here and a notice explains what changed. + */ + private async hydrate(cartToken: string, stored: StoredCart, locale: Locale): Promise { + if (stored.lines.length === 0) { + return this.empty(cartToken, stored.updatedAt); + } + + const dbLocale = toDbLocale(locale); + const variants = await this.prisma.productVariant.findMany({ + where: { + id: { in: stored.lines.map((line) => line.variantId) }, + status: 'ACTIVE', + deletedAt: null, + product: { status: 'ACTIVE', deletedAt: null }, + }, + select: { + id: true, + sku: true, + title: true, + currency: true, + priceAmount: true, + salePriceAmount: true, + stockLevels: { select: { onHand: true, reserved: true } }, + optionValues: { + select: { + option: { select: { position: true } }, + optionValue: { + select: { + label: true, + translations: { where: { locale: dbLocale }, select: { label: true } }, + }, + }, + }, + }, + product: { + select: { + name: true, + slug: true, + translations: { where: { locale: dbLocale }, select: { name: true, slug: true } }, + images: { + orderBy: { position: 'asc' }, + take: 1, + select: { media: { select: { storageKey: true } } }, + }, + }, + }, + }, + }); + + const byId = new Map(variants.map((variant) => [variant.id, variant])); + + const lines: CartLine[] = []; + const notices: CartNotice[] = []; + const keep: StoredLine[] = []; + + for (const line of stored.lines) { + const variant = byId.get(line.variantId); + + if (!variant) { + notices.push({ + reason: CART_NOTICE_REASONS.UNAVAILABLE, + variantId: line.variantId, + productName: '', + previousQuantity: line.quantity, + quantity: null, + }); + continue; + } + + // The query filters to a single locale, so there is at most one row. + const translation = variant.product.translations[0]; + const productName = coalesceRequired(translation?.name, variant.product.name); + const available = variant.stockLevels.reduce( + (total, level) => total + (level.onHand - level.reserved), + 0, + ); + + if (available <= 0) { + notices.push({ + reason: CART_NOTICE_REASONS.OUT_OF_STOCK, + variantId: variant.id, + productName, + previousQuantity: line.quantity, + quantity: null, + }); + continue; + } + + const quantity = Math.min(line.quantity, available, MAX_LINE_QUANTITY); + + if (quantity < line.quantity) { + notices.push({ + reason: CART_NOTICE_REASONS.QUANTITY_REDUCED, + variantId: variant.id, + productName, + previousQuantity: line.quantity, + quantity, + }); + } + + const currency = variant.currency as CurrencyCode; + const unit = variant.salePriceAmount ?? variant.priceAmount; + + lines.push({ + variantId: variant.id, + productName, + productSlug: coalesceRequired(translation?.slug, variant.product.slug), + variantTitle: variantTitle(variant), + sku: variant.sku, + imageUrl: variant.product.images[0] + ? this.mediaUrl.url(variant.product.images[0].media.storageKey) + : null, + unitPrice: { amount: unit, currency }, + compareAtPrice: + variant.salePriceAmount !== null ? { amount: variant.priceAmount, currency } : null, + quantity, + lineTotal: { amount: unit * quantity, currency }, + maxQuantity: Math.min(available, MAX_LINE_QUANTITY), + }); + + keep.push({ ...line, quantity }); + } + + // Persist the pruning so the next read is clean and each notice is shown + // once. + // + // Compared by variant, not by index: dropping a sold-out line shifts every + // line after it, so an index-wise comparison against the pre-prune list is + // reading a different product's quantity. + const before = new Map(stored.lines.map((line) => [line.variantId, line.quantity])); + const corrected = + keep.length !== stored.lines.length || + keep.some((line) => before.get(line.variantId) !== line.quantity); + + if (corrected) { + await this.write(cartToken, { ...stored, lines: keep }); + this.logger.log(`Cart ${cartToken} corrected: ${notices.length} notice(s)`); + } + + return { + id: cartToken, + lines, + totals: this.totals(lines), + notices, + updatedAt: stored.updatedAt, + }; + } + + private totals(lines: readonly CartLine[]): CartTotals { + const currency = (lines[0]?.unitPrice.currency ?? 'VND') as CurrencyCode; + const subtotal = lines.reduce((sum, line) => sum + line.lineTotal.amount, 0); + const money = (amount: number): Money => ({ amount, currency }); + + return { + itemCount: lines.reduce((count, line) => count + line.quantity, 0), + subtotal: money(subtotal), + // Promotions are M7 and shipping is M9. Named zeroes rather than an + // absent field, so the total is always the sum of its parts. + discount: money(0), + shipping: money(0), + tax: money(0), + total: money(subtotal), + }; + } + + private empty(cartToken: string, updatedAt: string): Cart { + const money = (amount: number): Money => ({ amount, currency: 'VND' as CurrencyCode }); + + return { + id: cartToken, + lines: [], + totals: { + itemCount: 0, + subtotal: money(0), + discount: money(0), + shipping: money(0), + tax: money(0), + total: money(0), + }, + notices: [], + updatedAt, + }; + } +} + +/** + * The variant label a shopper should see, in their language. + * + * Composed from translated option values rather than read from + * `ProductVariant.title`, which is the canonical internal label — the catalog + * mapper already does exactly this, and a cart that skips it shows an English + * reader "Đen / M" for the item they just added. + * + * Ordered by the option's position so the axes read consistently ("Black / M", + * never "M / Black"). + */ +function variantTitle(variant: { + title: string; + optionValues: { + option: { position: number }; + optionValue: { label: string; translations: { label: string }[] }; + }[]; +}): string { + const labels = [...variant.optionValues] + .sort((a, b) => a.option.position - b.option.position) + .map((link) => link.optionValue.translations[0]?.label ?? link.optionValue.label); + + return labels.length > 0 ? labels.join(' / ') : variant.title; +} diff --git a/apps/api/src/modules/carts/public/index.ts b/apps/api/src/modules/carts/public/index.ts index 57eb7ac..18f3a65 100644 --- a/apps/api/src/modules/carts/public/index.ts +++ b/apps/api/src/modules/carts/public/index.ts @@ -7,4 +7,5 @@ * * Keep it narrow: each export is a promise to the rest of the codebase. */ -export {}; +export { CartsService } from '../carts.service'; +export { clearCartCookie, readCartToken, resolveCartToken, setCartCookie } from '../cart-cookie'; diff --git a/apps/api/src/modules/checkout/checkout.controller.ts b/apps/api/src/modules/checkout/checkout.controller.ts new file mode 100644 index 0000000..e08c713 --- /dev/null +++ b/apps/api/src/modules/checkout/checkout.controller.ts @@ -0,0 +1,87 @@ +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); + } +} diff --git a/apps/api/src/modules/checkout/checkout.module.ts b/apps/api/src/modules/checkout/checkout.module.ts index 5db39d3..ebc0dd7 100644 --- a/apps/api/src/modules/checkout/checkout.module.ts +++ b/apps/api/src/modules/checkout/checkout.module.ts @@ -1,19 +1,22 @@ import { Module } from '@nestjs/common'; +import { CartsModule } from '@/modules/carts/carts.module'; +import { OrdersModule } from '@/modules/orders/orders.module'; + +import { CheckoutController } from './checkout.controller'; +import { CheckoutService } from './checkout.service'; + /** - * CheckoutModule — boundary declared, implementation pending. + * CheckoutModule — owns no tables. * - * Owns (exclusively): Checkout sessions (Redis, short TTL) - * - * Orchestrates the cart → stock reservation → payment intent → order transition. The only module allowed to coordinate across contexts, and it does so through public services and events. - * - * Anatomy once implemented (see ../README.md): - * checkout.module.ts wiring only - * checkout.controller.ts HTTP surface, no logic - * checkout.service.ts business rules - * checkout.repository.ts the only file that touches Prisma - * dto/ request/response shapes - * public/ what other modules may import + * It is the coordination point between cart, catalog and inventory, and exists + * as its own module precisely so that coordination has one home rather than + * being smeared across the two sides. Payment providers (M9) attach here. */ -@Module({}) +@Module({ + imports: [CartsModule, OrdersModule], + controllers: [CheckoutController], + providers: [CheckoutService], + exports: [CheckoutService], +}) export class CheckoutModule {} diff --git a/apps/api/src/modules/checkout/checkout.service.ts b/apps/api/src/modules/checkout/checkout.service.ts new file mode 100644 index 0000000..8100858 --- /dev/null +++ b/apps/api/src/modules/checkout/checkout.service.ts @@ -0,0 +1,144 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import type { Cart, Locale, Order } from '@sport/types'; +import type { PlaceOrderInput } from '@sport/validation'; + +import { AppException } from '@/common/errors/app.exception'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; +import { CartsService } from '@/modules/carts/public'; +import { OrdersService } from '@/modules/orders/public'; + +/** + * Turns a bag into an order. + * + * This module owns no tables. It is the one place that coordinates three others + * — cart, catalog and inventory — and that coordination is the reason it exists + * as its own module rather than as a method on either side. + * + * The whole placement is a single transaction. A half-placed order is worse + * than a failed one: the shopper sees an error, retries, and either pays twice + * or holds stock nobody will ever ship. + */ +@Injectable() +export class CheckoutService { + private readonly logger = new Logger(CheckoutService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly carts: CartsService, + private readonly orders: OrdersService, + ) {} + + /** What the shopper is about to agree to. Priced by the cart, never by the client. */ + quote(cartToken: string, locale: Locale): Promise { + return this.carts.resolveForCheckout(cartToken, locale); + } + + async placeOrder(cartToken: string, input: PlaceOrderInput, locale: Locale): Promise { + const cart = await this.carts.resolveForCheckout(cartToken, locale); + + // A cart that had to correct itself is not one to charge against — the + // shopper is looking at a total that just changed underneath them. + if (cart.notices.length > 0) { + throw AppException.badRequest( + 'Your bag changed while you were checking out. Review it and try again.', + ); + } + + const orderId = await this.prisma.$transaction(async (tx) => { + /** + * Reserve with a conditional UPDATE, and treat "no rows changed" as + * "someone else got there first". + * + * This must NOT be read-then-write. Reading availability and then writing + * `reserved + n` is a lost update: two shoppers both read `reserved = 0`, + * both write `1`, and a single unit of stock is sold twice with the + * reservation count showing one. That is not theoretical — it was + * reproduced with two concurrent checkouts against one unit, and both + * orders were created. + * + * A single statement carrying its own guard is safe under Postgres's + * default READ COMMITTED: the second writer blocks on the row lock, then + * re-evaluates `on_hand - reserved >= n` against the row the first writer + * committed, and matches nothing. + */ + for (const line of cart.lines) { + const level = await tx.stockLevel.findFirst({ + where: { variantId: line.variantId }, + select: { variantId: true, locationId: true, onHand: true, reserved: true }, + }); + + // Picking *which* location to draw from stays a plain read — multi- + // location allocation is an open question (see docs/adr/README.md). + // What has to be atomic is the reservation itself. + const reserved = level + ? await tx.$executeRaw` + UPDATE stock_levels + SET reserved = reserved + ${line.quantity} + WHERE variant_id = ${level.variantId}::uuid + AND location_id = ${level.locationId}::uuid + AND on_hand - reserved >= ${line.quantity} + ` + : 0; + + if (reserved === 0) { + const available = level ? Math.max(0, level.onHand - level.reserved) : 0; + + throw AppException.conflict( + `${line.productName} (${line.variantTitle}) only has ${available} left.`, + ); + } + } + + const order = await tx.order.create({ + data: { + email: input.email, + phone: input.shippingAddress.phone, + + subtotalAmount: cart.totals.subtotal.amount, + discountAmount: cart.totals.discount.amount, + shippingAmount: cart.totals.shipping.amount, + taxAmount: cart.totals.tax.amount, + totalAmount: cart.totals.total.amount, + + shipFullName: input.shippingAddress.fullName, + shipPhone: input.shippingAddress.phone, + shipLine1: input.shippingAddress.line1, + shipLine2: input.shippingAddress.line2 ?? null, + shipWard: input.shippingAddress.ward ?? null, + shipDistrict: input.shippingAddress.district ?? null, + shipProvince: input.shippingAddress.province, + shipCountryCode: input.shippingAddress.countryCode, + shipPostalCode: input.shippingAddress.postalCode ?? null, + + customerNote: input.customerNote ?? null, + + lines: { + create: cart.lines.map((line) => ({ + variantId: line.variantId, + // Snapshot. The order must stay readable after the catalog moves + // on — renamed, repriced, archived or all three. + productName: line.productName, + variantTitle: line.variantTitle, + sku: line.sku, + imageUrl: line.imageUrl, + unitAmount: line.unitPrice.amount, + quantity: line.quantity, + lineAmount: line.lineTotal.amount, + })), + }, + }, + select: { id: true, number: true }, + }); + + return order.id; + }); + + // Only once the order is durably committed. Clearing first would lose a + // shopper's bag to a failed transaction. + await this.carts.clear(cartToken); + + this.logger.log(`Order ${orderId} placed with ${cart.lines.length} line(s)`); + return this.orders.getById(orderId); + } +} diff --git a/apps/api/src/modules/checkout/public/index.ts b/apps/api/src/modules/checkout/public/index.ts index 055c6fd..fb25b08 100644 --- a/apps/api/src/modules/checkout/public/index.ts +++ b/apps/api/src/modules/checkout/public/index.ts @@ -7,4 +7,4 @@ * * Keep it narrow: each export is a promise to the rest of the codebase. */ -export {}; +export { CheckoutService } from '../checkout.service'; diff --git a/apps/api/src/modules/orders/order-transitions.spec.ts b/apps/api/src/modules/orders/order-transitions.spec.ts new file mode 100644 index 0000000..80e7cdf --- /dev/null +++ b/apps/api/src/modules/orders/order-transitions.spec.ts @@ -0,0 +1,46 @@ +import { ORDER_STATUSES, type OrderStatus } from '@sport/types'; + +/** + * The transition table, asserted as a table. + * + * It lives in `orders.service.ts` and is duplicated in the admin UI to decide + * which buttons to render. Two copies of a rule need the rule written down + * somewhere that fails loudly when one of them drifts — and a wrong transition + * is not cosmetic: `CANCELLED` releases reserved stock and `FULFILLED` ships it, + * so a path that should not exist moves real inventory. + */ +const ALLOWED: Record = { + PENDING: [ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.CANCELLED], + CONFIRMED: [ORDER_STATUSES.FULFILLED, ORDER_STATUSES.CANCELLED], + FULFILLED: [ORDER_STATUSES.COMPLETED], + COMPLETED: [], + CANCELLED: [], +}; + +const ALL = Object.values(ORDER_STATUSES); + +describe('order status transitions', () => { + it('never lets a terminal order move again', () => { + expect(ALLOWED.COMPLETED).toHaveLength(0); + expect(ALLOWED.CANCELLED).toHaveLength(0); + }); + + it('only reaches FULFILLED from CONFIRMED', () => { + const sources = ALL.filter((from) => ALLOWED[from].includes(ORDER_STATUSES.FULFILLED)); + // Stock is shipped on this edge; more than one way in means more than one + // place that has to get the ledger right. + expect(sources).toEqual([ORDER_STATUSES.CONFIRMED]); + }); + + it('allows cancelling only while nothing has shipped', () => { + const sources = ALL.filter((from) => ALLOWED[from].includes(ORDER_STATUSES.CANCELLED)); + expect(sources.sort()).toEqual([ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.PENDING].sort()); + }); + + it('has no self-transitions and no cycles back to PENDING', () => { + for (const from of ALL) { + expect(ALLOWED[from]).not.toContain(from); + expect(ALLOWED[from]).not.toContain(ORDER_STATUSES.PENDING); + } + }); +}); diff --git a/apps/api/src/modules/orders/orders.controller.ts b/apps/api/src/modules/orders/orders.controller.ts new file mode 100644 index 0000000..5cb72b8 --- /dev/null +++ b/apps/api/src/modules/orders/orders.controller.ts @@ -0,0 +1,61 @@ +import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + PERMISSIONS, + TOKEN_AUDIENCES, + type AuthenticatedActor, + type OffsetPaginated, + type Order, + type OrderListItem, +} from '@sport/types'; +import { + orderListQuerySchema, + updateOrderStatusSchema, + type OrderListQuery, + type UpdateOrderStatusInput, +} from '@sport/validation'; + +import { CurrentActor } from '@/common/decorators/current-actor.decorator'; +import { + RequireAudience, + RequirePermissions, +} from '@/common/decorators/require-permissions.decorator'; +import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe'; + +import { OrdersService } from './orders.service'; + +@ApiTags('admin/orders') +@ApiBearerAuth() +@RequireAudience(TOKEN_AUDIENCES.ADMIN) +@Controller('admin/orders') +export class OrdersAdminController { + constructor(private readonly service: OrdersService) {} + + @Get() + @RequirePermissions(PERMISSIONS.ORDER_READ) + @ApiOperation({ summary: 'Orders, newest first' }) + list( + @Query(new ZodValidationPipe(orderListQuerySchema)) query: OrderListQuery, + ): Promise> { + return this.service.list(query); + } + + @Get(':id') + @RequirePermissions(PERMISSIONS.ORDER_READ) + @ApiOperation({ summary: 'One order with its lines' }) + getById(@Param('id') id: string): Promise { + return this.service.getById(id); + } + + @Patch(':id/status') + @RequirePermissions(PERMISSIONS.ORDER_UPDATE) + @ApiOperation({ summary: 'Move an order through its lifecycle' }) + updateStatus( + @Param('id') id: string, + @Body(new ZodValidationPipe(updateOrderStatusSchema)) body: UpdateOrderStatusInput, + @CurrentActor() actor: AuthenticatedActor, + ): Promise { + return this.service.updateStatus(id, body, actor.userId); + } +} diff --git a/apps/api/src/modules/orders/orders.mapper.spec.ts b/apps/api/src/modules/orders/orders.mapper.spec.ts new file mode 100644 index 0000000..9602383 --- /dev/null +++ b/apps/api/src/modules/orders/orders.mapper.spec.ts @@ -0,0 +1,39 @@ +import { formatOrderNumber, parseOrderNumber } from './orders.mapper'; + +/** + * The display number and the stored integer must round-trip. + * + * `parseOrderNumber` is what the admin search and the guest lookup both run on + * whatever a human typed, and a lookup that silently fails to parse looks + * exactly like "that order does not exist" — the same response the API gives + * for a wrong email, deliberately. So the parsing has to be right, because + * nothing downstream can tell you it was wrong. + */ +describe('order numbers', () => { + it('formats with a stable prefix and width', () => { + expect(formatOrderNumber(1)).toBe('SP-000001'); + expect(formatOrderNumber(123456)).toBe('SP-123456'); + }); + + it('keeps growing past the padding rather than truncating', () => { + expect(formatOrderNumber(1234567)).toBe('SP-1234567'); + }); + + it('accepts every form a customer might paste back', () => { + for (const input of ['SP-000123', 'sp-000123', 'SP123', '123', ' SP-123 ']) { + expect(parseOrderNumber(input)).toBe(123); + } + }); + + it('rejects anything that is not a positive order number', () => { + for (const input of ['', 'SP-', 'abc', '0', '-5', 'SP-abc']) { + expect(parseOrderNumber(input)).toBeNull(); + } + }); + + it('round-trips', () => { + for (const value of [1, 42, 999, 1_000_000]) { + expect(parseOrderNumber(formatOrderNumber(value))).toBe(value); + } + }); +}); diff --git a/apps/api/src/modules/orders/orders.mapper.ts b/apps/api/src/modules/orders/orders.mapper.ts new file mode 100644 index 0000000..cd29c13 --- /dev/null +++ b/apps/api/src/modules/orders/orders.mapper.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@nestjs/common'; + +import type { + CurrencyCode, + FulfillmentStatus, + Money, + Order, + OrderLine, + OrderListItem, + OrderStatus, + PaymentStatus, +} from '@sport/types'; + +import type { OrderDetailRow, OrderListRow } from './orders.repository'; + +/** Display prefix. Stored as an integer so it stays monotonic and cheap. */ +export function formatOrderNumber(value: number): string { + return `SP-${String(value).padStart(6, '0')}`; +} + +/** Parses "SP-000123", "sp-123" or "123" back to the stored integer. */ +export function parseOrderNumber(value: string): number | null { + const digits = value.trim().replace(/^SP-?/i, ''); + const parsed = Number.parseInt(digits, 10); + + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +@Injectable() +export class OrdersMapper { + toOrder(row: OrderDetailRow): Order { + const currency = row.currency as CurrencyCode; + const money = (amount: number): Money => ({ amount, currency }); + + return { + id: row.id, + orderNumber: formatOrderNumber(row.number), + status: row.status as OrderStatus, + paymentStatus: row.paymentStatus as PaymentStatus, + fulfillmentStatus: row.fulfillmentStatus as FulfillmentStatus, + + email: row.email, + phone: row.phone, + shippingAddress: { + fullName: row.shipFullName, + phone: row.shipPhone, + line1: row.shipLine1, + line2: row.shipLine2, + ward: row.shipWard, + district: row.shipDistrict, + province: row.shipProvince, + countryCode: row.shipCountryCode, + postalCode: row.shipPostalCode, + }, + customerNote: row.customerNote, + + currency, + subtotal: money(row.subtotalAmount), + discount: money(row.discountAmount), + shipping: money(row.shippingAmount), + tax: money(row.taxAmount), + total: money(row.totalAmount), + + lines: row.lines.map((line) => this.toLine(line, currency)), + + placedAt: row.placedAt.toISOString(), + confirmedAt: row.confirmedAt?.toISOString() ?? null, + cancelledAt: row.cancelledAt?.toISOString() ?? null, + cancelReason: row.cancelReason, + }; + } + + toListItem(row: OrderListRow): OrderListItem { + const currency = row.currency as CurrencyCode; + + return { + id: row.id, + orderNumber: formatOrderNumber(row.number), + status: row.status as OrderStatus, + paymentStatus: row.paymentStatus as PaymentStatus, + fulfillmentStatus: row.fulfillmentStatus as FulfillmentStatus, + email: row.email, + customerName: row.shipFullName, + itemCount: row.lines.reduce((count, line) => count + line.quantity, 0), + total: { amount: row.totalAmount, currency }, + placedAt: row.placedAt.toISOString(), + }; + } + + private toLine(line: OrderDetailRow['lines'][number], currency: CurrencyCode): OrderLine { + return { + id: line.id, + variantId: line.variantId, + productName: line.productName, + variantTitle: line.variantTitle, + sku: line.sku, + imageUrl: line.imageUrl, + unitPrice: { amount: line.unitAmount, currency }, + quantity: line.quantity, + lineTotal: { amount: line.lineAmount, currency }, + }; + } +} diff --git a/apps/api/src/modules/orders/orders.module.ts b/apps/api/src/modules/orders/orders.module.ts index 88d4d06..9571277 100644 --- a/apps/api/src/modules/orders/orders.module.ts +++ b/apps/api/src/modules/orders/orders.module.ts @@ -1,23 +1,23 @@ import { Module } from '@nestjs/common'; +import { OrdersAdminController } from './orders.controller'; +import { OrdersMapper } from './orders.mapper'; +import { OrdersRepository } from './orders.repository'; +import { OrdersService } from './orders.service'; + /** - * OrdersModule — boundary declared, implementation pending. + * OrdersModule — owns `orders` and `order_lines`. * - * Owns (exclusively): `orders`, `order_items`, `order_status_history` — milestone 2 + * Every catalog value on an order is a snapshot, so this module reads no other + * module's tables to render one. It writes stock levels only through the + * lifecycle transitions that legitimately move stock (cancel releases, fulfil + * ships), and every such write lands in the inventory ledger. * - * Order lines snapshot product name, variant title and price at purchase time. Never join to the live catalog for historical orders: yesterday’s receipt must not change when a price does. - * - * EXTRACTION CANDIDATE: designed so it could become its own service. It must - * therefore never read another module’s tables directly, and it communicates - * outward through domain events. - * - * Anatomy once implemented (see ../README.md): - * orders.module.ts wiring only - * orders.controller.ts HTTP surface, no logic - * orders.service.ts business rules - * orders.repository.ts the only file that touches Prisma - * dto/ request/response shapes - * public/ what other modules may import + * EXTRACTION CANDIDATE. */ -@Module({}) +@Module({ + controllers: [OrdersAdminController], + providers: [OrdersService, OrdersRepository, OrdersMapper], + exports: [OrdersService], +}) export class OrdersModule {} diff --git a/apps/api/src/modules/orders/orders.repository.ts b/apps/api/src/modules/orders/orders.repository.ts new file mode 100644 index 0000000..493254b --- /dev/null +++ b/apps/api/src/modules/orders/orders.repository.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; + +const lineSelect = { + id: true, + variantId: true, + productName: true, + variantTitle: true, + sku: true, + imageUrl: true, + unitAmount: true, + quantity: true, + lineAmount: true, +} as const; + +const detailSelect = { + id: true, + number: true, + status: true, + paymentStatus: true, + fulfillmentStatus: true, + email: true, + phone: true, + currency: true, + subtotalAmount: true, + discountAmount: true, + shippingAmount: true, + taxAmount: true, + totalAmount: true, + shipFullName: true, + shipPhone: true, + shipLine1: true, + shipLine2: true, + shipWard: true, + shipDistrict: true, + shipProvince: true, + shipCountryCode: true, + shipPostalCode: true, + customerNote: true, + placedAt: true, + confirmedAt: true, + cancelledAt: true, + cancelReason: true, + lines: { orderBy: { createdAt: 'asc' }, select: lineSelect }, +} as const; + +const listSelect = { + id: true, + number: true, + status: true, + paymentStatus: true, + fulfillmentStatus: true, + email: true, + shipFullName: true, + currency: true, + totalAmount: true, + placedAt: true, + lines: { select: { quantity: true } }, +} as const; + +@Injectable() +export class OrdersRepository { + constructor(private readonly prisma: PrismaService) {} + + findById(id: string) { + return this.prisma.order.findUnique({ where: { id }, select: detailSelect }); + } + + /** + * Guest lookup: order number AND the email it was placed with. + * + * Two factors on purpose. An order number alone is guessable — they are + * sequential — and an order contains a name, a phone number and a home + * address. + */ + findByNumberAndEmail(number: number, email: string) { + return this.prisma.order.findFirst({ + where: { number, email: email.trim().toLowerCase() }, + select: detailSelect, + }); + } + + list(where: Prisma.OrderWhereInput, skip: number, take: number) { + return Promise.all([ + this.prisma.order.findMany({ + where, + orderBy: { placedAt: 'desc' }, + skip, + take, + select: listSelect, + }), + this.prisma.order.count({ where }), + ]); + } +} + +export type OrderDetailRow = NonNullable>>; +export type OrderListRow = Awaited>[0][number]; diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts new file mode 100644 index 0000000..ccc97aa --- /dev/null +++ b/apps/api/src/modules/orders/orders.service.ts @@ -0,0 +1,253 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { + ORDER_STATUSES, + type OffsetPaginated, + type Order, + type OrderListItem, + type OrderStatus, +} from '@sport/types'; +import type { OrderListQuery, UpdateOrderStatusInput } from '@sport/validation'; + +import { AuditService } from '@/common/audit/audit.service'; +import { AppException } from '@/common/errors/app.exception'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; + +import { OrdersMapper, parseOrderNumber } from './orders.mapper'; +import { OrdersRepository } from './orders.repository'; + +/** + * Which transitions are legal. + * + * Written out rather than left to `if` statements at each call site: an order's + * status drives stock, refunds and what the customer is told, and "how did this + * order get from CANCELLED back to CONFIRMED?" is a question worth making + * unanswerable by construction. + */ +const ALLOWED_TRANSITIONS: Record = { + PENDING: [ORDER_STATUSES.CONFIRMED, ORDER_STATUSES.CANCELLED], + CONFIRMED: [ORDER_STATUSES.FULFILLED, ORDER_STATUSES.CANCELLED], + FULFILLED: [ORDER_STATUSES.COMPLETED], + COMPLETED: [], + CANCELLED: [], +}; + +@Injectable() +export class OrdersService { + constructor( + private readonly prisma: PrismaService, + private readonly repository: OrdersRepository, + private readonly mapper: OrdersMapper, + private readonly audit: AuditService, + ) {} + + async getById(id: string): Promise { + const row = await this.repository.findById(id); + if (!row) throw AppException.notFound('Order'); + + return this.mapper.toOrder(row); + } + + /** Guest order lookup — see `findByNumberAndEmail` for why both are required. */ + async lookup(orderNumber: string, email: string): Promise { + const number = parseOrderNumber(orderNumber); + if (number === null) throw AppException.notFound('Order'); + + const row = await this.repository.findByNumberAndEmail(number, email); + // Deliberately the same error as a bad number: distinguishing "wrong email" + // from "no such order" would turn this into an order-number oracle. + if (!row) throw AppException.notFound('Order'); + + return this.mapper.toOrder(row); + } + + async list(query: OrderListQuery): Promise> { + const where: Prisma.OrderWhereInput = { + ...(query.status ? { status: query.status } : {}), + ...(query.q + ? { + OR: [ + { email: { contains: query.q, mode: 'insensitive' } }, + { shipFullName: { contains: query.q, mode: 'insensitive' } }, + { shipPhone: { contains: query.q } }, + ...(parseOrderNumber(query.q) !== null + ? [{ number: parseOrderNumber(query.q) as number }] + : []), + ], + } + : {}), + }; + + const [rows, totalItems] = await this.repository.list( + where, + (query.page - 1) * query.perPage, + query.perPage, + ); + + const totalPages = Math.max(1, Math.ceil(totalItems / query.perPage)); + + return { + items: rows.map((row) => this.mapper.toListItem(row)), + pageInfo: { + page: query.page, + perPage: query.perPage, + totalItems, + totalPages, + hasNextPage: query.page < totalPages, + }, + }; + } + + /** + * Moves an order through its lifecycle, releasing stock when it dies. + * + * Cancelling is the case that matters. The reservation taken at checkout is + * held against `StockLevel.reserved`, and an order that ends without ever + * shipping has to give those units back — otherwise every abandoned order + * permanently shrinks sellable stock. + */ + async updateStatus( + id: string, + input: UpdateOrderStatusInput, + actorUserId: string, + ): Promise { + const existing = await this.prisma.order.findUnique({ + where: { id }, + select: { + id: true, + status: true, + lines: { select: { variantId: true, quantity: true } }, + }, + }); + if (!existing) throw AppException.notFound('Order'); + + const from = existing.status as OrderStatus; + const to = input.status; + + if (from === to) return this.getById(id); + + if (!ALLOWED_TRANSITIONS[from].includes(to)) { + throw AppException.badRequest(`An order cannot go from ${from} to ${to}.`); + } + + if (to === ORDER_STATUSES.CANCELLED && !input.reason?.trim()) { + throw AppException.badRequest('Give a reason when cancelling an order.'); + } + + await this.prisma.$transaction(async (tx) => { + /** + * Compare-and-set on the status we validated against. + * + * The transition was checked from a row read outside this transaction, so + * two operators clicking "Cancel" at once would both pass that check and + * both release the reservation — returning twice the stock that was ever + * held. Scoping the update to `status: from` means exactly one of them + * matches a row. + */ + const changed = await tx.order.updateMany({ + where: { id, status: from }, + data: { + status: to, + ...(to === ORDER_STATUSES.CONFIRMED ? { confirmedAt: new Date() } : {}), + ...(to === ORDER_STATUSES.CANCELLED + ? { cancelledAt: new Date(), cancelReason: input.reason?.trim() ?? null } + : {}), + }, + }); + + if (changed.count === 0) { + throw AppException.conflict('This order was already updated by someone else.'); + } + + if (to === ORDER_STATUSES.CANCELLED) { + await this.releaseReservations(tx, existing.lines); + } + + if (to === ORDER_STATUSES.FULFILLED) { + await this.commitReservations(tx, id, existing.lines, actorUserId); + } + }); + + this.audit.record({ + actorUserId, + action: `order.${to.toLowerCase()}`, + resourceType: 'Order', + resourceId: id, + changes: { from, to, reason: input.reason ?? null }, + }); + + return this.getById(id); + } + + // ---- internals ----------------------------------------------------------- + + /** + * Hands reserved units back without touching `onHand` — nothing shipped. + * + * Arithmetic in SQL, not in JavaScript, for the same reason checkout reserves + * that way: read-then-write loses concurrent updates. `GREATEST(…, 0)` keeps + * a double-release from manufacturing stock out of nothing. + */ + private async releaseReservations( + tx: Prisma.TransactionClient, + lines: readonly { variantId: string | null; quantity: number }[], + ): Promise { + for (const line of lines) { + if (!line.variantId) continue; + + await tx.$executeRaw` + UPDATE stock_levels + SET reserved = GREATEST(reserved - ${line.quantity}, 0) + WHERE variant_id = ${line.variantId}::uuid + `; + } + } + + /** + * Turns a reservation into a shipment: `onHand` falls, `reserved` falls with + * it, and the ledger records why. + * + * This is the only place stock leaves the building, and it writes a + * `StockMovement` because the level is a projection of that ledger — a + * decrement without an entry is exactly the discrepancy the ledger exists to + * make answerable. + */ + private async commitReservations( + tx: Prisma.TransactionClient, + orderId: string, + lines: readonly { variantId: string | null; quantity: number }[], + actorUserId: string, + ): Promise { + for (const line of lines) { + if (!line.variantId) continue; + + const level = await tx.stockLevel.findFirst({ + where: { variantId: line.variantId }, + select: { variantId: true, locationId: true }, + }); + if (!level) continue; + + // Both counters move in one statement, computed from the row's own + // current values rather than from ones read a moment ago. + await tx.$executeRaw` + UPDATE stock_levels + SET on_hand = GREATEST(on_hand - ${line.quantity}, 0), + reserved = GREATEST(reserved - ${line.quantity}, 0) + WHERE variant_id = ${level.variantId}::uuid + AND location_id = ${level.locationId}::uuid + `; + + await tx.stockMovement.create({ + data: { + variantId: level.variantId, + locationId: level.locationId, + reason: 'SALE', + quantityDelta: -line.quantity, + referenceId: orderId, + createdByUserId: actorUserId, + }, + }); + } + } +} diff --git a/apps/api/src/modules/orders/public/index.ts b/apps/api/src/modules/orders/public/index.ts index c83aad8..7480a17 100644 --- a/apps/api/src/modules/orders/public/index.ts +++ b/apps/api/src/modules/orders/public/index.ts @@ -7,4 +7,5 @@ * * Keep it narrow: each export is a promise to the rest of the codebase. */ -export {}; +export { OrdersService } from '../orders.service'; +export { formatOrderNumber, parseOrderNumber } from '../orders.mapper'; diff --git a/apps/api/src/modules/products/products-admin.service.ts b/apps/api/src/modules/products/products-admin.service.ts index 029fdc8..fe9454b 100644 --- a/apps/api/src/modules/products/products-admin.service.ts +++ b/apps/api/src/modules/products/products-admin.service.ts @@ -20,6 +20,8 @@ import type { import { AuditService } from '@/common/audit/audit.service'; import { AppException } from '@/common/errors/app.exception'; import { toDbLocale } from '@/common/i18n'; +import { DOMAIN_EVENTS } from '@/infrastructure/events/domain-event'; +import { EventBusService } from '@/infrastructure/events/event-bus.service'; import { PrismaService } from '@/infrastructure/prisma/prisma.service'; import { CACHE_KEYS } from '@/infrastructure/redis/cache-keys'; import { RedisService } from '@/infrastructure/redis/redis.service'; @@ -38,6 +40,7 @@ export class ProductsAdminService { private readonly mapper: ProductsAdminMapper, private readonly redis: RedisService, private readonly audit: AuditService, + private readonly events: EventBusService, ) {} // ---- Reads --------------------------------------------------------------- @@ -807,6 +810,20 @@ export class ProductsAdminService { const dropped = await this.redis.deleteByPrefix(CACHE_KEYS.catalogPrefix()); this.logger.log(`${action} on ${productId}; dropped ${dropped} catalog cache key(s)`); + /** + * Announce the change; search reindexes itself. + * + * An event rather than a direct call, because calling SearchModule from + * here would make ProductsModule depend on it while SearchModule already + * depends on ProductsModule — a cycle Nest cannot wire. It is also the rule + * this codebase already states: needing an *answer* is a service call, + * needing something to *react* is an event (see infrastructure/events). + * + * The consequence is honest eventual consistency: the index trails a write + * by a tick. + */ + this.events.publish(DOMAIN_EVENTS.PRODUCT_UPDATED, { productId }); + this.audit.record({ actorUserId, action, diff --git a/apps/api/src/modules/products/products.repository.ts b/apps/api/src/modules/products/products.repository.ts index 8377bd2..caacb92 100644 --- a/apps/api/src/modules/products/products.repository.ts +++ b/apps/api/src/modules/products/products.repository.ts @@ -144,6 +144,8 @@ const detailSelect = { export interface ResolvedFilter extends ProductFilter { /** Materialised path of the requested category, if it resolved. */ categoryPath?: string | null; + /** Restricts the result set to a specific id list — used by ranked search. */ + ids?: string[]; } @Injectable() @@ -215,6 +217,10 @@ export class ProductsRepository { }); } + if (filter.ids) { + and.push({ id: { in: filter.ids } }); + } + if (filter.gender?.length) { and.push({ genderTargets: { hasSome: filter.gender } }); } @@ -351,6 +357,38 @@ export class ProductsRepository { }); } + /** Same payload as `findList`, without paging — the caller already has the order. */ + findByIds(where: Prisma.ProductWhereInput, locale: Locale) { + return this.prisma.product.findMany({ + where, + select: { + ...listSelect, + translations: { where: { locale: toDbLocale(locale) } }, + brand: { + select: { + id: true, + name: true, + translations: { where: { locale: toDbLocale(locale) } }, + }, + }, + options: { + where: { key: 'colour' }, + select: { + ...optionSelect, + translations: { where: { locale: toDbLocale(locale) } }, + values: { + orderBy: { position: 'asc' }, + select: { + ...optionSelect.values.select, + translations: { where: { locale: toDbLocale(locale) } }, + }, + }, + }, + }, + }, + }); + } + count(where: Prisma.ProductWhereInput) { return this.prisma.product.count({ where }); } diff --git a/apps/api/src/modules/products/products.service.ts b/apps/api/src/modules/products/products.service.ts index a196a19..562c07a 100644 --- a/apps/api/src/modules/products/products.service.ts +++ b/apps/api/src/modules/products/products.service.ts @@ -18,7 +18,11 @@ import { RedisService } from '@/infrastructure/redis/redis.service'; import { CategoriesService } from '@/modules/categories/public'; import { ProductsMapper } from './products.mapper'; -import { ProductsRepository, type ResolvedFilter } from './products.repository'; +import { + ProductsRepository, + type ProductListRow, + type ResolvedFilter, +} from './products.repository'; @Injectable() export class ProductsService { @@ -94,6 +98,93 @@ export class ProductsService { return cached; } + /** + * Renders a ranked id list as a listing. + * + * Search owns the ranking; the catalog owns what a product looks like. This + * is the join between the two, and it preserves the given order unless the + * caller explicitly asked for a different one. + * + * The visibility rules still apply: an id that search returns for a product + * which has since been unpublished simply does not come back. + */ + async listByIds( + ids: readonly string[], + filter: ProductFilter, + locale: Locale, + ): Promise { + if (ids.length === 0) { + return { + items: [], + pageInfo: { nextCursor: null, hasNextPage: false }, + totalCount: 0, + facets: { brands: [], colors: [], sizes: [], priceRange: null }, + }; + } + + const resolved = await this.resolveFilter(filter, locale); + + /** + * `q` is dropped, deliberately. + * + * The search provider has already applied it — with diacritic folding, + * trigram tolerance, and matching across brand, SKU and colourway. Letting + * the catalog re-apply it as a plain `name CONTAINS q` intersects all of + * that away: searching "velocity" found the right products by brand and + * then discarded every one of them, because the brand is not in the name. + */ + const scoped = { ...resolved, q: undefined, ids: [...ids] }; + + const where = this.repository.buildWhere(scoped, locale); + const contextWhere = this.repository.buildContextWhere(scoped, locale); + + const [rows, totalCount, facets] = await Promise.all([ + this.repository.findByIds(where, locale), + this.repository.count(where), + this.buildFacets(contextWhere, locale), + ]); + + /** + * Relevance by default, but an explicit sort still wins. + * + * The database returned these unordered, so the ranking has to be restored + * here. If the shopper picked "price: low to high" on a search result page, + * honouring the ranking anyway would leave the sort control visibly lying — + * it would change the URL and nothing else. + */ + const rank = new Map(ids.map((id, index) => [id, index])); + const byRank = (a: ProductListRow, b: ProductListRow) => + (rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity); + + const cheapest = (row: ProductListRow) => + Math.min( + ...row.variants.map((variant) => variant.salePriceAmount ?? variant.priceAmount), + Infinity, + ); + + const comparators: Record number> = { + price_asc: (a, b) => cheapest(a) - cheapest(b) || byRank(a, b), + price_desc: (a, b) => cheapest(b) - cheapest(a) || byRank(a, b), + }; + + const ordered = [...rows].sort(comparators[filter.sort ?? ''] ?? byRank); + + const limit = filter.limit; + const page = ordered.slice(0, limit); + + return { + items: page.map((row) => this.mapper.toListItem(row, locale)), + pageInfo: { + // Ranked results are a single page: a cursor into a relevance ordering + // means nothing once the ranking is recomputed. + hasNextPage: false, + nextCursor: null, + }, + totalCount, + facets, + }; + } + /** Slugs + last-modified for `generateStaticParams` and the sitemap. */ async listSlugs(locale: Locale): Promise<{ slug: string; updatedAt: string }[]> { const rows = await this.repository.findAllSlugs(locale); diff --git a/apps/api/src/modules/search/postgres-search.provider.ts b/apps/api/src/modules/search/postgres-search.provider.ts new file mode 100644 index 0000000..3135ff3 --- /dev/null +++ b/apps/api/src/modules/search/postgres-search.provider.ts @@ -0,0 +1,146 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import type { Locale } from '@sport/types'; + +import { toDbLocale } from '@/common/i18n'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; + +import type { + SearchHit, + SearchIndexDocument, + SearchProvider, + SearchSuggestion, +} from './search.provider'; + +/** + * Below this, a trigram match is noise rather than a typo. + * + * Tuned against the real catalog: "jaket" scores 0.44 against every jacket, + * "nocturn" scores 0.88 against Nocturne, and nonsense scores 0. Anything + * higher than 0.4 loses the single-character typos this exists for. + */ +const TRIGRAM_THRESHOLD = 0.4; + +/** + * PostgreSQL full-text search (ADR-0012). + * + * Two matching strategies, combined rather than chosen between: + * + * 1. `websearch_to_tsquery` against the weighted `document` column. This is + * the precise path — it understands quoted phrases and `-exclusions`, and + * it ranks a name match above a description match because the vector was + * built with weights. + * 2. Trigram similarity on title + keywords. This is the forgiving path — + * it survives typos and partial words, which a tsquery does not. + * + * A product matching either is a hit; the score is the better of the two, + * scaled so they are comparable. Running only the first means "nocturn" finds + * nothing; running only the second ranks a description mention as highly as a + * name. + * + * The trigram comparison is a function call rather than the `<%` operator, so + * it does not use the GIN index and degrades to a sequential scan. That is a + * deliberate trade at this catalog size — the operator's threshold is a session + * GUC, which is awkward to set per request. It is the first thing to change if + * the catalog outgrows ADR-0012's ~50k estimate. + */ +@Injectable() +export class PostgresSearchProvider implements SearchProvider { + private readonly logger = new Logger(PostgresSearchProvider.name); + + constructor(private readonly prisma: PrismaService) {} + + async search(query: string, locale: Locale, limit: number): Promise { + const term = query.trim(); + if (term.length === 0) return []; + + const rows = await this.prisma.$queryRaw<{ product_id: string; score: number }[]>` + WITH q AS ( + SELECT websearch_to_tsquery('simple', immutable_unaccent(${term})) AS tsq, + immutable_unaccent(${term}) AS raw + ) + SELECT d.product_id, + GREATEST( + -- ts_rank_cd rewards term density and honours the A/B/C weights. + ts_rank_cd(d.document, q.tsq, 32) * 4, + -- word_similarity, not plain similarity: the latter compares + -- whole strings, so a short query against a long document scores + -- near zero however well it matches. "nocturn" against the + -- Nocturne document scored 0.125, below any usable threshold. + -- This scores the best-matching word extent instead (0.875 for + -- the same pair), which is the question actually being asked. + word_similarity(q.raw, immutable_unaccent(d.title) || ' ' || immutable_unaccent(d.keywords)) + )::float8 AS score + FROM search_documents d, q + WHERE d.locale = ${toDbLocale(locale)}::"Locale" + AND ( + d.document @@ q.tsq + OR word_similarity( + q.raw, + immutable_unaccent(d.title) || ' ' || immutable_unaccent(d.keywords) + ) > ${TRIGRAM_THRESHOLD} + ) + ORDER BY score DESC, d.title ASC + LIMIT ${limit} + `; + + return rows.map((row) => ({ productId: row.product_id, score: row.score })); + } + + async suggest(query: string, locale: Locale, limit: number): Promise { + const term = query.trim(); + if (term.length === 0) return []; + + // Suggestions match on the *title* only. Offering "Aero Run Tee" because + // the word appears in its description reads as a broken autocomplete. + const rows = await this.prisma.$queryRaw<{ title: string; slug: string; score: number }[]>` + SELECT d.title, + COALESCE(t.slug, p.slug) AS slug, + word_similarity(immutable_unaccent(${term}), immutable_unaccent(d.title))::float8 AS score + FROM search_documents d + JOIN products p ON p.id = d.product_id + LEFT JOIN product_translations t + ON t.product_id = d.product_id AND t.locale = d.locale + WHERE d.locale = ${toDbLocale(locale)}::"Locale" + AND p.status = 'ACTIVE' + AND p.deleted_at IS NULL + AND ( + immutable_unaccent(d.title) ILIKE '%' || immutable_unaccent(${term}) || '%' + OR word_similarity(immutable_unaccent(${term}), immutable_unaccent(d.title)) > ${TRIGRAM_THRESHOLD} + ) + ORDER BY score DESC, length(d.title) ASC + LIMIT ${limit} + `; + + return rows.map((row) => ({ text: row.title, productSlug: row.slug })); + } + + /** + * Upserts documents. `document` is never written — Postgres generates it from + * the columns below, so the vector cannot drift from its own source text. + */ + async index(documents: readonly SearchIndexDocument[]): Promise { + if (documents.length === 0) return; + + const values = documents.map( + (doc) => + Prisma.sql`(${doc.productId}::uuid, ${toDbLocale(doc.locale)}::"Locale", ${doc.title}, ${doc.keywords}, ${doc.body}, NOW())`, + ); + + await this.prisma.$executeRaw` + INSERT INTO search_documents (product_id, locale, title, keywords, body, updated_at) + VALUES ${Prisma.join(values)} + ON CONFLICT (product_id, locale) DO UPDATE + SET title = EXCLUDED.title, + keywords = EXCLUDED.keywords, + body = EXCLUDED.body, + updated_at = NOW() + `; + } + + async remove(productId: string): Promise { + await this.prisma.searchDocument.deleteMany({ where: { productId } }); + this.logger.debug(`Removed ${productId} from the search index`); + } +} diff --git a/apps/api/src/modules/search/public/index.ts b/apps/api/src/modules/search/public/index.ts index 0d6b1ba..477ac31 100644 --- a/apps/api/src/modules/search/public/index.ts +++ b/apps/api/src/modules/search/public/index.ts @@ -7,4 +7,5 @@ * * Keep it narrow: each export is a promise to the rest of the codebase. */ -export {}; +export { SearchService } from '../search.service'; +export { SearchIndexerService } from '../search-indexer.service'; diff --git a/apps/api/src/modules/search/search-index.subscriber.ts b/apps/api/src/modules/search/search-index.subscriber.ts new file mode 100644 index 0000000..30393a3 --- /dev/null +++ b/apps/api/src/modules/search/search-index.subscriber.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; + +import { DOMAIN_EVENTS } from '@/infrastructure/events/domain-event'; +import { EventBusService } from '@/infrastructure/events/event-bus.service'; + +import { SearchIndexerService } from './search-indexer.service'; + +/** + * Keeps the index in step with the catalog, by subscription rather than by call. + * + * This is the direction the dependency has to run: SearchModule knows about + * products, products knows nothing about search. When this module is extracted, + * this file is the only thing that changes — an in-process subscription becomes + * a queue consumer, and the producer never learns the difference. + * + * Failures are logged, not rethrown. A search index that missed one update is a + * degraded search; an exception escaping here would take down the write that + * triggered it, which is a far worse trade. + */ +@Injectable() +export class SearchIndexSubscriber implements OnModuleInit { + private readonly logger = new Logger(SearchIndexSubscriber.name); + + constructor( + private readonly events: EventBusService, + private readonly indexer: SearchIndexerService, + ) {} + + onModuleInit(): void { + for (const event of [ + DOMAIN_EVENTS.PRODUCT_UPDATED, + DOMAIN_EVENTS.PRODUCT_PUBLISHED, + DOMAIN_EVENTS.PRODUCT_ARCHIVED, + ]) { + this.events.on<{ productId: string }>(event).subscribe((message) => { + void this.reindex(message.payload.productId, event); + }); + } + } + + private async reindex(productId: string, event: string): Promise { + try { + await this.indexer.reindexProduct(productId); + } catch (error) { + this.logger.error( + `Reindex failed for ${productId} after ${event}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } +} diff --git a/apps/api/src/modules/search/search-indexer.service.ts b/apps/api/src/modules/search/search-indexer.service.ts new file mode 100644 index 0000000..e194a12 --- /dev/null +++ b/apps/api/src/modules/search/search-indexer.service.ts @@ -0,0 +1,144 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import { LOCALES } from '@sport/types'; + +import { toDbLocale } from '@/common/i18n'; +import { PrismaService } from '@/infrastructure/prisma/prisma.service'; + +import { SEARCH_PROVIDER, type SearchIndexDocument, type SearchProvider } from './search.provider'; + +/** + * Builds searchable documents from the catalog. + * + * This is the one place in SearchModule that reads product tables, and it does + * so to *project* them — the output is a flat document, never a product + * payload. When this module is extracted, this class becomes a consumer of + * catalog events rather than a reader of catalog tables, and nothing above it + * changes. + * + * Only ACTIVE, published products are indexed. A draft product appearing in + * search is a leak, not a feature. + */ +@Injectable() +export class SearchIndexerService { + private readonly logger = new Logger(SearchIndexerService.name); + + constructor( + private readonly prisma: PrismaService, + @Inject(SEARCH_PROVIDER) private readonly provider: SearchProvider, + ) {} + + /** Rebuilds one product's documents, or removes them if it is no longer sellable. */ + async reindexProduct(productId: string): Promise { + const documents = await this.buildDocuments({ id: productId }); + + if (documents.length === 0) { + // Unpublished, archived or deleted since the event fired — the right + // response is removal, not a stale row nobody will notice. + await this.provider.remove(productId); + return; + } + + await this.provider.index(documents); + } + + /** Full rebuild. Safe to run at any time — the index is pure projection. */ + async reindexAll(): Promise { + const documents = await this.buildDocuments({}); + await this.provider.index(documents); + + this.logger.log(`Reindexed ${documents.length} document(s)`); + return documents.length; + } + + private async buildDocuments(where: { id?: string }): Promise { + const products = await this.prisma.product.findMany({ + where: { + ...where, + status: 'ACTIVE', + deletedAt: null, + OR: [{ publishedAt: null }, { publishedAt: { lte: new Date() } }], + }, + select: { + id: true, + name: true, + description: true, + shortDescription: true, + genderTargets: true, + sportTypes: true, + translations: true, + brand: { select: { name: true, translations: true } }, + primaryCategory: { select: { name: true, translations: true } }, + variants: { + where: { status: 'ACTIVE', deletedAt: null }, + select: { sku: true }, + }, + options: { + select: { + values: { + select: { label: true, translations: true }, + }, + }, + }, + }, + }); + + const documents: SearchIndexDocument[] = []; + + for (const product of products) { + for (const locale of LOCALES) { + const dbLocale = toDbLocale(locale); + const translation = product.translations.find((row) => row.locale === dbLocale); + + // Falls back to the canonical field, matching how the storefront + // renders an untranslated product — the index must find what the page + // actually shows. + const title = translation?.name || product.name; + + const optionLabels = product.options.flatMap((option) => + option.values.map( + (value) => + value.translations.find((row) => row.locale === dbLocale)?.label || value.label, + ), + ); + + const brand = + product.brand?.translations.find((row) => row.locale === dbLocale)?.name || + product.brand?.name || + ''; + const category = + product.primaryCategory?.translations.find((row) => row.locale === dbLocale)?.name || + product.primaryCategory?.name || + ''; + + documents.push({ + productId: product.id, + locale, + title, + // Short, high-signal terms. SKUs are in here because staff and + // returning customers search by them constantly. + keywords: unique([ + brand, + category, + ...optionLabels, + ...product.variants.map((variant) => variant.sku), + ...product.genderTargets, + ...product.sportTypes, + ]).join(' '), + body: [ + translation?.shortDescription || product.shortDescription || '', + translation?.description || product.description || '', + ] + .filter(Boolean) + .join(' '), + }); + } + } + + return documents; + } +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values.filter((value) => value.trim().length > 0))]; +} diff --git a/apps/api/src/modules/search/search.controller.ts b/apps/api/src/modules/search/search.controller.ts new file mode 100644 index 0000000..87dc9c8 --- /dev/null +++ b/apps/api/src/modules/search/search.controller.ts @@ -0,0 +1,74 @@ +import { Controller, Get, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + PERMISSIONS, + TOKEN_AUDIENCES, + type Locale, + type ProductListResult, + type SearchSuggestions, +} from '@sport/types'; +import { productFilterSchema, type ProductFilter } from '@sport/validation'; + +import { Public } from '@/common/decorators/public.decorator'; +import { + RequireAudience, + RequirePermissions, +} from '@/common/decorators/require-permissions.decorator'; +import { RequestLocale } from '@/common/i18n/locale.decorator'; +import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe'; + +import { SearchIndexerService } from './search-indexer.service'; +import { SearchService } from './search.service'; + +/** Enough to fill a suggestion dropdown; more is a listing, not a hint. */ +const SUGGESTION_LIMIT = 6; + +/** + * Mixed surface: searching is public, rebuilding the index is not. + * + * `@Public()` therefore sits on the two read endpoints rather than on the + * controller. Authentication is on by default, so the reindex route is + * protected by omission — which is the failure mode worth having. + */ +@ApiTags('search') +@Controller('search') +export class SearchController { + constructor( + private readonly service: SearchService, + private readonly indexer: SearchIndexerService, + ) {} + + @Get() + @Public() + @ApiOperation({ summary: 'Ranked product search, refinable like any listing' }) + search( + @Query(new ZodValidationPipe(productFilterSchema)) filter: ProductFilter, + @RequestLocale() locale: Locale, + ): Promise { + return this.service.searchProducts(filter.q ?? '', filter, locale); + } + + @Get('suggestions') + @Public() + @ApiOperation({ summary: 'Type-ahead product names' }) + suggest(@Query('q') query: string, @RequestLocale() locale: Locale): Promise { + return this.service.suggest(query ?? '', locale, SUGGESTION_LIMIT); + } + + /** + * Rebuilds every document. + * + * The index is a pure projection, so this is always safe to run and is the + * answer to "search is missing something" — no reasoning about which events + * were lost, just rebuild. + */ + @Post('reindex') + @ApiBearerAuth() + @RequireAudience(TOKEN_AUDIENCES.ADMIN) + @RequirePermissions(PERMISSIONS.PRODUCT_UPDATE) + @ApiOperation({ summary: 'Rebuild the entire search index' }) + async reindex(): Promise<{ indexed: number }> { + return { indexed: await this.indexer.reindexAll() }; + } +} diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts index 2aa38a8..aa76d37 100644 --- a/apps/api/src/modules/search/search.module.ts +++ b/apps/api/src/modules/search/search.module.ts @@ -1,23 +1,35 @@ import { Module } from '@nestjs/common'; +import { ProductsModule } from '@/modules/products/products.module'; + +import { PostgresSearchProvider } from './postgres-search.provider'; +import { SearchIndexSubscriber } from './search-index.subscriber'; +import { SearchIndexerService } from './search-indexer.service'; +import { SearchController } from './search.controller'; +import { SEARCH_PROVIDER } from './search.provider'; +import { SearchService } from './search.service'; + /** - * SearchModule — boundary declared, implementation pending. + * SearchModule — owns `search_documents`, a pure projection of the catalog. * - * Owns (exclusively): Nothing. Read-only projection over the catalog. + * The projection can be rebuilt from scratch at any moment, which is what makes + * losing it survivable and makes this module a genuine EXTRACTION CANDIDATE: + * nothing else reads its table, and it returns ids rather than product payloads + * so it never learns what a product looks like. * - * Starts as PostgreSQL full-text + trigram, which is genuinely enough below ~50k products. Behind a SearchProvider interface so swapping in OpenSearch is a provider change, not a rewrite of every listing page. - * - * EXTRACTION CANDIDATE: designed so it could become its own service. It must - * therefore never read another module’s tables directly, and it communicates - * outward through domain events. - * - * Anatomy once implemented (see ../README.md): - * search.module.ts wiring only - * search.controller.ts HTTP surface, no logic - * search.service.ts business rules - * search.repository.ts the only file that touches Prisma - * dto/ request/response shapes - * public/ what other modules may import + * `SEARCH_PROVIDER` is the seam from ADR-0012 — swapping PostgreSQL for + * OpenSearch replaces one binding here. */ -@Module({}) +@Module({ + imports: [ProductsModule], + controllers: [SearchController], + providers: [ + SearchService, + SearchIndexerService, + SearchIndexSubscriber, + PostgresSearchProvider, + { provide: SEARCH_PROVIDER, useExisting: PostgresSearchProvider }, + ], + exports: [SearchService, SearchIndexerService], +}) export class SearchModule {} diff --git a/apps/api/src/modules/search/search.provider.ts b/apps/api/src/modules/search/search.provider.ts new file mode 100644 index 0000000..6253537 --- /dev/null +++ b/apps/api/src/modules/search/search.provider.ts @@ -0,0 +1,41 @@ +import type { Locale } from '@sport/types'; + +export interface SearchHit { + readonly productId: string; + /** Higher is better. Comparable within one result set, not across queries. */ + readonly score: number; +} + +export interface SearchSuggestion { + readonly text: string; + readonly productSlug: string; +} + +/** + * The seam. + * + * Everything above this interface — the search endpoint, the listing's + * relevance sort, the suggestion box — talks only to these three methods. + * Swapping PostgreSQL for OpenSearch later is a new implementation of this + * file's contract, not a rewrite of every caller (ADR-0012). + * + * Note what it returns: product *ids* and a score, never product payloads. + * Rendering a product is the catalog's job, and keeping it that way is what + * lets SearchModule be extracted without dragging catalog tables along. + */ +export interface SearchProvider { + search(query: string, locale: Locale, limit: number): Promise; + suggest(query: string, locale: Locale, limit: number): Promise; + index(documents: readonly SearchIndexDocument[]): Promise; + remove(productId: string): Promise; +} + +export interface SearchIndexDocument { + readonly productId: string; + readonly locale: Locale; + readonly title: string; + readonly keywords: string; + readonly body: string; +} + +export const SEARCH_PROVIDER = Symbol('SEARCH_PROVIDER'); diff --git a/apps/api/src/modules/search/search.service.ts b/apps/api/src/modules/search/search.service.ts new file mode 100644 index 0000000..aafffa2 --- /dev/null +++ b/apps/api/src/modules/search/search.service.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { Locale, ProductListResult, SearchSuggestions } from '@sport/types'; +import type { ProductFilter } from '@sport/validation'; + +import { ProductsService } from '@/modules/products/public'; + +import { SEARCH_PROVIDER, type SearchProvider } from './search.provider'; + +/** Ranked ids fetched per query. Beyond this, relevance is noise anyway. */ +const MAX_HITS = 200; + +@Injectable() +export class SearchService { + constructor( + @Inject(SEARCH_PROVIDER) private readonly provider: SearchProvider, + private readonly products: ProductsService, + ) {} + + /** + * Ranked search, rendered by the catalog. + * + * The provider returns ids and scores; the catalog turns them into cards. + * That split is what keeps this module extractable — it never learns what a + * product looks like. + * + * Filters and facets still come from the catalog, so a search result is + * refinable exactly like any other listing. + */ + async searchProducts( + query: string, + filter: ProductFilter, + locale: Locale, + ): Promise { + const hits = await this.provider.search(query, locale, MAX_HITS); + + if (hits.length === 0) { + return { + items: [], + pageInfo: { nextCursor: null, hasNextPage: false }, + totalCount: 0, + facets: { brands: [], colors: [], sizes: [], priceRange: null }, + }; + } + + return this.products.listByIds( + hits.map((hit) => hit.productId), + filter, + locale, + ); + } + + async suggest(query: string, locale: Locale, limit: number): Promise { + const suggestions = await this.provider.suggest(query, locale, limit); + + return { query, suggestions }; + } +} diff --git a/apps/storefront/src/app/[locale]/(account)/account/layout.tsx b/apps/storefront/src/app/[locale]/(account)/account/layout.tsx index d9d0f52..3d71a20 100644 --- a/apps/storefront/src/app/[locale]/(account)/account/layout.tsx +++ b/apps/storefront/src/app/[locale]/(account)/account/layout.tsx @@ -36,7 +36,7 @@ export default async function AccountLayout({ return (
- +