# 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/ 23 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 accounts: registration, profile, address book, order history, wishlist; guest orders adopted on sign-up | | **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | **Recommended next step: M9 (payments) — or M10 (shipping), since payments are deferred.** 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 - 11 migrations, 44 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/` → 307 → `/en/products/`; `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 - **Accounts (M8):** registering with an email that had guest orders adopts them into the new account's history; a signed-in checkout attaches the order to the customer while a guest checkout — and one carrying a malformed token — still succeeds unattached; one customer cannot read another's order by id, address book or wishlist; an admin token is refused on account routes; the session survives a page reload with the access token held only in memory - **Content (M7):** a post published in one language still lists on the other locale's journal rather than vanishing; a body containing `