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

14 KiB
Raw Blame History

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:

  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)

  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)

  4. Authorization is permissions, never role checks. There is no if (user.role === 'ADMIN') anywhere. (ADR-0007)

  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)

  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)

  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)

  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)

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:check clean
  • 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 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 — 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/.