378 lines
20 KiB
Markdown
378 lines
20 KiB
Markdown
# 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/ 22 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 account |
|
||
| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
|
||
|
||
**Recommended next step: M8 (customer accounts).** 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
|
||
- 10 migrations, 43 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
|
||
- **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).
|