Files
web_sport/README.md
T
2026-08-13 23:20:23 +07:00

383 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sport Store
A modern sports-fashion e-commerce platform.
**Status: milestone 3 — the admin can now write to the catalog.**
Products, variants, media and stock are editable from the back office and appear on the
bilingual storefront immediately. Cart, checkout and orders are next; see [Roadmap](#roadmap).
```
Storefront (Next.js) ─┐
├─▶ REST API (NestJS) ─▶ PostgreSQL · Redis · R2/S3
Admin (Next.js) ──────┘
```
---
## Quick start
Requires **Node ≥ 22**, **pnpm ≥ 10** (`corepack enable`) and **Docker**.
```bash
pnpm setup # env files, real JWT secrets, deps, containers, migrations, seed
pnpm dev # storefront + admin + API, all watching
```
| Service | URL |
| ------------------ | --------------------------------------------------- |
| Storefront | http://localhost:3000 |
| Admin | http://localhost:3001 |
| API | http://localhost:4000/api/v1/health |
| API docs (Swagger) | http://localhost:4000/docs |
| MinIO console | http://localhost:9001 (`sportminio` / `sportminio`) |
| Mailpit | http://localhost:8025 |
Verify the stack is healthy:
```bash
curl -s http://localhost:4000/api/v1/health | jq
```
```json
{
"success": true,
"data": {
"status": "ok",
"dependencies": {
"database": { "status": "up", "latencyMs": 3 },
"redis": { "status": "up", "latencyMs": 1 }
}
},
"meta": { "requestId": "019fef70-a80f-77d5-a975-bbe9f5f0c32c", "timestamp": "…" }
}
```
> **Ports.** PostgreSQL is published on **5433** and Redis on **6380**, not their defaults. Many
> machines already run one or both, and a shadowed port surfaces as a baffling authentication
> error rather than a clear conflict. Inside the Docker network they still use 5432/6379.
---
## Commands
### Everyday
```bash
pnpm dev # everything
pnpm dev:storefront # one app (dependencies are built first)
pnpm dev:admin
pnpm dev:api
pnpm build # build all, in dependency order
pnpm lint # ESLint across the workspace
pnpm lint:fix
pnpm typecheck # tsc --noEmit everywhere
pnpm test
pnpm format # Prettier write
pnpm format:check
```
### Infrastructure
```bash
pnpm infra:up # PostgreSQL, Redis, MinIO, Mailpit
pnpm infra:down
pnpm infra:logs
pnpm infra:reset # destroys volumes, then restarts
docker compose --profile full up --build # full stack behind Nginx on :80
```
### Database
```bash
pnpm db:migrate # create + apply a migration (dev)
pnpm db:deploy # apply pending migrations (production)
pnpm db:generate # regenerate Prisma Client
pnpm db:seed # reconcile permissions + roles (idempotent)
pnpm db:studio # Prisma Studio
pnpm db:reset # drop, re-migrate, re-seed
pnpm db:create-admin # create/repair a SUPER_ADMIN (prints a generated password)
```
`pnpm db:seed` also creates three **development** sign-in accounts and prints their generated
passwords once. They are skipped when `NODE_ENV=production` or `SEED_DEMO=false`; real
environments use `pnpm db:create-admin`, which is also the lockout-recovery path.
### Running one app manually
```bash
pnpm --filter @sport/api run dev # :4000
pnpm --filter @sport/storefront run dev # :3000
pnpm --filter @sport/admin run dev # :3001
```
---
## Repository layout
```
sport-store/
├── apps/
│ ├── storefront/ Next.js customer site (:3000)
│ │ └── src/
│ │ ├── app/[locale]/ (shop) (checkout) (account) route groups
│ │ ├── components/
│ │ │ └── commerce/ Hand-built brand UI — hero, mega menu, header,
│ │ │ product card, gallery, PDP, filter sheet
│ │ ├── i18n/ next-intl routing, request config, navigation
│ │ ├── messages/ vi.json · en.json (UI strings)
│ │ ├── hooks/ lib/ stores/ styles/ types/
│ │
│ ├── admin/ Next.js back office (:3001)
│ │ └── src/
│ │ ├── app/ (auth) login · (dashboard) everything else
│ │ ├── components/ features/ lib/ styles/
│ │
│ └── api/ NestJS modular monolith (:4000)
│ ├── prisma/ schema.prisma · migrations · seed.ts
│ └── src/
│ ├── config/ env validation → typed config object
│ ├── common/ decorators · filters · guards · interceptors
│ │ middleware · pipes · errors
│ ├── infrastructure/ prisma · redis · storage · events · logging
│ └── modules/ 21 bounded contexts
│
├── packages/
│ ├── types/ Framework-free domain + API contracts (zero deps)
│ ├── validation/ Zod schemas shared by API and both frontends
│ ├── api-client/ The only sanctioned way for a frontend to reach the API
│ ├── ui/ shadcn/ui infrastructure, owned as source (ADR-0017)
│ ├── config/ Shared tsconfig bases + Tailwind theme tokens
│ └── eslint-config/ Flat configs incl. the architectural boundary rules
│
├── infrastructure/
│ ├── docker/ One Dockerfile per app (turbo prune → standalone)
│ ├── nginx/ Edge routing, rate ceiling, asset caching
│ └── scripts/ bootstrap.sh · reset-db.sh
│
├── docs/
│ ├── architecture.md Boundaries, conventions, risks — read this first
│ └── adr/ 23 decision records
│
├── docker-compose.yml Backing services; `--profile full` runs everything
├── turbo.json pnpm-workspace.yaml package.json
```
---
## Architecture in brief
Full detail in [`docs/architecture.md`](./docs/architecture.md). The rules that matter most:
1. **Only the API touches data.** Neither frontend has a database, Redis or storage client.
Enforced by ESLint and by the absence of `DATABASE_URL` from their environments.
([ADR-0004](./docs/adr/0004-the-admin-dashboard-has-no-database-access.md))
2. **All business logic lives in the backend.** The storefront may format `₫250.000`; it may
never compute a discount.
3. **Product ≠ ProductVariant.** A product has a page; a _variant_ has a SKU, a price and stock.
Every colour × size combination is its own variant.
([ADR-0003](./docs/adr/0003-product-and-productvariant-as-separate-entities.md))
4. **Authorization is permissions, never role checks.** There is no `if (user.role === 'ADMIN')`
anywhere. ([ADR-0007](./docs/adr/0007-rbac-permissions-instead-of-role-checks.md))
5. **Authentication is on by default.** The access-token guard is global; a route is public only
by declaring `@Public()`. Forgetting a decorator fails closed.
6. **Money is an integer in minor units**, everywhere.
([ADR-0011](./docs/adr/0011-money-as-integer-minor-units.md))
7. **Modules own their tables exclusively.** Cross-module access goes through a `public/` barrel
or a domain event — enforced by ESLint, which is what keeps a future service extraction
possible. ([ADR-0002](./docs/adr/0002-modular-monolith-not-microservices.md))
8. **Two languages, two mechanisms.** UI strings live in message catalogs; product content lives
in database translation tables with per-locale slugs and field-level fallback. Conflating them
is why most "add a language" projects end up half-translated.
([ADR-0013](./docs/adr/0013-content-translations-in-typed-tables-ui-strings-in-message-catalogs.md))
9. **The browser always calls the API on its own origin** — Nginx in production, a Next rewrite
in development. That is what makes the httpOnly refresh cookie first-party, and what keeps
dev and production authenticating identically.
([ADR-0015](./docs/adr/0015-frontends-reach-the-api-through-their-own-origin.md))
10. **shadcn/ui for infrastructure, hand-built for brand.** Dialog, Sheet, Dropdown, Tabs, Button
and Input come from the registry and are owned as source in `@sport/ui`. The hero, mega menu,
header, product card, gallery and PDP are written by hand in
`apps/storefront/src/components/commerce/` — those are the store, and a registry component
would make them look like a template.
([ADR-0017](./docs/adr/0017-shadcn-for-infrastructure-hand-built-for-brand.md))
### Languages
Vietnamese is the default and is served from clean URLs; English is prefixed with `/en`.
| | Vietnamese | English |
| ------- | --------------------------- | --------------------------- |
| Listing | `/men` | `/en/men` |
| Product | `/products/ao-chay-bo-aero` | `/en/products/aero-run-tee` |
Product slugs are translated too, so each language has its own indexable URL. Every product
page emits `hreflang` alternates and a canonical link, and requesting a product by the _other_
locale's slug redirects to the canonical one — which is what keeps the language switcher on a
product page from 404ing.
The admin switches language by cookie with no URL segment: it is `noindex` everywhere, so
locale-in-path would buy nothing.
### Storefront routes
`/` · `/men` · `/women` · `/sports/[running|football|training|gym|badminton|lifestyle]` ·
`/products/[slug]` · `/collections/[slug]` · `/search` · `/cart` · `/checkout` ·
`/account/[profile|orders|addresses|wishlist]` · `/blog`
### Backend modules
`auth` `users` `customers` `products` `product-variants` `categories` `collections` `brands`
`inventory` `carts` `checkout` `orders` `payments` `promotions` `coupons` `wishlist` `reviews`
`cms` `media` `search` (+ `health`)
---
## Roadmap
| Milestone | Scope |
| --------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI |
| **M1** ✅ | Catalog read API + Redis caching + vi/en localisation + storefront wired to real data |
| **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 (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** ◐ | Discounts end-to-end: one engine, one admin screen, stacking, windows, limits, redemptions. Reviews and CMS remain |
| **M8** ✅ | Customer accounts: registration, profile, address book, order history, wishlist; guest orders adopted on sign-up |
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
**Recommended next step: M9 (payments) — or M10 (shipping), since payments are deferred.** 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.
---
## Verified in this environment
Everything below was run, not assumed:
- `pnpm lint` · `pnpm typecheck` · `pnpm test` · `pnpm build` — 27/27 Turborepo tasks pass;
`pnpm format:check` clean
- 11 migrations, 44 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories,
3 collections, 12 products, **155 variants**, 64 uploaded images and 3 dev accounts
- **78 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, 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/<vi-slug>` → 307 →
`/en/products/<en-slug>`; `hreflang` + canonical emitted per locale
- **Auth:** admin sign-in works through the app's own origin; the refresh cookie is httpOnly and
scoped to `/api/v1/auth`; refresh rotates the token; **replaying a rotated token is rejected and
revokes the entire family** (verified: 2 of 3 sessions revoked)
- **Audience isolation:** a customer cannot sign in at the admin endpoint and an admin cannot sign
in at the storefront endpoint — both return the same `INVALID_CREDENTIALS` as a wrong password,
so the form cannot be used to enumerate accounts
- **RBAC:** a `catalog_manager` receives `PERMISSION_DENIED` on `/admin/users` and `/admin/roles`;
no token gives `UNAUTHENTICATED`
- Admin: renders Vietnamese by default and English with `sport_admin_locale=en`
### Verified in a real browser
Server-side checks and curl are not sufficient for client behaviour. Confirmed by clicking
through Chrome with the console and network panel open:
- Admin sign-in issues exactly **one** request, then redirects; sidebar is permission-filtered;
users table and role viewer load real data; language switch preserves session and page
- Storefront PDP: gallery swaps with the colourway, per-variant stock disables the right sizes,
SKU updates, language switch moves between translated slugs; filters apply; all grid images load
- **Admin catalog (M3):** product list with live stock and price ranges; publish/unpublish;
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
- **Discounts (M7):** an automatic promotion and a coupon stack to −150.000 ₫ on a 1.290.000 ₫
bag; a lowercase code is accepted; a fully-claimed code is refused with a reason and the bag
falls back to the automatic offer; a two-use limit allows exactly two orders; cancelling an
order returns its use; two simultaneous redemptions of the last use produce one discounted
order and one clear refusal; a bogus code reports once and is not remembered
- **The discount admin (M7):** a promotion authored at 09:00 local stores as 02:00Z and reopens
at 09:00, not shifted; a discount scheduled for next week reads "Scheduled" and stays out of
the bag; switching one off in admin drops it from a shopper's bag on the next load; retiring
one soft-deletes it, leaving redemptions and the audit trail intact
- **Accounts (M8):** registering with an email that had guest orders adopts them into the new
account's history; a signed-in checkout attaches the order to the customer while a guest
checkout — and one carrying a malformed token — still succeeds unattached; one customer
cannot read another's order by id, address book or wishlist; an admin token is refused on
account routes; the session survives a page reload with the access token held only in memory
- **Content (M7):** a post published in one language still lists on the other locale's journal
rather than vanishing; a body containing `<script>` and `<img onerror>` renders as visible text
and executes nothing; a Markdown link to `/men` keeps its locale prefix; a published page
appears in the footer; a draft is not reachable from the storefront
- **Reviews (M7):** a review can only be written against a line on an order whose email matches,
and a wrong email 404s exactly like an unknown order; the same item cannot be reviewed twice;
a submitted review stays invisible until approved; approving updates the product's rating
immediately rather than after the cache expires; rejecting an approved review takes its stars
back out; a rejected review never reaches the storefront
- **Checkout is idempotent:** a request without an `Idempotency-Key` is refused; the same key
twice returns the _same_ order rather than a second one; two simultaneous requests with one key
yield one order and one clear refusal — total order count grows by exactly one in every case
- **Order status and fulfilment agree:** `FULFILLED` and `COMPLETED` now set `fulfillmentStatus`,
so the "FULFILLED / UNFULFILLED" contradiction can no longer occur; two historical rows were
backfilled
- **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
SKU prefix (`VEL-NOC-BLACK-S` at 1.590.000 ₫, not `AO-GIO-…-S` at 0 ₫); a sale price above the
regular price is rejected; two products with the same name get distinct per-locale slugs; a
retired colourway leaves both the filter and the facet count; 404s no longer write junk keys
into Redis
- **Listing controls (M4):** sort menu changes the order and the URL together
(`?sort=price_asc`), and is shareable; "load more" appends the next page in place without
touching the address bar, updates "showing N of M", and disappears when the set is exhausted;
following the same button's `href` with JavaScript off returns a distinct, correctly
locale-prefixed second page — page 1 and page 2 verified disjoint with a working cursor chain
- **The whole M3 loop, authored through the UI:** created a product with vi + en content, two
colourways and two sizes → 4 variants generated with correct SKUs and translated titles
(`Đen / M`, `Xanh Neon / L`) → edited two prices and one sale price, with only the changed rows
sent → attached one image per colourway → received stock on all four variants → published →
the PDP renders in both languages at per-locale slugs, the gallery and price track the colourway
swatch, and the sale price shows in red
Known benign noise: NestJS logs two `Unsupported route path: "/api/*"` warnings at boot. They
come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are
auto-converted correctly, and routing is verified working. Nothing in this repository registers
that path.
## Contributing
- Branches: `feat/…`, `fix/…`, `chore/…`, `docs/…`
- Commits: [Conventional Commits](https://www.conventionalcommits.org/)
- CI runs lint, typecheck, format, tests against real PostgreSQL and Redis, builds every app,
and builds all three Docker images on push.
- Architectural changes need an ADR in [`docs/adr/`](./docs/adr/README.md).