Sport Store
A modern sports-fashion e-commerce platform.
Status: milestone 1 — catalog read API, live end to end, in Vietnamese and English. The storefront renders real products from the database through the REST API. Cart, checkout, orders and auth are still ahead; 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
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/ 14 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)
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, 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: M2 (auth). The enforcement half already exists — global access-token guard, RBAC permissions guard, audience separation — so only issuance is missing: login, refresh rotation, and the admin user/role screens. Everything after it (cart ownership, orders, the admin write path) depends on knowing who is asking.
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 applied; 32 tables; seed loads 36 permissions, 6 roles, 3 brands, 8 categories, 3 collections, 12 products, 155 variants and 64 generated images uploaded to MinIO
pnpm test— 17 passing (RBAC guards, translation fallback,Accept-Languagenegotiation)- API: listings with filters/facets/cursor paging, PDP, navigation, brands and collections all
return correctly localised payloads in both
vianden - Storefront: every route returns 200 in both locales; PDP renders translated options, spec
table and variant titles;
/en/products/<vi-slug>→ 307 →/en/products/<en-slug>;hreflang+ canonical emitted per locale - Money formats per locale from one integer:
690.000 ₫(vi) /₫690,000(en) - Admin: renders Vietnamese by default and English with
sport_admin_locale=en
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/.