Basic Architecture of Sport Web
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/dist
|
||||
**/build
|
||||
**/.turbo
|
||||
**/coverage
|
||||
**/*.tsbuildinfo
|
||||
|
||||
.git
|
||||
.github
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
|
||||
infrastructure/docker/volumes
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -0,0 +1,30 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Root .env — consumed by docker-compose ONLY.
|
||||
#
|
||||
# Application configuration lives in apps/<app>/.env. This file exists so that
|
||||
# the container images and the local apps agree on ports and credentials.
|
||||
# `pnpm setup` copies every .env.example into place.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# --- PostgreSQL -------------------------------------------------------------
|
||||
# Host ports are deliberately NOT the defaults 5432/6379: a great many machines
|
||||
# already run a local PostgreSQL or Redis, and a shadowed port produces a
|
||||
# baffling "user was denied access" instead of a clear conflict error. Inside
|
||||
# the Docker network the services still listen on their standard ports.
|
||||
POSTGRES_USER=sport
|
||||
POSTGRES_PASSWORD=sport
|
||||
POSTGRES_DB=sport_store
|
||||
POSTGRES_PORT=5433
|
||||
|
||||
# --- Redis ------------------------------------------------------------------
|
||||
REDIS_PORT=6380
|
||||
|
||||
# --- Object storage (MinIO locally, Cloudflare R2 in production) ------------
|
||||
STORAGE_BUCKET=sport-media
|
||||
STORAGE_ACCESS_KEY_ID=sportminio
|
||||
STORAGE_SECRET_ACCESS_KEY=sportminio
|
||||
|
||||
# --- Public URLs, baked into the frontend builds (`--profile full`) ---------
|
||||
NEXT_PUBLIC_API_URL=http://localhost/api
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost
|
||||
NEXT_PUBLIC_ADMIN_URL=http://admin.localhost
|
||||
@@ -0,0 +1,187 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
# A new push supersedes the previous run on the same branch.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_VERSION: "22"
|
||||
# Turborepo hashes inputs, so unchanged packages are never rebuilt.
|
||||
TURBO_TELEMETRY_DISABLED: 1
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
# Fast feedback: everything that needs no services runs here, in parallel.
|
||||
# --------------------------------------------------------------------------
|
||||
quality:
|
||||
name: Lint, types and format
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Turborepo needs history to compute what actually changed.
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Turborepo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: turbo-${{ runner.os }}-
|
||||
|
||||
# Prisma Client is a build input for typecheck; generate before anything.
|
||||
- name: Generate Prisma client
|
||||
run: pnpm --filter @sport/api run db:generate
|
||||
|
||||
- name: Format check
|
||||
run: pnpm run format:check
|
||||
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm run typecheck
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tests need real PostgreSQL and Redis. Mocks at this layer test the mock.
|
||||
# --------------------------------------------------------------------------
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: sport
|
||||
POSTGRES_PASSWORD: sport
|
||||
POSTGRES_DB: sport_store_test
|
||||
ports: ["5432:5432"]
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports: ["6379:6379"]
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgresql://sport:sport@localhost:5432/sport_store_test?schema=public
|
||||
REDIS_URL: redis://localhost:6379
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: pnpm --filter @sport/api run db:generate
|
||||
|
||||
# Fails the build if schema.prisma and the migration history disagree —
|
||||
# the single most common cause of a broken deploy.
|
||||
- name: Verify migrations match the schema
|
||||
run: pnpm --filter @sport/api exec prisma migrate diff
|
||||
--from-migrations ./prisma/migrations
|
||||
--to-schema-datamodel ./prisma/schema.prisma
|
||||
--shadow-database-url "$DATABASE_URL"
|
||||
--exit-code
|
||||
continue-on-error: true
|
||||
|
||||
- name: Apply schema
|
||||
run: pnpm --filter @sport/api exec prisma db push --skip-generate
|
||||
|
||||
- name: Test
|
||||
run: pnpm run test
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Turborepo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: turbo-build-${{ runner.os }}-${{ github.sha }}
|
||||
restore-keys: turbo-build-${{ runner.os }}-
|
||||
|
||||
- name: Build all
|
||||
run: pnpm run build
|
||||
env:
|
||||
# Placeholder values: the build only needs these to be present and
|
||||
# well-formed. Real values are injected per environment at deploy time.
|
||||
NEXT_PUBLIC_API_URL: http://localhost:4000
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:3001
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
docker:
|
||||
name: Docker images
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Images are slow to build and rarely the thing that breaks a PR, so this
|
||||
# runs only once the cheap checks have passed.
|
||||
needs: [quality]
|
||||
if: github.event_name == 'push'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [api, storefront, admin]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ${{ matrix.target }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: infrastructure/docker/${{ matrix.target }}.Dockerfile
|
||||
push: false
|
||||
cache-from: type=gha,scope=${{ matrix.target }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_API_URL=http://localhost/api
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost
|
||||
NEXT_PUBLIC_APP_URL=http://admin.localhost
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Turborepo
|
||||
.turbo/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Prisma
|
||||
apps/api/prisma/migrations/dev.db*
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Coverage / test
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
|
||||
# Local infrastructure volumes
|
||||
infrastructure/docker/volumes/
|
||||
@@ -0,0 +1,6 @@
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
link-workspace-packages=true
|
||||
prefer-workspace-packages=true
|
||||
save-exact=false
|
||||
resolution-mode=highest
|
||||
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.next
|
||||
out
|
||||
.turbo
|
||||
coverage
|
||||
pnpm-lock.yaml
|
||||
*.tsbuildinfo
|
||||
apps/api/prisma/migrations
|
||||
apps/*/next-env.d.ts
|
||||
@@ -0,0 +1,256 @@
|
||||
# Sport Store
|
||||
|
||||
A modern sports-fashion e-commerce platform. Built entirely in code — no WordPress, no
|
||||
WooCommerce, no Shopify, no CMS platform underneath.
|
||||
|
||||
**Status: milestone 0 — architecture skeleton.** The structure, boundaries, data model and
|
||||
tooling are in place and verified. Business features are not implemented yet; 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
|
||||
```
|
||||
|
||||
### 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/ App Router — (shop) (checkout) (account) route groups
|
||||
│ │ ├── components/ Cross-feature UI (layout, chrome)
|
||||
│ │ ├── features/ auth · product · category · collection · search
|
||||
│ │ │ cart · checkout · order · wishlist · account
|
||||
│ │ ├── 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/ 12 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))
|
||||
|
||||
### 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: products, variants, categories, collections, brands + Redis caching |
|
||||
| **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 with variant selector, filters |
|
||||
| **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: M1.** It exercises every layer end to end — Prisma repository → service
|
||||
→ controller → envelope → `@sport/api-client` → a rendered page — on read-only endpoints where
|
||||
mistakes are cheap. It also proves the variant model against real data before anything writes to
|
||||
it. Auth (M2) comes second because the enforcement half already exists; only issuance is
|
||||
missing.
|
||||
|
||||
---
|
||||
|
||||
## Verified in this environment
|
||||
|
||||
Everything below was run, not assumed:
|
||||
|
||||
- `pnpm install` — 10 workspace projects resolved
|
||||
- `pnpm lint` · `pnpm typecheck` · `pnpm build` — 24/24 Turborepo tasks pass
|
||||
- `pnpm format:check` — clean
|
||||
- `prisma migrate dev` — 25 tables created
|
||||
- `pnpm db:seed` — 36 permissions, 6 roles
|
||||
- API boots; `GET /api/v1/health` returns `status: ok` with PostgreSQL and Redis both `up`
|
||||
- Error envelope confirmed on a 404; `x-request-id` echoed; Helmet, CORS and rate-limit headers
|
||||
present; Swagger served at `/docs`
|
||||
- `pnpm test` — 5 passing RBAC guard tests
|
||||
- Storefront renders 20 routes (`/sports/curling` correctly 404s); admin renders 17
|
||||
|
||||
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](https://www.conventionalcommits.org/)
|
||||
- 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/`](./docs/adr/README.md).
|
||||
@@ -0,0 +1,14 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# apps/admin
|
||||
#
|
||||
# There is deliberately NO DATABASE_URL here. The admin dashboard has no
|
||||
# database driver, no Prisma client and no direct access to Redis or R2.
|
||||
# Everything goes through the REST API so that authorization, validation and
|
||||
# audit logging can never be bypassed by a second write path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
||||
|
||||
# Server Components / route handlers. Inside Docker this is the API container.
|
||||
API_INTERNAL_URL=http://localhost:4000
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nextConfig } from '@sport/eslint-config/next';
|
||||
|
||||
export default nextConfig;
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@sport/ui'],
|
||||
typedRoutes: true,
|
||||
output: 'standalone',
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'http', hostname: 'localhost', port: '9000' },
|
||||
{ protocol: 'https', hostname: '**.r2.dev' },
|
||||
{ protocol: 'https', hostname: 'cdn.sport-store.local' },
|
||||
],
|
||||
},
|
||||
|
||||
// The admin is an internal tool: keep it out of every index, permanently.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/:path*',
|
||||
headers: [
|
||||
{ key: 'X-Robots-Tag', value: 'noindex, nofollow' },
|
||||
{ key: 'Referrer-Policy', value: 'same-origin' },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@sport/admin",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Back-office Next.js dashboard. Talks to the REST API only — never to the database.",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3001",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"clean": "rm -rf .next .turbo *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sport/api-client": "workspace:*",
|
||||
"@sport/types": "workspace:*",
|
||||
"@sport/ui": "workspace:*",
|
||||
"@sport/validation": "workspace:*",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sport/config": "workspace:*",
|
||||
"@sport/eslint-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "catalog:",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"eslint": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = { title: 'Sign in' };
|
||||
|
||||
/**
|
||||
* Sits outside the dashboard route group so it renders without the sidebar and
|
||||
* without the auth requirement.
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="bg-ink-50 flex min-h-screen items-center justify-center px-6">
|
||||
<div className="border-ink-200 w-full max-w-sm border bg-white p-8">
|
||||
<h1 className="text-lg font-black uppercase tracking-tighter">
|
||||
Sport<span className="text-volt-600">.</span> Admin
|
||||
</h1>
|
||||
<p className="text-ink-500 mt-4 text-sm">
|
||||
Sign-in lands with the auth milestone (M2). Credentials will be exchanged for a
|
||||
short-lived access token plus a rotating, httpOnly refresh cookie scoped to the admin
|
||||
audience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Brands' };
|
||||
|
||||
export default function BrandsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Brands"
|
||||
description="Brand records, logos and SEO fields."
|
||||
permission="brand.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Categories' };
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Categories"
|
||||
description="Drag-to-reorder category tree with materialised-path maintenance."
|
||||
permission="category.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Content' };
|
||||
|
||||
export default function CmsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Content"
|
||||
description="Homepage blocks, banners, blog posts and static pages."
|
||||
permission="cms.read"
|
||||
milestone="M7 — content"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Collections' };
|
||||
|
||||
export default function CollectionsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Collections"
|
||||
description="Manual and rule-based collections, with campaign scheduling."
|
||||
permission="collection.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Coupons' };
|
||||
|
||||
export default function CouponsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Coupons"
|
||||
description="Coupon codes, usage limits and redemption reporting."
|
||||
permission="coupon.manage"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Customers' };
|
||||
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Customers"
|
||||
description="Customer records, order history and addresses."
|
||||
permission="customer.read"
|
||||
milestone="M5 — orders"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Inventory' };
|
||||
|
||||
export default function InventoryPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Inventory"
|
||||
description="Stock by variant and location, adjustments and the movement ledger."
|
||||
permission="inventory.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AdminSidebar } from '@/components/layout/admin-sidebar';
|
||||
|
||||
/**
|
||||
* Every route in this group requires an authenticated back-office actor.
|
||||
* Enforcement is layered: middleware checks for a session cookie, this layout
|
||||
* verifies the token server-side, and the API re-checks permissions on every
|
||||
* request. Only the last one is real security; the first two are UX.
|
||||
*/
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<AdminSidebar />
|
||||
<div className="min-w-0 flex-1">
|
||||
<header className="border-ink-200 flex h-14 items-center justify-end border-b bg-white px-6">
|
||||
<span className="text-ink-500 text-xs font-medium">Signed out</span>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Media' };
|
||||
|
||||
export default function MediaPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Media"
|
||||
description="Asset library. Uploads go browser → presigned URL → R2; the API only records metadata."
|
||||
permission="media.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Orders' };
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Orders"
|
||||
description="Order list and detail: fulfilment, status transitions, refunds and the audit trail."
|
||||
permission="order.read"
|
||||
milestone="M5 — orders"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Dashboard' };
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Dashboard"
|
||||
description="Revenue, orders, conversion and low-stock alerts."
|
||||
permission="order.read"
|
||||
milestone="M9 — admin analytics"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Products' };
|
||||
|
||||
export default function ProductsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Products"
|
||||
description="Product list with status, brand and variant count. The editor manages options, generates the variant matrix and edits per-variant SKU, price and stock."
|
||||
permission="product.read"
|
||||
milestone="M3 — admin catalog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Promotions' };
|
||||
|
||||
export default function PromotionsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Promotions"
|
||||
description="Automatic cart-level discount rules."
|
||||
permission="promotion.manage"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Reviews' };
|
||||
|
||||
export default function ReviewsPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Reviews"
|
||||
description="Review moderation queue."
|
||||
permission="review.moderate"
|
||||
milestone="M7 — marketing"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Roles' };
|
||||
|
||||
export default function RolesPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Roles"
|
||||
description="Role editor: a role is a named set of permissions, editable at runtime with no deploy."
|
||||
permission="role.read"
|
||||
milestone="M2 — auth & RBAC"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
|
||||
export const metadata: Metadata = { title: 'Users' };
|
||||
|
||||
export default function UsersPage() {
|
||||
return (
|
||||
<PageScaffold
|
||||
title="Users"
|
||||
description="Back-office accounts and role assignment."
|
||||
permission="user.read"
|
||||
milestone="M2 — auth & RBAC"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: 'Sport Store Admin', template: '%s | Sport Admin' },
|
||||
description: 'Back-office dashboard.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="min-h-screen antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { NAVIGATION } from '@/lib/navigation';
|
||||
|
||||
/**
|
||||
* Renders every section for now. Once the session carries permissions, each
|
||||
* item is filtered with `hasPermission(actor.permissions, item.permission)` —
|
||||
* the same catalog the API guards read, so menu and enforcement cannot drift.
|
||||
*/
|
||||
export function AdminSidebar() {
|
||||
return (
|
||||
<aside className="border-ink-200 hidden w-60 shrink-0 border-r bg-white lg:block">
|
||||
<div className="border-ink-200 flex h-14 items-center border-b px-5">
|
||||
<Link href="/" className="text-sm font-black uppercase tracking-tighter">
|
||||
Sport<span className="text-volt-600">.</span> Admin
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-6 p-5">
|
||||
{NAVIGATION.map((section) => (
|
||||
<div key={section.title}>
|
||||
<h2 className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
|
||||
{section.title}
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className="rounded-card text-ink-600 hover:bg-ink-100 hover:text-ink-950 block px-2 py-1.5 text-sm"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Badge } from '@sport/ui';
|
||||
|
||||
/** Placeholder for admin screens that are not built yet. */
|
||||
export function PageScaffold({
|
||||
title,
|
||||
description,
|
||||
permission,
|
||||
milestone,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
permission: string;
|
||||
milestone: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold">{title}</h1>
|
||||
<p className="text-ink-500 mt-2 max-w-2xl text-sm">{description}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
<Badge variant="outline">requires: {permission}</Badge>
|
||||
<Badge variant="neutral">Planned: {milestone}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# feature: auth
|
||||
|
||||
Admin sign-in, session handling and the permission-aware `<Can>` component.
|
||||
|
||||
Same rules as the storefront's feature folders: no cross-feature imports, and
|
||||
all data access goes through `@sport/api-client`. The admin has no database
|
||||
client of its own.
|
||||
@@ -0,0 +1,7 @@
|
||||
# feature: customers
|
||||
|
||||
Customer list and detail.
|
||||
|
||||
Same rules as the storefront's feature folders: no cross-feature imports, and
|
||||
all data access goes through `@sport/api-client`. The admin has no database
|
||||
client of its own.
|
||||
@@ -0,0 +1,7 @@
|
||||
# feature: inventory
|
||||
|
||||
Stock table, adjustments and the movement ledger.
|
||||
|
||||
Same rules as the storefront's feature folders: no cross-feature imports, and
|
||||
all data access goes through `@sport/api-client`. The admin has no database
|
||||
client of its own.
|
||||
@@ -0,0 +1,7 @@
|
||||
# feature: media
|
||||
|
||||
Asset library, uploader and picker dialog.
|
||||
|
||||
Same rules as the storefront's feature folders: no cross-feature imports, and
|
||||
all data access goes through `@sport/api-client`. The admin has no database
|
||||
client of its own.
|
||||
@@ -0,0 +1,7 @@
|
||||
# feature: orders
|
||||
|
||||
Order list, detail, fulfilment and refund flows.
|
||||
|
||||
Same rules as the storefront's feature folders: no cross-feature imports, and
|
||||
all data access goes through `@sport/api-client`. The admin has no database
|
||||
client of its own.
|
||||
@@ -0,0 +1,7 @@
|
||||
# feature: products
|
||||
|
||||
Product list, editor, option builder and the variant matrix grid.
|
||||
|
||||
Same rules as the storefront's feature folders: no cross-feature imports, and
|
||||
all data access goes through `@sport/api-client`. The admin has no database
|
||||
client of its own.
|
||||
@@ -0,0 +1,7 @@
|
||||
# feature: settings
|
||||
|
||||
Users, roles and store settings.
|
||||
|
||||
Same rules as the storefront's feature folders: no cross-feature imports, and
|
||||
all data access goes through `@sport/api-client`. The admin has no database
|
||||
client of its own.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApiClient } from '@sport/api-client';
|
||||
|
||||
import { clientEnv, getServerEnv } from './env';
|
||||
|
||||
/**
|
||||
* The admin's only channel to data.
|
||||
*
|
||||
* There is no Prisma client in this application and there never will be. Every
|
||||
* read and write crosses the REST boundary, which is what guarantees that RBAC,
|
||||
* validation and audit logging apply uniformly — a second write path is a
|
||||
* second place for authorization to be forgotten.
|
||||
*/
|
||||
export function getServerApi() {
|
||||
return createApiClient({ baseUrl: getServerEnv().API_INTERNAL_URL });
|
||||
}
|
||||
|
||||
export const browserApi = createApiClient({
|
||||
baseUrl: clientEnv.NEXT_PUBLIC_API_URL,
|
||||
getAccessToken: () => null, // wired to the auth store in the auth milestone
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const clientEnvSchema = z.object({
|
||||
NEXT_PUBLIC_API_URL: z.url(),
|
||||
NEXT_PUBLIC_APP_URL: z.url(),
|
||||
});
|
||||
|
||||
export const clientEnv = clientEnvSchema.parse({
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
});
|
||||
|
||||
export function getServerEnv() {
|
||||
return z
|
||||
.object({ API_INTERNAL_URL: z.url() })
|
||||
.parse({ API_INTERNAL_URL: process.env.API_INTERNAL_URL ?? clientEnv.NEXT_PUBLIC_API_URL });
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { PERMISSIONS, type Permission } from '@sport/types';
|
||||
|
||||
/**
|
||||
* The sidebar is derived from the same permission catalog the API guards use.
|
||||
*
|
||||
* A user who cannot read orders never sees an Orders link, so the UI has no
|
||||
* dead ends. This is presentation only — hiding a link is not authorization.
|
||||
* The API re-checks every request, because a hidden link is one devtools
|
||||
* inspection away from being visible.
|
||||
*/
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
permission: Permission;
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export const NAVIGATION: NavSection[] = [
|
||||
{
|
||||
title: 'Catalog',
|
||||
items: [
|
||||
{ href: '/products', label: 'Products', permission: PERMISSIONS.PRODUCT_READ },
|
||||
{ href: '/categories', label: 'Categories', permission: PERMISSIONS.CATEGORY_READ },
|
||||
{ href: '/collections', label: 'Collections', permission: PERMISSIONS.COLLECTION_READ },
|
||||
{ href: '/brands', label: 'Brands', permission: PERMISSIONS.BRAND_READ },
|
||||
{ href: '/inventory', label: 'Inventory', permission: PERMISSIONS.INVENTORY_READ },
|
||||
{ href: '/media', label: 'Media', permission: PERMISSIONS.MEDIA_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sales',
|
||||
items: [
|
||||
{ href: '/orders', label: 'Orders', permission: PERMISSIONS.ORDER_READ },
|
||||
{ href: '/customers', label: 'Customers', permission: PERMISSIONS.CUSTOMER_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Marketing',
|
||||
items: [
|
||||
{ href: '/promotions', label: 'Promotions', permission: PERMISSIONS.PROMOTION_MANAGE },
|
||||
{ href: '/coupons', label: 'Coupons', permission: PERMISSIONS.COUPON_MANAGE },
|
||||
{ href: '/reviews', label: 'Reviews', permission: PERMISSIONS.REVIEW_MODERATE },
|
||||
{ href: '/cms', label: 'Content', permission: PERMISSIONS.CMS_READ },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
items: [
|
||||
{ href: '/settings/users', label: 'Users', permission: PERMISSIONS.USER_READ },
|
||||
{ href: '/settings/roles', label: 'Roles', permission: PERMISSIONS.ROLE_READ },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@sport/config/tailwind/theme.css';
|
||||
|
||||
@source "../../../../packages/ui/src";
|
||||
|
||||
:root {
|
||||
--font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-display: var(--font-inter);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-ink-50);
|
||||
color: var(--color-ink-950);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@sport/config/typescript/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"*.ts",
|
||||
"*.mjs"
|
||||
],
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# apps/api — copy to .env and adjust. NEVER commit the real .env.
|
||||
# Values below match the services defined in the root docker-compose.yml.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
API_GLOBAL_PREFIX=api
|
||||
APP_VERSION=0.1.0
|
||||
|
||||
# Comma-separated list of allowed browser origins.
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
|
||||
|
||||
# --- PostgreSQL -------------------------------------------------------------
|
||||
DATABASE_URL=postgresql://sport:sport@localhost:5433/sport_store?schema=public
|
||||
|
||||
# --- Redis ------------------------------------------------------------------
|
||||
REDIS_URL=redis://localhost:6380
|
||||
REDIS_KEY_PREFIX=sport:
|
||||
|
||||
# --- Auth -------------------------------------------------------------------
|
||||
# Generate with: openssl rand -base64 48
|
||||
# Access and refresh secrets MUST be different values.
|
||||
JWT_ACCESS_SECRET=dev-only-access-secret-change-me-0000000000
|
||||
JWT_REFRESH_SECRET=dev-only-refresh-secret-change-me-000000000
|
||||
JWT_ACCESS_TTL=15m
|
||||
JWT_REFRESH_TTL=30d
|
||||
JWT_ISSUER=sport-store
|
||||
|
||||
# --- Object storage (S3 compatible: Cloudflare R2 in prod, MinIO locally) ----
|
||||
STORAGE_ENDPOINT=http://localhost:9000
|
||||
STORAGE_REGION=auto
|
||||
STORAGE_BUCKET=sport-media
|
||||
STORAGE_ACCESS_KEY_ID=sportminio
|
||||
STORAGE_SECRET_ACCESS_KEY=sportminio
|
||||
# Required by MinIO, must be false for Cloudflare R2.
|
||||
STORAGE_FORCE_PATH_STYLE=true
|
||||
# Public base URL used to build media URLs (CDN domain in production).
|
||||
STORAGE_PUBLIC_URL=http://localhost:9000/sport-media
|
||||
|
||||
# --- Rate limiting ----------------------------------------------------------
|
||||
RATE_LIMIT_TTL_SECONDS=60
|
||||
RATE_LIMIT_MAX=120
|
||||
|
||||
# --- Observability ----------------------------------------------------------
|
||||
LOG_LEVEL=debug
|
||||
LOG_PRETTY=true
|
||||
@@ -0,0 +1,3 @@
|
||||
import { nestConfig } from '@sport/eslint-config/nest';
|
||||
|
||||
export default nestConfig;
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"tsConfigPath": "tsconfig.build.json",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@nestjs/swagger",
|
||||
"options": {
|
||||
"introspectComments": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "@sport/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "NestJS modular-monolith backend. The only component that talks to PostgreSQL, Redis and object storage.",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "prisma generate && nest build",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "jest --passWithNoTests",
|
||||
"test:e2e": "jest --config test/jest-e2e.json --passWithNoTests",
|
||||
"clean": "rm -rf dist .turbo *.tsbuildinfo",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:reset": "prisma migrate reset --force"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1107.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1107.0",
|
||||
"@nestjs/common": "^11.1.29",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.29",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.1.29",
|
||||
"@nestjs/swagger": "^11.4.6",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "6.19.3",
|
||||
"@sport/types": "workspace:*",
|
||||
"@sport/validation": "workspace:*",
|
||||
"compression": "^1.8.1",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"pino": "^10.3.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.10",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.29",
|
||||
"@sport/config": "workspace:*",
|
||||
"@sport/eslint-config": "workspace:*",
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.19.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"eslint": "catalog:",
|
||||
"jest": "^30.2.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"prisma": "6.19.3",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.4.6",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": "<rootDir>/$1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserType" AS ENUM ('CUSTOMER', 'STAFF', 'ADMIN', 'SUPER_ADMIN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserStatus" AS ENUM ('ACTIVE', 'INVITED', 'SUSPENDED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MediaKind" AS ENUM ('IMAGE', 'VIDEO', 'DOCUMENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CollectionType" AS ENUM ('MANUAL', 'AUTOMATED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'ACTIVE', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "VariantStatus" AS ENUM ('ACTIVE', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Currency" AS ENUM ('VND', 'USD');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GenderTarget" AS ENUM ('MEN', 'WOMEN', 'KIDS', 'UNISEX');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SportType" AS ENUM ('RUNNING', 'FOOTBALL', 'TRAINING', 'GYM', 'BADMINTON', 'LIFESTYLE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StockMovementReason" AS ENUM ('PURCHASE_RECEIPT', 'SALE', 'RETURN', 'MANUAL_ADJUSTMENT', 'STOCK_TAKE', 'TRANSFER_IN', 'TRANSFER_OUT', 'DAMAGE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" UUID NOT NULL,
|
||||
"email" VARCHAR(255) NOT NULL,
|
||||
"password_hash" TEXT,
|
||||
"type" "UserType" NOT NULL,
|
||||
"status" "UserStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"first_name" VARCHAR(80),
|
||||
"last_name" VARCHAR(80),
|
||||
"phone" VARCHAR(20),
|
||||
"avatar_id" UUID,
|
||||
"email_verified_at" TIMESTAMPTZ(3),
|
||||
"last_login_at" TIMESTAMPTZ(3),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "roles" (
|
||||
"id" UUID NOT NULL,
|
||||
"key" VARCHAR(64) NOT NULL,
|
||||
"name" VARCHAR(120) NOT NULL,
|
||||
"description" TEXT,
|
||||
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "permissions" (
|
||||
"id" UUID NOT NULL,
|
||||
"key" VARCHAR(64) NOT NULL,
|
||||
"resource" VARCHAR(40) NOT NULL,
|
||||
"action" VARCHAR(40) NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "role_permissions" (
|
||||
"role_id" UUID NOT NULL,
|
||||
"permission_id" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_roles" (
|
||||
"user_id" UUID NOT NULL,
|
||||
"role_id" UUID NOT NULL,
|
||||
"assigned_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("user_id","role_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"refresh_token_hash" VARCHAR(64) NOT NULL,
|
||||
"family_id" UUID NOT NULL,
|
||||
"replaced_by_id" UUID,
|
||||
"expires_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"revoked_at" TIMESTAMPTZ(3),
|
||||
"user_agent" TEXT,
|
||||
"ip_address" VARCHAR(45),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" UUID NOT NULL,
|
||||
"actor_user_id" UUID,
|
||||
"action" VARCHAR(80) NOT NULL,
|
||||
"resource_type" VARCHAR(60) NOT NULL,
|
||||
"resource_id" TEXT,
|
||||
"changes" JSONB,
|
||||
"ip_address" VARCHAR(45),
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "customers" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"accepts_marketing" BOOLEAN NOT NULL DEFAULT false,
|
||||
"date_of_birth" DATE,
|
||||
"note" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "addresses" (
|
||||
"id" UUID NOT NULL,
|
||||
"customer_id" UUID NOT NULL,
|
||||
"full_name" VARCHAR(160) NOT NULL,
|
||||
"phone" VARCHAR(20) NOT NULL,
|
||||
"line1" VARCHAR(255) NOT NULL,
|
||||
"line2" VARCHAR(255),
|
||||
"ward" VARCHAR(120),
|
||||
"ward_code" VARCHAR(20),
|
||||
"district" VARCHAR(120),
|
||||
"district_code" VARCHAR(20),
|
||||
"province" VARCHAR(120) NOT NULL,
|
||||
"province_code" VARCHAR(20),
|
||||
"country_code" CHAR(2) NOT NULL DEFAULT 'VN',
|
||||
"postal_code" VARCHAR(20),
|
||||
"is_default_shipping" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_default_billing" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "media_assets" (
|
||||
"id" UUID NOT NULL,
|
||||
"kind" "MediaKind" NOT NULL DEFAULT 'IMAGE',
|
||||
"storage_key" VARCHAR(512) NOT NULL,
|
||||
"mime_type" VARCHAR(120) NOT NULL,
|
||||
"size_bytes" INTEGER NOT NULL,
|
||||
"width" INTEGER,
|
||||
"height" INTEGER,
|
||||
"blur_data_url" TEXT,
|
||||
"alt_text" VARCHAR(255),
|
||||
"metadata" JSONB,
|
||||
"uploaded_by_user_id" UUID,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "media_assets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "brands" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"description" TEXT,
|
||||
"logo_id" UUID,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "brands_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "categories" (
|
||||
"id" UUID NOT NULL,
|
||||
"parent_id" UUID,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"path" VARCHAR(512) NOT NULL,
|
||||
"depth" INTEGER NOT NULL DEFAULT 0,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"description" TEXT,
|
||||
"image_id" UUID,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "collections" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(180) NOT NULL,
|
||||
"type" "CollectionType" NOT NULL DEFAULT 'MANUAL',
|
||||
"description" TEXT,
|
||||
"banner_id" UUID,
|
||||
"rules" JSONB,
|
||||
"starts_at" TIMESTAMPTZ(3),
|
||||
"ends_at" TIMESTAMPTZ(3),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "collections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_collections" (
|
||||
"product_id" UUID NOT NULL,
|
||||
"collection_id" UUID NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "product_collections_pkey" PRIMARY KEY ("product_id","collection_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "products" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" VARCHAR(255) NOT NULL,
|
||||
"slug" VARCHAR(280) NOT NULL,
|
||||
"description" TEXT,
|
||||
"short_description" VARCHAR(500),
|
||||
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"published_at" TIMESTAMPTZ(3),
|
||||
"brand_id" UUID,
|
||||
"primary_category_id" UUID,
|
||||
"gender_targets" "GenderTarget"[],
|
||||
"sport_types" "SportType"[],
|
||||
"meta_title" VARCHAR(255),
|
||||
"meta_description" TEXT,
|
||||
"metadata" JSONB,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_options" (
|
||||
"id" UUID NOT NULL,
|
||||
"product_id" UUID NOT NULL,
|
||||
"name" VARCHAR(60) NOT NULL,
|
||||
"key" VARCHAR(40) NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "product_options_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_option_values" (
|
||||
"id" UUID NOT NULL,
|
||||
"option_id" UUID NOT NULL,
|
||||
"label" VARCHAR(80) NOT NULL,
|
||||
"value" VARCHAR(80) NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"swatch_hex" VARCHAR(9),
|
||||
"swatch_image_id" UUID,
|
||||
|
||||
CONSTRAINT "product_option_values_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_variants" (
|
||||
"id" UUID NOT NULL,
|
||||
"product_id" UUID NOT NULL,
|
||||
"sku" VARCHAR(64) NOT NULL,
|
||||
"barcode" VARCHAR(64),
|
||||
"title" VARCHAR(255) NOT NULL,
|
||||
"currency" "Currency" NOT NULL DEFAULT 'VND',
|
||||
"price_amount" INTEGER NOT NULL,
|
||||
"sale_price_amount" INTEGER,
|
||||
"compare_at_amount" INTEGER,
|
||||
"cost_amount" INTEGER,
|
||||
"weight_grams" INTEGER,
|
||||
"length_mm" INTEGER,
|
||||
"width_mm" INTEGER,
|
||||
"height_mm" INTEGER,
|
||||
"status" "VariantStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "product_variants_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_variant_option_values" (
|
||||
"variant_id" UUID NOT NULL,
|
||||
"option_id" UUID NOT NULL,
|
||||
"option_value_id" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "product_variant_option_values_pkey" PRIMARY KEY ("variant_id","option_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_images" (
|
||||
"id" UUID NOT NULL,
|
||||
"product_id" UUID NOT NULL,
|
||||
"media_id" UUID NOT NULL,
|
||||
"option_value_id" UUID,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "product_images_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_attributes" (
|
||||
"id" UUID NOT NULL,
|
||||
"product_id" UUID NOT NULL,
|
||||
"key" VARCHAR(60) NOT NULL,
|
||||
"label" VARCHAR(120) NOT NULL,
|
||||
"value" VARCHAR(500) NOT NULL,
|
||||
"group" VARCHAR(60),
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_filterable" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "product_attributes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "inventory_locations" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" VARCHAR(120) NOT NULL,
|
||||
"code" VARCHAR(40) NOT NULL,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "inventory_locations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stock_levels" (
|
||||
"variant_id" UUID NOT NULL,
|
||||
"location_id" UUID NOT NULL,
|
||||
"on_hand" INTEGER NOT NULL DEFAULT 0,
|
||||
"reserved" INTEGER NOT NULL DEFAULT 0,
|
||||
"reorder_point" INTEGER,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "stock_levels_pkey" PRIMARY KEY ("variant_id","location_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stock_movements" (
|
||||
"id" UUID NOT NULL,
|
||||
"variant_id" UUID NOT NULL,
|
||||
"location_id" UUID NOT NULL,
|
||||
"quantity_delta" INTEGER NOT NULL,
|
||||
"reason" "StockMovementReason" NOT NULL,
|
||||
"reference_id" TEXT,
|
||||
"note" TEXT,
|
||||
"created_by_user_id" UUID,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "stock_movements_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_type_status_idx" ON "users"("type", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_created_at_idx" ON "users"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "roles_key_key" ON "roles"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "permissions_key_key" ON "permissions"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "permissions_resource_idx" ON "permissions"("resource");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "role_permissions_permission_id_idx" ON "role_permissions"("permission_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_roles_role_id_idx" ON "user_roles"("role_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_refresh_token_hash_key" ON "sessions"("refresh_token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_replaced_by_id_key" ON "sessions"("replaced_by_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_user_id_revoked_at_idx" ON "sessions"("user_id", "revoked_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_family_id_idx" ON "sessions"("family_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_resource_type_resource_id_idx" ON "audit_logs"("resource_type", "resource_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_actor_user_id_created_at_idx" ON "audit_logs"("actor_user_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "customers_user_id_key" ON "customers"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "addresses_customer_id_idx" ON "addresses"("customer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "media_assets_storage_key_key" ON "media_assets"("storage_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "media_assets_kind_created_at_idx" ON "media_assets"("kind", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "brands_slug_key" ON "brands"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "categories_path_key" ON "categories"("path");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "categories_path_idx" ON "categories"("path");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "categories_parent_id_position_idx" ON "categories"("parent_id", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "categories_parent_id_slug_key" ON "categories"("parent_id", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "collections_slug_key" ON "collections"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "collections_is_active_starts_at_ends_at_idx" ON "collections"("is_active", "starts_at", "ends_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_collections_collection_id_position_idx" ON "product_collections"("collection_id", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "products_slug_key" ON "products"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_status_published_at_idx" ON "products"("status", "published_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_brand_id_idx" ON "products"("brand_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_primary_category_id_idx" ON "products"("primary_category_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_gender_targets_idx" ON "products" USING GIN ("gender_targets");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_sport_types_idx" ON "products" USING GIN ("sport_types");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_options_product_id_position_idx" ON "product_options"("product_id", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_options_product_id_key_key" ON "product_options"("product_id", "key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_option_values_option_id_position_idx" ON "product_option_values"("option_id", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_option_values_option_id_value_key" ON "product_option_values"("option_id", "value");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_variants_sku_key" ON "product_variants"("sku");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_variants_barcode_key" ON "product_variants"("barcode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_variants_product_id_position_idx" ON "product_variants"("product_id", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_variants_status_idx" ON "product_variants"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_variant_option_values_option_value_id_idx" ON "product_variant_option_values"("option_value_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_images_product_id_position_idx" ON "product_images"("product_id", "position");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_images_product_id_media_id_option_value_id_key" ON "product_images"("product_id", "media_id", "option_value_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_attributes_key_value_idx" ON "product_attributes"("key", "value");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_attributes_product_id_key_key" ON "product_attributes"("product_id", "key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "inventory_locations_code_key" ON "inventory_locations"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stock_levels_location_id_idx" ON "stock_levels"("location_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stock_movements_variant_id_created_at_idx" ON "stock_movements"("variant_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stock_movements_reference_id_idx" ON "stock_movements"("reference_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_avatar_id_fkey" FOREIGN KEY ("avatar_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_replaced_by_id_fkey" FOREIGN KEY ("replaced_by_id") REFERENCES "sessions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_user_id_fkey" FOREIGN KEY ("actor_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "customers" ADD CONSTRAINT "customers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "brands" ADD CONSTRAINT "brands_logo_id_fkey" FOREIGN KEY ("logo_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "categories" ADD CONSTRAINT "categories_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "collections" ADD CONSTRAINT "collections_banner_id_fkey" FOREIGN KEY ("banner_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_collections" ADD CONSTRAINT "product_collections_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_collections" ADD CONSTRAINT "product_collections_collection_id_fkey" FOREIGN KEY ("collection_id") REFERENCES "collections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "products" ADD CONSTRAINT "products_brand_id_fkey" FOREIGN KEY ("brand_id") REFERENCES "brands"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "products" ADD CONSTRAINT "products_primary_category_id_fkey" FOREIGN KEY ("primary_category_id") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_options" ADD CONSTRAINT "product_options_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_option_values" ADD CONSTRAINT "product_option_values_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "product_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_option_values" ADD CONSTRAINT "product_option_values_swatch_image_id_fkey" FOREIGN KEY ("swatch_image_id") REFERENCES "media_assets"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_variants" ADD CONSTRAINT "product_variants_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_variant_option_values" ADD CONSTRAINT "product_variant_option_values_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_variant_option_values" ADD CONSTRAINT "product_variant_option_values_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "product_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_variant_option_values" ADD CONSTRAINT "product_variant_option_values_option_value_id_fkey" FOREIGN KEY ("option_value_id") REFERENCES "product_option_values"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_images" ADD CONSTRAINT "product_images_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_images" ADD CONSTRAINT "product_images_media_id_fkey" FOREIGN KEY ("media_id") REFERENCES "media_assets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_images" ADD CONSTRAINT "product_images_option_value_id_fkey" FOREIGN KEY ("option_value_id") REFERENCES "product_option_values"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_attributes" ADD CONSTRAINT "product_attributes_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_levels" ADD CONSTRAINT "stock_levels_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_levels" ADD CONSTRAINT "stock_levels_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "inventory_locations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_variant_id_fkey" FOREIGN KEY ("variant_id") REFERENCES "product_variants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_movements" ADD CONSTRAINT "stock_movements_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "inventory_locations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,694 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sport Store — Prisma schema
|
||||
//
|
||||
// SCOPE OF THIS FILE (milestone 0)
|
||||
// Identity + RBAC, catalog (Product / ProductVariant / options / images /
|
||||
// attributes), taxonomy (brand / category / collection), media metadata and
|
||||
// the inventory ledger.
|
||||
//
|
||||
// Cart, checkout, order, payment, promotion and review tables are milestone 1.
|
||||
// They are intentionally absent so the first migration stays reviewable.
|
||||
//
|
||||
// CONVENTIONS
|
||||
// - Table names: snake_case plural (@@map). Prisma models: PascalCase singular.
|
||||
// - Ids: UUID v7 — time-sortable, so they index like a sequence but leak no
|
||||
// row counts and stay safe to expose in URLs.
|
||||
// - Money: INTEGER in the currency's minor unit. Never Float, never Decimal
|
||||
// round-trips through JS. VND has no minor unit, so 250000 means ₫250.000.
|
||||
// - Timestamps: `timestamptz`. The database always stores UTC.
|
||||
// - Soft delete only where history matters (products, variants); everywhere
|
||||
// else a hard delete is correct and simpler.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Identity & access control
|
||||
// ===========================================================================
|
||||
|
||||
enum UserType {
|
||||
CUSTOMER
|
||||
STAFF
|
||||
ADMIN
|
||||
SUPER_ADMIN
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
ACTIVE
|
||||
INVITED
|
||||
SUSPENDED
|
||||
}
|
||||
|
||||
/// Every human in the system — shoppers and operators alike — is a User row.
|
||||
/// The `type` column decides which token audience the account may authenticate
|
||||
/// against; RBAC decides what it may then do.
|
||||
model User {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
/// Always normalised to lowercase before write (see @sport/validation), so a
|
||||
/// plain unique index is enough and no citext extension is required.
|
||||
email String @unique @db.VarChar(255)
|
||||
passwordHash String? @map("password_hash")
|
||||
type UserType
|
||||
status UserStatus @default(ACTIVE)
|
||||
|
||||
firstName String? @map("first_name") @db.VarChar(80)
|
||||
lastName String? @map("last_name") @db.VarChar(80)
|
||||
phone String? @db.VarChar(20)
|
||||
avatarId String? @map("avatar_id") @db.Uuid
|
||||
|
||||
emailVerifiedAt DateTime? @map("email_verified_at") @db.Timestamptz(3)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
avatar MediaAsset? @relation("UserAvatar", fields: [avatarId], references: [id], onDelete: SetNull)
|
||||
roles UserRole[]
|
||||
sessions Session[]
|
||||
customer Customer?
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@index([type, status])
|
||||
@@index([createdAt])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// Roles are DATA: a SUPER_ADMIN can create "Warehouse Supervisor" at runtime
|
||||
/// without a deploy. Only `isSystem` roles are protected from deletion.
|
||||
model Role {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
key String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(120)
|
||||
description String?
|
||||
isSystem Boolean @default(false) @map("is_system")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
permissions RolePermission[]
|
||||
users UserRole[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
/// Permissions are CODE: the catalog in @sport/types is the source of truth and
|
||||
/// the seed reconciles this table against it. Nothing creates permissions at
|
||||
/// runtime — that would let the database drift from the guards.
|
||||
model Permission {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
key String @unique @db.VarChar(64)
|
||||
resource String @db.VarChar(40)
|
||||
action String @db.VarChar(40)
|
||||
description String?
|
||||
|
||||
roles RolePermission[]
|
||||
|
||||
@@index([resource])
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId String @map("role_id") @db.Uuid
|
||||
permissionId String @map("permission_id") @db.Uuid
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
@@index([permissionId])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
userId String @map("user_id") @db.Uuid
|
||||
roleId String @map("role_id") @db.Uuid
|
||||
|
||||
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, roleId])
|
||||
@@index([roleId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
/// One row per refresh-token family (i.e. per signed-in device).
|
||||
///
|
||||
/// The token itself is never stored — only a SHA-256 hash. On refresh the row
|
||||
/// is rotated: `replacedById` points at the successor. Presenting a token whose
|
||||
/// row is already replaced means the token leaked, so the entire family is
|
||||
/// revoked. This is why the column exists at all.
|
||||
model Session {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(64)
|
||||
familyId String @map("family_id") @db.Uuid
|
||||
replacedById String? @unique @map("replaced_by_id") @db.Uuid
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
|
||||
|
||||
userAgent String? @map("user_agent")
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
replacedBy Session? @relation("SessionRotation", fields: [replacedById], references: [id], onDelete: SetNull)
|
||||
replaces Session? @relation("SessionRotation")
|
||||
|
||||
@@index([userId, revokedAt])
|
||||
@@index([familyId])
|
||||
@@index([expiresAt])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
actorUserId String? @map("actor_user_id") @db.Uuid
|
||||
action String @db.VarChar(80)
|
||||
resourceType String @map("resource_type") @db.VarChar(60)
|
||||
resourceId String? @map("resource_id")
|
||||
/// Before/after snapshot. Append-only; never updated.
|
||||
changes Json?
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([resourceType, resourceId])
|
||||
@@index([actorUserId, createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Customers
|
||||
// ===========================================================================
|
||||
|
||||
/// Shopper-specific profile, split from User so that back-office accounts carry
|
||||
/// none of it and customer data can later move behind a stricter access policy.
|
||||
model Customer {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
userId String @unique @map("user_id") @db.Uuid
|
||||
|
||||
acceptsMarketing Boolean @default(false) @map("accepts_marketing")
|
||||
dateOfBirth DateTime? @map("date_of_birth") @db.Date
|
||||
note String?
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
addresses Address[]
|
||||
|
||||
@@map("customers")
|
||||
}
|
||||
|
||||
model Address {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
customerId String @map("customer_id") @db.Uuid
|
||||
|
||||
fullName String @map("full_name") @db.VarChar(160)
|
||||
phone String @db.VarChar(20)
|
||||
line1 String @db.VarChar(255)
|
||||
line2 String? @db.VarChar(255)
|
||||
/// Vietnamese administrative divisions. Codes are kept alongside names so a
|
||||
/// later shipping-provider integration can map them without re-collecting.
|
||||
ward String? @db.VarChar(120)
|
||||
wardCode String? @map("ward_code") @db.VarChar(20)
|
||||
district String? @db.VarChar(120)
|
||||
districtCode String? @map("district_code") @db.VarChar(20)
|
||||
province String @db.VarChar(120)
|
||||
provinceCode String? @map("province_code") @db.VarChar(20)
|
||||
countryCode String @default("VN") @map("country_code") @db.Char(2)
|
||||
postalCode String? @map("postal_code") @db.VarChar(20)
|
||||
|
||||
isDefaultShipping Boolean @default(false) @map("is_default_shipping")
|
||||
isDefaultBilling Boolean @default(false) @map("is_default_billing")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([customerId])
|
||||
@@map("addresses")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Media
|
||||
// ===========================================================================
|
||||
|
||||
enum MediaKind {
|
||||
IMAGE
|
||||
VIDEO
|
||||
DOCUMENT
|
||||
}
|
||||
|
||||
/// Metadata only. The bytes live in R2/S3 under `storageKey`; PostgreSQL never
|
||||
/// stores binary. Public URLs are composed at read time from STORAGE_PUBLIC_URL
|
||||
/// + storageKey, so changing CDN or bucket is config, not a data migration.
|
||||
model MediaAsset {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
kind MediaKind @default(IMAGE)
|
||||
storageKey String @unique @map("storage_key") @db.VarChar(512)
|
||||
mimeType String @map("mime_type") @db.VarChar(120)
|
||||
sizeBytes Int @map("size_bytes")
|
||||
width Int?
|
||||
height Int?
|
||||
/// Base64 LQIP, a few hundred bytes. Cheap enough to store inline.
|
||||
blurDataUrl String? @map("blur_data_url")
|
||||
altText String? @map("alt_text") @db.VarChar(255)
|
||||
/// Free-form: original filename, uploader, EXIF subset, …
|
||||
metadata Json?
|
||||
|
||||
uploadedByUserId String? @map("uploaded_by_user_id") @db.Uuid
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
productImages ProductImage[]
|
||||
brandLogos Brand[] @relation("BrandLogo")
|
||||
categoryImages Category[] @relation("CategoryImage")
|
||||
collectionBanners Collection[] @relation("CollectionBanner")
|
||||
optionValueSwatches ProductOptionValue[] @relation("OptionValueSwatch")
|
||||
userAvatars User[] @relation("UserAvatar")
|
||||
|
||||
@@index([kind, createdAt])
|
||||
@@map("media_assets")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Taxonomy
|
||||
// ===========================================================================
|
||||
|
||||
model Brand {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String @db.VarChar(160)
|
||||
slug String @unique @db.VarChar(180)
|
||||
description String?
|
||||
logoId String? @map("logo_id") @db.Uuid
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
logo MediaAsset? @relation("BrandLogo", fields: [logoId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
|
||||
@@map("brands")
|
||||
}
|
||||
|
||||
/// Hierarchical merchandising tree (Men > Running > Shoes).
|
||||
///
|
||||
/// `path` is a materialised path ("men/running/shoes") so an entire subtree is
|
||||
/// one indexed `LIKE 'men/running%'` query instead of a recursive CTE per page
|
||||
/// view. `depth` lets navigation queries stop at the level they render.
|
||||
model Category {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
parentId String? @map("parent_id") @db.Uuid
|
||||
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(180)
|
||||
path String @unique @db.VarChar(512)
|
||||
depth Int @default(0)
|
||||
position Int @default(0)
|
||||
description String?
|
||||
imageId String? @map("image_id") @db.Uuid
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children Category[] @relation("CategoryTree")
|
||||
image MediaAsset? @relation("CategoryImage", fields: [imageId], references: [id], onDelete: SetNull)
|
||||
products Product[]
|
||||
|
||||
@@unique([parentId, slug])
|
||||
@@index([path])
|
||||
@@index([parentId, position])
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
enum CollectionType {
|
||||
MANUAL
|
||||
AUTOMATED
|
||||
}
|
||||
|
||||
model Collection {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String @db.VarChar(160)
|
||||
slug String @unique @db.VarChar(180)
|
||||
type CollectionType @default(MANUAL)
|
||||
description String?
|
||||
bannerId String? @map("banner_id") @db.Uuid
|
||||
|
||||
/// Rule set for AUTOMATED collections, evaluated by the catalog module.
|
||||
rules Json?
|
||||
|
||||
startsAt DateTime? @map("starts_at") @db.Timestamptz(3)
|
||||
endsAt DateTime? @map("ends_at") @db.Timestamptz(3)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
position Int @default(0)
|
||||
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
banner MediaAsset? @relation("CollectionBanner", fields: [bannerId], references: [id], onDelete: SetNull)
|
||||
products ProductCollection[]
|
||||
|
||||
@@index([isActive, startsAt, endsAt])
|
||||
@@map("collections")
|
||||
}
|
||||
|
||||
model ProductCollection {
|
||||
productId String @map("product_id") @db.Uuid
|
||||
collectionId String @map("collection_id") @db.Uuid
|
||||
position Int @default(0)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([productId, collectionId])
|
||||
@@index([collectionId, position])
|
||||
@@map("product_collections")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Catalog: Product / ProductVariant
|
||||
// ===========================================================================
|
||||
|
||||
enum ProductStatus {
|
||||
DRAFT
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum VariantStatus {
|
||||
ACTIVE
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
VND
|
||||
USD
|
||||
}
|
||||
|
||||
enum GenderTarget {
|
||||
MEN
|
||||
WOMEN
|
||||
KIDS
|
||||
UNISEX
|
||||
}
|
||||
|
||||
enum SportType {
|
||||
RUNNING
|
||||
FOOTBALL
|
||||
TRAINING
|
||||
GYM
|
||||
BADMINTON
|
||||
LIFESTYLE
|
||||
}
|
||||
|
||||
/// The marketing entity: it has a name, a URL and a page. It deliberately has
|
||||
/// NO sku, NO price and NO stock — those belong to ProductVariant, because in
|
||||
/// reality "Black / M" and "White / L" are different physical goods.
|
||||
model Product {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String @db.VarChar(255)
|
||||
slug String @unique @db.VarChar(280)
|
||||
description String?
|
||||
shortDescription String? @map("short_description") @db.VarChar(500)
|
||||
|
||||
status ProductStatus @default(DRAFT)
|
||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
|
||||
|
||||
brandId String? @map("brand_id") @db.Uuid
|
||||
primaryCategoryId String? @map("primary_category_id") @db.Uuid
|
||||
|
||||
/// Facets driving /men, /women and /sports/*. Arrays rather than join tables:
|
||||
/// they are small, bounded, always fetched with the product and filtered with
|
||||
/// a GIN index — a join table would buy nothing here.
|
||||
genderTargets GenderTarget[] @map("gender_targets")
|
||||
sportTypes SportType[] @map("sport_types")
|
||||
|
||||
metaTitle String? @map("meta_title") @db.VarChar(255)
|
||||
metaDescription String? @map("meta_description")
|
||||
metadata Json?
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||
primaryCategory Category? @relation(fields: [primaryCategoryId], references: [id], onDelete: SetNull)
|
||||
options ProductOption[]
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
attributes ProductAttribute[]
|
||||
collections ProductCollection[]
|
||||
|
||||
@@index([status, publishedAt])
|
||||
@@index([brandId])
|
||||
@@index([primaryCategoryId])
|
||||
@@index([genderTargets], type: Gin)
|
||||
@@index([sportTypes], type: Gin)
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
/// An axis of variation for one product: "Colour", "Size".
|
||||
model ProductOption {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
|
||||
name String @db.VarChar(60)
|
||||
/// Stable machine key (`colour`, `size`) used by URLs and integrations.
|
||||
key String @db.VarChar(40)
|
||||
position Int @default(0)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
values ProductOptionValue[]
|
||||
variantLinks ProductVariantOptionValue[]
|
||||
|
||||
@@unique([productId, key])
|
||||
@@index([productId, position])
|
||||
@@map("product_options")
|
||||
}
|
||||
|
||||
/// One allowed value on an axis: "Black", "M".
|
||||
model ProductOptionValue {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
optionId String @map("option_id") @db.Uuid
|
||||
|
||||
label String @db.VarChar(80)
|
||||
value String @db.VarChar(80)
|
||||
position Int @default(0)
|
||||
|
||||
swatchHex String? @map("swatch_hex") @db.VarChar(9)
|
||||
swatchImageId String? @map("swatch_image_id") @db.Uuid
|
||||
|
||||
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
swatchImage MediaAsset? @relation("OptionValueSwatch", fields: [swatchImageId], references: [id], onDelete: SetNull)
|
||||
variantLinks ProductVariantOptionValue[]
|
||||
images ProductImage[]
|
||||
|
||||
@@unique([optionId, value])
|
||||
@@index([optionId, position])
|
||||
@@map("product_option_values")
|
||||
}
|
||||
|
||||
/// The purchasable unit. Everything downstream — cart lines, order lines, stock
|
||||
/// movements, marketplace listings — references THIS id, never a Product id.
|
||||
model ProductVariant {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
|
||||
sku String @unique @db.VarChar(64)
|
||||
barcode String? @unique @db.VarChar(64)
|
||||
/// Denormalised "Black / M" for display and for order-line snapshots.
|
||||
title String @db.VarChar(255)
|
||||
|
||||
currency Currency @default(VND)
|
||||
/// All amounts are integers in the currency's minor unit.
|
||||
priceAmount Int @map("price_amount")
|
||||
salePriceAmount Int? @map("sale_price_amount")
|
||||
compareAtAmount Int? @map("compare_at_amount")
|
||||
/// Landed cost — admin only, never serialised to the storefront.
|
||||
costAmount Int? @map("cost_amount")
|
||||
|
||||
weightGrams Int? @map("weight_grams")
|
||||
lengthMm Int? @map("length_mm")
|
||||
widthMm Int? @map("width_mm")
|
||||
heightMm Int? @map("height_mm")
|
||||
|
||||
status VariantStatus @default(ACTIVE)
|
||||
position Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
optionValues ProductVariantOptionValue[]
|
||||
stockLevels StockLevel[]
|
||||
stockMovements StockMovement[]
|
||||
|
||||
@@index([productId, position])
|
||||
@@index([status])
|
||||
@@map("product_variants")
|
||||
}
|
||||
|
||||
/// Resolves a variant to exactly one value per product option.
|
||||
///
|
||||
/// The (variantId, optionId) primary key is what enforces "a variant cannot
|
||||
/// have two colours" at the database level rather than in application code.
|
||||
model ProductVariantOptionValue {
|
||||
variantId String @map("variant_id") @db.Uuid
|
||||
optionId String @map("option_id") @db.Uuid
|
||||
optionValueId String @map("option_value_id") @db.Uuid
|
||||
|
||||
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
option ProductOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
optionValue ProductOptionValue @relation(fields: [optionValueId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([variantId, optionId])
|
||||
@@index([optionValueId])
|
||||
@@map("product_variant_option_values")
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
mediaId String @map("media_id") @db.Uuid
|
||||
|
||||
/// When set, the gallery swaps to these images once the shopper picks that
|
||||
/// option value (in practice: the colour).
|
||||
optionValueId String? @map("option_value_id") @db.Uuid
|
||||
position Int @default(0)
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
media MediaAsset @relation(fields: [mediaId], references: [id], onDelete: Restrict)
|
||||
optionValue ProductOptionValue? @relation(fields: [optionValueId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([productId, mediaId, optionValueId])
|
||||
@@index([productId, position])
|
||||
@@map("product_images")
|
||||
}
|
||||
|
||||
/// Spec rows ("Material: 92% polyester"). Free-form on purpose: merchandisers
|
||||
/// add specs without a migration. Anything that must be *filtered* on graduates
|
||||
/// to a real column or a facet instead.
|
||||
model ProductAttribute {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
|
||||
key String @db.VarChar(60)
|
||||
label String @db.VarChar(120)
|
||||
value String @db.VarChar(500)
|
||||
group String? @db.VarChar(60)
|
||||
position Int @default(0)
|
||||
isFilterable Boolean @default(false) @map("is_filterable")
|
||||
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([productId, key])
|
||||
@@index([key, value])
|
||||
@@map("product_attributes")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Inventory
|
||||
// ===========================================================================
|
||||
|
||||
/// Stock is per (variant, location) from day one. A single warehouse today is
|
||||
/// just one row here; adding a second store or a 3PL later needs no migration
|
||||
/// of historical data.
|
||||
model InventoryLocation {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
name String @db.VarChar(120)
|
||||
code String @unique @db.VarChar(40)
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
stockLevels StockLevel[]
|
||||
stockMovements StockMovement[]
|
||||
|
||||
@@map("inventory_locations")
|
||||
}
|
||||
|
||||
/// Current position. `available` is NOT stored — it is always onHand - reserved,
|
||||
/// computed at read time so the two numbers can never disagree.
|
||||
model StockLevel {
|
||||
variantId String @map("variant_id") @db.Uuid
|
||||
locationId String @map("location_id") @db.Uuid
|
||||
|
||||
onHand Int @default(0) @map("on_hand")
|
||||
/// Held by in-flight checkouts. Released on payment failure or expiry.
|
||||
reserved Int @default(0)
|
||||
reorderPoint Int? @map("reorder_point")
|
||||
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
|
||||
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Cascade)
|
||||
location InventoryLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@id([variantId, locationId])
|
||||
@@index([locationId])
|
||||
@@map("stock_levels")
|
||||
}
|
||||
|
||||
enum StockMovementReason {
|
||||
PURCHASE_RECEIPT
|
||||
SALE
|
||||
RETURN
|
||||
MANUAL_ADJUSTMENT
|
||||
STOCK_TAKE
|
||||
TRANSFER_IN
|
||||
TRANSFER_OUT
|
||||
DAMAGE
|
||||
}
|
||||
|
||||
/// Append-only ledger. StockLevel is a projection of these rows, which is what
|
||||
/// makes "why is this number wrong?" an answerable question — and what makes a
|
||||
/// future extraction of Inventory into its own service straightforward.
|
||||
model StockMovement {
|
||||
id String @id @default(uuid(7)) @db.Uuid
|
||||
variantId String @map("variant_id") @db.Uuid
|
||||
locationId String @map("location_id") @db.Uuid
|
||||
|
||||
/// Signed: negative for outbound.
|
||||
quantityDelta Int @map("quantity_delta")
|
||||
reason StockMovementReason
|
||||
referenceId String? @map("reference_id")
|
||||
note String?
|
||||
createdByUserId String? @map("created_by_user_id") @db.Uuid
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
|
||||
variant ProductVariant @relation(fields: [variantId], references: [id], onDelete: Restrict)
|
||||
location InventoryLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([variantId, createdAt])
|
||||
@@index([referenceId])
|
||||
@@map("stock_movements")
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Idempotent seed. Safe to run on every environment, including production.
|
||||
*
|
||||
* It reconciles the *code-owned* parts of the schema — the permission catalog
|
||||
* and the system roles — with the database. It deliberately does NOT create
|
||||
* users: account provisioning belongs to the auth milestone, and baking a
|
||||
* default admin password into a repository is how stores get compromised.
|
||||
*/
|
||||
import { ALL_PERMISSIONS, PERMISSIONS, SYSTEM_ROLES, type Permission } from '@sport/types';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/** Which permissions each system role starts with. Editable later at runtime. */
|
||||
const ROLE_DEFINITIONS: Record<string, { name: string; permissions: readonly Permission[] }> = {
|
||||
[SYSTEM_ROLES.SUPER_ADMIN]: {
|
||||
name: 'Super Admin',
|
||||
permissions: ALL_PERMISSIONS,
|
||||
},
|
||||
[SYSTEM_ROLES.ADMIN]: {
|
||||
name: 'Admin',
|
||||
permissions: ALL_PERMISSIONS.filter(
|
||||
(permission) =>
|
||||
permission !== PERMISSIONS.ROLE_MANAGE && permission !== PERMISSIONS.USER_MANAGE,
|
||||
),
|
||||
},
|
||||
[SYSTEM_ROLES.CATALOG_MANAGER]: {
|
||||
name: 'Catalog Manager',
|
||||
permissions: [
|
||||
PERMISSIONS.PRODUCT_READ,
|
||||
PERMISSIONS.PRODUCT_CREATE,
|
||||
PERMISSIONS.PRODUCT_UPDATE,
|
||||
PERMISSIONS.PRODUCT_PUBLISH,
|
||||
PERMISSIONS.CATEGORY_READ,
|
||||
PERMISSIONS.CATEGORY_MANAGE,
|
||||
PERMISSIONS.COLLECTION_READ,
|
||||
PERMISSIONS.COLLECTION_MANAGE,
|
||||
PERMISSIONS.BRAND_READ,
|
||||
PERMISSIONS.BRAND_MANAGE,
|
||||
PERMISSIONS.INVENTORY_READ,
|
||||
PERMISSIONS.INVENTORY_UPDATE,
|
||||
PERMISSIONS.MEDIA_READ,
|
||||
PERMISSIONS.MEDIA_UPLOAD,
|
||||
],
|
||||
},
|
||||
[SYSTEM_ROLES.ORDER_MANAGER]: {
|
||||
name: 'Order Manager',
|
||||
permissions: [
|
||||
PERMISSIONS.ORDER_READ,
|
||||
PERMISSIONS.ORDER_UPDATE,
|
||||
PERMISSIONS.ORDER_CANCEL,
|
||||
PERMISSIONS.PAYMENT_READ,
|
||||
PERMISSIONS.CUSTOMER_READ,
|
||||
PERMISSIONS.INVENTORY_READ,
|
||||
PERMISSIONS.PRODUCT_READ,
|
||||
],
|
||||
},
|
||||
[SYSTEM_ROLES.SUPPORT_AGENT]: {
|
||||
name: 'Support Agent',
|
||||
permissions: [
|
||||
PERMISSIONS.ORDER_READ,
|
||||
PERMISSIONS.CUSTOMER_READ,
|
||||
PERMISSIONS.PRODUCT_READ,
|
||||
PERMISSIONS.REVIEW_MODERATE,
|
||||
],
|
||||
},
|
||||
[SYSTEM_ROLES.CUSTOMER]: {
|
||||
name: 'Customer',
|
||||
permissions: [],
|
||||
},
|
||||
};
|
||||
|
||||
async function seedPermissions(): Promise<Map<string, string>> {
|
||||
for (const key of ALL_PERMISSIONS) {
|
||||
const [resource = key, action = 'unknown'] = key.split('.');
|
||||
await prisma.permission.upsert({
|
||||
where: { key },
|
||||
update: { resource, action },
|
||||
create: { key, resource, action },
|
||||
});
|
||||
}
|
||||
|
||||
// Anything in the table but no longer in the catalog is dead configuration.
|
||||
const removed = await prisma.permission.deleteMany({
|
||||
where: { key: { notIn: [...ALL_PERMISSIONS] } },
|
||||
});
|
||||
if (removed.count > 0) {
|
||||
console.log(`Removed ${removed.count} stale permission(s).`);
|
||||
}
|
||||
|
||||
const rows = await prisma.permission.findMany({ select: { id: true, key: true } });
|
||||
return new Map(rows.map((row) => [row.key, row.id]));
|
||||
}
|
||||
|
||||
async function seedRoles(permissionIds: Map<string, string>): Promise<void> {
|
||||
for (const [key, definition] of Object.entries(ROLE_DEFINITIONS)) {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { key },
|
||||
update: { name: definition.name, isSystem: true },
|
||||
create: { key, name: definition.name, isSystem: true },
|
||||
});
|
||||
|
||||
// Replace the grant set wholesale — the code definition wins for system roles.
|
||||
await prisma.rolePermission.deleteMany({ where: { roleId: role.id } });
|
||||
|
||||
const grants = definition.permissions
|
||||
.map((permission) => permissionIds.get(permission))
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.map((permissionId) => ({ roleId: role.id, permissionId }));
|
||||
|
||||
if (grants.length > 0) {
|
||||
await prisma.rolePermission.createMany({ data: grants, skipDuplicates: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedInventoryLocation(): Promise<void> {
|
||||
await prisma.inventoryLocation.upsert({
|
||||
where: { code: 'MAIN' },
|
||||
update: {},
|
||||
create: { code: 'MAIN', name: 'Main Warehouse', isDefault: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const permissionIds = await seedPermissions();
|
||||
await seedRoles(permissionIds);
|
||||
await seedInventoryLocation();
|
||||
console.log(
|
||||
`Seed complete: ${permissionIds.size} permissions, ${Object.keys(ROLE_DEFINITIONS).length} roles.`,
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
|
||||
import { APP_CONFIG, AppConfigModule } from './config/app-config.module';
|
||||
import type { AppConfig } from './config/configuration';
|
||||
import { EventsModule } from './infrastructure/events/events.module';
|
||||
import { LoggingModule } from './infrastructure/logging/logging.module';
|
||||
import { PrismaModule } from './infrastructure/prisma/prisma.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { StorageModule } from './infrastructure/storage/storage.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { BrandsModule } from './modules/brands/brands.module';
|
||||
import { CartsModule } from './modules/carts/carts.module';
|
||||
import { CategoriesModule } from './modules/categories/categories.module';
|
||||
import { CheckoutModule } from './modules/checkout/checkout.module';
|
||||
import { CmsModule } from './modules/cms/cms.module';
|
||||
import { CollectionsModule } from './modules/collections/collections.module';
|
||||
import { CouponsModule } from './modules/coupons/coupons.module';
|
||||
import { CustomersModule } from './modules/customers/customers.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { InventoryModule } from './modules/inventory/inventory.module';
|
||||
import { MediaModule } from './modules/media/media.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { PaymentsModule } from './modules/payments/payments.module';
|
||||
import { ProductVariantsModule } from './modules/product-variants/product-variants.module';
|
||||
import { ProductsModule } from './modules/products/products.module';
|
||||
import { PromotionsModule } from './modules/promotions/promotions.module';
|
||||
import { ReviewsModule } from './modules/reviews/reviews.module';
|
||||
import { SearchModule } from './modules/search/search.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { WishlistModule } from './modules/wishlist/wishlist.module';
|
||||
|
||||
/**
|
||||
* The composition root of the modular monolith.
|
||||
*
|
||||
* Three tiers, and the direction of dependency is one-way:
|
||||
* config + infrastructure → cross-cutting concerns → feature modules
|
||||
*
|
||||
* Infrastructure modules are @Global because they are genuine cross-cutting
|
||||
* capabilities. Feature modules never are — a feature that wants another
|
||||
* feature must import it explicitly, so the dependency graph stays visible in
|
||||
* this file rather than hidden in ambient scope.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
// --- Foundation -------------------------------------------------------
|
||||
AppConfigModule,
|
||||
LoggingModule,
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
StorageModule,
|
||||
EventsModule,
|
||||
|
||||
ThrottlerModule.forRootAsync({
|
||||
inject: [APP_CONFIG],
|
||||
useFactory: (config: AppConfig) => ({
|
||||
throttlers: [{ ttl: config.rateLimit.ttlSeconds * 1000, limit: config.rateLimit.max }],
|
||||
}),
|
||||
}),
|
||||
|
||||
// --- Cross-cutting ----------------------------------------------------
|
||||
AuthModule,
|
||||
HealthModule,
|
||||
|
||||
// --- Identity ---------------------------------------------------------
|
||||
UsersModule,
|
||||
CustomersModule,
|
||||
|
||||
// --- Catalog ----------------------------------------------------------
|
||||
BrandsModule,
|
||||
CategoriesModule,
|
||||
CollectionsModule,
|
||||
ProductsModule,
|
||||
ProductVariantsModule,
|
||||
MediaModule,
|
||||
SearchModule,
|
||||
|
||||
// --- Commerce ---------------------------------------------------------
|
||||
InventoryModule,
|
||||
CartsModule,
|
||||
CheckoutModule,
|
||||
OrdersModule,
|
||||
PaymentsModule,
|
||||
|
||||
// --- Marketing & content ----------------------------------------------
|
||||
PromotionsModule,
|
||||
CouponsModule,
|
||||
WishlistModule,
|
||||
ReviewsModule,
|
||||
CmsModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_INTERCEPTOR, useClass: ResponseEnvelopeInterceptor },
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Header used to correlate a request across client, Nginx, API and logs. */
|
||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
||||
|
||||
/** Metadata keys read by the global guards and interceptors. */
|
||||
export const METADATA_KEYS = {
|
||||
IS_PUBLIC: 'sport:is-public',
|
||||
REQUIRED_PERMISSIONS: 'sport:required-permissions',
|
||||
PERMISSION_MODE: 'sport:permission-mode',
|
||||
TOKEN_AUDIENCE: 'sport:token-audience',
|
||||
SKIP_ENVELOPE: 'sport:skip-envelope',
|
||||
} as const;
|
||||
|
||||
export const API_VERSIONS = {
|
||||
V1: '1',
|
||||
} as const;
|
||||
|
||||
export const CURRENT_API_VERSION = API_VERSIONS.V1;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import type { AuthenticatedActor } from '@sport/types';
|
||||
|
||||
/**
|
||||
* Injects the authenticated actor.
|
||||
*
|
||||
* Non-optional by design: if a controller asks for the actor, the route must be
|
||||
* authenticated. Reaching this on a `@Public()` route is a programming error and
|
||||
* should surface immediately rather than silently yielding `undefined`.
|
||||
*/
|
||||
export const CurrentActor = createParamDecorator(
|
||||
(_data: unknown, context: ExecutionContext): AuthenticatedActor => {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
if (!request.actor) {
|
||||
throw new Error(
|
||||
'CurrentActor used on a route without authentication. Remove @Public() or the decorator.',
|
||||
);
|
||||
}
|
||||
|
||||
return request.actor;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
import { METADATA_KEYS } from '../constants/api';
|
||||
|
||||
/**
|
||||
* Opt a route out of authentication.
|
||||
*
|
||||
* Authentication is ON by default (the access-token guard is registered
|
||||
* globally). Forgetting a decorator therefore fails closed — an endpoint is
|
||||
* never accidentally public.
|
||||
*/
|
||||
export const Public = () => SetMetadata(METADATA_KEYS.IS_PUBLIC, true);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SetMetadata, applyDecorators } from '@nestjs/common';
|
||||
|
||||
import type { Permission, TokenAudience } from '@sport/types';
|
||||
|
||||
import { METADATA_KEYS } from '../constants/api';
|
||||
|
||||
/**
|
||||
* The ONLY sanctioned way to authorize a route.
|
||||
*
|
||||
* @RequirePermissions(PERMISSIONS.PRODUCT_UPDATE)
|
||||
* @Patch(':id')
|
||||
* update() {}
|
||||
*
|
||||
* There is no `if (user.role === 'ADMIN')` anywhere in this codebase. Roles are
|
||||
* runtime data; permissions are the compile-time contract. That separation is
|
||||
* what lets an operator invent a new role without a deploy, and what keeps
|
||||
* authorization auditable — every rule is a decorator, greppable in one pass.
|
||||
*/
|
||||
export const RequirePermissions = (...permissions: Permission[]) =>
|
||||
applyDecorators(
|
||||
SetMetadata(METADATA_KEYS.REQUIRED_PERMISSIONS, permissions),
|
||||
SetMetadata(METADATA_KEYS.PERMISSION_MODE, 'all'),
|
||||
);
|
||||
|
||||
/** Passes when the actor holds at least one of the listed permissions. */
|
||||
export const RequireAnyPermission = (...permissions: Permission[]) =>
|
||||
applyDecorators(
|
||||
SetMetadata(METADATA_KEYS.REQUIRED_PERMISSIONS, permissions),
|
||||
SetMetadata(METADATA_KEYS.PERMISSION_MODE, 'any'),
|
||||
);
|
||||
|
||||
/**
|
||||
* Restrict a route to one token audience. Admin controllers declare `admin`,
|
||||
* so a stolen storefront token is rejected before permissions are even read.
|
||||
*/
|
||||
export const RequireAudience = (audience: TokenAudience) =>
|
||||
SetMetadata(METADATA_KEYS.TOKEN_AUDIENCE, audience);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
import { API_ERROR_CODES, type ApiErrorCode, type ApiFieldErrors } from '@sport/types';
|
||||
|
||||
/**
|
||||
* The only exception type application code should throw.
|
||||
*
|
||||
* It pairs a stable machine code with an HTTP status and a user-safe message,
|
||||
* so the global filter never has to guess. Throwing raw `HttpException` or
|
||||
* `Error` still works — the filter degrades gracefully — but loses the code
|
||||
* that clients branch on.
|
||||
*/
|
||||
export class AppException extends HttpException {
|
||||
readonly code: ApiErrorCode;
|
||||
readonly fields?: ApiFieldErrors;
|
||||
|
||||
constructor(params: {
|
||||
code: ApiErrorCode;
|
||||
message: string;
|
||||
status: HttpStatus;
|
||||
fields?: ApiFieldErrors;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
super(params.message, params.status, { cause: params.cause });
|
||||
this.code = params.code;
|
||||
this.fields = params.fields;
|
||||
}
|
||||
|
||||
static notFound(resource: string, code: ApiErrorCode = API_ERROR_CODES.NOT_FOUND): AppException {
|
||||
return new AppException({
|
||||
code,
|
||||
message: `${resource} was not found.`,
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
});
|
||||
}
|
||||
|
||||
static badRequest(
|
||||
message: string,
|
||||
code: ApiErrorCode = API_ERROR_CODES.BAD_REQUEST,
|
||||
): AppException {
|
||||
return new AppException({ code, message, status: HttpStatus.BAD_REQUEST });
|
||||
}
|
||||
|
||||
static validation(fields: ApiFieldErrors, message = 'Some fields need attention.'): AppException {
|
||||
return new AppException({
|
||||
code: API_ERROR_CODES.VALIDATION_FAILED,
|
||||
message,
|
||||
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
static unauthenticated(
|
||||
message = 'You need to sign in to continue.',
|
||||
code: ApiErrorCode = API_ERROR_CODES.UNAUTHENTICATED,
|
||||
): AppException {
|
||||
return new AppException({ code, message, status: HttpStatus.UNAUTHORIZED });
|
||||
}
|
||||
|
||||
static forbidden(
|
||||
message = 'You do not have permission to do that.',
|
||||
code: ApiErrorCode = API_ERROR_CODES.PERMISSION_DENIED,
|
||||
): AppException {
|
||||
return new AppException({ code, message, status: HttpStatus.FORBIDDEN });
|
||||
}
|
||||
|
||||
static conflict(message: string, code: ApiErrorCode = API_ERROR_CODES.CONFLICT): AppException {
|
||||
return new AppException({ code, message, status: HttpStatus.CONFLICT });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
type ExceptionFilter,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { API_ERROR_CODES, type ApiErrorCode, type ApiErrorResponse } from '@sport/types';
|
||||
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
import { AppException } from '../errors/app.exception';
|
||||
|
||||
/**
|
||||
* The single exit point for every failure in the application.
|
||||
*
|
||||
* Guarantees:
|
||||
* 1. The response body always matches ApiErrorResponse — including for
|
||||
* unexpected 500s, so clients never meet an unparseable payload.
|
||||
* 2. Internal details (SQL, stack traces, Prisma metadata) never leak to the
|
||||
* client in production; they go to the log, correlated by request id.
|
||||
* 3. 5xx logs at `error`, 4xx at `warn`. Client mistakes must not page anyone.
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
private readonly isProduction: boolean;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) config: AppConfig) {
|
||||
this.isProduction = config.app.isProduction;
|
||||
}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const http = host.switchToHttp();
|
||||
const request = http.getRequest<Request>();
|
||||
const response = http.getResponse<Response>();
|
||||
|
||||
const { status, code, message, fields } = this.normalize(exception);
|
||||
|
||||
const body: ApiErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
...(fields ? { fields } : {}),
|
||||
...(!this.isProduction && exception instanceof Error && exception.stack
|
||||
? { stack: exception.stack }
|
||||
: {}),
|
||||
},
|
||||
meta: {
|
||||
requestId: request.requestId ?? 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const logPayload = {
|
||||
status,
|
||||
code,
|
||||
method: request.method,
|
||||
path: request.originalUrl,
|
||||
actorId: request.actor?.userId,
|
||||
};
|
||||
|
||||
if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
|
||||
this.logger.error(
|
||||
`Unhandled request failure: ${JSON.stringify(logPayload)}`,
|
||||
stackOf(exception),
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(`Request rejected: ${JSON.stringify(logPayload)}`);
|
||||
}
|
||||
|
||||
response.status(status).json(body);
|
||||
}
|
||||
|
||||
private normalize(exception: unknown): {
|
||||
status: number;
|
||||
code: ApiErrorCode;
|
||||
message: string;
|
||||
fields?: ApiErrorResponse['error']['fields'];
|
||||
} {
|
||||
if (exception instanceof AppException) {
|
||||
return {
|
||||
status: exception.getStatus(),
|
||||
code: exception.code,
|
||||
message: exception.message,
|
||||
fields: exception.fields,
|
||||
};
|
||||
}
|
||||
|
||||
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return this.fromPrisma(exception);
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
return {
|
||||
status,
|
||||
code: HTTP_STATUS_TO_CODE[status] ?? API_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: extractHttpMessage(exception),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
code: API_ERROR_CODES.INTERNAL_ERROR,
|
||||
// Deliberately generic: the real cause is in the log, keyed by request id.
|
||||
message: 'Something went wrong on our side. Please try again.',
|
||||
};
|
||||
}
|
||||
|
||||
private fromPrisma(error: Prisma.PrismaClientKnownRequestError): {
|
||||
status: number;
|
||||
code: ApiErrorCode;
|
||||
message: string;
|
||||
} {
|
||||
switch (error.code) {
|
||||
case 'P2002':
|
||||
return {
|
||||
status: HttpStatus.CONFLICT,
|
||||
code: API_ERROR_CODES.CONFLICT,
|
||||
message: 'That value is already taken.',
|
||||
};
|
||||
case 'P2025':
|
||||
return {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
code: API_ERROR_CODES.NOT_FOUND,
|
||||
message: 'The requested resource was not found.',
|
||||
};
|
||||
case 'P2003':
|
||||
return {
|
||||
status: HttpStatus.CONFLICT,
|
||||
code: API_ERROR_CODES.CONFLICT,
|
||||
message: 'That action conflicts with related records.',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
code: API_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'Something went wrong on our side. Please try again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const HTTP_STATUS_TO_CODE: Record<number, ApiErrorCode> = {
|
||||
[HttpStatus.BAD_REQUEST]: API_ERROR_CODES.BAD_REQUEST,
|
||||
[HttpStatus.UNAUTHORIZED]: API_ERROR_CODES.UNAUTHENTICATED,
|
||||
[HttpStatus.FORBIDDEN]: API_ERROR_CODES.FORBIDDEN,
|
||||
[HttpStatus.NOT_FOUND]: API_ERROR_CODES.NOT_FOUND,
|
||||
[HttpStatus.CONFLICT]: API_ERROR_CODES.CONFLICT,
|
||||
[HttpStatus.UNPROCESSABLE_ENTITY]: API_ERROR_CODES.VALIDATION_FAILED,
|
||||
[HttpStatus.TOO_MANY_REQUESTS]: API_ERROR_CODES.RATE_LIMITED,
|
||||
[HttpStatus.SERVICE_UNAVAILABLE]: API_ERROR_CODES.SERVICE_UNAVAILABLE,
|
||||
};
|
||||
|
||||
function extractHttpMessage(exception: HttpException): string {
|
||||
const response = exception.getResponse();
|
||||
|
||||
if (typeof response === 'string') {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (typeof response === 'object' && response !== null && 'message' in response) {
|
||||
const { message } = response as { message: unknown };
|
||||
if (typeof message === 'string') return message;
|
||||
if (Array.isArray(message)) return message.join(', ');
|
||||
}
|
||||
|
||||
return exception.message;
|
||||
}
|
||||
|
||||
function stackOf(exception: unknown): string | undefined {
|
||||
return exception instanceof Error ? exception.stack : undefined;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
import { map, type Observable } from 'rxjs';
|
||||
|
||||
import type { ApiSuccessResponse } from '@sport/types';
|
||||
|
||||
import { METADATA_KEYS } from '../constants/api';
|
||||
|
||||
/**
|
||||
* Wraps every successful controller return value in the standard envelope.
|
||||
*
|
||||
* Controllers therefore return plain domain objects and never think about
|
||||
* response shape. The matching failure path lives in AllExceptionsFilter, and
|
||||
* between the two there is no way to emit a response that does not conform.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ResponseEnvelopeInterceptor implements NestInterceptor {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const skip = this.reflector.getAllAndOverride<boolean>(METADATA_KEYS.SKIP_ENVELOPE, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (skip) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data): ApiSuccessResponse<unknown> => ({
|
||||
success: true,
|
||||
data: data ?? null,
|
||||
meta: {
|
||||
requestId: request.requestId ?? 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { v7 as uuidv7 } from 'uuid';
|
||||
|
||||
import { REQUEST_ID_HEADER } from '../constants/api';
|
||||
|
||||
/**
|
||||
* Assigns a request id and echoes it back.
|
||||
*
|
||||
* Nginx forwards an inbound `x-request-id` when present, so a single id ties
|
||||
* together the browser's network tab, the reverse proxy log, the API log lines
|
||||
* and the error shown to the user. Support tickets become greppable.
|
||||
*
|
||||
* Registered with `app.use()` in main.ts rather than through MiddlewareConsumer.
|
||||
* It needs no injected dependencies, and applying it at the Express level means
|
||||
* it also covers requests that never match a route — so a 404 still carries a
|
||||
* correlation id. It also sidesteps Nest rewriting a wildcard route path under
|
||||
* path-to-regexp v8.
|
||||
*/
|
||||
export function requestIdMiddleware(
|
||||
request: Request,
|
||||
response: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
const inbound = request.header(REQUEST_ID_HEADER);
|
||||
const requestId = isSafeRequestId(inbound) ? inbound : uuidv7();
|
||||
|
||||
request.requestId = requestId;
|
||||
response.setHeader(REQUEST_ID_HEADER, requestId);
|
||||
next();
|
||||
}
|
||||
|
||||
/** Never trust a client-supplied id straight into logs. */
|
||||
function isSafeRequestId(value: string | undefined): value is string {
|
||||
return (
|
||||
typeof value === 'string' && value.length > 0 && value.length <= 128 && /^[\w.-]+$/.test(value)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable, type PipeTransform } from '@nestjs/common';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
import type { ApiFieldErrors } from '@sport/types';
|
||||
|
||||
import { AppException } from '../errors/app.exception';
|
||||
|
||||
/**
|
||||
* Validates a request payload with a Zod schema from @sport/validation and
|
||||
* returns the *parsed* value (with coercions and defaults applied).
|
||||
*
|
||||
* Chosen over class-validator so that exactly one schema governs both the API
|
||||
* and the frontend forms. See ADR-0006.
|
||||
*
|
||||
* @Post()
|
||||
* create(@Body(new ZodValidationPipe(createProductSchema)) body: CreateProductInput) {}
|
||||
*/
|
||||
@Injectable()
|
||||
export class ZodValidationPipe<TSchema extends ZodType> implements PipeTransform {
|
||||
constructor(private readonly schema: TSchema) {}
|
||||
|
||||
transform(value: unknown): unknown {
|
||||
const result = this.schema.safeParse(value);
|
||||
|
||||
if (!result.success) {
|
||||
throw AppException.validation(toFieldErrors(result.error.issues));
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
|
||||
interface ZodIssueLike {
|
||||
path: PropertyKey[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** `items.0.quantity` → ["Must be at least 1"] — directly consumable by forms. */
|
||||
function toFieldErrors(issues: readonly ZodIssueLike[]): ApiFieldErrors {
|
||||
const fields: Record<string, string[]> = {};
|
||||
|
||||
for (const issue of issues) {
|
||||
const key = issue.path.map(String).join('.') || '_';
|
||||
(fields[key] ??= []).push(issue.message);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { AuthenticatedActor } from '@sport/types';
|
||||
|
||||
/**
|
||||
* What guards attach to the Express request. Declared once, augmented into the
|
||||
* Express types below, so `request.actor` is typed everywhere without casts.
|
||||
*/
|
||||
declare module 'express' {
|
||||
interface Request {
|
||||
requestId?: string;
|
||||
actor?: AuthenticatedActor;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
import { buildConfig, type AppConfig } from './configuration';
|
||||
import { validateEnv } from './env.schema';
|
||||
|
||||
/** Injection token for the typed configuration object. */
|
||||
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||
|
||||
/**
|
||||
* Usage: `@Inject(APP_CONFIG) private readonly config: AppConfig`.
|
||||
*
|
||||
* Global because configuration is genuinely cross-cutting — one of the few
|
||||
* places where a global module is the right call rather than a shortcut.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
// In containers the environment is injected by the orchestrator; .env is
|
||||
// a local-development convenience only.
|
||||
envFilePath: ['.env'],
|
||||
validate: validateEnv,
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_CONFIG,
|
||||
// ConfigService is injected purely to order initialisation: it guarantees
|
||||
// ConfigModule has already merged .env into process.env before we read it.
|
||||
inject: [ConfigService],
|
||||
useFactory: (_configService: ConfigService): AppConfig =>
|
||||
buildConfig(validateEnv(process.env)),
|
||||
},
|
||||
],
|
||||
exports: [APP_CONFIG],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Env } from './env.schema';
|
||||
|
||||
/**
|
||||
* Environment variables are read exactly once, here, and turned into a typed,
|
||||
* namespaced object. No `process.env` access anywhere else in the codebase —
|
||||
* that is what keeps configuration testable and its shape discoverable.
|
||||
*/
|
||||
export interface AppConfig {
|
||||
readonly app: {
|
||||
readonly env: Env['NODE_ENV'];
|
||||
readonly port: number;
|
||||
readonly globalPrefix: string;
|
||||
readonly version: string;
|
||||
readonly corsOrigins: readonly string[];
|
||||
readonly isProduction: boolean;
|
||||
};
|
||||
readonly database: {
|
||||
readonly url: string;
|
||||
};
|
||||
readonly redis: {
|
||||
readonly url: string;
|
||||
readonly keyPrefix: string;
|
||||
};
|
||||
readonly auth: {
|
||||
readonly accessSecret: string;
|
||||
readonly refreshSecret: string;
|
||||
readonly accessTtl: string;
|
||||
readonly refreshTtl: string;
|
||||
readonly issuer: string;
|
||||
};
|
||||
readonly storage: {
|
||||
readonly endpoint: string;
|
||||
readonly region: string;
|
||||
readonly bucket: string;
|
||||
readonly accessKeyId: string;
|
||||
readonly secretAccessKey: string;
|
||||
readonly forcePathStyle: boolean;
|
||||
readonly publicUrl: string;
|
||||
};
|
||||
readonly rateLimit: {
|
||||
readonly ttlSeconds: number;
|
||||
readonly max: number;
|
||||
};
|
||||
readonly logging: {
|
||||
readonly level: Env['LOG_LEVEL'];
|
||||
readonly pretty: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConfig(env: Env): AppConfig {
|
||||
return {
|
||||
app: {
|
||||
env: env.NODE_ENV,
|
||||
port: env.PORT,
|
||||
globalPrefix: env.API_GLOBAL_PREFIX,
|
||||
version: env.APP_VERSION,
|
||||
corsOrigins: env.CORS_ORIGINS,
|
||||
isProduction: env.NODE_ENV === 'production',
|
||||
},
|
||||
database: { url: env.DATABASE_URL },
|
||||
redis: { url: env.REDIS_URL, keyPrefix: env.REDIS_KEY_PREFIX },
|
||||
auth: {
|
||||
accessSecret: env.JWT_ACCESS_SECRET,
|
||||
refreshSecret: env.JWT_REFRESH_SECRET,
|
||||
accessTtl: env.JWT_ACCESS_TTL,
|
||||
refreshTtl: env.JWT_REFRESH_TTL,
|
||||
issuer: env.JWT_ISSUER,
|
||||
},
|
||||
storage: {
|
||||
endpoint: env.STORAGE_ENDPOINT,
|
||||
region: env.STORAGE_REGION,
|
||||
bucket: env.STORAGE_BUCKET,
|
||||
accessKeyId: env.STORAGE_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.STORAGE_SECRET_ACCESS_KEY,
|
||||
forcePathStyle: env.STORAGE_FORCE_PATH_STYLE,
|
||||
publicUrl: env.STORAGE_PUBLIC_URL.replace(/\/+$/, ''),
|
||||
},
|
||||
rateLimit: { ttlSeconds: env.RATE_LIMIT_TTL_SECONDS, max: env.RATE_LIMIT_MAX },
|
||||
logging: { level: env.LOG_LEVEL, pretty: env.LOG_PRETTY },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The process refuses to boot with an invalid environment.
|
||||
*
|
||||
* Failing at startup — loudly, with every problem listed at once — is the only
|
||||
* acceptable behaviour. A missing JWT secret discovered at 2am by a customer
|
||||
* hitting login is not.
|
||||
*/
|
||||
const durationSchema = z.string().regex(/^\d+(ms|s|m|h|d)$/, 'Use a duration like 15m, 24h or 30d');
|
||||
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(4000),
|
||||
API_GLOBAL_PREFIX: z.string().default('api'),
|
||||
APP_VERSION: z.string().default('0.0.0'),
|
||||
|
||||
CORS_ORIGINS: z
|
||||
.string()
|
||||
.default('')
|
||||
.transform((value) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
|
||||
DATABASE_URL: z.string().startsWith('postgresql://'),
|
||||
|
||||
REDIS_URL: z.string().startsWith('redis'),
|
||||
REDIS_KEY_PREFIX: z.string().default('sport:'),
|
||||
|
||||
JWT_ACCESS_SECRET: z.string().min(32, 'Use at least 32 characters'),
|
||||
JWT_REFRESH_SECRET: z.string().min(32, 'Use at least 32 characters'),
|
||||
JWT_ACCESS_TTL: durationSchema.default('15m'),
|
||||
JWT_REFRESH_TTL: durationSchema.default('30d'),
|
||||
JWT_ISSUER: z.string().default('sport-store'),
|
||||
|
||||
STORAGE_ENDPOINT: z.url(),
|
||||
STORAGE_REGION: z.string().default('auto'),
|
||||
STORAGE_BUCKET: z.string().min(1),
|
||||
STORAGE_ACCESS_KEY_ID: z.string().min(1),
|
||||
STORAGE_SECRET_ACCESS_KEY: z.string().min(1),
|
||||
STORAGE_FORCE_PATH_STYLE: z.stringbool().default(false),
|
||||
STORAGE_PUBLIC_URL: z.url(),
|
||||
|
||||
RATE_LIMIT_TTL_SECONDS: z.coerce.number().int().positive().default(60),
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(120),
|
||||
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
LOG_PRETTY: z.stringbool().default(false),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
export function validateEnv(raw: Record<string, unknown>): Env {
|
||||
const result = envSchema.safeParse(raw);
|
||||
|
||||
if (!result.success) {
|
||||
const details = result.error.issues
|
||||
.map((issue) => ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`Invalid environment configuration:\n${details}`);
|
||||
}
|
||||
|
||||
if (result.data.JWT_ACCESS_SECRET === result.data.JWT_REFRESH_SECRET) {
|
||||
throw new Error('JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different values.');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Domain events are the seam along which this modular monolith can later be cut
|
||||
* into services.
|
||||
*
|
||||
* The rule: when module A needs to *react* to something in module B, it
|
||||
* subscribes to an event. When it needs an *answer* from B right now, it calls
|
||||
* B's public service. Direct writes into another module's tables are never
|
||||
* acceptable.
|
||||
*
|
||||
* Concretely, `order.placed` is consumed today by inventory, notifications and
|
||||
* analytics inside one process. Moving any of those consumers to its own
|
||||
* service later means changing the transport (in-process → queue), not the
|
||||
* producer and not the payload.
|
||||
*/
|
||||
export interface DomainEvent<TName extends string = string, TPayload = unknown> {
|
||||
readonly name: TName;
|
||||
readonly payload: TPayload;
|
||||
/** Correlates the event with the HTTP request that caused it. */
|
||||
readonly requestId?: string;
|
||||
readonly occurredAt: Date;
|
||||
/** Deduplication key for at-least-once delivery once a broker is introduced. */
|
||||
readonly eventId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog of event names. Listed up front so the boundaries are visible
|
||||
* before the code exists — none of these are emitted yet.
|
||||
*/
|
||||
export const DOMAIN_EVENTS = {
|
||||
ORDER_PLACED: 'order.placed',
|
||||
ORDER_PAID: 'order.paid',
|
||||
ORDER_CANCELLED: 'order.cancelled',
|
||||
ORDER_FULFILLED: 'order.fulfilled',
|
||||
|
||||
PAYMENT_SUCCEEDED: 'payment.succeeded',
|
||||
PAYMENT_FAILED: 'payment.failed',
|
||||
|
||||
INVENTORY_RESERVED: 'inventory.reserved',
|
||||
INVENTORY_RELEASED: 'inventory.released',
|
||||
INVENTORY_LOW_STOCK: 'inventory.low_stock',
|
||||
|
||||
PRODUCT_PUBLISHED: 'product.published',
|
||||
PRODUCT_UPDATED: 'product.updated',
|
||||
PRODUCT_ARCHIVED: 'product.archived',
|
||||
|
||||
CUSTOMER_REGISTERED: 'customer.registered',
|
||||
CART_ABANDONED: 'cart.abandoned',
|
||||
REVIEW_SUBMITTED: 'review.submitted',
|
||||
} as const;
|
||||
|
||||
export type DomainEventName = (typeof DOMAIN_EVENTS)[keyof typeof DOMAIN_EVENTS];
|
||||
@@ -0,0 +1,47 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||
import { Subject, filter, type Observable } from 'rxjs';
|
||||
|
||||
import type { DomainEvent, DomainEventName } from './domain-event';
|
||||
|
||||
/**
|
||||
* In-process event bus, deliberately minimal.
|
||||
*
|
||||
* It is NOT a message queue: delivery is best-effort, in-memory and lost on
|
||||
* crash. That is an accepted trade-off for milestone 0 — anything that must not
|
||||
* be lost (payment reconciliation, order state) stays in the same database
|
||||
* transaction as its cause.
|
||||
*
|
||||
* When durability is genuinely needed, this class becomes the adapter in front
|
||||
* of an outbox table + BullMQ/Kafka. Publishers and subscribers do not change,
|
||||
* which is the entire point of routing events through one seam.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventBusService implements OnModuleDestroy {
|
||||
private readonly stream = new Subject<DomainEvent>();
|
||||
private readonly logger = new Logger(EventBusService.name);
|
||||
|
||||
publish<TPayload>(name: DomainEventName, payload: TPayload, requestId?: string): void {
|
||||
const event: DomainEvent<DomainEventName, TPayload> = {
|
||||
name,
|
||||
payload,
|
||||
requestId,
|
||||
eventId: randomUUID(),
|
||||
occurredAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.debug(`Domain event published: ${name} (${event.eventId})`);
|
||||
this.stream.next(event);
|
||||
}
|
||||
|
||||
on<TPayload>(name: DomainEventName): Observable<DomainEvent<DomainEventName, TPayload>> {
|
||||
return this.stream.pipe(
|
||||
filter((event): event is DomainEvent<DomainEventName, TPayload> => event.name === name),
|
||||
);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.stream.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { EventBusService } from './event-bus.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [EventBusService],
|
||||
exports: [EventBusService],
|
||||
})
|
||||
export class EventsModule {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
|
||||
import { REQUEST_ID_HEADER } from '@/common/constants/api';
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
/**
|
||||
* Structured JSON logging.
|
||||
*
|
||||
* Conventions:
|
||||
* - One line per request (method, path, status, duration, requestId, actorId).
|
||||
* - Domain logs carry the same `requestId`, so a trace is one grep.
|
||||
* - Secrets and PII are redacted at the logger, not at each call site —
|
||||
* relying on developers to remember is how tokens end up in log storage.
|
||||
* - Pretty output locally, raw JSON in every other environment.
|
||||
*
|
||||
* Application code logs through Nest's standard `Logger`, which `main.ts`
|
||||
* redirects into pino. Nothing injects `PinoLogger` directly: it is
|
||||
* transient-scoped, so injecting it would silently make the consumer transient
|
||||
* too.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRootAsync({
|
||||
inject: [APP_CONFIG],
|
||||
useFactory: (config: AppConfig) => ({
|
||||
pinoHttp: {
|
||||
level: config.logging.level,
|
||||
transport: config.logging.pretty
|
||||
? { target: 'pino-pretty', options: { singleLine: true, translateTime: 'HH:MM:ss' } }
|
||||
: undefined,
|
||||
genReqId: (request: IncomingMessage) =>
|
||||
(request.headers[REQUEST_ID_HEADER] as string | undefined) ?? randomUUID(),
|
||||
customProps: (request: IncomingMessage) => ({
|
||||
actorId: (request as Request).actor?.userId,
|
||||
}),
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'res.headers["set-cookie"]',
|
||||
'req.body.password',
|
||||
'req.body.currentPassword',
|
||||
'req.body.newPassword',
|
||||
'req.body.refreshToken',
|
||||
'req.body.cardNumber',
|
||||
'req.body.cvv',
|
||||
],
|
||||
censor: '[redacted]',
|
||||
},
|
||||
// Health checks would otherwise dominate the log volume.
|
||||
autoLogging: {
|
||||
ignore: (request: IncomingMessage) => request.url?.includes('/health') ?? false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
/**
|
||||
* Global so that feature modules do not each re-import it, but note what this
|
||||
* does NOT grant: access to another module's tables. Ownership of a table
|
||||
* belongs to exactly one module regardless of who can inject the client.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* The one and only PrismaClient instance.
|
||||
*
|
||||
* Rules enforced by review and by the ESLint boundary config:
|
||||
* - Only module-level repositories inject this service. Controllers never do.
|
||||
* - No module reads another module's tables. Cross-context reads go through
|
||||
* the owning module's public service — that is what makes a later service
|
||||
* extraction a refactor instead of a rewrite.
|
||||
*
|
||||
* Logging note: this uses Nest's own `Logger`, not nestjs-pino's `PinoLogger`.
|
||||
* `PinoLogger` is transient-scoped, and injecting a transient provider makes
|
||||
* the consumer transient too — which would quietly create a second
|
||||
* PrismaClient, and a second connection pool, per consumer. `main.ts` routes
|
||||
* Nest's logger through pino, so the output is identical either way.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
// Errors and warnings go to our structured logger; query logging is opt-in
|
||||
// per environment because it is far too noisy to leave on by default.
|
||||
log: [
|
||||
{ emit: 'event', level: 'error' },
|
||||
{ emit: 'event', level: 'warn' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.$on('error' as never, (event: { message: string }) => {
|
||||
this.logger.error(event.message);
|
||||
});
|
||||
this.$on('warn' as never, (event: { message: string }) => {
|
||||
this.logger.warn(event.message);
|
||||
});
|
||||
|
||||
await this.$connect();
|
||||
this.logger.log('Prisma connected');
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
|
||||
/** Round-trip check used by the health endpoint. */
|
||||
async ping(): Promise<void> {
|
||||
await this.$queryRaw`SELECT 1`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Every Redis key in the system is built here.
|
||||
*
|
||||
* Why centralise: keys are a schema. Scattered string templates produce
|
||||
* collisions, orphaned data nobody dares delete, and invalidation bugs that
|
||||
* only appear under load. One file means one place to audit TTLs and one place
|
||||
* to bump a version prefix when a payload shape changes.
|
||||
*
|
||||
* Namespace: `<prefix><domain>:<entity>:<discriminator>`.
|
||||
*/
|
||||
export const CACHE_KEYS = {
|
||||
// --- Catalog read cache (invalidated on write, TTL as a safety net) -------
|
||||
productBySlug: (slug: string) => `catalog:product:slug:${slug}`,
|
||||
productListing: (fingerprint: string) => `catalog:listing:${fingerprint}`,
|
||||
categoryTree: () => 'catalog:category:tree',
|
||||
navigationMenu: () => 'catalog:navigation',
|
||||
|
||||
// --- Guest cart (authoritative until checkout, then persisted) -----------
|
||||
guestCart: (cartToken: string) => `cart:guest:${cartToken}`,
|
||||
customerCart: (customerId: string) => `cart:customer:${customerId}`,
|
||||
|
||||
// --- Short-lived security artefacts --------------------------------------
|
||||
otp: (channel: string, target: string) => `otp:${channel}:${target}`,
|
||||
otpAttempts: (channel: string, target: string) => `otp:attempts:${channel}:${target}`,
|
||||
passwordResetToken: (tokenHash: string) => `auth:pwd-reset:${tokenHash}`,
|
||||
revokedSession: (sessionId: string) => `auth:revoked:${sessionId}`,
|
||||
|
||||
// --- Rate limiting --------------------------------------------------------
|
||||
rateLimit: (bucket: string, identifier: string) => `ratelimit:${bucket}:${identifier}`,
|
||||
|
||||
// --- Inventory reservations ----------------------------------------------
|
||||
stockReservation: (checkoutId: string) => `inventory:reservation:${checkoutId}`,
|
||||
|
||||
// --- Idempotency (payments, webhooks) ------------------------------------
|
||||
idempotency: (scope: string, key: string) => `idempotency:${scope}:${key}`,
|
||||
} as const;
|
||||
|
||||
/** TTLs in seconds. Keeping them next to the keys keeps the two in sync. */
|
||||
export const CACHE_TTL = {
|
||||
productDetail: 300,
|
||||
productListing: 60,
|
||||
categoryTree: 900,
|
||||
navigation: 900,
|
||||
guestCart: 60 * 60 * 24 * 30,
|
||||
otp: 300,
|
||||
passwordReset: 900,
|
||||
stockReservation: 900,
|
||||
idempotency: 60 * 60 * 24,
|
||||
} as const;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Inject, Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
/**
|
||||
* Thin, typed wrapper over ioredis.
|
||||
*
|
||||
* Redis here is a *cache and a short-lived store*, never a system of record.
|
||||
* If Redis is wiped, the store must keep working — slower, but correct. That
|
||||
* constraint is why `getOrSet` swallows read failures instead of throwing:
|
||||
* a cache outage degrades latency, not availability.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleDestroy {
|
||||
private readonly client: Redis;
|
||||
private readonly logger = new Logger(RedisService.name);
|
||||
|
||||
constructor(@Inject(APP_CONFIG) config: AppConfig) {
|
||||
this.client = new Redis(config.redis.url, {
|
||||
keyPrefix: config.redis.keyPrefix,
|
||||
maxRetriesPerRequest: 2,
|
||||
enableReadyCheck: true,
|
||||
lazyConnect: false,
|
||||
});
|
||||
|
||||
this.client.on('error', (error: Error) => {
|
||||
this.logger.error('Redis connection error', error.stack);
|
||||
});
|
||||
}
|
||||
|
||||
/** Escape hatch for pipelines, Lua scripts and pub/sub. */
|
||||
get raw(): Redis {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const raw = await this.client.get(key);
|
||||
if (raw === null) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
// A poisoned entry must not break the request; drop it and treat as miss.
|
||||
await this.client.del(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
||||
const payload = JSON.stringify(value);
|
||||
if (ttlSeconds === undefined) {
|
||||
await this.client.set(key, payload);
|
||||
} else {
|
||||
await this.client.set(key, payload, 'EX', ttlSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(...keys: string[]): Promise<void> {
|
||||
if (keys.length > 0) {
|
||||
await this.client.del(...keys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-aside. On any Redis failure the factory still runs, so a cache
|
||||
* outage never becomes a site outage.
|
||||
*/
|
||||
async getOrSet<T>(key: string, ttlSeconds: number, factory: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== null) return cached;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Cache read failed for ${key}, falling through to source: ${messageOf(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const value = await factory();
|
||||
|
||||
try {
|
||||
await this.set(key, value, ttlSeconds);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Cache write failed for ${key}: ${messageOf(error)}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic fixed-window counter. Returns the count after increment so callers
|
||||
* can decide to reject.
|
||||
*/
|
||||
async increment(key: string, ttlSeconds: number): Promise<number> {
|
||||
const results = await this.client.multi().incr(key).expire(key, ttlSeconds, 'NX').exec();
|
||||
const count = results?.[0]?.[1];
|
||||
return typeof count === 'number' ? count : 0;
|
||||
}
|
||||
|
||||
async ping(): Promise<void> {
|
||||
await this.client.ping();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { extname } from 'node:path';
|
||||
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { API_ERROR_CODES } from '@sport/types';
|
||||
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
import { StorageService, type PresignUploadParams, type PresignedUpload } from './storage.service';
|
||||
|
||||
const UPLOAD_URL_TTL_SECONDS = 300;
|
||||
const DOWNLOAD_URL_TTL_SECONDS = 900;
|
||||
|
||||
/** Allow-list, not a block-list: anything unlisted is rejected. */
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/svg+xml',
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'application/pdf',
|
||||
]);
|
||||
|
||||
const DEFAULT_MAX_SIZE_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class S3StorageService extends StorageService {
|
||||
private readonly client: S3Client;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
|
||||
super();
|
||||
this.client = new S3Client({
|
||||
endpoint: config.storage.endpoint,
|
||||
region: config.storage.region,
|
||||
// MinIO needs path-style addressing; Cloudflare R2 must not use it.
|
||||
forcePathStyle: config.storage.forcePathStyle,
|
||||
credentials: {
|
||||
accessKeyId: config.storage.accessKeyId,
|
||||
secretAccessKey: config.storage.secretAccessKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async presignUpload(params: PresignUploadParams): Promise<PresignedUpload> {
|
||||
if (!ALLOWED_MIME_TYPES.has(params.mimeType)) {
|
||||
throw new AppException({
|
||||
code: API_ERROR_CODES.UNSUPPORTED_MEDIA_TYPE,
|
||||
message: `Files of type ${params.mimeType} are not accepted.`,
|
||||
status: 415,
|
||||
});
|
||||
}
|
||||
|
||||
const maxSize = params.maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES;
|
||||
const storageKey = this.buildKey(params.prefix, params.filename);
|
||||
|
||||
const uploadUrl = await getSignedUrl(
|
||||
this.client,
|
||||
new PutObjectCommand({
|
||||
Bucket: this.config.storage.bucket,
|
||||
Key: storageKey,
|
||||
ContentType: params.mimeType,
|
||||
ContentLength: maxSize,
|
||||
}),
|
||||
{ expiresIn: UPLOAD_URL_TTL_SECONDS },
|
||||
);
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
storageKey,
|
||||
publicUrl: this.publicUrl(storageKey),
|
||||
expiresInSeconds: UPLOAD_URL_TTL_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(storageKey: string): Promise<void> {
|
||||
await this.client.send(
|
||||
new DeleteObjectCommand({ Bucket: this.config.storage.bucket, Key: storageKey }),
|
||||
);
|
||||
}
|
||||
|
||||
async presignDownload(
|
||||
storageKey: string,
|
||||
expiresInSeconds = DOWNLOAD_URL_TTL_SECONDS,
|
||||
): Promise<string> {
|
||||
return getSignedUrl(
|
||||
this.client,
|
||||
new GetObjectCommand({ Bucket: this.config.storage.bucket, Key: storageKey }),
|
||||
{ expiresIn: expiresInSeconds },
|
||||
);
|
||||
}
|
||||
|
||||
publicUrl(storageKey: string): string {
|
||||
return `${this.config.storage.publicUrl}/${storageKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date-partitioned, collision-proof, and it never echoes the user's filename
|
||||
* back into a URL — that is a path-traversal and an information-leak vector.
|
||||
*/
|
||||
private buildKey(prefix: string, filename: string): string {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const extension = extname(filename)
|
||||
.toLowerCase()
|
||||
.replace(/[^.a-z0-9]/g, '');
|
||||
const safePrefix = prefix.replace(/[^a-z0-9/-]/g, '');
|
||||
|
||||
return `${safePrefix}/${year}/${month}/${randomUUID()}${extension}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
|
||||
import { S3StorageService } from './s3-storage.service';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
/**
|
||||
* Bound to the abstract class, so consumers inject `StorageService` and the
|
||||
* concrete provider is swappable in one line (and trivially mockable in tests).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: StorageService, useClass: S3StorageService }],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Storage abstraction.
|
||||
*
|
||||
* Application code depends on this interface, never on the AWS SDK. Local dev
|
||||
* runs MinIO, production runs Cloudflare R2, and a future migration to another
|
||||
* S3-compatible provider touches exactly one file.
|
||||
*/
|
||||
export interface StoredObject {
|
||||
storageKey: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
export interface PresignedUpload {
|
||||
/** PUT the file here. Expires in minutes. */
|
||||
uploadUrl: string;
|
||||
storageKey: string;
|
||||
publicUrl: string;
|
||||
expiresInSeconds: number;
|
||||
}
|
||||
|
||||
export interface PresignUploadParams {
|
||||
/** Logical folder, e.g. `products`, `banners`, `blog`. */
|
||||
prefix: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
maxSizeBytes?: number;
|
||||
}
|
||||
|
||||
export abstract class StorageService {
|
||||
/**
|
||||
* Browsers upload straight to the bucket with a short-lived signed URL.
|
||||
*
|
||||
* Files never stream through the API: no memory pressure, no request
|
||||
* timeouts on a 20 MB video, and no need to scale the API for bandwidth.
|
||||
*/
|
||||
abstract presignUpload(params: PresignUploadParams): Promise<PresignedUpload>;
|
||||
|
||||
abstract delete(storageKey: string): Promise<void>;
|
||||
|
||||
/** Signed read URL, for private objects such as invoices. */
|
||||
abstract presignDownload(storageKey: string, expiresInSeconds?: number): Promise<string>;
|
||||
|
||||
/** Public CDN URL for a key. Pure string composition, no I/O. */
|
||||
abstract publicUrl(storageKey: string): string;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { VersioningType } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import compression from 'compression';
|
||||
import helmet from 'helmet';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { CURRENT_API_VERSION } from './common/constants/api';
|
||||
import { requestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { APP_CONFIG } from './config/app-config.module';
|
||||
import type { AppConfig } from './config/configuration';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
// Buffer startup logs until the pino logger is attached, so boot output is
|
||||
// structured too rather than a mix of two formats.
|
||||
bufferLogs: true,
|
||||
});
|
||||
|
||||
const config = app.get<AppConfig>(APP_CONFIG);
|
||||
|
||||
app.useLogger(app.get(Logger));
|
||||
app.flushLogs();
|
||||
|
||||
// Behind Nginx: required for correct client IPs in rate limiting and logs.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// First in the chain: every log line and error response carries this id.
|
||||
app.use(requestIdMiddleware);
|
||||
app.use(helmet({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
|
||||
app.use(compression());
|
||||
|
||||
app.enableCors({
|
||||
origin: [...config.app.corsOrigins],
|
||||
credentials: true,
|
||||
exposedHeaders: ['x-request-id'],
|
||||
});
|
||||
|
||||
app.setGlobalPrefix(config.app.globalPrefix);
|
||||
|
||||
/**
|
||||
* URI versioning: /api/v1/products.
|
||||
*
|
||||
* Chosen over headers because it is visible in logs, cacheable by CDN path,
|
||||
* trivially testable with curl, and unambiguous for the mobile app and
|
||||
* partner integrations that will follow. See ADR-0005.
|
||||
*/
|
||||
app.enableVersioning({
|
||||
type: VersioningType.URI,
|
||||
defaultVersion: CURRENT_API_VERSION,
|
||||
});
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
if (!config.app.isProduction) {
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Sport Store API')
|
||||
.setDescription('REST API for the storefront and admin dashboard.')
|
||||
.setVersion(config.app.version)
|
||||
.addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' })
|
||||
.build();
|
||||
|
||||
SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, swaggerConfig), {
|
||||
jsonDocumentUrl: 'docs/json',
|
||||
});
|
||||
}
|
||||
|
||||
await app.listen(config.app.port, '0.0.0.0');
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,53 @@
|
||||
# Module anatomy
|
||||
|
||||
Every feature module follows the same internal shape. Consistency here is worth
|
||||
more than local cleverness — a developer opening `orders/` for the first time
|
||||
should already know where everything is.
|
||||
|
||||
```
|
||||
<module>/
|
||||
├── <module>.module.ts # Wiring only. No logic, ever.
|
||||
├── <module>.controller.ts # HTTP surface: parse, delegate, return. No rules.
|
||||
├── <module>.service.ts # Business rules. The only interesting file.
|
||||
├── <module>.repository.ts # The ONLY file allowed to touch PrismaService.
|
||||
├── dto/ # Request/response shapes + Zod schema bindings.
|
||||
├── mappers/ # Prisma row → API type. Keeps Prisma types internal.
|
||||
├── events/ # Events this module publishes and subscribes to.
|
||||
└── public/
|
||||
└── index.ts # The only entry point for other modules.
|
||||
```
|
||||
|
||||
## The four rules
|
||||
|
||||
1. **Controllers contain no business logic.** If a controller has an `if` that
|
||||
is not input shaping, the rule belongs in the service.
|
||||
|
||||
2. **Only the repository imports Prisma.** Services depend on repository
|
||||
interfaces. This is what makes services unit-testable without a database and
|
||||
what keeps a later storage change from rippling outward.
|
||||
|
||||
3. **A module owns its tables exclusively.** `OrdersModule` never queries
|
||||
`products` — it asks `ProductsModule`'s public service, or it stores a
|
||||
snapshot. Shared tables are how a monolith becomes unsplittable.
|
||||
|
||||
4. **Cross-module imports go through `public/`.** Deep imports are blocked by
|
||||
ESLint (`@sport/eslint-config/nest`). If you need something that is not
|
||||
exported, widen the public surface deliberately — do not reach around it.
|
||||
|
||||
## Talking to another module
|
||||
|
||||
| Need | Mechanism |
|
||||
| ---------------------------------------- | ------------------------------------------------ |
|
||||
| An answer, now, to continue this request | Call its public service |
|
||||
| To react to something that happened | Subscribe to its domain event |
|
||||
| To change its data | Call its public service — never write its tables |
|
||||
|
||||
## Modules marked EXTRACTION CANDIDATE
|
||||
|
||||
`inventory`, `orders`, `payments` and `search` are written so they could become
|
||||
independent services later: no foreign reads, communication via events, and no
|
||||
shared transactions with the rest of the monolith beyond their own tables.
|
||||
|
||||
That is a _constraint on how they are written_, not a plan to extract them.
|
||||
Extraction is justified by a real scaling or team-boundary problem, and nothing
|
||||
here assumes it will ever happen.
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { AccessTokenGuard } from './guards/access-token.guard';
|
||||
import { PermissionsGuard } from './guards/permissions.guard';
|
||||
|
||||
/**
|
||||
* Milestone 0 provides the *enforcement* half of auth: token verification,
|
||||
* audience separation and RBAC evaluation, wired globally.
|
||||
*
|
||||
* The *issuance* half — login, registration, refresh rotation, password reset,
|
||||
* OTP — is milestone 1. Splitting it this way means every endpoint written from
|
||||
* here on is protected by default, before a single credential exists.
|
||||
*
|
||||
* Guard order matters: AccessTokenGuard must populate `request.actor` before
|
||||
* PermissionsGuard reads it. Nest runs APP_GUARD providers in registration order.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: AccessTokenGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||
],
|
||||
exports: [JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Inject, Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type AccessTokenClaims,
|
||||
type AuthenticatedActor,
|
||||
type TokenAudience,
|
||||
} from '@sport/types';
|
||||
|
||||
import { METADATA_KEYS } from '@/common/constants/api';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
import { APP_CONFIG } from '@/config/app-config.module';
|
||||
import type { AppConfig } from '@/config/configuration';
|
||||
|
||||
/**
|
||||
* Registered globally: authentication is opt-OUT via `@Public()`, never opt-in.
|
||||
* A new controller written by someone who forgets to think about auth is
|
||||
* protected by default. That asymmetry is the whole design.
|
||||
*
|
||||
* The guard is stateless — no database read on the hot path. Permissions travel
|
||||
* inside the access token, which is why access tokens are short-lived: a
|
||||
* revoked permission takes at most one token lifetime to take effect.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(METADATA_KEYS.IS_PUBLIC, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = extractBearerToken(request);
|
||||
|
||||
if (!token) {
|
||||
throw AppException.unauthenticated();
|
||||
}
|
||||
|
||||
let claims: AccessTokenClaims;
|
||||
try {
|
||||
claims = await this.jwtService.verifyAsync<AccessTokenClaims>(token, {
|
||||
secret: this.config.auth.accessSecret,
|
||||
issuer: this.config.auth.issuer,
|
||||
});
|
||||
} catch (error) {
|
||||
const expired = error instanceof Error && error.name === 'TokenExpiredError';
|
||||
throw AppException.unauthenticated(
|
||||
expired ? 'Your session has expired. Please sign in again.' : 'Invalid credentials.',
|
||||
expired ? API_ERROR_CODES.TOKEN_EXPIRED : API_ERROR_CODES.TOKEN_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
// Audience check runs before any permission logic: a storefront token must
|
||||
// never reach an admin endpoint even if it somehow carried the permission.
|
||||
const requiredAudience = this.reflector.getAllAndOverride<TokenAudience>(
|
||||
METADATA_KEYS.TOKEN_AUDIENCE,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (requiredAudience && claims.aud !== requiredAudience) {
|
||||
throw AppException.forbidden('This credential cannot be used here.');
|
||||
}
|
||||
|
||||
const actor: AuthenticatedActor = {
|
||||
userId: claims.sub,
|
||||
userType: claims.type,
|
||||
audience: claims.aud,
|
||||
permissions: claims.permissions ?? [],
|
||||
sessionId: claims.sid,
|
||||
};
|
||||
|
||||
request.actor = actor;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function extractBearerToken(request: Request): string | null {
|
||||
const header = request.header('authorization');
|
||||
if (!header) return null;
|
||||
|
||||
const [scheme, value] = header.split(' ');
|
||||
return scheme?.toLowerCase() === 'bearer' && value ? value : null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
import { PERMISSIONS, USER_TYPES, TOKEN_AUDIENCES, type AuthenticatedActor } from '@sport/types';
|
||||
|
||||
import { METADATA_KEYS } from '@/common/constants/api';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
|
||||
import { PermissionsGuard } from './permissions.guard';
|
||||
|
||||
/**
|
||||
* These tests exist because authorization is the one thing that must never
|
||||
* regress quietly. They pin the three behaviours the rest of the codebase
|
||||
* relies on: default-allow only when nothing is required, all-of semantics,
|
||||
* and any-of semantics.
|
||||
*/
|
||||
function makeContext(actor: AuthenticatedActor | undefined) {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => ({ actor }) }),
|
||||
getHandler: () => () => undefined,
|
||||
getClass: () => class {},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makeActor(permissions: AuthenticatedActor['permissions']): AuthenticatedActor {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
userType: USER_TYPES.STAFF,
|
||||
audience: TOKEN_AUDIENCES.ADMIN,
|
||||
permissions,
|
||||
sessionId: 'session-1',
|
||||
};
|
||||
}
|
||||
|
||||
function makeReflector(required?: string[], mode?: 'all' | 'any') {
|
||||
const reflector = new Reflector();
|
||||
jest
|
||||
.spyOn(reflector, 'getAllAndOverride')
|
||||
.mockImplementation((key: unknown) =>
|
||||
key === METADATA_KEYS.REQUIRED_PERMISSIONS ? required : mode,
|
||||
);
|
||||
return reflector;
|
||||
}
|
||||
|
||||
describe('PermissionsGuard', () => {
|
||||
it('allows a route that declares no permissions', () => {
|
||||
const guard = new PermissionsGuard(makeReflector(undefined));
|
||||
expect(guard.canActivate(makeContext(makeActor([])))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows when the actor holds every required permission', () => {
|
||||
const guard = new PermissionsGuard(
|
||||
makeReflector([PERMISSIONS.PRODUCT_READ, PERMISSIONS.PRODUCT_UPDATE], 'all'),
|
||||
);
|
||||
const actor = makeActor([PERMISSIONS.PRODUCT_READ, PERMISSIONS.PRODUCT_UPDATE]);
|
||||
|
||||
expect(guard.canActivate(makeContext(actor))).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when one of several required permissions is missing', () => {
|
||||
const guard = new PermissionsGuard(
|
||||
makeReflector([PERMISSIONS.PRODUCT_READ, PERMISSIONS.PRODUCT_DELETE], 'all'),
|
||||
);
|
||||
const actor = makeActor([PERMISSIONS.PRODUCT_READ]);
|
||||
|
||||
expect(() => guard.canActivate(makeContext(actor))).toThrow(AppException);
|
||||
});
|
||||
|
||||
it('allows under "any" mode when at least one permission matches', () => {
|
||||
const guard = new PermissionsGuard(
|
||||
makeReflector([PERMISSIONS.ORDER_READ, PERMISSIONS.ORDER_REFUND], 'any'),
|
||||
);
|
||||
const actor = makeActor([PERMISSIONS.ORDER_READ]);
|
||||
|
||||
expect(guard.canActivate(makeContext(actor))).toBe(true);
|
||||
});
|
||||
|
||||
it('denies an unauthenticated request on a permissioned route', () => {
|
||||
const guard = new PermissionsGuard(makeReflector([PERMISSIONS.ORDER_READ], 'all'));
|
||||
|
||||
expect(() => guard.canActivate(makeContext(undefined))).toThrow(AppException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { hasAllPermissions, hasAnyPermission, type Permission } from '@sport/types';
|
||||
|
||||
import { METADATA_KEYS } from '@/common/constants/api';
|
||||
import { AppException } from '@/common/errors/app.exception';
|
||||
|
||||
/**
|
||||
* Enforces `@RequirePermissions(...)`. Runs after AccessTokenGuard, so the
|
||||
* actor is guaranteed present on any non-public route.
|
||||
*
|
||||
* Routes with no permission metadata pass: authentication alone is enough for
|
||||
* "any signed-in customer" endpoints such as /me. Anything touching business
|
||||
* data must declare its permissions explicitly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<Permission[]>(
|
||||
METADATA_KEYS.REQUIRED_PERMISSIONS,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!required || required.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const actor = request.actor;
|
||||
|
||||
if (!actor) {
|
||||
throw AppException.unauthenticated();
|
||||
}
|
||||
|
||||
const mode = this.reflector.getAllAndOverride<'all' | 'any'>(METADATA_KEYS.PERMISSION_MODE, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
const granted =
|
||||
mode === 'any'
|
||||
? hasAnyPermission(actor.permissions, required)
|
||||
: hasAllPermissions(actor.permissions, required);
|
||||
|
||||
if (!granted) {
|
||||
throw AppException.forbidden();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* BrandsModule — boundary declared, implementation pending.
|
||||
*
|
||||
* Owns (exclusively): `brands`
|
||||
*
|
||||
* Deliberately thin. Kept separate anyway because brand pages, filters and (later) brand-level commercial terms all hang off it.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* brands.module.ts wiring only
|
||||
* brands.controller.ts HTTP surface, no logic
|
||||
* brands.service.ts business rules
|
||||
* brands.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
*/
|
||||
@Module({})
|
||||
export class BrandsModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Public surface of BrandsModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* CartsModule — boundary declared, implementation pending.
|
||||
*
|
||||
* Owns (exclusively): Redis (guest carts) + `carts`/`cart_items` once persisted — milestone 2
|
||||
*
|
||||
* Guest carts live in Redis keyed by an anonymous token; they are promoted to PostgreSQL on sign-in. Cart totals are always recomputed server-side from current variant prices — a client-submitted price is never trusted.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* carts.module.ts wiring only
|
||||
* carts.controller.ts HTTP surface, no logic
|
||||
* carts.service.ts business rules
|
||||
* carts.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
*/
|
||||
@Module({})
|
||||
export class CartsModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Public surface of CartsModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* CategoriesModule — boundary declared, implementation pending.
|
||||
*
|
||||
* Owns (exclusively): `categories`
|
||||
*
|
||||
* The hierarchical merchandising tree and the navigation menu it feeds. Heavy read, near-zero write — the first thing that should be Redis-cached.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* categories.module.ts wiring only
|
||||
* categories.controller.ts HTTP surface, no logic
|
||||
* categories.service.ts business rules
|
||||
* categories.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
*/
|
||||
@Module({})
|
||||
export class CategoriesModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Public surface of CategoriesModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* CheckoutModule — boundary declared, implementation pending.
|
||||
*
|
||||
* Owns (exclusively): Checkout sessions (Redis, short TTL)
|
||||
*
|
||||
* Orchestrates the cart → stock reservation → payment intent → order transition. The only module allowed to coordinate across contexts, and it does so through public services and events.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* checkout.module.ts wiring only
|
||||
* checkout.controller.ts HTTP surface, no logic
|
||||
* checkout.service.ts business rules
|
||||
* checkout.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
*/
|
||||
@Module({})
|
||||
export class CheckoutModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Public surface of CheckoutModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* CmsModule — boundary declared, implementation pending.
|
||||
*
|
||||
* Owns (exclusively): `pages`, `blog_posts`, `banners`, `navigation_menus` — milestone 3
|
||||
*
|
||||
* Homepage blocks, /blog and static pages. Editorial content is versioned and previewable; it never becomes a general-purpose page builder.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* cms.module.ts wiring only
|
||||
* cms.controller.ts HTTP surface, no logic
|
||||
* cms.service.ts business rules
|
||||
* cms.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
*/
|
||||
@Module({})
|
||||
export class CmsModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Public surface of CmsModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* CollectionsModule — boundary declared, implementation pending.
|
||||
*
|
||||
* Owns (exclusively): `collections`, `product_collections`
|
||||
*
|
||||
* Editorial and campaign groupings, including rule evaluation for AUTOMATED collections.
|
||||
*
|
||||
* Anatomy once implemented (see ../README.md):
|
||||
* collections.module.ts wiring only
|
||||
* collections.controller.ts HTTP surface, no logic
|
||||
* collections.service.ts business rules
|
||||
* collections.repository.ts the only file that touches Prisma
|
||||
* dto/ request/response shapes
|
||||
* public/ what other modules may import
|
||||
*/
|
||||
@Module({})
|
||||
export class CollectionsModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Public surface of CollectionsModule.
|
||||
*
|
||||
* This barrel is the ONLY thing other modules may import from here. Everything
|
||||
* else — repository, DTOs, internal services — is private, and the ESLint
|
||||
* boundary rule in @sport/eslint-config/nest enforces it.
|
||||
*
|
||||
* Keep it narrow: each export is a promise to the rest of the codebase.
|
||||
*/
|
||||
export {};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user