2026-08-13 23:20:22 +07:00
2026-08-13 23:20:22 +07:00
2026-08-13 23:20:22 +07:00
2026-08-13 23:20:22 +07:00
2026-08-11 13:37:25 +07:00
2026-08-11 13:37:25 +07:00
2026-08-11 13:37:25 +07:00
2026-08-11 13:37:25 +07:00
2026-08-11 13:37:25 +07:00
2026-08-13 23:20:22 +07:00
2026-08-13 23:20:22 +07:00
2026-08-13 23:20:22 +07:00
2026-08-11 13:37:25 +07:00

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.

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/
│   │       │   └── 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/         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/              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/             17 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)

  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)

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, checkout, orders
M6 Search + faceting
M7 Promotions, coupons, reviews, CMS
M8 Customer account
M9 Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications

Recommended next step: M5 (cart & checkout). M3 now closes the loop end to end: an operator creates a product with per-locale content, defines the option axes, gets a generated variant matrix, prices it, attaches imagery per colourway, receives stock through the ledger and publishes — and the result renders on the storefront in both languages. Cart and checkout are the first flows that put the variant model under real concurrency.


Verified in this environment

Everything below was run, not assumed:

  • pnpm lint · pnpm typecheck · pnpm test · pnpm build — 26/26 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
  • 44 tests — RBAC guards, password hashing, translation fallback, Accept-Language, the variant matrix planner, the HTTP client's fetch receiver and retry recursion, and the inventory list's variant-driven projection
  • 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
  • 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
  • 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/.
S
Description
Basic web for student project
Readme
1.2 MiB
Languages
TypeScript 93.4%
Python 4.4%
CSS 0.7%
JavaScript 0.7%
Dockerfile 0.4%
Other 0.4%