Basic Architecture of Sport Web

This commit is contained in:
Nông Đức Huy
2026-08-11 13:37:25 +07:00
commit 8032fff6ac
262 changed files with 20348 additions and 0 deletions
+14
View File
@@ -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
+9
View File
@@ -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 -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+3
View File
@@ -0,0 +1,3 @@
import { nextConfig } from '@sport/eslint-config/next';
export default nextConfig;
+7
View File
@@ -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.
+31
View File
@@ -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;
+37
View File
@@ -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:"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+24
View File
@@ -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"
/>
);
}
+21
View File
@@ -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"
/>
);
}
+16
View File
@@ -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"
/>
);
}
+17
View File
@@ -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>
);
}
+7
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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.
+20
View File
@@ -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
});
+17
View File
@@ -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 });
}
+57
View File
@@ -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 },
],
},
];
+20
View File
@@ -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);
}
+18
View File
@@ -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"]
}
+47
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
import { nestConfig } from '@sport/eslint-config/nest';
export default nestConfig;
+17
View File
@@ -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
}
}
]
}
}
+90
View File
@@ -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"
+694
View File
@@ -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")
}
+141
View File
@@ -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();
});
+101
View File
@@ -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 {}
+17
View File
@@ -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 {};
+40
View File
@@ -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 {}
+81
View File
@@ -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 },
};
}
+71
View File
@@ -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;
}
+75
View File
@@ -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();
+53
View File
@@ -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.
+28
View File
@@ -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 {};
+19
View File
@@ -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 {}
+10
View File
@@ -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 {};
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
/**
* CouponsModule — boundary declared, implementation pending.
*
* Owns (exclusively): `coupons`, `coupon_redemptions` — milestone 3
*
* Code-driven discounts. Redemption counting must be transactional; a race here gives away unlimited free money.
*
* Anatomy once implemented (see ../README.md):
* coupons.module.ts wiring only
* coupons.controller.ts HTTP surface, no logic
* coupons.service.ts business rules
* coupons.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CouponsModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of CouponsModule.
*
* 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';
/**
* CustomersModule — boundary declared, implementation pending.
*
* Owns (exclusively): `customers`, `addresses`
*
* Shopper profiles and address book. Separate from `users` so customer PII can later live under a stricter access policy without touching staff accounts.
*
* Anatomy once implemented (see ../README.md):
* customers.module.ts wiring only
* customers.controller.ts HTTP surface, no logic
* customers.service.ts business rules
* customers.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class CustomersModule {}
@@ -0,0 +1,10 @@
/**
* Public surface of CustomersModule.
*
* 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,34 @@
import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '@/common/decorators/public.decorator';
import { HealthService, type HealthCheckResult } from './health.service';
@ApiTags('health')
@Controller('health')
export class HealthController {
constructor(private readonly healthService: HealthService) {}
/**
* Liveness. Answers "is the process running?" and nothing else — it must not
* touch the database, or a brief DB blip would make the orchestrator kill
* otherwise-healthy containers.
*/
@Public()
@Get('live')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Liveness probe' })
live(): { status: 'ok' } {
return { status: 'ok' };
}
/** Readiness / deep check: should this instance receive traffic? */
@Public()
@Get()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Readiness probe with dependency status' })
check(): Promise<HealthCheckResult> {
return this.healthService.check();
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [HealthService],
})
export class HealthModule {}
@@ -0,0 +1,67 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_CONFIG } from '@/config/app-config.module';
import type { AppConfig } from '@/config/configuration';
import { PrismaService } from '@/infrastructure/prisma/prisma.service';
import { RedisService } from '@/infrastructure/redis/redis.service';
export interface DependencyStatus {
status: 'up' | 'down';
latencyMs: number | null;
error?: string;
}
export interface HealthCheckResult {
status: 'ok' | 'degraded';
uptimeSeconds: number;
version: string;
environment: string;
dependencies: {
database: DependencyStatus;
redis: DependencyStatus;
};
}
@Injectable()
export class HealthService {
constructor(
@Inject(APP_CONFIG) private readonly config: AppConfig,
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
/**
* Deep check — used by dashboards and by `docker compose` dependency gates.
* Dependencies are probed in parallel so a slow one cannot mask another.
*/
async check(): Promise<HealthCheckResult> {
const [database, redis] = await Promise.all([
probe(() => this.prisma.ping()),
probe(() => this.redis.ping()),
]);
const healthy = database.status === 'up' && redis.status === 'up';
return {
status: healthy ? 'ok' : 'degraded',
uptimeSeconds: Math.round(process.uptime()),
version: this.config.app.version,
environment: this.config.app.env,
dependencies: { database, redis },
};
}
}
async function probe(fn: () => Promise<unknown>): Promise<DependencyStatus> {
const startedAt = performance.now();
try {
await fn();
return { status: 'up', latencyMs: Math.round(performance.now() - startedAt) };
} catch (error) {
return {
status: 'down',
latencyMs: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
/**
* InventoryModule — boundary declared, implementation pending.
*
* Owns (exclusively): `inventory_locations`, `stock_levels`, `stock_movements`
*
* Stock ledger, reservations and release. Consumes order events rather than being called by OrdersModule.
*
* EXTRACTION CANDIDATE: designed so it could become its own service. It must
* therefore never read another module’s tables directly, and it communicates
* outward through domain events.
*
* Anatomy once implemented (see ../README.md):
* inventory.module.ts wiring only
* inventory.controller.ts HTTP surface, no logic
* inventory.service.ts business rules
* inventory.repository.ts the only file that touches Prisma
* dto/ request/response shapes
* public/ what other modules may import
*/
@Module({})
export class InventoryModule {}

Some files were not shown because too many files have changed in this diff Show More