Sport Store
A modern sports-fashion e-commerce platform.
Status: milestone 2 — auth and RBAC, on top of a live bilingual catalog. The storefront renders real products in Vietnamese and English; the admin has working sign-in with rotating refresh tokens and permission-filtered navigation. Cart, checkout and orders are next; see 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.
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:
curl -s http://localhost:4000/api/v1/health | jq
{
"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
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
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
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
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/ Cross-feature UI (layout, chrome)
│ │ ├── features/ auth · product · category · collection · search
│ │ │ cart · checkout · order · wishlist · account
│ │ ├── i18n/ next-intl routing, request config, navigation
│ │ ├── messages/ vi.json · en.json (UI strings)
│ │ ├── hooks/ lib/ services/ 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/ 20 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/ Design-system primitives (Button, Input, Badge, Skeleton)
│ ├── 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/ 15 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. The rules that matter most:
-
Only the API touches data. Neither frontend has a database, Redis or storage client. Enforced by ESLint and by the absence of
DATABASE_URLfrom their environments. (ADR-0004) -
All business logic lives in the backend. The storefront may format
₫250.000; it may never compute a discount. -
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)
-
Authorization is permissions, never role checks. There is no
if (user.role === 'ADMIN')anywhere. (ADR-0007) -
Authentication is on by default. The access-token guard is global; a route is public only by declaring
@Public(). Forgetting a decorator fails closed. -
Money is an integer in minor units, everywhere. (ADR-0011)
-
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) -
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)
-
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)
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: product editor, variant matrix, media uploads, inventory |
| M4 ◐ | Storefront catalog — listings, PDP, variant selector and filters landed with M1; sort UI, pagination and a mobile filter drawer remain |
| M5 | Cart, checkout, orders |
| M6 | Search + faceting |
| M7 | Promotions, coupons, reviews, CMS |
| M8 | Customer account |
| M9 | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications |
Recommended next step: M3 (admin catalog write path). Reads, auth and RBAC are in place, so the product editor and variant matrix now have everything they need — a known operator, a permission to check, and a catalog to edit. It is also what makes the seed replaceable by real merchandising.
Verified in this environment
Everything below was run, not assumed:
pnpm lint·pnpm typecheck·pnpm test·pnpm build— 25/25 Turborepo tasks pass;pnpm format:checkclean- 5 migrations, 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, 3 collections, 12 products, 155 variants, 64 uploaded images and 3 dev accounts
- 25 tests — RBAC guards, password hashing, translation fallback,
Accept-Language - Catalog: listings with filters/facets/cursor paging, PDP, navigation — correctly localised in
both
vianden; 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_CREDENTIALSas a wrong password, so the form cannot be used to enumerate accounts - RBAC: a
catalog_managerreceivesPERMISSION_DENIEDon/admin/usersand/admin/roles; no token givesUNAUTHENTICATED - 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 — three bugs proved it. Confirmed by clicking through Chrome with the console and network panel open:
- Admin sign-in issues exactly one
POST /auth/admin/login, then redirects to the dashboard - Sidebar is filtered by the signed-in operator's permissions; users table and role viewer load real data; language switch preserves the session and the current page; sign-out returns to login
- Storefront PDP: gallery swaps with the colourway, per-variant stock disables the right sizes, SKU updates, and switching language moves between translated slugs
- Filters apply (
/men?colors=black&onSale=true), and all 16 grid images load
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
- 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/.