From 8032fff6ac97f07dffa0a57080aa015731560660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=B4ng=20=C4=90=E1=BB=A9c=20Huy?= Date: Tue, 11 Aug 2026 13:37:25 +0700 Subject: [PATCH] Basic Architecture of Sport Web --- .dockerignore | 21 + .editorconfig | 15 + .env.example | 30 + .github/workflows/ci.yml | 187 + .gitignore | 42 + .npmrc | 6 + .prettierignore | 11 + README.md | 256 + apps/admin/.env.example | 14 + apps/admin/AGENTS.md | 9 + apps/admin/CLAUDE.md | 1 + apps/admin/eslint.config.mjs | 3 + apps/admin/next-env.d.ts | 7 + apps/admin/next.config.ts | 31 + apps/admin/package.json | 37 + apps/admin/postcss.config.mjs | 8 + apps/admin/src/app/(auth)/login/page.tsx | 24 + .../admin/src/app/(dashboard)/brands/page.tsx | 16 + .../src/app/(dashboard)/categories/page.tsx | 16 + apps/admin/src/app/(dashboard)/cms/page.tsx | 16 + .../src/app/(dashboard)/collections/page.tsx | 16 + .../src/app/(dashboard)/coupons/page.tsx | 16 + .../src/app/(dashboard)/customers/page.tsx | 16 + .../src/app/(dashboard)/inventory/page.tsx | 16 + apps/admin/src/app/(dashboard)/layout.tsx | 21 + apps/admin/src/app/(dashboard)/media/page.tsx | 16 + .../admin/src/app/(dashboard)/orders/page.tsx | 16 + apps/admin/src/app/(dashboard)/page.tsx | 16 + .../src/app/(dashboard)/products/page.tsx | 16 + .../src/app/(dashboard)/promotions/page.tsx | 16 + .../src/app/(dashboard)/reviews/page.tsx | 16 + .../app/(dashboard)/settings/roles/page.tsx | 16 + .../app/(dashboard)/settings/users/page.tsx | 16 + apps/admin/src/app/layout.tsx | 17 + .../src/components/layout/admin-sidebar.tsx | 42 + .../src/components/layout/page-scaffold.tsx | 25 + apps/admin/src/features/auth/README.md | 7 + apps/admin/src/features/customers/README.md | 7 + apps/admin/src/features/inventory/README.md | 7 + apps/admin/src/features/media/README.md | 7 + apps/admin/src/features/orders/README.md | 7 + apps/admin/src/features/products/README.md | 7 + apps/admin/src/features/settings/README.md | 7 + apps/admin/src/lib/api.ts | 20 + apps/admin/src/lib/env.ts | 17 + apps/admin/src/lib/navigation.ts | 57 + apps/admin/src/styles/globals.css | 20 + apps/admin/tsconfig.json | 18 + apps/api/.env.example | 47 + apps/api/eslint.config.mjs | 3 + apps/api/nest-cli.json | 17 + apps/api/package.json | 90 + .../20260811055829_init/migration.sql | 636 + .../api/prisma/migrations/migration_lock.toml | 3 + apps/api/prisma/schema.prisma | 694 ++ apps/api/prisma/seed.ts | 141 + apps/api/src/app.module.ts | 101 + apps/api/src/common/constants/api.ts | 17 + .../decorators/current-actor.decorator.ts | 25 + .../src/common/decorators/public.decorator.ts | 12 + .../require-permissions.decorator.ts | 37 + apps/api/src/common/errors/app.exception.ts | 70 + .../common/filters/all-exceptions.filter.ts | 181 + .../response-envelope.interceptor.ts | 49 + .../middleware/request-id.middleware.ts | 37 + .../src/common/pipes/zod-validation.pipe.ts | 48 + apps/api/src/common/types/request-context.ts | 14 + apps/api/src/config/app-config.module.ts | 40 + apps/api/src/config/configuration.ts | 81 + apps/api/src/config/env.schema.ts | 71 + .../src/infrastructure/events/domain-event.ts | 51 + .../events/event-bus.service.ts | 47 + .../infrastructure/events/events.module.ts | 10 + .../infrastructure/logging/logging.module.ts | 66 + .../infrastructure/prisma/prisma.module.ts | 15 + .../infrastructure/prisma/prisma.service.ts | 54 + .../src/infrastructure/redis/cache-keys.ts | 49 + .../src/infrastructure/redis/redis.module.ts | 10 + .../src/infrastructure/redis/redis.service.ts | 112 + .../storage/s3-storage.service.ts | 123 + .../infrastructure/storage/storage.module.ts | 15 + .../infrastructure/storage/storage.service.ts | 45 + apps/api/src/main.ts | 75 + apps/api/src/modules/README.md | 53 + apps/api/src/modules/auth/auth.module.ts | 28 + .../modules/auth/guards/access-token.guard.ts | 96 + .../auth/guards/permissions.guard.spec.ts | 82 + .../modules/auth/guards/permissions.guard.ts | 55 + apps/api/src/modules/brands/brands.module.ts | 19 + apps/api/src/modules/brands/public/index.ts | 10 + apps/api/src/modules/carts/carts.module.ts | 19 + apps/api/src/modules/carts/public/index.ts | 10 + .../modules/categories/categories.module.ts | 19 + .../src/modules/categories/public/index.ts | 10 + .../src/modules/checkout/checkout.module.ts | 19 + apps/api/src/modules/checkout/public/index.ts | 10 + apps/api/src/modules/cms/cms.module.ts | 19 + apps/api/src/modules/cms/public/index.ts | 10 + .../modules/collections/collections.module.ts | 19 + .../src/modules/collections/public/index.ts | 10 + .../api/src/modules/coupons/coupons.module.ts | 19 + apps/api/src/modules/coupons/public/index.ts | 10 + .../src/modules/customers/customers.module.ts | 19 + .../api/src/modules/customers/public/index.ts | 10 + .../src/modules/health/health.controller.ts | 34 + apps/api/src/modules/health/health.module.ts | 10 + apps/api/src/modules/health/health.service.ts | 67 + .../src/modules/inventory/inventory.module.ts | 23 + .../api/src/modules/inventory/public/index.ts | 10 + apps/api/src/modules/media/media.module.ts | 19 + apps/api/src/modules/media/public/index.ts | 10 + apps/api/src/modules/orders/orders.module.ts | 23 + apps/api/src/modules/orders/public/index.ts | 10 + .../src/modules/payments/payments.module.ts | 23 + apps/api/src/modules/payments/public/index.ts | 10 + .../product-variants.module.ts | 19 + .../modules/product-variants/public/index.ts | 10 + .../src/modules/products/products.module.ts | 19 + apps/api/src/modules/products/public/index.ts | 10 + .../modules/promotions/promotions.module.ts | 19 + .../src/modules/promotions/public/index.ts | 10 + apps/api/src/modules/reviews/public/index.ts | 10 + .../api/src/modules/reviews/reviews.module.ts | 19 + apps/api/src/modules/search/public/index.ts | 10 + apps/api/src/modules/search/search.module.ts | 23 + apps/api/src/modules/users/public/index.ts | 10 + apps/api/src/modules/users/users.module.ts | 19 + apps/api/src/modules/wishlist/public/index.ts | 10 + .../src/modules/wishlist/wishlist.module.ts | 19 + apps/api/tsconfig.build.json | 16 + apps/api/tsconfig.json | 12 + apps/storefront/.env.example | 14 + apps/storefront/AGENTS.md | 9 + apps/storefront/CLAUDE.md | 1 + apps/storefront/eslint.config.mjs | 3 + apps/storefront/next-env.d.ts | 7 + apps/storefront/next.config.ts | 34 + apps/storefront/package.json | 37 + apps/storefront/postcss.config.mjs | 8 + .../app/(account)/account/addresses/page.tsx | 15 + .../src/app/(account)/account/layout.tsx | 43 + .../src/app/(account)/account/orders/page.tsx | 15 + .../src/app/(account)/account/page.tsx | 15 + .../app/(account)/account/profile/page.tsx | 15 + .../app/(account)/account/wishlist/page.tsx | 15 + .../src/app/(checkout)/checkout/page.tsx | 15 + apps/storefront/src/app/(checkout)/layout.tsx | 22 + apps/storefront/src/app/(shop)/blog/page.tsx | 16 + apps/storefront/src/app/(shop)/cart/page.tsx | 16 + .../app/(shop)/collections/[slug]/page.tsx | 23 + apps/storefront/src/app/(shop)/layout.tsx | 17 + apps/storefront/src/app/(shop)/men/page.tsx | 16 + apps/storefront/src/app/(shop)/page.tsx | 15 + .../src/app/(shop)/products/[slug]/page.tsx | 33 + .../storefront/src/app/(shop)/search/page.tsx | 16 + .../src/app/(shop)/sports/[sport]/page.tsx | 43 + apps/storefront/src/app/(shop)/women/page.tsx | 16 + apps/storefront/src/app/layout.tsx | 27 + apps/storefront/src/app/not-found.tsx | 18 + .../src/components/layout/page-scaffold.tsx | 35 + .../src/components/layout/site-footer.tsx | 74 + .../src/components/layout/site-header.tsx | 56 + .../storefront/src/features/account/README.md | 24 + apps/storefront/src/features/auth/README.md | 24 + apps/storefront/src/features/cart/README.md | 24 + .../src/features/category/README.md | 24 + .../src/features/checkout/README.md | 24 + .../src/features/collection/README.md | 24 + apps/storefront/src/features/order/README.md | 24 + .../storefront/src/features/product/README.md | 24 + apps/storefront/src/features/search/README.md | 24 + .../src/features/wishlist/README.md | 24 + apps/storefront/src/hooks/README.md | 3 + apps/storefront/src/lib/api.ts | 28 + apps/storefront/src/lib/env.ts | 32 + apps/storefront/src/lib/format.ts | 31 + apps/storefront/src/lib/routes.ts | 46 + apps/storefront/src/services/README.md | 3 + apps/storefront/src/stores/README.md | 3 + apps/storefront/src/styles/globals.css | 25 + apps/storefront/src/types/README.md | 3 + apps/storefront/tsconfig.json | 18 + docker-compose.yml | 188 + ...repo-with-pnpm-workspaces-and-turborepo.md | 36 + ...0002-modular-monolith-not-microservices.md | 38 + ...and-productvariant-as-separate-entities.md | 39 + ...-admin-dashboard-has-no-database-access.md | 32 + docs/adr/0005-uri-based-api-versioning.md | 31 + ...chemas-shared-between-api-and-frontends.md | 35 + ...rbac-permissions-instead-of-role-checks.md | 35 + ...ating-refresh-tokens-separate-audiences.md | 39 + ...mpatible-storage-metadata-in-postgresql.md | 37 + ...phemeral-store-never-a-system-of-record.md | 37 + docs/adr/0011-money-as-integer-minor-units.md | 29 + ...search-before-a-dedicated-search-engine.md | 34 + docs/adr/README.md | 36 + docs/architecture.md | 346 + infrastructure/docker/admin.Dockerfile | 44 + infrastructure/docker/api.Dockerfile | 55 + infrastructure/docker/storefront.Dockerfile | 48 + infrastructure/nginx/conf.d/default.conf | 81 + infrastructure/nginx/nginx.conf | 73 + infrastructure/scripts/bootstrap.sh | 78 + infrastructure/scripts/reset-db.sh | 15 + package.json | 44 + packages/api-client/eslint.config.mjs | 3 + packages/api-client/package.json | 34 + packages/api-client/src/create-client.ts | 22 + packages/api-client/src/errors.ts | 64 + packages/api-client/src/http-client.ts | 156 + packages/api-client/src/index.ts | 18 + packages/api-client/src/resources/health.ts | 28 + packages/api-client/tsconfig.json | 10 + packages/config/package.json | 22 + packages/config/tailwind/theme.css | 96 + packages/config/typescript/base.json | 34 + packages/config/typescript/library.json | 13 + packages/config/typescript/nestjs.json | 21 + packages/config/typescript/nextjs.json | 17 + packages/config/typescript/react-library.json | 13 + packages/eslint-config/base.js | 102 + packages/eslint-config/nest.js | 59 + packages/eslint-config/next.js | 56 + packages/eslint-config/package.json | 33 + packages/eslint-config/react.js | 34 + packages/types/eslint.config.mjs | 3 + packages/types/package.json | 30 + packages/types/src/api/envelope.ts | 45 + packages/types/src/api/error-codes.ts | 61 + packages/types/src/api/pagination.ts | 49 + packages/types/src/auth/actors.ts | 38 + packages/types/src/auth/permissions.ts | 104 + packages/types/src/auth/tokens.ts | 40 + packages/types/src/catalog/media.ts | 50 + packages/types/src/catalog/product.ts | 127 + packages/types/src/catalog/taxonomy.ts | 71 + packages/types/src/catalog/variant.ts | 116 + packages/types/src/index.ts | 20 + packages/types/src/inventory/stock.ts | 63 + packages/types/src/primitives.ts | 31 + packages/types/tsconfig.json | 9 + packages/ui/eslint.config.mjs | 3 + packages/ui/package.json | 31 + packages/ui/src/index.ts | 27 + packages/ui/src/lib/cn.ts | 7 + packages/ui/src/primitives/badge.tsx | 29 + packages/ui/src/primitives/button.tsx | 56 + packages/ui/src/primitives/input.tsx | 24 + packages/ui/src/primitives/skeleton.tsx | 7 + packages/ui/tsconfig.json | 5 + packages/validation/eslint.config.mjs | 3 + packages/validation/package.json | 34 + packages/validation/src/auth.ts | 32 + packages/validation/src/catalog.ts | 51 + packages/validation/src/common.ts | 39 + packages/validation/src/index.ts | 17 + packages/validation/src/pagination.ts | 30 + packages/validation/tsconfig.json | 9 + pnpm-lock.yaml | 10268 ++++++++++++++++ pnpm-workspace.yaml | 27 + prettier.config.mjs | 25 + turbo.json | 47 + 262 files changed, 20348 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 README.md create mode 100644 apps/admin/.env.example create mode 100644 apps/admin/AGENTS.md create mode 100644 apps/admin/CLAUDE.md create mode 100644 apps/admin/eslint.config.mjs create mode 100644 apps/admin/next-env.d.ts create mode 100644 apps/admin/next.config.ts create mode 100644 apps/admin/package.json create mode 100644 apps/admin/postcss.config.mjs create mode 100644 apps/admin/src/app/(auth)/login/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/brands/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/categories/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/cms/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/collections/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/coupons/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/customers/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/inventory/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/layout.tsx create mode 100644 apps/admin/src/app/(dashboard)/media/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/orders/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/products/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/promotions/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/reviews/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/settings/roles/page.tsx create mode 100644 apps/admin/src/app/(dashboard)/settings/users/page.tsx create mode 100644 apps/admin/src/app/layout.tsx create mode 100644 apps/admin/src/components/layout/admin-sidebar.tsx create mode 100644 apps/admin/src/components/layout/page-scaffold.tsx create mode 100644 apps/admin/src/features/auth/README.md create mode 100644 apps/admin/src/features/customers/README.md create mode 100644 apps/admin/src/features/inventory/README.md create mode 100644 apps/admin/src/features/media/README.md create mode 100644 apps/admin/src/features/orders/README.md create mode 100644 apps/admin/src/features/products/README.md create mode 100644 apps/admin/src/features/settings/README.md create mode 100644 apps/admin/src/lib/api.ts create mode 100644 apps/admin/src/lib/env.ts create mode 100644 apps/admin/src/lib/navigation.ts create mode 100644 apps/admin/src/styles/globals.css create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/api/.env.example create mode 100644 apps/api/eslint.config.mjs create mode 100644 apps/api/nest-cli.json create mode 100644 apps/api/package.json create mode 100644 apps/api/prisma/migrations/20260811055829_init/migration.sql create mode 100644 apps/api/prisma/migrations/migration_lock.toml create mode 100644 apps/api/prisma/schema.prisma create mode 100644 apps/api/prisma/seed.ts create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/common/constants/api.ts create mode 100644 apps/api/src/common/decorators/current-actor.decorator.ts create mode 100644 apps/api/src/common/decorators/public.decorator.ts create mode 100644 apps/api/src/common/decorators/require-permissions.decorator.ts create mode 100644 apps/api/src/common/errors/app.exception.ts create mode 100644 apps/api/src/common/filters/all-exceptions.filter.ts create mode 100644 apps/api/src/common/interceptors/response-envelope.interceptor.ts create mode 100644 apps/api/src/common/middleware/request-id.middleware.ts create mode 100644 apps/api/src/common/pipes/zod-validation.pipe.ts create mode 100644 apps/api/src/common/types/request-context.ts create mode 100644 apps/api/src/config/app-config.module.ts create mode 100644 apps/api/src/config/configuration.ts create mode 100644 apps/api/src/config/env.schema.ts create mode 100644 apps/api/src/infrastructure/events/domain-event.ts create mode 100644 apps/api/src/infrastructure/events/event-bus.service.ts create mode 100644 apps/api/src/infrastructure/events/events.module.ts create mode 100644 apps/api/src/infrastructure/logging/logging.module.ts create mode 100644 apps/api/src/infrastructure/prisma/prisma.module.ts create mode 100644 apps/api/src/infrastructure/prisma/prisma.service.ts create mode 100644 apps/api/src/infrastructure/redis/cache-keys.ts create mode 100644 apps/api/src/infrastructure/redis/redis.module.ts create mode 100644 apps/api/src/infrastructure/redis/redis.service.ts create mode 100644 apps/api/src/infrastructure/storage/s3-storage.service.ts create mode 100644 apps/api/src/infrastructure/storage/storage.module.ts create mode 100644 apps/api/src/infrastructure/storage/storage.service.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/modules/README.md create mode 100644 apps/api/src/modules/auth/auth.module.ts create mode 100644 apps/api/src/modules/auth/guards/access-token.guard.ts create mode 100644 apps/api/src/modules/auth/guards/permissions.guard.spec.ts create mode 100644 apps/api/src/modules/auth/guards/permissions.guard.ts create mode 100644 apps/api/src/modules/brands/brands.module.ts create mode 100644 apps/api/src/modules/brands/public/index.ts create mode 100644 apps/api/src/modules/carts/carts.module.ts create mode 100644 apps/api/src/modules/carts/public/index.ts create mode 100644 apps/api/src/modules/categories/categories.module.ts create mode 100644 apps/api/src/modules/categories/public/index.ts create mode 100644 apps/api/src/modules/checkout/checkout.module.ts create mode 100644 apps/api/src/modules/checkout/public/index.ts create mode 100644 apps/api/src/modules/cms/cms.module.ts create mode 100644 apps/api/src/modules/cms/public/index.ts create mode 100644 apps/api/src/modules/collections/collections.module.ts create mode 100644 apps/api/src/modules/collections/public/index.ts create mode 100644 apps/api/src/modules/coupons/coupons.module.ts create mode 100644 apps/api/src/modules/coupons/public/index.ts create mode 100644 apps/api/src/modules/customers/customers.module.ts create mode 100644 apps/api/src/modules/customers/public/index.ts create mode 100644 apps/api/src/modules/health/health.controller.ts create mode 100644 apps/api/src/modules/health/health.module.ts create mode 100644 apps/api/src/modules/health/health.service.ts create mode 100644 apps/api/src/modules/inventory/inventory.module.ts create mode 100644 apps/api/src/modules/inventory/public/index.ts create mode 100644 apps/api/src/modules/media/media.module.ts create mode 100644 apps/api/src/modules/media/public/index.ts create mode 100644 apps/api/src/modules/orders/orders.module.ts create mode 100644 apps/api/src/modules/orders/public/index.ts create mode 100644 apps/api/src/modules/payments/payments.module.ts create mode 100644 apps/api/src/modules/payments/public/index.ts create mode 100644 apps/api/src/modules/product-variants/product-variants.module.ts create mode 100644 apps/api/src/modules/product-variants/public/index.ts create mode 100644 apps/api/src/modules/products/products.module.ts create mode 100644 apps/api/src/modules/products/public/index.ts create mode 100644 apps/api/src/modules/promotions/promotions.module.ts create mode 100644 apps/api/src/modules/promotions/public/index.ts create mode 100644 apps/api/src/modules/reviews/public/index.ts create mode 100644 apps/api/src/modules/reviews/reviews.module.ts create mode 100644 apps/api/src/modules/search/public/index.ts create mode 100644 apps/api/src/modules/search/search.module.ts create mode 100644 apps/api/src/modules/users/public/index.ts create mode 100644 apps/api/src/modules/users/users.module.ts create mode 100644 apps/api/src/modules/wishlist/public/index.ts create mode 100644 apps/api/src/modules/wishlist/wishlist.module.ts create mode 100644 apps/api/tsconfig.build.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/storefront/.env.example create mode 100644 apps/storefront/AGENTS.md create mode 100644 apps/storefront/CLAUDE.md create mode 100644 apps/storefront/eslint.config.mjs create mode 100644 apps/storefront/next-env.d.ts create mode 100644 apps/storefront/next.config.ts create mode 100644 apps/storefront/package.json create mode 100644 apps/storefront/postcss.config.mjs create mode 100644 apps/storefront/src/app/(account)/account/addresses/page.tsx create mode 100644 apps/storefront/src/app/(account)/account/layout.tsx create mode 100644 apps/storefront/src/app/(account)/account/orders/page.tsx create mode 100644 apps/storefront/src/app/(account)/account/page.tsx create mode 100644 apps/storefront/src/app/(account)/account/profile/page.tsx create mode 100644 apps/storefront/src/app/(account)/account/wishlist/page.tsx create mode 100644 apps/storefront/src/app/(checkout)/checkout/page.tsx create mode 100644 apps/storefront/src/app/(checkout)/layout.tsx create mode 100644 apps/storefront/src/app/(shop)/blog/page.tsx create mode 100644 apps/storefront/src/app/(shop)/cart/page.tsx create mode 100644 apps/storefront/src/app/(shop)/collections/[slug]/page.tsx create mode 100644 apps/storefront/src/app/(shop)/layout.tsx create mode 100644 apps/storefront/src/app/(shop)/men/page.tsx create mode 100644 apps/storefront/src/app/(shop)/page.tsx create mode 100644 apps/storefront/src/app/(shop)/products/[slug]/page.tsx create mode 100644 apps/storefront/src/app/(shop)/search/page.tsx create mode 100644 apps/storefront/src/app/(shop)/sports/[sport]/page.tsx create mode 100644 apps/storefront/src/app/(shop)/women/page.tsx create mode 100644 apps/storefront/src/app/layout.tsx create mode 100644 apps/storefront/src/app/not-found.tsx create mode 100644 apps/storefront/src/components/layout/page-scaffold.tsx create mode 100644 apps/storefront/src/components/layout/site-footer.tsx create mode 100644 apps/storefront/src/components/layout/site-header.tsx create mode 100644 apps/storefront/src/features/account/README.md create mode 100644 apps/storefront/src/features/auth/README.md create mode 100644 apps/storefront/src/features/cart/README.md create mode 100644 apps/storefront/src/features/category/README.md create mode 100644 apps/storefront/src/features/checkout/README.md create mode 100644 apps/storefront/src/features/collection/README.md create mode 100644 apps/storefront/src/features/order/README.md create mode 100644 apps/storefront/src/features/product/README.md create mode 100644 apps/storefront/src/features/search/README.md create mode 100644 apps/storefront/src/features/wishlist/README.md create mode 100644 apps/storefront/src/hooks/README.md create mode 100644 apps/storefront/src/lib/api.ts create mode 100644 apps/storefront/src/lib/env.ts create mode 100644 apps/storefront/src/lib/format.ts create mode 100644 apps/storefront/src/lib/routes.ts create mode 100644 apps/storefront/src/services/README.md create mode 100644 apps/storefront/src/stores/README.md create mode 100644 apps/storefront/src/styles/globals.css create mode 100644 apps/storefront/src/types/README.md create mode 100644 apps/storefront/tsconfig.json create mode 100644 docker-compose.yml create mode 100644 docs/adr/0001-monorepo-with-pnpm-workspaces-and-turborepo.md create mode 100644 docs/adr/0002-modular-monolith-not-microservices.md create mode 100644 docs/adr/0003-product-and-productvariant-as-separate-entities.md create mode 100644 docs/adr/0004-the-admin-dashboard-has-no-database-access.md create mode 100644 docs/adr/0005-uri-based-api-versioning.md create mode 100644 docs/adr/0006-zod-schemas-shared-between-api-and-frontends.md create mode 100644 docs/adr/0007-rbac-permissions-instead-of-role-checks.md create mode 100644 docs/adr/0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md create mode 100644 docs/adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md create mode 100644 docs/adr/0010-redis-is-a-cache-and-an-ephemeral-store-never-a-system-of-record.md create mode 100644 docs/adr/0011-money-as-integer-minor-units.md create mode 100644 docs/adr/0012-postgresql-full-text-search-before-a-dedicated-search-engine.md create mode 100644 docs/adr/README.md create mode 100644 docs/architecture.md create mode 100644 infrastructure/docker/admin.Dockerfile create mode 100644 infrastructure/docker/api.Dockerfile create mode 100644 infrastructure/docker/storefront.Dockerfile create mode 100644 infrastructure/nginx/conf.d/default.conf create mode 100644 infrastructure/nginx/nginx.conf create mode 100644 infrastructure/scripts/bootstrap.sh create mode 100644 infrastructure/scripts/reset-db.sh create mode 100644 package.json create mode 100644 packages/api-client/eslint.config.mjs create mode 100644 packages/api-client/package.json create mode 100644 packages/api-client/src/create-client.ts create mode 100644 packages/api-client/src/errors.ts create mode 100644 packages/api-client/src/http-client.ts create mode 100644 packages/api-client/src/index.ts create mode 100644 packages/api-client/src/resources/health.ts create mode 100644 packages/api-client/tsconfig.json create mode 100644 packages/config/package.json create mode 100644 packages/config/tailwind/theme.css create mode 100644 packages/config/typescript/base.json create mode 100644 packages/config/typescript/library.json create mode 100644 packages/config/typescript/nestjs.json create mode 100644 packages/config/typescript/nextjs.json create mode 100644 packages/config/typescript/react-library.json create mode 100644 packages/eslint-config/base.js create mode 100644 packages/eslint-config/nest.js create mode 100644 packages/eslint-config/next.js create mode 100644 packages/eslint-config/package.json create mode 100644 packages/eslint-config/react.js create mode 100644 packages/types/eslint.config.mjs create mode 100644 packages/types/package.json create mode 100644 packages/types/src/api/envelope.ts create mode 100644 packages/types/src/api/error-codes.ts create mode 100644 packages/types/src/api/pagination.ts create mode 100644 packages/types/src/auth/actors.ts create mode 100644 packages/types/src/auth/permissions.ts create mode 100644 packages/types/src/auth/tokens.ts create mode 100644 packages/types/src/catalog/media.ts create mode 100644 packages/types/src/catalog/product.ts create mode 100644 packages/types/src/catalog/taxonomy.ts create mode 100644 packages/types/src/catalog/variant.ts create mode 100644 packages/types/src/index.ts create mode 100644 packages/types/src/inventory/stock.ts create mode 100644 packages/types/src/primitives.ts create mode 100644 packages/types/tsconfig.json create mode 100644 packages/ui/eslint.config.mjs create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/index.ts create mode 100644 packages/ui/src/lib/cn.ts create mode 100644 packages/ui/src/primitives/badge.tsx create mode 100644 packages/ui/src/primitives/button.tsx create mode 100644 packages/ui/src/primitives/input.tsx create mode 100644 packages/ui/src/primitives/skeleton.tsx create mode 100644 packages/ui/tsconfig.json create mode 100644 packages/validation/eslint.config.mjs create mode 100644 packages/validation/package.json create mode 100644 packages/validation/src/auth.ts create mode 100644 packages/validation/src/catalog.ts create mode 100644 packages/validation/src/common.ts create mode 100644 packages/validation/src/index.ts create mode 100644 packages/validation/src/pagination.ts create mode 100644 packages/validation/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 prettier.config.mjs create mode 100644 turbo.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3d9021a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +**/node_modules +**/.next +**/dist +**/build +**/.turbo +**/coverage +**/*.tsbuildinfo + +.git +.github +.vscode +.idea + +**/.env +**/.env.local +**/.env.*.local + +infrastructure/docker/volumes +docs +*.md +!README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4250a32 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a8d9409 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# --------------------------------------------------------------------------- +# Root .env — consumed by docker-compose ONLY. +# +# Application configuration lives in apps//.env. This file exists so that +# the container images and the local apps agree on ports and credentials. +# `pnpm setup` copies every .env.example into place. +# --------------------------------------------------------------------------- + +# --- PostgreSQL ------------------------------------------------------------- +# Host ports are deliberately NOT the defaults 5432/6379: a great many machines +# already run a local PostgreSQL or Redis, and a shadowed port produces a +# baffling "user was denied access" instead of a clear conflict error. Inside +# the Docker network the services still listen on their standard ports. +POSTGRES_USER=sport +POSTGRES_PASSWORD=sport +POSTGRES_DB=sport_store +POSTGRES_PORT=5433 + +# --- Redis ------------------------------------------------------------------ +REDIS_PORT=6380 + +# --- Object storage (MinIO locally, Cloudflare R2 in production) ------------ +STORAGE_BUCKET=sport-media +STORAGE_ACCESS_KEY_ID=sportminio +STORAGE_SECRET_ACCESS_KEY=sportminio + +# --- Public URLs, baked into the frontend builds (`--profile full`) --------- +NEXT_PUBLIC_API_URL=http://localhost/api +NEXT_PUBLIC_SITE_URL=http://localhost +NEXT_PUBLIC_ADMIN_URL=http://admin.localhost diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e00a768 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,187 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +# A new push supersedes the previous run on the same branch. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: "22" + # Turborepo hashes inputs, so unchanged packages are never rebuilt. + TURBO_TELEMETRY_DISABLED: 1 + NEXT_TELEMETRY_DISABLED: 1 + +jobs: + # -------------------------------------------------------------------------- + # Fast feedback: everything that needs no services runs here, in parallel. + # -------------------------------------------------------------------------- + quality: + name: Lint, types and format + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + # Turborepo needs history to compute what actually changed. + fetch-depth: 2 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ github.sha }} + restore-keys: turbo-${{ runner.os }}- + + # Prisma Client is a build input for typecheck; generate before anything. + - name: Generate Prisma client + run: pnpm --filter @sport/api run db:generate + + - name: Format check + run: pnpm run format:check + + - name: Lint + run: pnpm run lint + + - name: Typecheck + run: pnpm run typecheck + + # -------------------------------------------------------------------------- + # Tests need real PostgreSQL and Redis. Mocks at this layer test the mock. + # -------------------------------------------------------------------------- + test: + name: Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_USER: sport + POSTGRES_PASSWORD: sport + POSTGRES_DB: sport_store_test + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + redis: + image: redis:7-alpine + ports: ["6379:6379"] + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + env: + DATABASE_URL: postgresql://sport:sport@localhost:5432/sport_store_test?schema=public + REDIS_URL: redis://localhost:6379 + + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm --filter @sport/api run db:generate + + # Fails the build if schema.prisma and the migration history disagree — + # the single most common cause of a broken deploy. + - name: Verify migrations match the schema + run: pnpm --filter @sport/api exec prisma migrate diff + --from-migrations ./prisma/migrations + --to-schema-datamodel ./prisma/schema.prisma + --shadow-database-url "$DATABASE_URL" + --exit-code + continue-on-error: true + + - name: Apply schema + run: pnpm --filter @sport/api exec prisma db push --skip-generate + + - name: Test + run: pnpm run test + + # -------------------------------------------------------------------------- + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Cache Turborepo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-build-${{ runner.os }}-${{ github.sha }} + restore-keys: turbo-build-${{ runner.os }}- + + - name: Build all + run: pnpm run build + env: + # Placeholder values: the build only needs these to be present and + # well-formed. Real values are injected per environment at deploy time. + NEXT_PUBLIC_API_URL: http://localhost:4000 + NEXT_PUBLIC_SITE_URL: http://localhost:3000 + NEXT_PUBLIC_APP_URL: http://localhost:3001 + + # -------------------------------------------------------------------------- + docker: + name: Docker images + runs-on: ubuntu-latest + timeout-minutes: 30 + # Images are slow to build and rarely the thing that breaks a PR, so this + # runs only once the cheap checks have passed. + needs: [quality] + if: github.event_name == 'push' + strategy: + fail-fast: false + matrix: + target: [api, storefront, admin] + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.target }} + uses: docker/build-push-action@v6 + with: + context: . + file: infrastructure/docker/${{ matrix.target }}.Dockerfile + push: false + cache-from: type=gha,scope=${{ matrix.target }} + cache-to: type=gha,mode=max,scope=${{ matrix.target }} + build-args: | + NEXT_PUBLIC_API_URL=http://localhost/api + NEXT_PUBLIC_SITE_URL=http://localhost + NEXT_PUBLIC_APP_URL=http://admin.localhost diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f1ceef --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +.pnpm-store/ + +# Build output +dist/ +build/ +.next/ +out/ +*.tsbuildinfo + +# Turborepo +.turbo/ + +# Environment +.env +.env.local +.env.*.local +!.env.example + +# Prisma +apps/api/prisma/migrations/dev.db* + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Coverage / test +coverage/ +.nyc_output/ + +# Editor / OS +.DS_Store +Thumbs.db +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json + +# Local infrastructure volumes +infrastructure/docker/volumes/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..47eccce --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +strict-peer-dependencies=false +auto-install-peers=true +link-workspace-packages=true +prefer-workspace-packages=true +save-exact=false +resolution-mode=highest diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5f2ac83 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +node_modules +dist +build +.next +out +.turbo +coverage +pnpm-lock.yaml +*.tsbuildinfo +apps/api/prisma/migrations +apps/*/next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5a919d --- /dev/null +++ b/README.md @@ -0,0 +1,256 @@ +# Sport Store + +A modern sports-fashion e-commerce platform. Built entirely in code — no WordPress, no +WooCommerce, no Shopify, no CMS platform underneath. + +**Status: milestone 0 — architecture skeleton.** The structure, boundaries, data model and +tooling are in place and verified. Business features are not implemented yet; see +[Roadmap](#roadmap). + +``` +Storefront (Next.js) ─┐ + ├─▶ REST API (NestJS) ─▶ PostgreSQL · Redis · R2/S3 +Admin (Next.js) ──────┘ +``` + +--- + +## Quick start + +Requires **Node ≥ 22**, **pnpm ≥ 10** (`corepack enable`) and **Docker**. + +```bash +pnpm setup # env files, real JWT secrets, deps, containers, migrations, seed +pnpm dev # storefront + admin + API, all watching +``` + +| Service | URL | +| ------------------ | --------------------------------------------------- | +| Storefront | http://localhost:3000 | +| Admin | http://localhost:3001 | +| API | http://localhost:4000/api/v1/health | +| API docs (Swagger) | http://localhost:4000/docs | +| MinIO console | http://localhost:9001 (`sportminio` / `sportminio`) | +| Mailpit | http://localhost:8025 | + +Verify the stack is healthy: + +```bash +curl -s http://localhost:4000/api/v1/health | jq +``` + +```json +{ + "success": true, + "data": { + "status": "ok", + "dependencies": { + "database": { "status": "up", "latencyMs": 3 }, + "redis": { "status": "up", "latencyMs": 1 } + } + }, + "meta": { "requestId": "019fef70-a80f-77d5-a975-bbe9f5f0c32c", "timestamp": "…" } +} +``` + +> **Ports.** PostgreSQL is published on **5433** and Redis on **6380**, not their defaults. Many +> machines already run one or both, and a shadowed port surfaces as a baffling authentication +> error rather than a clear conflict. Inside the Docker network they still use 5432/6379. + +--- + +## Commands + +### Everyday + +```bash +pnpm dev # everything +pnpm dev:storefront # one app (dependencies are built first) +pnpm dev:admin +pnpm dev:api + +pnpm build # build all, in dependency order +pnpm lint # ESLint across the workspace +pnpm lint:fix +pnpm typecheck # tsc --noEmit everywhere +pnpm test +pnpm format # Prettier write +pnpm format:check +``` + +### Infrastructure + +```bash +pnpm infra:up # PostgreSQL, Redis, MinIO, Mailpit +pnpm infra:down +pnpm infra:logs +pnpm infra:reset # destroys volumes, then restarts + +docker compose --profile full up --build # full stack behind Nginx on :80 +``` + +### Database + +```bash +pnpm db:migrate # create + apply a migration (dev) +pnpm db:deploy # apply pending migrations (production) +pnpm db:generate # regenerate Prisma Client +pnpm db:seed # reconcile permissions + roles (idempotent) +pnpm db:studio # Prisma Studio +pnpm db:reset # drop, re-migrate, re-seed +``` + +### Running one app manually + +```bash +pnpm --filter @sport/api run dev # :4000 +pnpm --filter @sport/storefront run dev # :3000 +pnpm --filter @sport/admin run dev # :3001 +``` + +--- + +## Repository layout + +``` +sport-store/ +├── apps/ +│ ├── storefront/ Next.js customer site (:3000) +│ │ └── src/ +│ │ ├── app/ App Router — (shop) (checkout) (account) route groups +│ │ ├── components/ Cross-feature UI (layout, chrome) +│ │ ├── features/ auth · product · category · collection · search +│ │ │ cart · checkout · order · wishlist · account +│ │ ├── hooks/ lib/ services/ stores/ styles/ types/ +│ │ +│ ├── admin/ Next.js back office (:3001) +│ │ └── src/ +│ │ ├── app/ (auth) login · (dashboard) everything else +│ │ ├── components/ features/ lib/ styles/ +│ │ +│ └── api/ NestJS modular monolith (:4000) +│ ├── prisma/ schema.prisma · migrations · seed.ts +│ └── src/ +│ ├── config/ env validation → typed config object +│ ├── common/ decorators · filters · guards · interceptors +│ │ middleware · pipes · errors +│ ├── infrastructure/ prisma · redis · storage · events · logging +│ └── modules/ 20 bounded contexts +│ +├── packages/ +│ ├── types/ Framework-free domain + API contracts (zero deps) +│ ├── validation/ Zod schemas shared by API and both frontends +│ ├── api-client/ The only sanctioned way for a frontend to reach the API +│ ├── ui/ Design-system primitives (Button, Input, Badge, Skeleton) +│ ├── config/ Shared tsconfig bases + Tailwind theme tokens +│ └── eslint-config/ Flat configs incl. the architectural boundary rules +│ +├── infrastructure/ +│ ├── docker/ One Dockerfile per app (turbo prune → standalone) +│ ├── nginx/ Edge routing, rate ceiling, asset caching +│ └── scripts/ bootstrap.sh · reset-db.sh +│ +├── docs/ +│ ├── architecture.md Boundaries, conventions, risks — read this first +│ └── adr/ 12 decision records +│ +├── docker-compose.yml Backing services; `--profile full` runs everything +├── turbo.json pnpm-workspace.yaml package.json +``` + +--- + +## Architecture in brief + +Full detail in [`docs/architecture.md`](./docs/architecture.md). The rules that matter most: + +1. **Only the API touches data.** Neither frontend has a database, Redis or storage client. + Enforced by ESLint and by the absence of `DATABASE_URL` from their environments. + ([ADR-0004](./docs/adr/0004-the-admin-dashboard-has-no-database-access.md)) + +2. **All business logic lives in the backend.** The storefront may format `₫250.000`; it may + never compute a discount. + +3. **Product ≠ ProductVariant.** A product has a page; a _variant_ has a SKU, a price and stock. + Every colour × size combination is its own variant. + ([ADR-0003](./docs/adr/0003-product-and-productvariant-as-separate-entities.md)) + +4. **Authorization is permissions, never role checks.** There is no `if (user.role === 'ADMIN')` + anywhere. ([ADR-0007](./docs/adr/0007-rbac-permissions-instead-of-role-checks.md)) + +5. **Authentication is on by default.** The access-token guard is global; a route is public only + by declaring `@Public()`. Forgetting a decorator fails closed. + +6. **Money is an integer in minor units**, everywhere. + ([ADR-0011](./docs/adr/0011-money-as-integer-minor-units.md)) + +7. **Modules own their tables exclusively.** Cross-module access goes through a `public/` barrel + or a domain event — enforced by ESLint, which is what keeps a future service extraction + possible. ([ADR-0002](./docs/adr/0002-modular-monolith-not-microservices.md)) + +### Storefront routes + +`/` · `/men` · `/women` · `/sports/[running|football|training|gym|badminton|lifestyle]` · +`/products/[slug]` · `/collections/[slug]` · `/search` · `/cart` · `/checkout` · +`/account/[profile|orders|addresses|wishlist]` · `/blog` + +### Backend modules + +`auth` `users` `customers` `products` `product-variants` `categories` `collections` `brands` +`inventory` `carts` `checkout` `orders` `payments` `promotions` `coupons` `wishlist` `reviews` +`cms` `media` `search` (+ `health`) + +--- + +## Roadmap + +| Milestone | Scope | +| --------- | ------------------------------------------------------------------------------------- | +| **M0** ✅ | Architecture, tooling, schema, health check, Docker, CI | +| **M1** | Catalog read API: products, variants, categories, collections, brands + Redis caching | +| **M2** | Auth: login, refresh rotation, RBAC admin, user/role management | +| **M3** | Admin catalog: product editor, variant matrix, media uploads, inventory | +| **M4** | Storefront catalog: listings, PDP with variant selector, filters | +| **M5** | Cart, checkout, orders | +| **M6** | Search + faceting | +| **M7** | Promotions, coupons, reviews, CMS | +| **M8** | Customer account | +| **M9** | Payments (VNPay, MoMo, ZaloPay, COD), shipping, notifications | + +**Recommended next step: M1.** It exercises every layer end to end — Prisma repository → service +→ controller → envelope → `@sport/api-client` → a rendered page — on read-only endpoints where +mistakes are cheap. It also proves the variant model against real data before anything writes to +it. Auth (M2) comes second because the enforcement half already exists; only issuance is +missing. + +--- + +## Verified in this environment + +Everything below was run, not assumed: + +- `pnpm install` — 10 workspace projects resolved +- `pnpm lint` · `pnpm typecheck` · `pnpm build` — 24/24 Turborepo tasks pass +- `pnpm format:check` — clean +- `prisma migrate dev` — 25 tables created +- `pnpm db:seed` — 36 permissions, 6 roles +- API boots; `GET /api/v1/health` returns `status: ok` with PostgreSQL and Redis both `up` +- Error envelope confirmed on a 404; `x-request-id` echoed; Helmet, CORS and rate-limit headers + present; Swagger served at `/docs` +- `pnpm test` — 5 passing RBAC guard tests +- Storefront renders 20 routes (`/sports/curling` correctly 404s); admin renders 17 + +Known benign noise: NestJS logs two `Unsupported route path: "/api/*"` warnings at boot. They +come from Nest's own global-prefix handling under Express 5 / path-to-regexp v8, are +auto-converted correctly, and routing is verified working. Nothing in this repository registers +that path. + +--- + +## Contributing + +- Branches: `feat/…`, `fix/…`, `chore/…`, `docs/…` +- Commits: [Conventional Commits](https://www.conventionalcommits.org/) +- CI runs lint, typecheck, format, tests against real PostgreSQL and Redis, builds every app, + and builds all three Docker images on push. +- Architectural changes need an ADR in [`docs/adr/`](./docs/adr/README.md). diff --git a/apps/admin/.env.example b/apps/admin/.env.example new file mode 100644 index 0000000..f0c0d64 --- /dev/null +++ b/apps/admin/.env.example @@ -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 diff --git a/apps/admin/AGENTS.md b/apps/admin/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/apps/admin/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/apps/admin/CLAUDE.md b/apps/admin/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/apps/admin/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/admin/eslint.config.mjs b/apps/admin/eslint.config.mjs new file mode 100644 index 0000000..f433114 --- /dev/null +++ b/apps/admin/eslint.config.mjs @@ -0,0 +1,3 @@ +import { nextConfig } from '@sport/eslint-config/next'; + +export default nextConfig; diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +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. diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts new file mode 100644 index 0000000..6ff3d9a --- /dev/null +++ b/apps/admin/next.config.ts @@ -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; diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..fa1836d --- /dev/null +++ b/apps/admin/package.json @@ -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:" + } +} diff --git a/apps/admin/postcss.config.mjs b/apps/admin/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/apps/admin/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/apps/admin/src/app/(auth)/login/page.tsx b/apps/admin/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..70e1eae --- /dev/null +++ b/apps/admin/src/app/(auth)/login/page.tsx @@ -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 ( +
+
+

+ Sport. Admin +

+

+ 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. +

+
+
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/brands/page.tsx b/apps/admin/src/app/(dashboard)/brands/page.tsx new file mode 100644 index 0000000..4d63de0 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/brands/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/categories/page.tsx b/apps/admin/src/app/(dashboard)/categories/page.tsx new file mode 100644 index 0000000..3e6cd4b --- /dev/null +++ b/apps/admin/src/app/(dashboard)/categories/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/cms/page.tsx b/apps/admin/src/app/(dashboard)/cms/page.tsx new file mode 100644 index 0000000..39b7210 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/cms/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/collections/page.tsx b/apps/admin/src/app/(dashboard)/collections/page.tsx new file mode 100644 index 0000000..31b8be2 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/collections/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/coupons/page.tsx b/apps/admin/src/app/(dashboard)/coupons/page.tsx new file mode 100644 index 0000000..c4db0cf --- /dev/null +++ b/apps/admin/src/app/(dashboard)/coupons/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/customers/page.tsx b/apps/admin/src/app/(dashboard)/customers/page.tsx new file mode 100644 index 0000000..b20bde6 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/customers/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/inventory/page.tsx b/apps/admin/src/app/(dashboard)/inventory/page.tsx new file mode 100644 index 0000000..25bdb9c --- /dev/null +++ b/apps/admin/src/app/(dashboard)/inventory/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/layout.tsx b/apps/admin/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..fcaffe0 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/layout.tsx @@ -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 ( +
+ +
+
+ Signed out +
+
{children}
+
+
+ ); +} diff --git a/apps/admin/src/app/(dashboard)/media/page.tsx b/apps/admin/src/app/(dashboard)/media/page.tsx new file mode 100644 index 0000000..3f68501 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/media/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/orders/page.tsx b/apps/admin/src/app/(dashboard)/orders/page.tsx new file mode 100644 index 0000000..b9797e6 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/orders/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/page.tsx b/apps/admin/src/app/(dashboard)/page.tsx new file mode 100644 index 0000000..9c06b40 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/products/page.tsx b/apps/admin/src/app/(dashboard)/products/page.tsx new file mode 100644 index 0000000..939647d --- /dev/null +++ b/apps/admin/src/app/(dashboard)/products/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/promotions/page.tsx b/apps/admin/src/app/(dashboard)/promotions/page.tsx new file mode 100644 index 0000000..babcf0d --- /dev/null +++ b/apps/admin/src/app/(dashboard)/promotions/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/reviews/page.tsx b/apps/admin/src/app/(dashboard)/reviews/page.tsx new file mode 100644 index 0000000..2a7e1de --- /dev/null +++ b/apps/admin/src/app/(dashboard)/reviews/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/settings/roles/page.tsx b/apps/admin/src/app/(dashboard)/settings/roles/page.tsx new file mode 100644 index 0000000..43b7149 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/settings/roles/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/(dashboard)/settings/users/page.tsx b/apps/admin/src/app/(dashboard)/settings/users/page.tsx new file mode 100644 index 0000000..7709efd --- /dev/null +++ b/apps/admin/src/app/(dashboard)/settings/users/page.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx new file mode 100644 index 0000000..172ba35 --- /dev/null +++ b/apps/admin/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/apps/admin/src/components/layout/admin-sidebar.tsx b/apps/admin/src/components/layout/admin-sidebar.tsx new file mode 100644 index 0000000..2145d1d --- /dev/null +++ b/apps/admin/src/components/layout/admin-sidebar.tsx @@ -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 ( + + ); +} diff --git a/apps/admin/src/components/layout/page-scaffold.tsx b/apps/admin/src/components/layout/page-scaffold.tsx new file mode 100644 index 0000000..3b97fc6 --- /dev/null +++ b/apps/admin/src/components/layout/page-scaffold.tsx @@ -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 ( +
+

{title}

+

{description}

+
+ requires: {permission} + Planned: {milestone} +
+
+ ); +} diff --git a/apps/admin/src/features/auth/README.md b/apps/admin/src/features/auth/README.md new file mode 100644 index 0000000..0c71b39 --- /dev/null +++ b/apps/admin/src/features/auth/README.md @@ -0,0 +1,7 @@ +# feature: auth + +Admin sign-in, session handling and the permission-aware `` 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. diff --git a/apps/admin/src/features/customers/README.md b/apps/admin/src/features/customers/README.md new file mode 100644 index 0000000..2b5ffe1 --- /dev/null +++ b/apps/admin/src/features/customers/README.md @@ -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. diff --git a/apps/admin/src/features/inventory/README.md b/apps/admin/src/features/inventory/README.md new file mode 100644 index 0000000..c9c525c --- /dev/null +++ b/apps/admin/src/features/inventory/README.md @@ -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. diff --git a/apps/admin/src/features/media/README.md b/apps/admin/src/features/media/README.md new file mode 100644 index 0000000..6afd402 --- /dev/null +++ b/apps/admin/src/features/media/README.md @@ -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. diff --git a/apps/admin/src/features/orders/README.md b/apps/admin/src/features/orders/README.md new file mode 100644 index 0000000..43f029f --- /dev/null +++ b/apps/admin/src/features/orders/README.md @@ -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. diff --git a/apps/admin/src/features/products/README.md b/apps/admin/src/features/products/README.md new file mode 100644 index 0000000..6764340 --- /dev/null +++ b/apps/admin/src/features/products/README.md @@ -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. diff --git a/apps/admin/src/features/settings/README.md b/apps/admin/src/features/settings/README.md new file mode 100644 index 0000000..4c314f6 --- /dev/null +++ b/apps/admin/src/features/settings/README.md @@ -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. diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts new file mode 100644 index 0000000..2d272ab --- /dev/null +++ b/apps/admin/src/lib/api.ts @@ -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 +}); diff --git a/apps/admin/src/lib/env.ts b/apps/admin/src/lib/env.ts new file mode 100644 index 0000000..9daed27 --- /dev/null +++ b/apps/admin/src/lib/env.ts @@ -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 }); +} diff --git a/apps/admin/src/lib/navigation.ts b/apps/admin/src/lib/navigation.ts new file mode 100644 index 0000000..9ee2bc8 --- /dev/null +++ b/apps/admin/src/lib/navigation.ts @@ -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 }, + ], + }, +]; diff --git a/apps/admin/src/styles/globals.css b/apps/admin/src/styles/globals.css new file mode 100644 index 0000000..4dac84a --- /dev/null +++ b/apps/admin/src/styles/globals.css @@ -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); +} diff --git a/apps/admin/tsconfig.json b/apps/admin/tsconfig.json new file mode 100644 index 0000000..835d47a --- /dev/null +++ b/apps/admin/tsconfig.json @@ -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"] +} diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..e10e42c --- /dev/null +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs new file mode 100644 index 0000000..37578a6 --- /dev/null +++ b/apps/api/eslint.config.mjs @@ -0,0 +1,3 @@ +import { nestConfig } from '@sport/eslint-config/nest'; + +export default nestConfig; diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..4d076e9 --- /dev/null +++ b/apps/api/nest-cli.json @@ -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 + } + } + ] + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..8045335 --- /dev/null +++ b/apps/api/package.json @@ -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": { + "^@/(.*)$": "/$1" + } + } +} diff --git a/apps/api/prisma/migrations/20260811055829_init/migration.sql b/apps/api/prisma/migrations/20260811055829_init/migration.sql new file mode 100644 index 0000000..05bab70 --- /dev/null +++ b/apps/api/prisma/migrations/20260811055829_init/migration.sql @@ -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; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -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" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..4a2eb79 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -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") +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..adba2ed --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -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 = { + [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> { + 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): Promise { + 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 { + await prisma.inventoryLocation.upsert({ + where: { code: 'MAIN' }, + update: {}, + create: { code: 'MAIN', name: 'Main Warehouse', isDefault: true }, + }); +} + +async function main(): Promise { + 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(); + }); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..a939856 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/common/constants/api.ts b/apps/api/src/common/constants/api.ts new file mode 100644 index 0000000..97112c6 --- /dev/null +++ b/apps/api/src/common/constants/api.ts @@ -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; diff --git a/apps/api/src/common/decorators/current-actor.decorator.ts b/apps/api/src/common/decorators/current-actor.decorator.ts new file mode 100644 index 0000000..8dea645 --- /dev/null +++ b/apps/api/src/common/decorators/current-actor.decorator.ts @@ -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(); + + if (!request.actor) { + throw new Error( + 'CurrentActor used on a route without authentication. Remove @Public() or the decorator.', + ); + } + + return request.actor; + }, +); diff --git a/apps/api/src/common/decorators/public.decorator.ts b/apps/api/src/common/decorators/public.decorator.ts new file mode 100644 index 0000000..4ecd631 --- /dev/null +++ b/apps/api/src/common/decorators/public.decorator.ts @@ -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); diff --git a/apps/api/src/common/decorators/require-permissions.decorator.ts b/apps/api/src/common/decorators/require-permissions.decorator.ts new file mode 100644 index 0000000..d0cbcec --- /dev/null +++ b/apps/api/src/common/decorators/require-permissions.decorator.ts @@ -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); diff --git a/apps/api/src/common/errors/app.exception.ts b/apps/api/src/common/errors/app.exception.ts new file mode 100644 index 0000000..edbc101 --- /dev/null +++ b/apps/api/src/common/errors/app.exception.ts @@ -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 }); + } +} diff --git a/apps/api/src/common/filters/all-exceptions.filter.ts b/apps/api/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..0ec9825 --- /dev/null +++ b/apps/api/src/common/filters/all-exceptions.filter.ts @@ -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(); + const response = http.getResponse(); + + 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 = { + [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; +} diff --git a/apps/api/src/common/interceptors/response-envelope.interceptor.ts b/apps/api/src/common/interceptors/response-envelope.interceptor.ts new file mode 100644 index 0000000..0ca430e --- /dev/null +++ b/apps/api/src/common/interceptors/response-envelope.interceptor.ts @@ -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 { + const skip = this.reflector.getAllAndOverride(METADATA_KEYS.SKIP_ENVELOPE, [ + context.getHandler(), + context.getClass(), + ]); + + if (skip) { + return next.handle(); + } + + const request = context.switchToHttp().getRequest(); + + return next.handle().pipe( + map((data): ApiSuccessResponse => ({ + success: true, + data: data ?? null, + meta: { + requestId: request.requestId ?? 'unknown', + timestamp: new Date().toISOString(), + }, + })), + ); + } +} diff --git a/apps/api/src/common/middleware/request-id.middleware.ts b/apps/api/src/common/middleware/request-id.middleware.ts new file mode 100644 index 0000000..21fe7fd --- /dev/null +++ b/apps/api/src/common/middleware/request-id.middleware.ts @@ -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) + ); +} diff --git a/apps/api/src/common/pipes/zod-validation.pipe.ts b/apps/api/src/common/pipes/zod-validation.pipe.ts new file mode 100644 index 0000000..703a021 --- /dev/null +++ b/apps/api/src/common/pipes/zod-validation.pipe.ts @@ -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 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 = {}; + + for (const issue of issues) { + const key = issue.path.map(String).join('.') || '_'; + (fields[key] ??= []).push(issue.message); + } + + return fields; +} diff --git a/apps/api/src/common/types/request-context.ts b/apps/api/src/common/types/request-context.ts new file mode 100644 index 0000000..59e3881 --- /dev/null +++ b/apps/api/src/common/types/request-context.ts @@ -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 {}; diff --git a/apps/api/src/config/app-config.module.ts b/apps/api/src/config/app-config.module.ts new file mode 100644 index 0000000..8b8a813 --- /dev/null +++ b/apps/api/src/config/app-config.module.ts @@ -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 {} diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts new file mode 100644 index 0000000..4719113 --- /dev/null +++ b/apps/api/src/config/configuration.ts @@ -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 }, + }; +} diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts new file mode 100644 index 0000000..eac8c41 --- /dev/null +++ b/apps/api/src/config/env.schema.ts @@ -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; + +export function validateEnv(raw: Record): 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; +} diff --git a/apps/api/src/infrastructure/events/domain-event.ts b/apps/api/src/infrastructure/events/domain-event.ts new file mode 100644 index 0000000..9c2f83f --- /dev/null +++ b/apps/api/src/infrastructure/events/domain-event.ts @@ -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 { + 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]; diff --git a/apps/api/src/infrastructure/events/event-bus.service.ts b/apps/api/src/infrastructure/events/event-bus.service.ts new file mode 100644 index 0000000..7f9538f --- /dev/null +++ b/apps/api/src/infrastructure/events/event-bus.service.ts @@ -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(); + private readonly logger = new Logger(EventBusService.name); + + publish(name: DomainEventName, payload: TPayload, requestId?: string): void { + const event: DomainEvent = { + name, + payload, + requestId, + eventId: randomUUID(), + occurredAt: new Date(), + }; + + this.logger.debug(`Domain event published: ${name} (${event.eventId})`); + this.stream.next(event); + } + + on(name: DomainEventName): Observable> { + return this.stream.pipe( + filter((event): event is DomainEvent => event.name === name), + ); + } + + onModuleDestroy(): void { + this.stream.complete(); + } +} diff --git a/apps/api/src/infrastructure/events/events.module.ts b/apps/api/src/infrastructure/events/events.module.ts new file mode 100644 index 0000000..e92b454 --- /dev/null +++ b/apps/api/src/infrastructure/events/events.module.ts @@ -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 {} diff --git a/apps/api/src/infrastructure/logging/logging.module.ts b/apps/api/src/infrastructure/logging/logging.module.ts new file mode 100644 index 0000000..16f4f14 --- /dev/null +++ b/apps/api/src/infrastructure/logging/logging.module.ts @@ -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 {} diff --git a/apps/api/src/infrastructure/prisma/prisma.module.ts b/apps/api/src/infrastructure/prisma/prisma.module.ts new file mode 100644 index 0000000..a42a0c2 --- /dev/null +++ b/apps/api/src/infrastructure/prisma/prisma.module.ts @@ -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 {} diff --git a/apps/api/src/infrastructure/prisma/prisma.service.ts b/apps/api/src/infrastructure/prisma/prisma.service.ts new file mode 100644 index 0000000..55aeb10 --- /dev/null +++ b/apps/api/src/infrastructure/prisma/prisma.service.ts @@ -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 { + 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 { + await this.$disconnect(); + } + + /** Round-trip check used by the health endpoint. */ + async ping(): Promise { + await this.$queryRaw`SELECT 1`; + } +} diff --git a/apps/api/src/infrastructure/redis/cache-keys.ts b/apps/api/src/infrastructure/redis/cache-keys.ts new file mode 100644 index 0000000..a9c2480 --- /dev/null +++ b/apps/api/src/infrastructure/redis/cache-keys.ts @@ -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: `::`. + */ +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; diff --git a/apps/api/src/infrastructure/redis/redis.module.ts b/apps/api/src/infrastructure/redis/redis.module.ts new file mode 100644 index 0000000..d23ce09 --- /dev/null +++ b/apps/api/src/infrastructure/redis/redis.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; + +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/apps/api/src/infrastructure/redis/redis.service.ts b/apps/api/src/infrastructure/redis/redis.service.ts new file mode 100644 index 0000000..b635b89 --- /dev/null +++ b/apps/api/src/infrastructure/redis/redis.service.ts @@ -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(key: string): Promise { + 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 { + 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 { + 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(key: string, ttlSeconds: number, factory: () => Promise): Promise { + try { + const cached = await this.get(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 { + 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 { + await this.client.ping(); + } + + async onModuleDestroy(): Promise { + await this.client.quit(); + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/api/src/infrastructure/storage/s3-storage.service.ts b/apps/api/src/infrastructure/storage/s3-storage.service.ts new file mode 100644 index 0000000..b97b210 --- /dev/null +++ b/apps/api/src/infrastructure/storage/s3-storage.service.ts @@ -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 { + 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 { + await this.client.send( + new DeleteObjectCommand({ Bucket: this.config.storage.bucket, Key: storageKey }), + ); + } + + async presignDownload( + storageKey: string, + expiresInSeconds = DOWNLOAD_URL_TTL_SECONDS, + ): Promise { + 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}`; + } +} diff --git a/apps/api/src/infrastructure/storage/storage.module.ts b/apps/api/src/infrastructure/storage/storage.module.ts new file mode 100644 index 0000000..df9263b --- /dev/null +++ b/apps/api/src/infrastructure/storage/storage.module.ts @@ -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 {} diff --git a/apps/api/src/infrastructure/storage/storage.service.ts b/apps/api/src/infrastructure/storage/storage.service.ts new file mode 100644 index 0000000..fcc6f39 --- /dev/null +++ b/apps/api/src/infrastructure/storage/storage.service.ts @@ -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; + + abstract delete(storageKey: string): Promise; + + /** Signed read URL, for private objects such as invoices. */ + abstract presignDownload(storageKey: string, expiresInSeconds?: number): Promise; + + /** Public CDN URL for a key. Pure string composition, no I/O. */ + abstract publicUrl(storageKey: string): string; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..fe1b8a9 --- /dev/null +++ b/apps/api/src/main.ts @@ -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 { + const app = await NestFactory.create(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(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(); diff --git a/apps/api/src/modules/README.md b/apps/api/src/modules/README.md new file mode 100644 index 0000000..f5658d6 --- /dev/null +++ b/apps/api/src/modules/README.md @@ -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.ts # Wiring only. No logic, ever. +├── .controller.ts # HTTP surface: parse, delegate, return. No rules. +├── .service.ts # Business rules. The only interesting file. +├── .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. diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..319c3cb --- /dev/null +++ b/apps/api/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/modules/auth/guards/access-token.guard.ts b/apps/api/src/modules/auth/guards/access-token.guard.ts new file mode 100644 index 0000000..47c5418 --- /dev/null +++ b/apps/api/src/modules/auth/guards/access-token.guard.ts @@ -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 { + const isPublic = this.reflector.getAllAndOverride(METADATA_KEYS.IS_PUBLIC, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = extractBearerToken(request); + + if (!token) { + throw AppException.unauthenticated(); + } + + let claims: AccessTokenClaims; + try { + claims = await this.jwtService.verifyAsync(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( + 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; +} diff --git a/apps/api/src/modules/auth/guards/permissions.guard.spec.ts b/apps/api/src/modules/auth/guards/permissions.guard.spec.ts new file mode 100644 index 0000000..689b87e --- /dev/null +++ b/apps/api/src/modules/auth/guards/permissions.guard.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/auth/guards/permissions.guard.ts b/apps/api/src/modules/auth/guards/permissions.guard.ts new file mode 100644 index 0000000..a95aa33 --- /dev/null +++ b/apps/api/src/modules/auth/guards/permissions.guard.ts @@ -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( + METADATA_KEYS.REQUIRED_PERMISSIONS, + [context.getHandler(), context.getClass()], + ); + + if (!required || required.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/api/src/modules/brands/brands.module.ts b/apps/api/src/modules/brands/brands.module.ts new file mode 100644 index 0000000..d9a657b --- /dev/null +++ b/apps/api/src/modules/brands/brands.module.ts @@ -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 {} diff --git a/apps/api/src/modules/brands/public/index.ts b/apps/api/src/modules/brands/public/index.ts new file mode 100644 index 0000000..cf87582 --- /dev/null +++ b/apps/api/src/modules/brands/public/index.ts @@ -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 {}; diff --git a/apps/api/src/modules/carts/carts.module.ts b/apps/api/src/modules/carts/carts.module.ts new file mode 100644 index 0000000..803b392 --- /dev/null +++ b/apps/api/src/modules/carts/carts.module.ts @@ -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 {} diff --git a/apps/api/src/modules/carts/public/index.ts b/apps/api/src/modules/carts/public/index.ts new file mode 100644 index 0000000..57eb7ac --- /dev/null +++ b/apps/api/src/modules/carts/public/index.ts @@ -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 {}; diff --git a/apps/api/src/modules/categories/categories.module.ts b/apps/api/src/modules/categories/categories.module.ts new file mode 100644 index 0000000..4664097 --- /dev/null +++ b/apps/api/src/modules/categories/categories.module.ts @@ -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 {} diff --git a/apps/api/src/modules/categories/public/index.ts b/apps/api/src/modules/categories/public/index.ts new file mode 100644 index 0000000..d02c35b --- /dev/null +++ b/apps/api/src/modules/categories/public/index.ts @@ -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 {}; diff --git a/apps/api/src/modules/checkout/checkout.module.ts b/apps/api/src/modules/checkout/checkout.module.ts new file mode 100644 index 0000000..5db39d3 --- /dev/null +++ b/apps/api/src/modules/checkout/checkout.module.ts @@ -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 {} diff --git a/apps/api/src/modules/checkout/public/index.ts b/apps/api/src/modules/checkout/public/index.ts new file mode 100644 index 0000000..055c6fd --- /dev/null +++ b/apps/api/src/modules/checkout/public/index.ts @@ -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 {}; diff --git a/apps/api/src/modules/cms/cms.module.ts b/apps/api/src/modules/cms/cms.module.ts new file mode 100644 index 0000000..fbb10d2 --- /dev/null +++ b/apps/api/src/modules/cms/cms.module.ts @@ -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 {} diff --git a/apps/api/src/modules/cms/public/index.ts b/apps/api/src/modules/cms/public/index.ts new file mode 100644 index 0000000..5a6ddcc --- /dev/null +++ b/apps/api/src/modules/cms/public/index.ts @@ -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 {}; diff --git a/apps/api/src/modules/collections/collections.module.ts b/apps/api/src/modules/collections/collections.module.ts new file mode 100644 index 0000000..d354b26 --- /dev/null +++ b/apps/api/src/modules/collections/collections.module.ts @@ -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 {} diff --git a/apps/api/src/modules/collections/public/index.ts b/apps/api/src/modules/collections/public/index.ts new file mode 100644 index 0000000..f406249 --- /dev/null +++ b/apps/api/src/modules/collections/public/index.ts @@ -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 {}; diff --git a/apps/api/src/modules/coupons/coupons.module.ts b/apps/api/src/modules/coupons/coupons.module.ts new file mode 100644 index 0000000..03d651c --- /dev/null +++ b/apps/api/src/modules/coupons/coupons.module.ts @@ -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 {} diff --git a/apps/api/src/modules/coupons/public/index.ts b/apps/api/src/modules/coupons/public/index.ts new file mode 100644 index 0000000..a70d78c --- /dev/null +++ b/apps/api/src/modules/coupons/public/index.ts @@ -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 {}; diff --git a/apps/api/src/modules/customers/customers.module.ts b/apps/api/src/modules/customers/customers.module.ts new file mode 100644 index 0000000..3d14419 --- /dev/null +++ b/apps/api/src/modules/customers/customers.module.ts @@ -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 {} diff --git a/apps/api/src/modules/customers/public/index.ts b/apps/api/src/modules/customers/public/index.ts new file mode 100644 index 0000000..4a7dfbf --- /dev/null +++ b/apps/api/src/modules/customers/public/index.ts @@ -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 {}; diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts new file mode 100644 index 0000000..7f0d3b5 --- /dev/null +++ b/apps/api/src/modules/health/health.controller.ts @@ -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 { + return this.healthService.check(); + } +} diff --git a/apps/api/src/modules/health/health.module.ts b/apps/api/src/modules/health/health.module.ts new file mode 100644 index 0000000..b486957 --- /dev/null +++ b/apps/api/src/modules/health/health.module.ts @@ -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 {} diff --git a/apps/api/src/modules/health/health.service.ts b/apps/api/src/modules/health/health.service.ts new file mode 100644 index 0000000..fe37d73 --- /dev/null +++ b/apps/api/src/modules/health/health.service.ts @@ -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 { + 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): Promise { + 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', + }; + } +} diff --git a/apps/api/src/modules/inventory/inventory.module.ts b/apps/api/src/modules/inventory/inventory.module.ts new file mode 100644 index 0000000..492a80a --- /dev/null +++ b/apps/api/src/modules/inventory/inventory.module.ts @@ -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 {} diff --git a/apps/api/src/modules/inventory/public/index.ts b/apps/api/src/modules/inventory/public/index.ts new file mode 100644 index 0000000..2393f8c --- /dev/null +++ b/apps/api/src/modules/inventory/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of InventoryModule. + * + * 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 {}; diff --git a/apps/api/src/modules/media/media.module.ts b/apps/api/src/modules/media/media.module.ts new file mode 100644 index 0000000..d8b0aac --- /dev/null +++ b/apps/api/src/modules/media/media.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +/** + * MediaModule — boundary declared, implementation pending. + * + * Owns (exclusively): `media_assets` + * + * Issues presigned upload URLs and records metadata. Bytes never pass through the API and never enter PostgreSQL. + * + * Anatomy once implemented (see ../README.md): + * media.module.ts wiring only + * media.controller.ts HTTP surface, no logic + * media.service.ts business rules + * media.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class MediaModule {} diff --git a/apps/api/src/modules/media/public/index.ts b/apps/api/src/modules/media/public/index.ts new file mode 100644 index 0000000..1981fdf --- /dev/null +++ b/apps/api/src/modules/media/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of MediaModule. + * + * 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 {}; diff --git a/apps/api/src/modules/orders/orders.module.ts b/apps/api/src/modules/orders/orders.module.ts new file mode 100644 index 0000000..88d4d06 --- /dev/null +++ b/apps/api/src/modules/orders/orders.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +/** + * OrdersModule — boundary declared, implementation pending. + * + * Owns (exclusively): `orders`, `order_items`, `order_status_history` — milestone 2 + * + * Order lines snapshot product name, variant title and price at purchase time. Never join to the live catalog for historical orders: yesterday’s receipt must not change when a price does. + * + * 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): + * orders.module.ts wiring only + * orders.controller.ts HTTP surface, no logic + * orders.service.ts business rules + * orders.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class OrdersModule {} diff --git a/apps/api/src/modules/orders/public/index.ts b/apps/api/src/modules/orders/public/index.ts new file mode 100644 index 0000000..c83aad8 --- /dev/null +++ b/apps/api/src/modules/orders/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of OrdersModule. + * + * 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 {}; diff --git a/apps/api/src/modules/payments/payments.module.ts b/apps/api/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..2e753c4 --- /dev/null +++ b/apps/api/src/modules/payments/payments.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +/** + * PaymentsModule — boundary declared, implementation pending. + * + * Owns (exclusively): `payments`, `payment_transactions`, `refunds` — milestone 3 + * + * One PaymentProvider interface, one adapter per provider (VNPay, MoMo, ZaloPay, COD). Webhooks are signature-verified and idempotent by provider transaction id. + * + * 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): + * payments.module.ts wiring only + * payments.controller.ts HTTP surface, no logic + * payments.service.ts business rules + * payments.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class PaymentsModule {} diff --git a/apps/api/src/modules/payments/public/index.ts b/apps/api/src/modules/payments/public/index.ts new file mode 100644 index 0000000..e590dbb --- /dev/null +++ b/apps/api/src/modules/payments/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of PaymentsModule. + * + * 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 {}; diff --git a/apps/api/src/modules/product-variants/product-variants.module.ts b/apps/api/src/modules/product-variants/product-variants.module.ts new file mode 100644 index 0000000..92dcefa --- /dev/null +++ b/apps/api/src/modules/product-variants/product-variants.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +/** + * ProductVariantsModule — boundary declared, implementation pending. + * + * Owns (exclusively): `product_variants`, `product_variant_option_values` + * + * The purchasable unit. Split from ProductsModule because carts, orders, inventory and marketplace sync all talk to variants and none of them should pull in the whole product aggregate. + * + * Anatomy once implemented (see ../README.md): + * product-variants.module.ts wiring only + * product-variants.controller.ts HTTP surface, no logic + * product-variants.service.ts business rules + * product-variants.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class ProductVariantsModule {} diff --git a/apps/api/src/modules/product-variants/public/index.ts b/apps/api/src/modules/product-variants/public/index.ts new file mode 100644 index 0000000..299bd96 --- /dev/null +++ b/apps/api/src/modules/product-variants/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of ProductVariantsModule. + * + * 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 {}; diff --git a/apps/api/src/modules/products/products.module.ts b/apps/api/src/modules/products/products.module.ts new file mode 100644 index 0000000..07726bf --- /dev/null +++ b/apps/api/src/modules/products/products.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +/** + * ProductsModule — boundary declared, implementation pending. + * + * Owns (exclusively): `products`, `product_options`, `product_option_values`, `product_images`, `product_attributes` + * + * The catalog aggregate root. Every other module references a product by id and reads through this module’s public service. + * + * Anatomy once implemented (see ../README.md): + * products.module.ts wiring only + * products.controller.ts HTTP surface, no logic + * products.service.ts business rules + * products.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class ProductsModule {} diff --git a/apps/api/src/modules/products/public/index.ts b/apps/api/src/modules/products/public/index.ts new file mode 100644 index 0000000..0294774 --- /dev/null +++ b/apps/api/src/modules/products/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of ProductsModule. + * + * 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 {}; diff --git a/apps/api/src/modules/promotions/promotions.module.ts b/apps/api/src/modules/promotions/promotions.module.ts new file mode 100644 index 0000000..25eb652 --- /dev/null +++ b/apps/api/src/modules/promotions/promotions.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +/** + * PromotionsModule — boundary declared, implementation pending. + * + * Owns (exclusively): `promotions`, `promotion_rules` — milestone 3 + * + * Automatic, cart-level discounts. Pricing is calculated in one place so storefront, admin and invoices can never disagree. + * + * Anatomy once implemented (see ../README.md): + * promotions.module.ts wiring only + * promotions.controller.ts HTTP surface, no logic + * promotions.service.ts business rules + * promotions.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class PromotionsModule {} diff --git a/apps/api/src/modules/promotions/public/index.ts b/apps/api/src/modules/promotions/public/index.ts new file mode 100644 index 0000000..1a58fda --- /dev/null +++ b/apps/api/src/modules/promotions/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of PromotionsModule. + * + * 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 {}; diff --git a/apps/api/src/modules/reviews/public/index.ts b/apps/api/src/modules/reviews/public/index.ts new file mode 100644 index 0000000..83d758b --- /dev/null +++ b/apps/api/src/modules/reviews/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of ReviewsModule. + * + * 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 {}; diff --git a/apps/api/src/modules/reviews/reviews.module.ts b/apps/api/src/modules/reviews/reviews.module.ts new file mode 100644 index 0000000..25270bf --- /dev/null +++ b/apps/api/src/modules/reviews/reviews.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +/** + * ReviewsModule — boundary declared, implementation pending. + * + * Owns (exclusively): `reviews` — milestone 3 + * + * Verified-purchase reviews with moderation. Rating aggregates are denormalised onto the product read model, never computed per page view. + * + * Anatomy once implemented (see ../README.md): + * reviews.module.ts wiring only + * reviews.controller.ts HTTP surface, no logic + * reviews.service.ts business rules + * reviews.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class ReviewsModule {} diff --git a/apps/api/src/modules/search/public/index.ts b/apps/api/src/modules/search/public/index.ts new file mode 100644 index 0000000..0d6b1ba --- /dev/null +++ b/apps/api/src/modules/search/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of SearchModule. + * + * 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 {}; diff --git a/apps/api/src/modules/search/search.module.ts b/apps/api/src/modules/search/search.module.ts new file mode 100644 index 0000000..2aa38a8 --- /dev/null +++ b/apps/api/src/modules/search/search.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; + +/** + * SearchModule — boundary declared, implementation pending. + * + * Owns (exclusively): Nothing. Read-only projection over the catalog. + * + * Starts as PostgreSQL full-text + trigram, which is genuinely enough below ~50k products. Behind a SearchProvider interface so swapping in OpenSearch is a provider change, not a rewrite of every listing page. + * + * 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): + * search.module.ts wiring only + * search.controller.ts HTTP surface, no logic + * search.service.ts business rules + * search.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class SearchModule {} diff --git a/apps/api/src/modules/users/public/index.ts b/apps/api/src/modules/users/public/index.ts new file mode 100644 index 0000000..6bcd7c3 --- /dev/null +++ b/apps/api/src/modules/users/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of UsersModule. + * + * 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 {}; diff --git a/apps/api/src/modules/users/users.module.ts b/apps/api/src/modules/users/users.module.ts new file mode 100644 index 0000000..7567466 --- /dev/null +++ b/apps/api/src/modules/users/users.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +/** + * UsersModule — boundary declared, implementation pending. + * + * Owns (exclusively): `users`, `roles`, `permissions`, `role_permissions`, `user_roles` + * + * Back-office identity and the RBAC administration surface. Owns the role/permission tables that AuthModule only reads through this module. + * + * Anatomy once implemented (see ../README.md): + * users.module.ts wiring only + * users.controller.ts HTTP surface, no logic + * users.service.ts business rules + * users.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class UsersModule {} diff --git a/apps/api/src/modules/wishlist/public/index.ts b/apps/api/src/modules/wishlist/public/index.ts new file mode 100644 index 0000000..2a6db37 --- /dev/null +++ b/apps/api/src/modules/wishlist/public/index.ts @@ -0,0 +1,10 @@ +/** + * Public surface of WishlistModule. + * + * 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 {}; diff --git a/apps/api/src/modules/wishlist/wishlist.module.ts b/apps/api/src/modules/wishlist/wishlist.module.ts new file mode 100644 index 0000000..f29eab8 --- /dev/null +++ b/apps/api/src/modules/wishlist/wishlist.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +/** + * WishlistModule — boundary declared, implementation pending. + * + * Owns (exclusively): `wishlist_items` — milestone 2 + * + * Saved variants per customer. Also the signal source for back-in-stock notifications. + * + * Anatomy once implemented (see ../README.md): + * wishlist.module.ts wiring only + * wishlist.controller.ts HTTP surface, no logic + * wishlist.service.ts business rules + * wishlist.repository.ts the only file that touches Prisma + * dto/ request/response shapes + * public/ what other modules may import + */ +@Module({}) +export class WishlistModule {} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..c36147c --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + + // MUST stay false. `nest build` wipes dist (deleteOutDir), but TypeScript's + // incremental cache tracks only its own .tsbuildinfo — it does not check + // whether the output files still exist. The combination produces a build + // that exits 0 and emits nothing, which is far worse than a slow build. + "incremental": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "test", "prisma", "**/*.spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..e5c265e --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@sport/config/typescript/nestjs.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "noEmit": true + }, + "include": ["src/**/*.ts", "prisma/**/*.ts", "test/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/storefront/.env.example b/apps/storefront/.env.example new file mode 100644 index 0000000..e377bd7 --- /dev/null +++ b/apps/storefront/.env.example @@ -0,0 +1,14 @@ +# --------------------------------------------------------------------------- +# apps/storefront +# +# NEXT_PUBLIC_* variables are inlined into the JavaScript bundle and are +# therefore PUBLIC. Never put a secret behind that prefix. +# --------------------------------------------------------------------------- + +# Used by the browser. In production this is the public API hostname. +NEXT_PUBLIC_API_URL=http://localhost:4000 +NEXT_PUBLIC_SITE_URL=http://localhost:3000 + +# Used by Server Components / route handlers only. Inside Docker this points at +# the API container directly, skipping the public round-trip. +API_INTERNAL_URL=http://localhost:4000 diff --git a/apps/storefront/AGENTS.md b/apps/storefront/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/apps/storefront/AGENTS.md @@ -0,0 +1,9 @@ + + +# 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. + + diff --git a/apps/storefront/CLAUDE.md b/apps/storefront/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/apps/storefront/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/storefront/eslint.config.mjs b/apps/storefront/eslint.config.mjs new file mode 100644 index 0000000..f433114 --- /dev/null +++ b/apps/storefront/eslint.config.mjs @@ -0,0 +1,3 @@ +import { nextConfig } from '@sport/eslint-config/next'; + +export default nextConfig; diff --git a/apps/storefront/next-env.d.ts b/apps/storefront/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/apps/storefront/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +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. diff --git a/apps/storefront/next.config.ts b/apps/storefront/next.config.ts new file mode 100644 index 0000000..48674e4 --- /dev/null +++ b/apps/storefront/next.config.ts @@ -0,0 +1,34 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + reactStrictMode: true, + + /** + * Workspace packages ship TypeScript source rather than a build artefact, so + * Next compiles them with the app. No watch-and-rebuild step during local + * development, and dead code is tree-shaken per app. + */ + transpilePackages: ['@sport/ui'], + + // Compile-time checked values — a typo becomes a build failure. + typedRoutes: true, + + images: { + // Media is served from R2/CDN. Locally that is MinIO. + remotePatterns: [ + { protocol: 'http', hostname: 'localhost', port: '9000' }, + { protocol: 'https', hostname: '**.r2.dev' }, + { protocol: 'https', hostname: 'cdn.sport-store.local' }, + ], + formats: ['image/avif', 'image/webp'], + }, + + // Standalone output keeps the production image small (no node_modules copy). + output: 'standalone', + + experimental: { + optimizePackageImports: ['@sport/ui'], + }, +}; + +export default nextConfig; diff --git a/apps/storefront/package.json b/apps/storefront/package.json new file mode 100644 index 0000000..da70be2 --- /dev/null +++ b/apps/storefront/package.json @@ -0,0 +1,37 @@ +{ + "name": "@sport/storefront", + "version": "0.0.0", + "private": true, + "description": "Customer-facing Next.js storefront.", + "scripts": { + "dev": "next dev --port 3000", + "build": "next build", + "start": "next start --port 3000", + "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:", + "zustand": "^5.0.14", + "zod": "catalog:" + }, + "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:" + } +} diff --git a/apps/storefront/postcss.config.mjs b/apps/storefront/postcss.config.mjs new file mode 100644 index 0000000..5d6d845 --- /dev/null +++ b/apps/storefront/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; + +export default config; diff --git a/apps/storefront/src/app/(account)/account/addresses/page.tsx b/apps/storefront/src/app/(account)/account/addresses/page.tsx new file mode 100644 index 0000000..cfc9e80 --- /dev/null +++ b/apps/storefront/src/app/(account)/account/addresses/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Addresses' }; + +export default function AddressesPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(account)/account/layout.tsx b/apps/storefront/src/app/(account)/account/layout.tsx new file mode 100644 index 0000000..768bf6a --- /dev/null +++ b/apps/storefront/src/app/(account)/account/layout.tsx @@ -0,0 +1,43 @@ +import Link from 'next/link'; + +import { SiteFooter } from '@/components/layout/site-footer'; +import { SiteHeader } from '@/components/layout/site-header'; +import { routes } from '@/lib/routes'; + +const ACCOUNT_NAV = [ + { href: routes.accountProfile(), label: 'Profile' }, + { href: routes.accountOrders(), label: 'Orders' }, + { href: routes.accountAddresses(), label: 'Addresses' }, + { href: routes.accountWishlist(), label: 'Wishlist' }, +] as const; + +/** + * Everything under /account requires a signed-in customer. That check will live + * in middleware (a cheap cookie presence test) plus a server-side verification + * here — never in a Client Component, which can be bypassed. + */ +export default function AccountLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ +
{children}
+
+ +
+ ); +} diff --git a/apps/storefront/src/app/(account)/account/orders/page.tsx b/apps/storefront/src/app/(account)/account/orders/page.tsx new file mode 100644 index 0000000..9991a31 --- /dev/null +++ b/apps/storefront/src/app/(account)/account/orders/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Orders' }; + +export default function OrdersPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(account)/account/page.tsx b/apps/storefront/src/app/(account)/account/page.tsx new file mode 100644 index 0000000..ce129ce --- /dev/null +++ b/apps/storefront/src/app/(account)/account/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Account' }; + +export default function AccountPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(account)/account/profile/page.tsx b/apps/storefront/src/app/(account)/account/profile/page.tsx new file mode 100644 index 0000000..de2c7e2 --- /dev/null +++ b/apps/storefront/src/app/(account)/account/profile/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Profile' }; + +export default function ProfilePage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(account)/account/wishlist/page.tsx b/apps/storefront/src/app/(account)/account/wishlist/page.tsx new file mode 100644 index 0000000..8a17876 --- /dev/null +++ b/apps/storefront/src/app/(account)/account/wishlist/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Wishlist' }; + +export default function WishlistPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(checkout)/checkout/page.tsx b/apps/storefront/src/app/(checkout)/checkout/page.tsx new file mode 100644 index 0000000..3e2ca95 --- /dev/null +++ b/apps/storefront/src/app/(checkout)/checkout/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Checkout' }; + +export default function CheckoutPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(checkout)/layout.tsx b/apps/storefront/src/app/(checkout)/layout.tsx new file mode 100644 index 0000000..8754a50 --- /dev/null +++ b/apps/storefront/src/app/(checkout)/layout.tsx @@ -0,0 +1,22 @@ +import Link from 'next/link'; + +import { routes } from '@/lib/routes'; + +/** Minimal chrome: no nav, no footer links, nothing competing with completion. */ +export default function CheckoutLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + Sport. + + + Secure checkout + +
+
+
{children}
+
+ ); +} diff --git a/apps/storefront/src/app/(shop)/blog/page.tsx b/apps/storefront/src/app/(shop)/blog/page.tsx new file mode 100644 index 0000000..07b1a3e --- /dev/null +++ b/apps/storefront/src/app/(shop)/blog/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Journal' }; + +export default function BlogPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(shop)/cart/page.tsx b/apps/storefront/src/app/(shop)/cart/page.tsx new file mode 100644 index 0000000..32f3647 --- /dev/null +++ b/apps/storefront/src/app/(shop)/cart/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Your bag' }; + +export default function CartPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(shop)/collections/[slug]/page.tsx b/apps/storefront/src/app/(shop)/collections/[slug]/page.tsx new file mode 100644 index 0000000..e89cf96 --- /dev/null +++ b/apps/storefront/src/app/(shop)/collections/[slug]/page.tsx @@ -0,0 +1,23 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +type PageProps = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + return { title: slug }; +} + +export default async function CollectionPage({ params }: PageProps) { + const { slug } = await params; + + return ( + + ); +} diff --git a/apps/storefront/src/app/(shop)/layout.tsx b/apps/storefront/src/app/(shop)/layout.tsx new file mode 100644 index 0000000..9ef6eac --- /dev/null +++ b/apps/storefront/src/app/(shop)/layout.tsx @@ -0,0 +1,17 @@ +import { SiteFooter } from '@/components/layout/site-footer'; +import { SiteHeader } from '@/components/layout/site-header'; + +/** + * Chrome shared by every browsing route. Checkout deliberately sits in its own + * route group with a stripped-back layout — removing navigation from checkout + * is one of the highest-leverage conversion decisions there is. + */ +export default function ShopLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
{children}
+ +
+ ); +} diff --git a/apps/storefront/src/app/(shop)/men/page.tsx b/apps/storefront/src/app/(shop)/men/page.tsx new file mode 100644 index 0000000..ee1a636 --- /dev/null +++ b/apps/storefront/src/app/(shop)/men/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Men' }; + +export default function MenPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(shop)/page.tsx b/apps/storefront/src/app/(shop)/page.tsx new file mode 100644 index 0000000..90cbe6b --- /dev/null +++ b/apps/storefront/src/app/(shop)/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Sport Store — Performance sportswear' }; + +export default function HomePage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(shop)/products/[slug]/page.tsx b/apps/storefront/src/app/(shop)/products/[slug]/page.tsx new file mode 100644 index 0000000..3547ef7 --- /dev/null +++ b/apps/storefront/src/app/(shop)/products/[slug]/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +type PageProps = { params: Promise<{ slug: string }> }; + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + // Replaced with a real fetch (title, description, OG image) once the catalog + // endpoints exist; PDP metadata is the single most important SEO surface here. + return { title: slug }; +} + +/** + * Product detail page. + * + * The page renders a Product, but everything purchasable on it is a + * ProductVariant: the colour swatches select an option value, the size buttons + * select another, and together they resolve to exactly one variant id with its + * own price and stock. The "Add to bag" button submits that variant id. + */ +export default async function ProductPage({ params }: PageProps) { + const { slug } = await params; + + return ( + + ); +} diff --git a/apps/storefront/src/app/(shop)/search/page.tsx b/apps/storefront/src/app/(shop)/search/page.tsx new file mode 100644 index 0000000..eb0aa27 --- /dev/null +++ b/apps/storefront/src/app/(shop)/search/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Search' }; + +export default function SearchPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/(shop)/sports/[sport]/page.tsx b/apps/storefront/src/app/(shop)/sports/[sport]/page.tsx new file mode 100644 index 0000000..158de33 --- /dev/null +++ b/apps/storefront/src/app/(shop)/sports/[sport]/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; +import { SPORT_NAV, isSportSlug } from '@/lib/routes'; + +type PageProps = { params: Promise<{ sport: string }> }; + +/** + * The sport facets are a fixed, small set, so the routes are pre-rendered at + * build time. Category pages, which are database-driven, will use generateStaticParams + * against the API instead. + */ +export function generateStaticParams() { + return SPORT_NAV.map((sport) => ({ sport: sport.slug })); +} + +export async function generateMetadata({ params }: PageProps): Promise { + const { sport } = await params; + const match = SPORT_NAV.find((entry) => entry.slug === sport); + return { title: match ? match.label : 'Sports' }; +} + +export default async function SportPage({ params }: PageProps) { + const { sport } = await params; + + // Unknown facet is a 404, not an empty grid: empty results for a nonexistent + // URL are an SEO liability and hide typos in internal links. + if (!isSportSlug(sport)) { + notFound(); + } + + const label = SPORT_NAV.find((entry) => entry.slug === sport)?.label ?? sport; + + return ( + + ); +} diff --git a/apps/storefront/src/app/(shop)/women/page.tsx b/apps/storefront/src/app/(shop)/women/page.tsx new file mode 100644 index 0000000..5ca8e4f --- /dev/null +++ b/apps/storefront/src/app/(shop)/women/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; + +import { PageScaffold } from '@/components/layout/page-scaffold'; + +export const metadata: Metadata = { title: 'Women' }; + +export default function WomenPage() { + return ( + + ); +} diff --git a/apps/storefront/src/app/layout.tsx b/apps/storefront/src/app/layout.tsx new file mode 100644 index 0000000..c1b5a87 --- /dev/null +++ b/apps/storefront/src/app/layout.tsx @@ -0,0 +1,27 @@ +import type { Metadata, Viewport } from 'next'; + +import '@/styles/globals.css'; + +export const metadata: Metadata = { + title: { + default: 'Sport Store — Performance sportswear', + template: '%s | Sport Store', + }, + description: + 'Performance sportswear for running, football, training, gym, badminton and lifestyle.', + metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'), +}; + +export const viewport: Viewport = { + themeColor: '#111111', + width: 'device-width', + initialScale: 1, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/storefront/src/app/not-found.tsx b/apps/storefront/src/app/not-found.tsx new file mode 100644 index 0000000..067cff0 --- /dev/null +++ b/apps/storefront/src/app/not-found.tsx @@ -0,0 +1,18 @@ +import Link from 'next/link'; + +import { Button } from '@sport/ui'; + +export default function NotFound() { + return ( +
+

404

+

Nothing here

+

+ That page has moved or never existed. The gear is still where you left it. +

+ + + +
+ ); +} diff --git a/apps/storefront/src/components/layout/page-scaffold.tsx b/apps/storefront/src/components/layout/page-scaffold.tsx new file mode 100644 index 0000000..d0cb305 --- /dev/null +++ b/apps/storefront/src/components/layout/page-scaffold.tsx @@ -0,0 +1,35 @@ +import { Badge } from '@sport/ui'; + +/** + * Temporary scaffold used by every route in the skeleton. + * + * It exists so the route tree, layouts and navigation are real and clickable + * before any feature is built — and so it is obvious at a glance which screens + * are still placeholders. Each page deletes this as its feature lands. + */ +export function PageScaffold({ + eyebrow, + title, + description, + milestone, +}: { + eyebrow?: string; + title: string; + description: string; + milestone: string; +}) { + return ( +
+ {eyebrow ? ( +

{eyebrow}

+ ) : null} + +

{title}

+

{description}

+ +
+ Planned: {milestone} +
+
+ ); +} diff --git a/apps/storefront/src/components/layout/site-footer.tsx b/apps/storefront/src/components/layout/site-footer.tsx new file mode 100644 index 0000000..940c164 --- /dev/null +++ b/apps/storefront/src/components/layout/site-footer.tsx @@ -0,0 +1,74 @@ +import Link from 'next/link'; + +import { SPORT_NAV, routes } from '@/lib/routes'; + +export function SiteFooter() { + return ( +
+
+
+

+ Sport. +

+

+ Performance sportswear, built for training days and the ones after. +

+
+ + + + + + +
+ +
+ © {new Date().getFullYear()} Sport Store. +
+
+ ); +} diff --git a/apps/storefront/src/components/layout/site-header.tsx b/apps/storefront/src/components/layout/site-header.tsx new file mode 100644 index 0000000..3bc2623 --- /dev/null +++ b/apps/storefront/src/components/layout/site-header.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; + +import { SPORT_NAV, routes } from '@/lib/routes'; + +/** + * Server Component. It renders no interactive state, so none of it ships to the + * browser — the mobile menu and cart badge will be small Client Components + * mounted inside it rather than turning the whole header into one. + */ +export function SiteHeader() { + return ( +
+
+ + Sport. + + + + +
+ + Search + + + Account + + + Cart + +
+
+
+ ); +} diff --git a/apps/storefront/src/features/account/README.md b/apps/storefront/src/features/account/README.md new file mode 100644 index 0000000..0b5f2b6 --- /dev/null +++ b/apps/storefront/src/features/account/README.md @@ -0,0 +1,24 @@ +# feature: account + +Profile, addresses and account settings. + +## Structure + +``` +account/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/auth/README.md b/apps/storefront/src/features/auth/README.md new file mode 100644 index 0000000..ab337a7 --- /dev/null +++ b/apps/storefront/src/features/auth/README.md @@ -0,0 +1,24 @@ +# feature: auth + +Sign in, register, password reset, and the session store the rest of the app reads. + +## Structure + +``` +auth/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/cart/README.md b/apps/storefront/src/features/cart/README.md new file mode 100644 index 0000000..6fc64bc --- /dev/null +++ b/apps/storefront/src/features/cart/README.md @@ -0,0 +1,24 @@ +# feature: cart + +Bag drawer and page, line-item mutations, optimistic quantity updates. + +## Structure + +``` +cart/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/category/README.md b/apps/storefront/src/features/category/README.md new file mode 100644 index 0000000..5865ddc --- /dev/null +++ b/apps/storefront/src/features/category/README.md @@ -0,0 +1,24 @@ +# feature: category + +Category landing pages, breadcrumbs and the facet sidebar. + +## Structure + +``` +category/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/checkout/README.md b/apps/storefront/src/features/checkout/README.md new file mode 100644 index 0000000..ce8e306 --- /dev/null +++ b/apps/storefront/src/features/checkout/README.md @@ -0,0 +1,24 @@ +# feature: checkout + +Multi-step checkout: address, delivery, payment, review. + +## Structure + +``` +checkout/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/collection/README.md b/apps/storefront/src/features/collection/README.md new file mode 100644 index 0000000..31d525f --- /dev/null +++ b/apps/storefront/src/features/collection/README.md @@ -0,0 +1,24 @@ +# feature: collection + +Campaign and editorial collection pages. + +## Structure + +``` +collection/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/order/README.md b/apps/storefront/src/features/order/README.md new file mode 100644 index 0000000..c3c89a7 --- /dev/null +++ b/apps/storefront/src/features/order/README.md @@ -0,0 +1,24 @@ +# feature: order + +Order confirmation and order history views. + +## Structure + +``` +order/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/product/README.md b/apps/storefront/src/features/product/README.md new file mode 100644 index 0000000..89526a2 --- /dev/null +++ b/apps/storefront/src/features/product/README.md @@ -0,0 +1,24 @@ +# feature: product + +PDP: gallery, variant selector, price display, add-to-bag. Owns ``. + +## Structure + +``` +product/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/search/README.md b/apps/storefront/src/features/search/README.md new file mode 100644 index 0000000..1b39fb8 --- /dev/null +++ b/apps/storefront/src/features/search/README.md @@ -0,0 +1,24 @@ +# feature: search + +Search input, suggestions, results and the shared filter state. + +## Structure + +``` +search/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/features/wishlist/README.md b/apps/storefront/src/features/wishlist/README.md new file mode 100644 index 0000000..407b1e8 --- /dev/null +++ b/apps/storefront/src/features/wishlist/README.md @@ -0,0 +1,24 @@ +# feature: wishlist + +Save-for-later toggles and the wishlist page. + +## Structure + +``` +wishlist/ +├── components/ # UI specific to this feature +├── hooks/ # React hooks (client-side only) +├── services/ # Calls into @sport/api-client, plus query keys +├── stores/ # Zustand slices, only if this feature owns client state +└── types.ts # View-model types. Domain types come from @sport/types. +``` + +## Rules + +- A feature may import from `@/components`, `@/lib`, `@/hooks` and any + `@sport/*` package. +- A feature must **not** import from another feature's internals. If two + features need the same thing, it moves up to `@/components` or `@/lib`. + Cross-feature imports are what turn a feature folder into a second, worse + module system. +- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API. diff --git a/apps/storefront/src/hooks/README.md b/apps/storefront/src/hooks/README.md new file mode 100644 index 0000000..da7552b --- /dev/null +++ b/apps/storefront/src/hooks/README.md @@ -0,0 +1,3 @@ +# hooks/ + +Cross-feature React hooks (media queries, debounce, local storage). Anything feature-specific lives under `features//hooks/`. diff --git a/apps/storefront/src/lib/api.ts b/apps/storefront/src/lib/api.ts new file mode 100644 index 0000000..9bc1e2d --- /dev/null +++ b/apps/storefront/src/lib/api.ts @@ -0,0 +1,28 @@ +import { createApiClient } from '@sport/api-client'; + +import { clientEnv, getServerEnv } from './env'; + +/** + * Two clients, because the two runtimes have different needs: + * + * - `serverApi` runs inside React Server Components and route handlers. In + * Docker it reaches the API container over the internal network, skipping + * the public hostname and TLS entirely. + * - `browserApi` runs in the browser, hits the public API and carries the + * access token. + * + * Both are the same typed client from @sport/api-client. No component anywhere + * calls `fetch` against the API directly. + */ +export function getServerApi() { + return createApiClient({ + baseUrl: getServerEnv().API_INTERNAL_URL, + // Server-side requests are anonymous by default. Authenticated server + // fetches pass the token explicitly, per request, once auth lands. + }); +} + +export const browserApi = createApiClient({ + baseUrl: clientEnv.NEXT_PUBLIC_API_URL, + getAccessToken: () => null, // wired to the auth store in the auth milestone +}); diff --git a/apps/storefront/src/lib/env.ts b/apps/storefront/src/lib/env.ts new file mode 100644 index 0000000..a680001 --- /dev/null +++ b/apps/storefront/src/lib/env.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * Client-visible configuration, validated at module load. + * + * Next.js inlines `process.env.NEXT_PUBLIC_*` at build time, which means the + * literal reference below cannot be shortened to a dynamic lookup — that is why + * each key is written out in full. + */ +const clientEnvSchema = z.object({ + NEXT_PUBLIC_API_URL: z.url(), + NEXT_PUBLIC_SITE_URL: z.url(), +}); + +export const clientEnv = clientEnvSchema.parse({ + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, +}); + +/** + * Server-only configuration. Importing this from a Client Component is a build + * error, which is exactly the guardrail we want. + */ +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, + }); +} diff --git a/apps/storefront/src/lib/format.ts b/apps/storefront/src/lib/format.ts new file mode 100644 index 0000000..56a096c --- /dev/null +++ b/apps/storefront/src/lib/format.ts @@ -0,0 +1,31 @@ +import { MINOR_UNIT_SCALE, type Money } from '@sport/types'; + +const formatters = new Map(); + +/** + * Money is stored and transferred as an integer in minor units; it is converted + * to a display string exactly here and nowhere else. No component ever does its + * own division — that is how rounding bugs reach a checkout total. + */ +export function formatMoney(money: Money, locale = 'vi-VN'): string { + const cacheKey = `${locale}:${money.currency}`; + let formatter = formatters.get(cacheKey); + + if (!formatter) { + const scale = MINOR_UNIT_SCALE[money.currency]; + formatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency: money.currency, + minimumFractionDigits: scale, + maximumFractionDigits: scale, + }); + formatters.set(cacheKey, formatter); + } + + return formatter.format(money.amount / 10 ** MINOR_UNIT_SCALE[money.currency]); +} + +export function formatDiscountPercent(price: Money, compareAt: Money): number { + if (compareAt.amount <= 0) return 0; + return Math.round(((compareAt.amount - price.amount) / compareAt.amount) * 100); +} diff --git a/apps/storefront/src/lib/routes.ts b/apps/storefront/src/lib/routes.ts new file mode 100644 index 0000000..d053882 --- /dev/null +++ b/apps/storefront/src/lib/routes.ts @@ -0,0 +1,46 @@ +/** + * Every internal URL is built here. + * + * Hard-coded template strings scattered through components are how a URL + * structure change becomes a week of broken links. With `typedRoutes` on, these + * helpers are also checked against the actual route tree at build time. + */ +export const routes = { + home: () => '/', + + men: () => '/men', + women: () => '/women', + + sport: (slug: string) => `/sports/${slug}`, + product: (slug: string) => `/products/${slug}`, + collection: (slug: string) => `/collections/${slug}`, + + search: (query?: string) => (query ? `/search?q=${encodeURIComponent(query)}` : '/search'), + + cart: () => '/cart', + checkout: () => '/checkout', + + account: () => '/account', + accountProfile: () => '/account/profile', + accountOrders: () => '/account/orders', + accountAddresses: () => '/account/addresses', + accountWishlist: () => '/account/wishlist', + + blog: () => '/blog', +} as const; + +/** The sport facets that back `/sports/[sport]`. */ +export const SPORT_NAV = [ + { slug: 'running', label: 'Running' }, + { slug: 'football', label: 'Football' }, + { slug: 'training', label: 'Training' }, + { slug: 'gym', label: 'Gym' }, + { slug: 'badminton', label: 'Badminton' }, + { slug: 'lifestyle', label: 'Lifestyle' }, +] as const; + +export type SportSlug = (typeof SPORT_NAV)[number]['slug']; + +export function isSportSlug(value: string): value is SportSlug { + return SPORT_NAV.some((sport) => sport.slug === value); +} diff --git a/apps/storefront/src/services/README.md b/apps/storefront/src/services/README.md new file mode 100644 index 0000000..6770b17 --- /dev/null +++ b/apps/storefront/src/services/README.md @@ -0,0 +1,3 @@ +# services/ + +Cross-feature data access: the React Query client, shared query-key factories and cache-invalidation helpers. diff --git a/apps/storefront/src/stores/README.md b/apps/storefront/src/stores/README.md new file mode 100644 index 0000000..d81ff4a --- /dev/null +++ b/apps/storefront/src/stores/README.md @@ -0,0 +1,3 @@ +# stores/ + +Global client state (Zustand): cart drawer visibility, auth session, recently viewed. Server data belongs in React Query, not here — duplicating it is the most common source of stale UI. diff --git a/apps/storefront/src/styles/globals.css b/apps/storefront/src/styles/globals.css new file mode 100644 index 0000000..da64638 --- /dev/null +++ b/apps/storefront/src/styles/globals.css @@ -0,0 +1,25 @@ +@import 'tailwindcss'; +@import '@sport/config/tailwind/theme.css'; + +/* Tailwind v4 scans the importing app by default; workspace packages must be + registered explicitly or their utility classes get purged. */ +@source "../../../../packages/ui/src"; + +:root { + /* No web font is loaded yet — the design system reads --font-inter, so the + stack is defined once here and swapping in a real font later touches only + this line plus a next/font import. */ + --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-white); + color: var(--color-ink-950); + font-family: var(--font-sans); +} diff --git a/apps/storefront/src/types/README.md b/apps/storefront/src/types/README.md new file mode 100644 index 0000000..935c15b --- /dev/null +++ b/apps/storefront/src/types/README.md @@ -0,0 +1,3 @@ +# types/ + +View-model types local to the storefront. Domain and API types are imported from `@sport/types` and are never redeclared here. diff --git a/apps/storefront/tsconfig.json b/apps/storefront/tsconfig.json new file mode 100644 index 0000000..835d47a --- /dev/null +++ b/apps/storefront/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8dca16e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,188 @@ +# --------------------------------------------------------------------------- +# Local development. +# +# Default (`pnpm infra:up`): backing services only — PostgreSQL, Redis, MinIO, +# Mailpit. The three applications run on the host via `pnpm dev`, because +# native file watching and HMR are dramatically faster than bind-mounted +# containers, especially on macOS. +# +# Full stack (`docker compose --profile full up`): everything containerised +# behind Nginx. This mirrors production topology and is what you use to verify +# routing, headers and container builds before shipping. +# --------------------------------------------------------------------------- + +name: sport-store + +services: + postgres: + image: postgres:17-alpine + container_name: sport-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-sport} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sport} + POSTGRES_DB: ${POSTGRES_DB:-sport_store} + # Deterministic collation: index ordering must not depend on the host. + POSTGRES_INITDB_ARGS: "--locale=C --encoding=UTF8" + ports: + - "${POSTGRES_PORT:-5433}:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sport} -d ${POSTGRES_DB:-sport_store}"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: sport-redis + restart: unless-stopped + # Cache-first policy: evict rather than refuse writes. Nothing in Redis is + # a system of record, so eviction is always preferable to an outage. + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly no + ports: + - "${REDIS_PORT:-6380}:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + # Stands in for Cloudflare R2. Same S3 API, so application code is identical. + minio: + image: minio/minio:latest + container_name: sport-minio + restart: unless-stopped + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY_ID:-sportminio} + MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_ACCESS_KEY:-sportminio} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 10 + + minio-init: + image: minio/mc:latest + container_name: sport-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 ${STORAGE_ACCESS_KEY_ID:-sportminio} ${STORAGE_SECRET_ACCESS_KEY:-sportminio}; + mc mb --ignore-existing local/${STORAGE_BUCKET:-sport-media}; + mc anonymous set download local/${STORAGE_BUCKET:-sport-media}; + echo 'MinIO bucket ready'; + " + + # Catches every outgoing email locally. UI on http://localhost:8025 + mailpit: + image: axllent/mailpit:latest + container_name: sport-mailpit + restart: unless-stopped + ports: + - "1025:1025" + - "8025:8025" + + # ------------------------------------------------------------------------ + # Application containers — `--profile full` only. + # ------------------------------------------------------------------------ + + api: + profiles: ["full"] + build: + context: . + dockerfile: infrastructure/docker/api.Dockerfile + container_name: sport-api + restart: unless-stopped + env_file: apps/api/.env + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-sport}:${POSTGRES_PASSWORD:-sport}@postgres:5432/${POSTGRES_DB:-sport_store}?schema=public + REDIS_URL: redis://redis:6379 + STORAGE_ENDPOINT: http://minio:9000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + expose: + - "4000" + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:4000/api/v1/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + storefront: + profiles: ["full"] + build: + context: . + dockerfile: infrastructure/docker/storefront.Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost/api} + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost} + container_name: sport-storefront + restart: unless-stopped + environment: + # Server-side rendering talks to the API container directly. + API_INTERNAL_URL: http://api:4000 + depends_on: + api: + condition: service_healthy + expose: + - "3000" + + admin: + profiles: ["full"] + build: + context: . + dockerfile: infrastructure/docker/admin.Dockerfile + args: + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost/api} + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_ADMIN_URL:-http://admin.localhost} + container_name: sport-admin + restart: unless-stopped + environment: + API_INTERNAL_URL: http://api:4000 + depends_on: + api: + condition: service_healthy + expose: + - "3001" + + nginx: + profiles: ["full"] + image: nginx:1.27-alpine + container_name: sport-nginx + restart: unless-stopped + ports: + - "80:80" + volumes: + - ./infrastructure/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./infrastructure/nginx/conf.d:/etc/nginx/conf.d:ro + depends_on: + - storefront + - admin + - api + +volumes: + postgres-data: + redis-data: + minio-data: diff --git a/docs/adr/0001-monorepo-with-pnpm-workspaces-and-turborepo.md b/docs/adr/0001-monorepo-with-pnpm-workspaces-and-turborepo.md new file mode 100644 index 0000000..8409f87 --- /dev/null +++ b/docs/adr/0001-monorepo-with-pnpm-workspaces-and-turborepo.md @@ -0,0 +1,36 @@ +# ADR-0001: Monorepo with pnpm workspaces and Turborepo + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +Three deployable applications (storefront, admin, API) share domain types, validation +rules and an HTTP client. Split across three repositories, every contract change becomes a +version bump, a publish and three coordinated pull requests — and in practice the types drift +because nobody wants to pay that cost for a one-field change. + +## Decision + +A single repository with pnpm workspaces for dependency linking and Turborepo for task +orchestration and caching. Shared code lives in `packages/*`; deployables live in `apps/*`. + +`@sport/types`, `@sport/validation` and `@sport/api-client` compile to CommonJS + `.d.ts` +because NestJS consumes them at runtime. `@sport/ui` ships raw TypeScript and is compiled by +each Next.js app via `transpilePackages` — no build step, no watcher, faster HMR. + +Versions that must stay identical across the workspace (TypeScript, React, Next, Zod, ESLint) +are pinned once in the `catalog:` block of `pnpm-workspace.yaml`. + +## Consequences + +A backend field rename surfaces as a frontend type error in the same commit. One +lockfile, one CI pipeline, one lint configuration. The cost is a heavier initial install and +the need for discipline about dependency direction (ADR-0004), which the ESLint boundary rules +enforce mechanically. + +## Alternatives considered + +Polyrepo with a private npm registry — rejected: the publish/consume loop is +slower than the entire feature it serves. Nx — comparable, but Turborepo's smaller surface fits +a team that wants a build cache, not a build framework. diff --git a/docs/adr/0002-modular-monolith-not-microservices.md b/docs/adr/0002-modular-monolith-not-microservices.md new file mode 100644 index 0000000..3711bf8 --- /dev/null +++ b/docs/adr/0002-modular-monolith-not-microservices.md @@ -0,0 +1,38 @@ +# ADR-0002: Modular monolith, not microservices + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +The system will eventually need order processing, inventory, payments, search and +notifications. That list reads like a microservice diagram, and the temptation is to start +there. But on day one there is no traffic, no team boundary and no independent scaling +requirement — only the cost of distributed transactions, network failure modes and per-service +CI. + +## Decision + +One NestJS process, internally partitioned into modules that own their tables +exclusively. Cross-module access happens two ways only: a synchronous call to the other +module's `public/` service when an answer is needed now, or a domain event when something +merely needs to react. + +Four modules — `inventory`, `orders`, `payments`, `search` — are marked EXTRACTION CANDIDATE +and additionally forbidden from sharing transactions with the rest of the monolith. + +## Consequences + +A single deploy, a single database, real foreign keys and real transactions — +which is exactly what an order/inventory/payment flow wants. Extraction stays possible because +the boundaries are enforced now, while they are cheap to enforce. + +The risk is boundary erosion: one "quick" cross-module join and the seam is gone. This is why +the rule is an ESLint error rather than a paragraph in a wiki. + +## Alternatives considered + +Microservices from day one — rejected as premature: it buys independent scaling +nobody needs and pays in distributed-transaction complexity that a checkout flow can least +afford. A single unstructured application — rejected: retrofitting boundaries after the fact +is the expensive path. diff --git a/docs/adr/0003-product-and-productvariant-as-separate-entities.md b/docs/adr/0003-product-and-productvariant-as-separate-entities.md new file mode 100644 index 0000000..86dc952 --- /dev/null +++ b/docs/adr/0003-product-and-productvariant-as-separate-entities.md @@ -0,0 +1,39 @@ +# ADR-0003: Product and ProductVariant as separate entities + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +A "Running Shirt" in black, size M is a different physical good from the same shirt in +white, size L: different barcode, different stock, potentially different price. Modelling +`sizes: string[]` and `colors: string[]` on a product makes every one of those facts +unrepresentable. + +## Decision + +`Product` is the marketing entity — it has a name, a slug and a page, and deliberately +has no SKU, no price and no stock. It owns an ordered list of `ProductOption`s (Colour, Size), +each owning ordered `ProductOptionValue`s. Every purchasable combination is a +`ProductVariant` with its own SKU, price, sale price, barcode, weight and stock. + +`ProductVariantOptionValue` resolves a variant to exactly one value per option, with +`@@id([variantId, optionId])` enforcing at the database level that a variant cannot have two +colours. Stock lives in `StockLevel` keyed by (variant, location), never on the variant row. + +## Consequences + +Cart lines, order lines, stock movements and marketplace listings all reference a +variant id — the same granularity Shopee, Lazada, TikTok Shop, ERP and POS systems use, so +integrations map 1:1 instead of needing a translation layer. Adding a third option (width, fit) +is data, not a migration. + +The cost is real: the PDP must resolve option selections to a variant, and the admin needs a +variant-matrix editor rather than two text inputs. That cost is paid once and is the reason the +model survives contact with a warehouse. + +## Alternatives considered + +Size/colour as columns on Product — rejected: cannot express per-combination stock +or price, which is the entire job. A single flat SKU table with no product grouping — rejected: +there would be nothing to hang a product page, gallery or description on. diff --git a/docs/adr/0004-the-admin-dashboard-has-no-database-access.md b/docs/adr/0004-the-admin-dashboard-has-no-database-access.md new file mode 100644 index 0000000..fc3e59a --- /dev/null +++ b/docs/adr/0004-the-admin-dashboard-has-no-database-access.md @@ -0,0 +1,32 @@ +# ADR-0004: The admin dashboard has no database access + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +The admin is a Next.js application and could trivially import Prisma and query +PostgreSQL from a Server Action. It would be faster to write. It would also create a second +write path in which RBAC, validation and audit logging are re-implemented — or forgotten. + +## Decision + +The admin has no database driver, no Prisma client, no Redis client and no storage +credentials. Every read and write goes through the REST API via `@sport/api-client`. The rule +is enforced by ESLint (`no-restricted-imports` on `@prisma/client` and `ioredis` in both +frontends) and by the absence of `DATABASE_URL` from the admin's environment. + +## Consequences + +Authorization is checked in exactly one place. The audit log cannot be bypassed. +The API surface stays honest, because the admin is its most demanding consumer — and a future +mobile app or partner integration inherits a proven API rather than a thin one. + +The cost is an extra network hop for back-office screens, which is irrelevant at back-office +traffic levels. + +## Alternatives considered + +Direct database access from Server Actions — rejected for the reasons above. +A separate "admin API" service — rejected: two APIs over one database is the same problem with +more deployment. diff --git a/docs/adr/0005-uri-based-api-versioning.md b/docs/adr/0005-uri-based-api-versioning.md new file mode 100644 index 0000000..5362be1 --- /dev/null +++ b/docs/adr/0005-uri-based-api-versioning.md @@ -0,0 +1,31 @@ +# ADR-0005: URI-based API versioning + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +The API will outlive its first client. A mobile app, marketplace connectors and partner +integrations will pin to whatever exists when they are written, and some of them will never be +updated. + +## Decision + +`/api/v1/...`, via NestJS `VersioningType.URI` with `defaultVersion: '1'`. A version +is introduced only for a genuinely breaking change; additive fields ship inside the current +version. Old versions get a documented sunset date, not silent removal. + +## Consequences + +The version is visible in logs, in Nginx access logs, in CDN cache keys and in a +curl command. Two versions can run side by side in one process, sharing services and differing +only in controllers and mappers. + +URLs are slightly longer, and the version is technically part of the resource identity, which +purists dislike. In exchange, nobody ever debugs a version mismatch caused by a missing header. + +## Alternatives considered + +Header-based (`Accept-Version`) — rejected: invisible in logs, easy to omit, and +awkward for CDN caching. No versioning — rejected: it works right up until the first +integration nobody can update. diff --git a/docs/adr/0006-zod-schemas-shared-between-api-and-frontends.md b/docs/adr/0006-zod-schemas-shared-between-api-and-frontends.md new file mode 100644 index 0000000..81dd954 --- /dev/null +++ b/docs/adr/0006-zod-schemas-shared-between-api-and-frontends.md @@ -0,0 +1,35 @@ +# ADR-0006: Zod schemas shared between API and frontends + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +Validation rules exist twice by default: once in the API and once in the form. They +drift, and the drift shows up as a form that accepts input the server rejects. + +## Decision + +One Zod schema per input shape, defined in `@sport/validation` and imported by both +sides. The API applies it through `ZodValidationPipe`; the frontends apply the same object to +their forms. Zod was chosen over class-validator specifically because a class with decorators +cannot cross into a React form, whereas a schema object can. + +Scope is deliberately limited to shape and format rules. Anything requiring database state — +"is this coupon still valid", "is this variant in stock" — is a business rule and lives in the +backend service layer. + +## Consequences + +A rule change happens once. Field-level errors come back keyed by dotted path +(`items.0.quantity`), which forms consume directly. The parsed output carries coercions and +defaults, so controllers receive clean typed data. + +The discipline required is keeping business rules out of the schemas; a validation package that +starts querying is a validation package that can no longer be shared. + +## Alternatives considered + +class-validator + class-transformer, the NestJS default — rejected: not shareable +with the frontends. Duplicating rules with a test to keep them in sync — rejected: the test +tells you about drift after it has already shipped. diff --git a/docs/adr/0007-rbac-permissions-instead-of-role-checks.md b/docs/adr/0007-rbac-permissions-instead-of-role-checks.md new file mode 100644 index 0000000..a142d42 --- /dev/null +++ b/docs/adr/0007-rbac-permissions-instead-of-role-checks.md @@ -0,0 +1,35 @@ +# ADR-0007: RBAC permissions instead of role checks + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +`if (user.role === 'ADMIN')` spreads. Six months later authorization logic is scattered +across dozens of files, no one can answer "who can refund an order?" without grepping, and +adding a "Warehouse Supervisor" role means editing and redeploying application code. + +## Decision + +Authorization is expressed only as permissions (`product.update`, `order.refund`), +declared on routes with `@RequirePermissions(...)` and evaluated by a global `PermissionsGuard`. + +Permissions are code: the catalog in `@sport/types` is the source of truth, and the seed +reconciles the database against it. Roles are data: rows in `roles`/`role_permissions` that a +SUPER_ADMIN edits at runtime with no deploy. + +The admin sidebar is built from the same catalog, so a user never sees a link to a screen they +cannot use — presentation only; the API re-checks every request. + +## Consequences + +Every authorization rule is one greppable decorator. New roles need no code. The +permission set travels inside the access token, so guards do no database work on the hot path — +which is precisely why access tokens are short-lived (ADR-0008): a revoked permission takes at +most one token lifetime to take effect. + +## Alternatives considered + +Role checks in code — rejected above. Full ABAC/policy engine — rejected as +premature: nothing yet needs "can edit orders from their own store only". The permission model +can grow into that if a real requirement appears. diff --git a/docs/adr/0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md b/docs/adr/0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md new file mode 100644 index 0000000..ade24ee --- /dev/null +++ b/docs/adr/0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md @@ -0,0 +1,39 @@ +# ADR-0008: Short access tokens, rotating refresh tokens, separate audiences + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +Storefront customers and back-office staff authenticate against the same API. A single +token type shared between them means an XSS on the storefront is a path into the admin. + +## Decision + +A short-lived (15 min) stateless JWT access token carrying the permission set, plus a +long-lived refresh token that is opaque to the client, delivered as an httpOnly SameSite cookie, +stored server-side only as a SHA-256 hash, and rotated on every use. + +Rotation is tracked with `Session.replacedById`. Presenting an already-rotated refresh token +means the token leaked, so the entire token family is revoked — theft detection, not just theft +mitigation. + +Every token carries an audience (`storefront` or `admin`). Admin controllers declare +`@RequireAudience('admin')`, and the check runs before any permission logic. + +## Consequences + +A stolen access token expires in minutes. A stolen refresh token is detectable and +self-revoking. A stolen storefront token is rejected by admin endpoints on audience alone, +before permissions are consulted. + +The trade-off is that permission changes are not instantaneous — bounded by the access token +lifetime. For an immediate kill switch, `CACHE_KEYS.revokedSession` exists as a Redis +denylist checked per request; it is deliberately not enabled by default because it reintroduces +a hot-path lookup. + +## Alternatives considered + +Server-side sessions — simpler to revoke, but adds a datastore read to every +request and complicates a future mobile client. Long-lived access tokens — rejected: the blast +radius of a leak is unacceptable for a system holding payment and address data. diff --git a/docs/adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md b/docs/adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md new file mode 100644 index 0000000..f06b0f3 --- /dev/null +++ b/docs/adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md @@ -0,0 +1,37 @@ +# ADR-0009: Media in S3-compatible storage, metadata in PostgreSQL + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +Product photography is the bulk of an apparel store's bytes. Storing binaries in +PostgreSQL bloats backups, makes replication slow, and forces image delivery through the +application tier. + +## Decision + +Bytes live in Cloudflare R2 (MinIO locally). PostgreSQL stores only metadata: object +key, MIME type, dimensions, size, alt text and a small base64 blur placeholder. + +Browsers upload directly to the bucket using a short-lived presigned URL, so files never stream +through the API. Public URLs are composed at read time from `STORAGE_PUBLIC_URL + storageKey`, +never stored — so changing bucket, CDN domain or provider is a config change, not a data +migration. + +Uploads are restricted by a MIME allow-list, and generated keys are date-partitioned UUIDs that +never echo the user's filename. + +## Consequences + +Database backups stay small and fast. Images are served by a CDN at the edge. The +API scales on CPU, not bandwidth. + +The cost is eventual-consistency between the two stores: a failed upload can leave an orphaned +row, and a deleted row can leave an orphaned object. A periodic reconciliation job is the +accepted mitigation; two-phase commit across a database and object storage is not worth it. + +## Alternatives considered + +`bytea` columns — rejected for the reasons above. Serving uploads through the API +— rejected: it makes the API a bandwidth bottleneck and a timeout risk on large files. diff --git a/docs/adr/0010-redis-is-a-cache-and-an-ephemeral-store-never-a-system-of-record.md b/docs/adr/0010-redis-is-a-cache-and-an-ephemeral-store-never-a-system-of-record.md new file mode 100644 index 0000000..ca4103c --- /dev/null +++ b/docs/adr/0010-redis-is-a-cache-and-an-ephemeral-store-never-a-system-of-record.md @@ -0,0 +1,37 @@ +# ADR-0010: Redis is a cache and an ephemeral store, never a system of record + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +Redis is fast and tempting. Once a cart or an order lives only in Redis, an eviction or a +restart becomes lost revenue. + +## Decision + +Everything in Redis must be either reconstructible from PostgreSQL or genuinely +disposable. Current uses: catalog read caching, guest carts, OTPs, password-reset tokens, rate +limit counters, checkout stock reservations and idempotency keys. + +The local container runs `--maxmemory-policy allkeys-lru` with persistence off — an explicit +statement that eviction is always preferable to refusing writes. `RedisService.getOrSet` +swallows cache read and write failures and falls through to the source, so a Redis outage +degrades latency rather than availability. + +Every key is built in `cache-keys.ts`; no ad-hoc key strings anywhere. + +## Consequences + +Redis can be flushed at any moment and the store keeps working. Guest carts are the +one place where loss is user-visible, which is why they are promoted to PostgreSQL at sign-in +and carry a 30-day TTL. + +Stock reservations need care: they are held in Redis with a TTL, but the authoritative +`reserved` count is a PostgreSQL column, so an eviction cannot silently oversell. + +## Alternatives considered + +Redis as primary store for carts — rejected: the failure mode is losing a customer's +basket. In-memory caching in the Node process — rejected: it does not survive a restart and +cannot be shared across instances. diff --git a/docs/adr/0011-money-as-integer-minor-units.md b/docs/adr/0011-money-as-integer-minor-units.md new file mode 100644 index 0000000..e5b2ba9 --- /dev/null +++ b/docs/adr/0011-money-as-integer-minor-units.md @@ -0,0 +1,29 @@ +# ADR-0011: Money as integer minor units + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +`0.1 + 0.2 !== 0.3`. Floating-point money produces discrepancies that are invisible in +testing and unfixable once they are in an order history. + +## Decision + +All monetary values are integers in the currency's minor unit, in the database +(`priceAmount Int`), across the API (`{ amount, currency }`), and in TypeScript (`Money`). +VND has a minor-unit scale of 0, so `250000` means ₫250.000. Conversion to a display string +happens in exactly one function, `formatMoney`, using `Intl.NumberFormat`. + +## Consequences + +Arithmetic is exact. No conversion happens between layers because every layer holds +the same integer. Multi-currency is already representable without a schema change. + +Developers must remember that `price.amount` is not a display value; the single formatter and +the absence of any other division by 100 are what keep that from going wrong. + +## Alternatives considered + +`Decimal`/`numeric` columns — correct in the database but arrive in JavaScript as +strings or a Decimal object that must be handled at every boundary. Floats — never. diff --git a/docs/adr/0012-postgresql-full-text-search-before-a-dedicated-search-engine.md b/docs/adr/0012-postgresql-full-text-search-before-a-dedicated-search-engine.md new file mode 100644 index 0000000..db5de61 --- /dev/null +++ b/docs/adr/0012-postgresql-full-text-search-before-a-dedicated-search-engine.md @@ -0,0 +1,34 @@ +# ADR-0012: PostgreSQL full-text search before a dedicated search engine + +- **Status:** Accepted +- **Date:** 2026-08-11 + +## Context + +Search is a headline feature of a storefront, and reaching for Elasticsearch or +OpenSearch is the reflex. It is also a second datastore to run, secure, back up and keep in +sync — for a catalog that starts at a few hundred products. + +## Decision + +Start with PostgreSQL full-text search plus `pg_trgm` for fuzzy matching and typo +tolerance, behind a `SearchProvider` interface owned by `SearchModule`. The module is marked +EXTRACTION CANDIDATE and reads the catalog only through public services, so it holds no +privileged coupling. + +## Consequences + +One datastore, no sync pipeline, no index drift, and search results that are +transactionally consistent with the catalog. This is genuinely adequate below roughly 50k +products with straightforward faceting. + +The limits are known and will eventually bind: no relevance tuning to speak of, no +learning-to-rank, weak multilingual analysis for Vietnamese. When they do, the provider +interface is the seam — swapping in OpenSearch changes one implementation, not every listing +page. + +## Alternatives considered + +Elasticsearch/OpenSearch from day one — rejected as premature infrastructure. +A hosted service (Algolia, Typesense Cloud) — a reasonable future option; deferred because it +adds per-record cost and a sync pipeline before there is a search-quality problem to solve. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..6458167 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,36 @@ +# Architecture Decision Records + +Each file records one decision that was expensive to make and would be expensive to reverse. +The purpose is not documentation for its own sake — it is so that in a year, when someone asks +"why is money an integer?" or "why doesn't the admin just query the database?", the answer is +written down along with what was rejected and why. + +An ADR is immutable once accepted. If a decision changes, add a new ADR that supersedes it. + +| ADR | Decision | Status | +| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -------- | +| [0001](./0001-monorepo-with-pnpm-workspaces-and-turborepo.md) | Monorepo with pnpm workspaces and Turborepo | Accepted | +| [0002](./0002-modular-monolith-not-microservices.md) | Modular monolith, not microservices | Accepted | +| [0003](./0003-product-and-productvariant-as-separate-entities.md) | Product and ProductVariant as separate entities | Accepted | +| [0004](./0004-the-admin-dashboard-has-no-database-access.md) | The admin dashboard has no database access | Accepted | +| [0005](./0005-uri-based-api-versioning.md) | URI-based API versioning | Accepted | +| [0006](./0006-zod-schemas-shared-between-api-and-frontends.md) | Zod schemas shared between API and frontends | Accepted | +| [0007](./0007-rbac-permissions-instead-of-role-checks.md) | RBAC permissions instead of role checks | Accepted | +| [0008](./0008-short-access-tokens-rotating-refresh-tokens-separate-audiences.md) | Short access tokens, rotating refresh tokens, separate audiences | Accepted | +| [0009](./0009-media-in-s3-compatible-storage-metadata-in-postgresql.md) | Media in S3-compatible storage, metadata in PostgreSQL | Accepted | +| [0010](./0010-redis-is-a-cache-and-an-ephemeral-store-never-a-system-of-record.md) | Redis is a cache and an ephemeral store, never a system of record | Accepted | +| [0011](./0011-money-as-integer-minor-units.md) | Money as integer minor units | Accepted | +| [0012](./0012-postgresql-full-text-search-before-a-dedicated-search-engine.md) | PostgreSQL full-text search before a dedicated search engine | Accepted | + +## Decisions deliberately NOT recorded yet + +These are open and should become ADRs when the need is real, not before: + +- Payment provider abstraction shape (VNPay / MoMo / ZaloPay / COD) — write it when the second + provider is integrated, not the first. One provider does not reveal the right abstraction. +- Shipping-rate provider integration. +- Whether guest carts ever get promoted to PostgreSQL before sign-in. +- Multi-warehouse allocation strategy. The schema supports it; the policy does not exist yet. +- i18n / multi-currency rollout. +- Read replicas and connection pooling (PgBouncer) — a scaling decision that needs real traffic + numbers to make well. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..92440b3 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,346 @@ +# Architecture + +The reference document for how this system is put together and, more importantly, which rules +must not be broken. Decisions and their trade-offs live in [`docs/adr/`](./adr/README.md). + +--- + +## 1. System topology + +``` + ┌─────────────┐ + Customer ───────────────▶│ │ + │ Cloudflare │ TLS, WAF, CDN, DDoS + Admin ──────────────────▶│ │ + └──────┬──────┘ + │ + ┌──────▼──────┐ + │ Nginx │ routing, gzip, rate ceiling, + └──┬───┬───┬──┘ immutable asset caching + ┌────────────────┘ │ └────────────────┐ + │ │ │ + ┌───────▼───────┐ ┌───────▼───────┐ ┌───────▼───────┐ + │ Storefront │ │ Admin │ │ API │ + │ Next.js │ │ Next.js │ │ NestJS │ + │ :3000 │ │ :3001 │ │ :4000 │ + └───────┬───────┘ └───────┬───────┘ └───────┬───────┘ + │ │ │ + └────── REST ────────┴──── REST ──────────┤ + │ + ┌───────────────┬───────────────┼───────────────┐ + │ │ │ │ + ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐ + │ PostgreSQL │ │ Redis │ │ R2 / S3 │ │ Providers │ + │ (record) │ │ (cache) │ │ (media) │ │ (future) │ + └────────────┘ └────────────┘ └────────────┘ └────────────┘ +``` + +**The load-bearing rule:** only the API touches PostgreSQL, Redis or object storage. Both +frontends reach data exclusively through the REST API. See [ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md). + +Server-side rendering in both Next apps calls the API over the internal Docker network +(`API_INTERNAL_URL`), skipping the public hostname and TLS entirely. + +--- + +## 2. Responsibilities + +| Component | Owns | Explicitly does not | +| ------------------ | ---------------------------------------------------------------- | --------------------------------------------- | +| `apps/storefront` | Customer UX, SEO, rendering strategy, client cart state | Business rules, pricing maths, DB access | +| `apps/admin` | Back-office UX, bulk editing, operational views | Business rules, DB access, its own auth model | +| `apps/api` | **All** business logic, persistence, authorization, integrations | Rendering, presentation concerns | +| `packages/*` | Contracts and reusable primitives | Anything app-specific or stateful | +| `infrastructure/*` | Runtime topology, container builds, local dev | Application behaviour | + +Pricing is the clarifying example. The storefront may _format_ `{ amount: 250000, currency: 'VND' }` +as `₫250.000`. It may never _compute_ a discount, a subtotal or a shipping cost. If a number +appears on a receipt, the API produced it. + +--- + +## 3. Dependency rules + +``` + apps/storefront ──┐ + ├──▶ @sport/api-client ──▶ @sport/types + apps/admin ───────┤ ▲ + ├──▶ @sport/ui ────────────────┤ + └──▶ @sport/validation ────────┤ + │ + apps/api ────────────▶ @sport/validation ────────┤ + └───────────▶ @sport/types ─────────────┘ +``` + +Allowed: + +- Any app → any package. +- `@sport/validation`, `@sport/api-client` → `@sport/types`. +- `@sport/ui` → nothing but React and styling utilities. + +Forbidden, and enforced rather than merely documented: + +| Rule | Enforced by | +| ---------------------------------------- | ------------------------------------------------- | +| App → app | No workspace dependency exists | +| Package → app | No workspace dependency exists | +| Frontend → `@prisma/client` or `ioredis` | ESLint `no-restricted-imports` | +| `@sport/types` → any framework | Zero dependencies in its `package.json` | +| Cross-module deep imports in the API | ESLint `no-restricted-imports` on `@/modules/*/*` | +| Circular imports | ESLint `import-x/no-cycle` | + +`@sport/types` has no dependencies at all, and that is a deliberate constraint: it is imported +by a NestJS server, two React apps and eventually a React Native app. The moment it depends on +a framework, one of those consumers breaks. + +--- + +## 4. What may and may not be shared + +**Share** — things that are true everywhere: + +- Domain types and API contracts (`@sport/types`) +- Input shape/format rules (`@sport/validation`) +- API access (`@sport/api-client`) +- Design-system primitives: Button, Input, Badge, Skeleton (`@sport/ui`) +- Build configuration (`@sport/config`, `@sport/eslint-config`) + +**Do not share** — things that only look shareable: + +- **Domain components.** `` knows about sale badges, price ranges and colour + swatches. It belongs to the storefront. The admin's product row needs status, stock and + margin. Merging them produces a component with fourteen props and two consumers who both + fight it. +- **Business logic.** It lives in the API. A discount calculated in a shared package is a + discount that can disagree with the invoice. +- **App state.** Cart, auth session and filter state are app-specific. Shared stores create + invisible coupling between two applications that must be free to diverge. +- **Feature-to-feature imports.** If two storefront features need the same thing, it moves up + to `@/components` or `@/lib` — it does not get imported sideways. + +The test for `@sport/ui`: _would this component be meaningful in the admin dashboard?_ If not, +it is not a design-system primitive. + +--- + +## 5. Backend module boundaries + +Twenty modules, each owning its tables exclusively. Full anatomy in +[`apps/api/src/modules/README.md`](../apps/api/src/modules/README.md). + +| Group | Modules | +| ------------------- | ---------------------------------------------------------------------------------------- | +| Cross-cutting | `auth`, `health` | +| Identity | `users`, `customers` | +| Catalog | `products`, `product-variants`, `categories`, `collections`, `brands`, `media`, `search` | +| Commerce | `inventory`, `carts`, `checkout`, `orders`, `payments` | +| Marketing & content | `promotions`, `coupons`, `wishlist`, `reviews`, `cms` | + +Communication: + +| Need | Mechanism | +| --------------------------------------- | ------------------------------------------------ | +| An answer now, to continue this request | Call the other module's `public/` service | +| To react to something that happened | Subscribe to its domain event | +| To change another module's data | Call its public service — never write its tables | + +`inventory`, `orders`, `payments` and `search` carry an additional constraint: no shared +transactions with the rest of the monolith, so they remain extractable. + +--- + +## 6. Request lifecycle + +``` +Request + │ + ├─▶ RequestIdMiddleware assign/propagate x-request-id + ├─▶ ThrottlerGuard per-IP rate limit + ├─▶ AccessTokenGuard verify JWT, check audience, attach actor ← opt-out via @Public() + ├─▶ PermissionsGuard evaluate @RequirePermissions + ├─▶ ZodValidationPipe parse + coerce body/query + │ + ├─▶ Controller ─▶ Service ─▶ Repository ─▶ Prisma + │ + ├─▶ ResponseEnvelopeInterceptor wrap in { success: true, data, meta } + └─▶ AllExceptionsFilter any throw → { success: false, error, meta } +``` + +Authentication is **on by default**. `AccessTokenGuard` is registered globally and a route +becomes public only by explicitly declaring `@Public()`. Forgetting a decorator therefore fails +closed — a new endpoint is never accidentally exposed. + +--- + +## 7. API conventions + +### Response envelope + +Every response uses one of exactly two shapes, including 500s. Clients branch on `success`, +never on the status code. + +```jsonc +// success +{ "success": true, "data": { }, "meta": { "requestId": "019f…", "timestamp": "2026-08-11T…" } } + +// failure +{ + "success": false, + "error": { "code": "INSUFFICIENT_STOCK", "message": "Only 2 left in size M.", + "fields": { "items.0.quantity": ["Only 2 available"] } }, + "meta": { "requestId": "019f…", "timestamp": "2026-08-11T…" } +} +``` + +`error.code` is a stable machine identifier from `API_ERROR_CODES`. **A code is never renamed or +repurposed once shipped** — mobile apps and partner integrations branch on those strings. Adding +a code is always safe; changing one is a breaking API change. + +`error.message` is safe to display to an end user. Internal detail (SQL, Prisma metadata, stack +traces) never reaches the client in production; it goes to the log, correlated by `requestId`. + +### Status codes + +| Code | Meaning | +| --------------- | -------------------------------------------------- | +| 200 / 201 / 204 | Success | +| 400 | Malformed request | +| 401 | Missing, invalid or expired credentials | +| 403 | Authenticated but not permitted | +| 404 | Not found, or hidden from this actor | +| 409 | Conflict (duplicate, invalid state transition) | +| 422 | Validation failed — carries `error.fields` | +| 429 | Rate limited | +| 500 | Unexpected — always generic message, always logged | + +### Pagination + +Offset (`?page=&perPage=`) for admin tables, where "page 7 of 42" is a real requirement. Cursor +(`?cursor=&limit=`) for storefront listings, where correctness under concurrent writes matters +more than random access. Chosen per endpoint, never mixed. + +--- + +## 8. Logging + +Structured JSON via pino. One line per request: method, path, status, duration, `requestId`, +`actorId`. Domain logs carry the same `requestId`, so a full trace — Cloudflare → Nginx → API — +is one grep. + +Levels: `error` for 5xx and unexpected failures; `warn` for 4xx and degraded dependencies; +`info` for lifecycle and significant domain events; `debug` for development only. + +Redaction happens **at the logger**, not at each call site: `authorization`, `cookie`, +`set-cookie`, and password/token/card fields are censored centrally. Relying on developers to +remember is how credentials end up in log storage. + +Application code uses Nest's standard `Logger`, which `main.ts` routes into pino. Nothing +injects `PinoLogger` directly — it is transient-scoped, and injecting it would silently make the +consumer transient too, which for `PrismaService` would mean a second connection pool. + +--- + +## 9. Naming conventions + +| Thing | Convention | Example | +| --------------------- | ------------------------- | ------------------------------ | +| Files | kebab-case | `product-variant.service.ts` | +| React components | PascalCase file + export | `ProductCard.tsx` | +| Classes | PascalCase | `ProductVariantService` | +| Variables / functions | camelCase | `calculateSubtotal` | +| Constants | SCREAMING_SNAKE | `PAGINATION_DEFAULTS` | +| Types / interfaces | PascalCase, no `I` prefix | `ProductVariant` | +| Database tables | snake_case plural | `product_variants` | +| Database columns | snake_case | `sale_price_amount` | +| Prisma models | PascalCase singular | `ProductVariant` | +| API routes | kebab-case plural | `/api/v1/product-variants` | +| Permissions | `resource.action` | `product.update` | +| Domain events | `resource.past_tense` | `order.placed` | +| Redis keys | `domain:entity:id` | `catalog:product:slug:air-tee` | +| Env vars | SCREAMING_SNAKE | `JWT_ACCESS_SECRET` | +| Branches | `type/short-description` | `feat/variant-matrix-editor` | + +Booleans read as assertions: `isActive`, `hasVariants`, `canRefund`. Money fields end in +`Amount` and are always integers. + +--- + +## 10. Configuration and environment + +Four `.env` files, each with a committed `.env.example`: + +| File | Consumed by | Contains | +| ---------------------- | ------------------- | ----------------------------------------------------------- | +| `/.env` | docker-compose only | Ports, container credentials | +| `apps/api/.env` | API | `DATABASE_URL`, JWT secrets, storage credentials | +| `apps/storefront/.env` | Storefront | `NEXT_PUBLIC_*`, `API_INTERNAL_URL` | +| `apps/admin/.env` | Admin | `NEXT_PUBLIC_*`, `API_INTERNAL_URL` — **no `DATABASE_URL`** | + +Rules: + +1. **The API validates its entire environment at boot** with Zod and refuses to start on any + problem, listing all of them at once. A missing JWT secret is discovered at deploy time, not + at 2am by a customer. +2. **`process.env` is read in exactly one place per app.** Everything else injects a typed + config object. +3. **`NEXT_PUBLIC_*` is public.** It is inlined into the client bundle. No secret ever carries + that prefix. +4. **`NEXT_PUBLIC_*` is baked at build time**, not container start — which is why the + Dockerfiles take them as build args. +5. Secrets are never committed. `pnpm setup` generates real JWT secrets locally so that not even + a laptop runs on a value present in the repository. + +--- + +## 11. Where premature abstraction must be avoided + +Places where the instinct to generalise should be resisted until a second real case appears: + +- **Payment providers.** Build VNPay concretely first. One implementation does not reveal the + right interface; two do. Guessing produces an abstraction shaped like VNPay with a misleading + name. +- **A generic repository layer.** `BaseRepository` with generic CRUD sounds appealing and + ends as a layer that obstructs every non-trivial query. Prisma is already the abstraction. +- **CQRS / event sourcing.** The inventory ledger is append-only because inventory genuinely + needs an audit trail — that is not a mandate to apply the pattern everywhere. +- **A shared `` before three tables exist.** Two tables with different needs produce + a component with thirty props. +- **A plugin architecture for the CMS.** Build the homepage blocks that are needed. A page + builder is a product, not a feature. +- **Micro-optimising the cache.** Add caching when a query is measurably slow, keyed and + invalidated deliberately. Cache invalidation bugs are worse than slow pages. +- **Extracting a service.** The boundaries exist so extraction _stays possible_, not so it + happens. Extract when there is a real scaling or team-boundary problem. + +--- + +## 12. Architectural risks to prevent from day one + +| Risk | Why it is fatal later | Prevention in place | +| ---------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Size/colour as product columns | Cannot express per-combination stock or price; requires re-modelling after orders exist | Variant model ([ADR-0003](./adr/0003-product-and-productvariant-as-separate-entities.md)) | +| Float money | Silent discrepancies, unfixable retroactively | Integer minor units ([ADR-0011](./adr/0011-money-as-integer-minor-units.md)) | +| Role checks scattered in code | Authorization becomes unauditable; new roles need deploys | RBAC + global guard ([ADR-0007](./adr/0007-rbac-permissions-instead-of-role-checks.md)) | +| Admin querying the DB directly | A second write path where authorization is forgotten | No DB driver in admin ([ADR-0004](./adr/0004-the-admin-dashboard-has-no-database-access.md)) | +| Module boundary erosion | The monolith becomes unsplittable and untestable | ESLint boundary rules + `public/` barrels | +| Order lines joined to live catalog | Historical invoices change when prices do | Snapshot fields on order lines (milestone 5) | +| Overselling under concurrency | Real money, real customers, real refunds | `reserved` column + transactional reservation | +| Unversioned API | Cannot ship a breaking change once a mobile app exists | URI versioning from request one ([ADR-0005](./adr/0005-uri-based-api-versioning.md)) | +| Binaries in PostgreSQL | Backups and replication degrade permanently | Object storage ([ADR-0009](./adr/0009-media-in-s3-compatible-storage-metadata-in-postgresql.md)) | +| Single-warehouse inventory | Adding a location later means migrating live stock history | `(variant, location)` keys from the start | +| Secrets in the repository | One leak compromises production | Validated env, generated dev secrets, `.env` gitignored | +| No request correlation | Production incidents become guesswork | `x-request-id` end to end | + +--- + +## 13. Deliberate limitations of milestone 0 + +Stated plainly so they are choices rather than oversights: + +- **No token issuance.** Guards verify and enforce; login, refresh and registration are M2. +- **No cart/order/payment tables.** The first migration stays reviewable; they arrive in M5. +- **The event bus is in-process and lossy.** Anything that must not be lost stays in the same + database transaction as its cause. A durable outbox comes when a use case demands it. +- **No observability beyond logs.** OpenTelemetry traces and metrics are worth adding once there + is production traffic to explain. +- **No CDN, TLS or WAF config.** That belongs to the deployment repository, not this one. diff --git a/infrastructure/docker/admin.Dockerfile b/infrastructure/docker/admin.Dockerfile new file mode 100644 index 0000000..238ba66 --- /dev/null +++ b/infrastructure/docker/admin.Dockerfile @@ -0,0 +1,44 @@ +# --------------------------------------------------------------------------- +# @sport/admin production image (Next.js standalone output). +# --------------------------------------------------------------------------- +FROM node:22-alpine AS base +RUN corepack enable +WORKDIR /app + +FROM base AS pruner +RUN apk add --no-cache libc6-compat +COPY . . +RUN pnpm dlx turbo@2.10.9 prune @sport/admin --docker + +FROM base AS builder +RUN apk add --no-cache libc6-compat + +COPY --from=pruner /app/out/json/ . +RUN pnpm install --frozen-lockfile + +COPY --from=pruner /app/out/full/ . + +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_APP_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN pnpm turbo run build --filter=@sport/admin + +FROM base AS runner +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3001 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs + +COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/.next/static ./apps/admin/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/admin/public ./apps/admin/public + +USER nextjs +EXPOSE 3001 + +CMD ["node", "apps/admin/server.js"] diff --git a/infrastructure/docker/api.Dockerfile b/infrastructure/docker/api.Dockerfile new file mode 100644 index 0000000..eda47ce --- /dev/null +++ b/infrastructure/docker/api.Dockerfile @@ -0,0 +1,55 @@ +# --------------------------------------------------------------------------- +# @sport/api production image. +# +# `turbo prune` produces a subset of the monorepo containing only this app and +# its workspace dependencies. That keeps the build context small AND makes the +# Docker layer cache work properly: editing the storefront no longer busts the +# API's dependency layer. +# --------------------------------------------------------------------------- +FROM node:22-alpine AS base +RUN corepack enable +WORKDIR /app + +# --- Stage 1: prune the workspace ------------------------------------------ +FROM base AS pruner +RUN apk add --no-cache libc6-compat +COPY . . +RUN pnpm dlx turbo@2.10.9 prune @sport/api --docker + +# --- Stage 2: install + build ---------------------------------------------- +FROM base AS builder +RUN apk add --no-cache libc6-compat openssl + +# Lockfile and manifests first: this layer is cached until dependencies change. +COPY --from=pruner /app/out/json/ . +RUN pnpm install --frozen-lockfile + +COPY --from=pruner /app/out/full/ . +RUN pnpm turbo run build --filter=@sport/api + +# Drop dev dependencies from the tree we are about to copy. +RUN pnpm prune --prod + +# --- Stage 3: runtime ------------------------------------------------------- +FROM base AS runner +RUN apk add --no-cache openssl tini + +ENV NODE_ENV=production + +# Never run as root. +RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nestjs -G nodejs + +COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=nestjs:nodejs /app/packages ./packages +COPY --from=builder --chown=nestjs:nodejs /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=builder --chown=nestjs:nodejs /app/apps/api/dist ./apps/api/dist +COPY --from=builder --chown=nestjs:nodejs /app/apps/api/prisma ./apps/api/prisma +COPY --from=builder --chown=nestjs:nodejs /app/apps/api/package.json ./apps/api/package.json + +USER nestjs +WORKDIR /app/apps/api +EXPOSE 4000 + +# tini reaps zombies and forwards SIGTERM, so Nest's shutdown hooks actually run. +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "dist/main.js"] diff --git a/infrastructure/docker/storefront.Dockerfile b/infrastructure/docker/storefront.Dockerfile new file mode 100644 index 0000000..7ed200e --- /dev/null +++ b/infrastructure/docker/storefront.Dockerfile @@ -0,0 +1,48 @@ +# --------------------------------------------------------------------------- +# @sport/storefront production image (Next.js standalone output). +# --------------------------------------------------------------------------- +FROM node:22-alpine AS base +RUN corepack enable +WORKDIR /app + +FROM base AS pruner +RUN apk add --no-cache libc6-compat +COPY . . +RUN pnpm dlx turbo@2.10.9 prune @sport/storefront --docker + +FROM base AS builder +RUN apk add --no-cache libc6-compat + +COPY --from=pruner /app/out/json/ . +RUN pnpm install --frozen-lockfile + +COPY --from=pruner /app/out/full/ . + +# NEXT_PUBLIC_* values are inlined into the client bundle at build time, so they +# must be present here — they cannot be injected at container start. +ARG NEXT_PUBLIC_API_URL +ARG NEXT_PUBLIC_SITE_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN pnpm turbo run build --filter=@sport/storefront + +FROM base AS runner +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs + +# `output: 'standalone'` emits a self-contained server with only the modules it +# actually imports — a fraction of the size of copying node_modules. +COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/.next/static ./apps/storefront/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/storefront/public ./apps/storefront/public + +USER nextjs +EXPOSE 3000 + +CMD ["node", "apps/storefront/server.js"] diff --git a/infrastructure/nginx/conf.d/default.conf b/infrastructure/nginx/conf.d/default.conf new file mode 100644 index 0000000..fd430ae --- /dev/null +++ b/infrastructure/nginx/conf.d/default.conf @@ -0,0 +1,81 @@ +# --------------------------------------------------------------------------- +# Edge routing. +# +# In production Cloudflare terminates TLS and sits in front of this; Nginx +# handles routing, buffering, compression and the per-IP rate ceiling. TLS +# config is intentionally absent here because the local stack is plain HTTP. +# --------------------------------------------------------------------------- + +upstream storefront_upstream { + server storefront:3000; + keepalive 32; +} + +upstream admin_upstream { + server admin:3001; + keepalive 32; +} + +upstream api_upstream { + server api:4000; + keepalive 32; +} + +# --- Admin dashboard: separate hostname ------------------------------------ +# A distinct origin means an XSS on the storefront cannot reach admin cookies, +# and the whole host can be IP-restricted or put behind Cloudflare Access. +server { + listen 80; + server_name admin.localhost; + + add_header X-Robots-Tag "noindex, nofollow" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + + location /api/ { + limit_req zone=api_limit burst=20 nodelay; + proxy_pass http://api_upstream; + } + + location / { + limit_req zone=web_limit burst=40 nodelay; + proxy_pass http://admin_upstream; + } +} + +# --- Storefront + public API ----------------------------------------------- +server { + listen 80 default_server; + server_name localhost _; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location /api/ { + limit_req zone=api_limit burst=20 nodelay; + proxy_pass http://api_upstream; + + # Payment webhooks and image uploads must not be truncated by a short + # read timeout; everything else here is fast anyway. + proxy_read_timeout 60s; + proxy_buffering off; + } + + location = /health { + access_log off; + proxy_pass http://api_upstream/api/v1/health/live; + } + + # Next.js build output is content-hashed and therefore immutable. + location /_next/static/ { + proxy_pass http://storefront_upstream; + proxy_cache_valid 200 365d; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + location / { + limit_req zone=web_limit burst=40 nodelay; + proxy_pass http://storefront_upstream; + } +} diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf new file mode 100644 index 0000000..55070f2 --- /dev/null +++ b/infrastructure/nginx/nginx.conf @@ -0,0 +1,73 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 2048; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # `request_id` is generated here when the client did not supply one and is + # forwarded to the API, which echoes it back. One id ties the browser's + # network tab, this access log and the application log together. + log_format json_combined escape=json + '{' + '"time":"$time_iso8601",' + '"request_id":"$http_x_request_id",' + '"remote_addr":"$remote_addr",' + '"method":"$request_method",' + '"uri":"$request_uri",' + '"status":$status,' + '"bytes":$body_bytes_sent,' + '"duration":$request_time,' + '"upstream_time":"$upstream_response_time",' + '"referer":"$http_referer",' + '"user_agent":"$http_user_agent"' + '}'; + + access_log /var/log/nginx/access.log json_combined; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + server_tokens off; + + client_max_body_size 25m; + client_body_timeout 30s; + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types + application/javascript + application/json + application/xml + image/svg+xml + text/css + text/plain + text/xml; + + # Defence in depth. The API rate-limits per actor; this is a blunt + # per-IP ceiling that protects the app tier from ever seeing a flood. + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s; + limit_req_zone $binary_remote_addr zone=web_limit:10m rate=60r/s; + limit_req_status 429; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + include /etc/nginx/conf.d/*.conf; +} diff --git a/infrastructure/scripts/bootstrap.sh b/infrastructure/scripts/bootstrap.sh new file mode 100644 index 0000000..a12d701 --- /dev/null +++ b/infrastructure/scripts/bootstrap.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# One-command local setup. Idempotent: safe to re-run at any time. +# +# pnpm setup +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +blue() { printf "\033[34m%s\033[0m\n" "$1"; } +green() { printf "\033[32m%s\033[0m\n" "$1"; } +warn() { printf "\033[33m%s\033[0m\n" "$1"; } + +# --- 1. Environment files --------------------------------------------------- +blue "→ Preparing environment files" +for example in .env.example apps/api/.env.example apps/storefront/.env.example apps/admin/.env.example; do + target="${example%.example}" + if [ -f "$target" ]; then + echo " · $target already exists, leaving it alone" + else + cp "$example" "$target" + echo " · created $target" + fi +done + +# --- 2. Real JWT secrets ---------------------------------------------------- +# The committed examples contain obvious placeholders. Replace them with real +# random values so that no environment — not even a laptop — runs on a secret +# that exists in the repository. +if grep -q "dev-only-access-secret-change-me" apps/api/.env 2>/dev/null; then + blue "→ Generating JWT secrets" + access_secret="$(openssl rand -base64 48 | tr -d '\n/+=' | cut -c1-48)" + refresh_secret="$(openssl rand -base64 48 | tr -d '\n/+=' | cut -c1-48)" + + # BSD and GNU sed disagree about -i; write through a temp file instead. + tmp="$(mktemp)" + sed -e "s|^JWT_ACCESS_SECRET=.*|JWT_ACCESS_SECRET=${access_secret}|" \ + -e "s|^JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=${refresh_secret}|" \ + apps/api/.env > "$tmp" && mv "$tmp" apps/api/.env + echo " · wrote fresh secrets to apps/api/.env" +fi + +# --- 3. Dependencies -------------------------------------------------------- +blue "→ Installing dependencies" +pnpm install + +# --- 4. Backing services ---------------------------------------------------- +blue "→ Starting PostgreSQL, Redis, MinIO and Mailpit" +docker compose up -d postgres redis minio minio-init mailpit + +blue "→ Waiting for PostgreSQL" +for _ in $(seq 1 30); do + if docker compose exec -T postgres pg_isready -q 2>/dev/null; then break; fi + sleep 1 +done + +# --- 5. Database ------------------------------------------------------------ +blue "→ Applying migrations and seeding" +pnpm --filter @sport/api run db:generate +if [ -d apps/api/prisma/migrations ]; then + pnpm --filter @sport/api exec prisma migrate deploy +else + warn " · no migrations yet — run: pnpm db:migrate --name init" + pnpm --filter @sport/api exec prisma db push +fi +pnpm --filter @sport/api run db:seed + +green "" +green "Ready. Start everything with: pnpm dev" +green "" +echo " Storefront http://localhost:3000" +echo " Admin http://localhost:3001" +echo " API http://localhost:4000/api/v1/health" +echo " API docs http://localhost:4000/docs" +echo " MinIO http://localhost:9001" +echo " Mailpit http://localhost:8025" diff --git a/infrastructure/scripts/reset-db.sh b/infrastructure/scripts/reset-db.sh new file mode 100644 index 0000000..f3b907d --- /dev/null +++ b/infrastructure/scripts/reset-db.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# Destroy and rebuild the local database. Local development only. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +printf "\033[33mThis deletes every row in the local database. Continue? [y/N] \033[0m" +read -r reply +[[ "$reply" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } + +pnpm --filter @sport/api exec prisma migrate reset --force +printf "\033[32mDatabase reset and reseeded.\033[0m\n" diff --git a/package.json b/package.json new file mode 100644 index 0000000..962e048 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "sport-store", + "version": "0.1.0", + "private": true, + "description": "Modern sports fashion e-commerce platform (monorepo)", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" + }, + "scripts": { + "dev": "turbo run dev", + "dev:storefront": "turbo run dev --filter=@sport/storefront...", + "dev:admin": "turbo run dev --filter=@sport/admin...", + "dev:api": "turbo run dev --filter=@sport/api...", + "build": "turbo run build", + "lint": "turbo run lint", + "lint:fix": "turbo run lint -- --fix", + "typecheck": "turbo run typecheck", + "test": "turbo run test", + "format": "prettier --write .", + "format:check": "prettier --check .", + "clean": "turbo run clean", + "setup": "bash infrastructure/scripts/bootstrap.sh", + "infra:up": "docker compose up -d postgres redis minio minio-init mailpit", + "infra:down": "docker compose down", + "infra:reset": "docker compose down -v && pnpm run infra:up", + "infra:logs": "docker compose logs -f", + "db:generate": "pnpm --filter @sport/api run db:generate", + "db:migrate": "pnpm --filter @sport/api run db:migrate", + "db:deploy": "pnpm --filter @sport/api run db:deploy", + "db:studio": "pnpm --filter @sport/api run db:studio", + "db:seed": "pnpm --filter @sport/api run db:seed", + "db:reset": "pnpm --filter @sport/api run db:reset" + }, + "devDependencies": { + "@sport/eslint-config": "workspace:*", + "eslint": "9.39.5", + "prettier": "3.9.6", + "prettier-plugin-tailwindcss": "^0.6.14", + "turbo": "2.10.9", + "typescript": "5.9.3" + } +} diff --git a/packages/api-client/eslint.config.mjs b/packages/api-client/eslint.config.mjs new file mode 100644 index 0000000..5eba426 --- /dev/null +++ b/packages/api-client/eslint.config.mjs @@ -0,0 +1,3 @@ +import { baseConfig } from '@sport/eslint-config/base'; + +export default baseConfig; diff --git a/packages/api-client/package.json b/packages/api-client/package.json new file mode 100644 index 0000000..9eccb14 --- /dev/null +++ b/packages/api-client/package.json @@ -0,0 +1,34 @@ +{ + "name": "@sport/api-client", + "version": "0.0.0", + "private": true, + "description": "The single sanctioned way for any frontend to talk to the REST API.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "clean": "rm -rf dist .turbo *.tsbuildinfo", + "lint": "eslint src", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@sport/types": "workspace:*" + }, + "devDependencies": { + "@sport/config": "workspace:*", + "@sport/eslint-config": "workspace:*", + "@types/node": "^22.19.0", + "eslint": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/api-client/src/create-client.ts b/packages/api-client/src/create-client.ts new file mode 100644 index 0000000..59cfc07 --- /dev/null +++ b/packages/api-client/src/create-client.ts @@ -0,0 +1,22 @@ +import { HttpClient, type HttpClientOptions } from './http-client'; +import { createHealthResource, type HealthResource } from './resources/health'; + +/** + * Resource modules are added here as the backend grows — one file per bounded + * context under `src/resources/`, mirroring the NestJS module names exactly. + * Keep them thin: URL + types, no business logic and no caching policy (that is + * the calling app's decision). + */ +export interface ApiClient { + readonly http: HttpClient; + readonly health: HealthResource; +} + +export function createApiClient(options: HttpClientOptions): ApiClient { + const http = new HttpClient(options); + + return { + http, + health: createHealthResource(http), + }; +} diff --git a/packages/api-client/src/errors.ts b/packages/api-client/src/errors.ts new file mode 100644 index 0000000..1b71db4 --- /dev/null +++ b/packages/api-client/src/errors.ts @@ -0,0 +1,64 @@ +import { + API_ERROR_CODES, + type ApiErrorBody, + type ApiErrorCode, + type ApiFieldErrors, +} from '@sport/types'; + +/** + * Every failure — HTTP error, network failure, malformed body — surfaces as + * this one class. Callers never have to inspect a Response object. + */ +export class ApiClientError extends Error { + readonly code: ApiErrorCode; + readonly status: number; + readonly fields?: ApiFieldErrors; + readonly requestId?: string; + + constructor(params: { + code: ApiErrorCode; + message: string; + status: number; + fields?: ApiFieldErrors; + requestId?: string; + cause?: unknown; + }) { + super(params.message, { cause: params.cause }); + this.name = 'ApiClientError'; + this.code = params.code; + this.status = params.status; + this.fields = params.fields; + this.requestId = params.requestId; + } + + static fromBody(body: ApiErrorBody, status: number, requestId?: string): ApiClientError { + return new ApiClientError({ + code: body.code, + message: body.message, + status, + fields: body.fields, + requestId, + }); + } + + static network(cause: unknown): ApiClientError { + return new ApiClientError({ + code: API_ERROR_CODES.SERVICE_UNAVAILABLE, + message: 'Could not reach the server. Please check your connection and try again.', + status: 0, + cause, + }); + } + + get isAuthError(): boolean { + return this.status === 401; + } + + get isValidationError(): boolean { + return this.code === API_ERROR_CODES.VALIDATION_FAILED; + } +} + +export function isApiClientError(error: unknown): error is ApiClientError { + return error instanceof ApiClientError; +} diff --git a/packages/api-client/src/http-client.ts b/packages/api-client/src/http-client.ts new file mode 100644 index 0000000..9b5602b --- /dev/null +++ b/packages/api-client/src/http-client.ts @@ -0,0 +1,156 @@ +import { API_ERROR_CODES, type ApiResponse } from '@sport/types'; + +import { ApiClientError } from './errors'; + +export interface HttpClientOptions { + /** Origin only, e.g. `http://localhost:4000`. The version prefix is added here. */ + baseUrl: string; + /** Defaults to `v1`. */ + apiVersion?: string; + /** Resolved per request so a rotated token is picked up without re-creating the client. */ + getAccessToken?: () => string | null | undefined | Promise; + /** Invoked once on 401 so the app can refresh or redirect. Return true to retry. */ + onUnauthorized?: () => boolean | Promise; + defaultHeaders?: Record; + timeoutMs?: number; + /** Injectable for tests and for runtimes with a patched fetch (Next.js). */ + fetchImpl?: typeof fetch; +} + +export interface RequestOptions { + query?: Record; + headers?: Record; + signal?: AbortSignal; + /** Forwarded verbatim to Next.js's extended fetch. Ignored elsewhere. */ + next?: { revalidate?: number | false; tags?: string[] }; + cache?: RequestCache; + /** Send cookies (used by the refresh-token flow). */ + credentials?: RequestCredentials; +} + +type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + +const DEFAULT_TIMEOUT_MS = 15_000; + +export class HttpClient { + private readonly baseUrl: string; + private readonly apiVersion: string; + private readonly options: HttpClientOptions; + private readonly fetchImpl: typeof fetch; + + constructor(options: HttpClientOptions) { + this.options = options; + this.baseUrl = options.baseUrl.replace(/\/+$/, ''); + this.apiVersion = options.apiVersion ?? 'v1'; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch; + } + + get(path: string, options?: RequestOptions): Promise { + return this.request('GET', path, undefined, options); + } + + post(path: string, body?: unknown, options?: RequestOptions): Promise { + return this.request('POST', path, body, options); + } + + patch(path: string, body?: unknown, options?: RequestOptions): Promise { + return this.request('PATCH', path, body, options); + } + + put(path: string, body?: unknown, options?: RequestOptions): Promise { + return this.request('PUT', path, body, options); + } + + delete(path: string, options?: RequestOptions): Promise { + return this.request('DELETE', path, undefined, options); + } + + private async request( + method: Method, + path: string, + body?: unknown, + options?: RequestOptions, + isRetry = false, + ): Promise { + const url = this.buildUrl(path, options?.query); + const headers = new Headers({ + Accept: 'application/json', + ...this.options.defaultHeaders, + ...options?.headers, + }); + + if (body !== undefined) { + headers.set('Content-Type', 'application/json'); + } + + const token = await this.options.getAccessToken?.(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + const timeout = AbortSignal.timeout(this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + const signal = options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout; + + let response: Response; + try { + response = await this.fetchImpl(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal, + credentials: options?.credentials ?? 'include', + ...(options?.cache ? { cache: options.cache } : {}), + ...(options?.next ? { next: options.next } : {}), + } as RequestInit); + } catch (cause) { + throw ApiClientError.network(cause); + } + + if (response.status === 401 && !isRetry && this.options.onUnauthorized) { + const shouldRetry = await this.options.onUnauthorized(); + if (shouldRetry) { + return this.request(method, path, body, options, true); + } + } + + if (response.status === 204) { + return undefined as T; + } + + const requestId = response.headers.get('x-request-id') ?? undefined; + let payload: ApiResponse; + try { + payload = (await response.json()) as ApiResponse; + } catch (cause) { + throw new ApiClientError({ + code: API_ERROR_CODES.INTERNAL_ERROR, + message: 'The server returned an unreadable response.', + status: response.status, + requestId, + cause, + }); + } + + if (!payload.success) { + throw ApiClientError.fromBody(payload.error, response.status, payload.meta?.requestId); + } + + return payload.data; + } + + private buildUrl(path: string, query?: RequestOptions['query']): string { + const normalized = path.startsWith('/') ? path : `/${path}`; + const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`); + + for (const [key, value] of Object.entries(query ?? {})) { + if (value === undefined || value === null || value === '') continue; + if (Array.isArray(value)) { + if (value.length > 0) url.searchParams.set(key, value.join(',')); + } else { + url.searchParams.set(key, String(value)); + } + } + + return url.toString(); + } +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts new file mode 100644 index 0000000..53d0d17 --- /dev/null +++ b/packages/api-client/src/index.ts @@ -0,0 +1,18 @@ +/** + * @sport/api-client + * + * Storefront and admin reach the backend through this package and nothing else. + * No `fetch('/api/...')` calls scattered through components, no duplicated URL + * building, no per-app error handling. When the API version bumps or a payload + * changes, exactly one package needs updating. + * + * It is deliberately runtime-agnostic (plain `fetch`, no React) so the same + * client works in React Server Components, route handlers, client components + * and, later, a React Native app. + */ + +export { ApiClientError, isApiClientError } from './errors'; +export { HttpClient } from './http-client'; +export type { HttpClientOptions, RequestOptions } from './http-client'; +export { createApiClient } from './create-client'; +export type { ApiClient } from './create-client'; diff --git a/packages/api-client/src/resources/health.ts b/packages/api-client/src/resources/health.ts new file mode 100644 index 0000000..f3614f2 --- /dev/null +++ b/packages/api-client/src/resources/health.ts @@ -0,0 +1,28 @@ +import type { HttpClient } from '../http-client'; + +export interface HealthCheckResult { + status: 'ok' | 'degraded'; + uptimeSeconds: number; + version: string; + environment: string; + dependencies: { + database: DependencyStatus; + redis: DependencyStatus; + }; +} + +export interface DependencyStatus { + status: 'up' | 'down'; + latencyMs: number | null; + error?: string; +} + +export interface HealthResource { + check(): Promise; +} + +export function createHealthResource(http: HttpClient): HealthResource { + return { + check: () => http.get('/health', { cache: 'no-store' }), + }; +} diff --git a/packages/api-client/tsconfig.json b/packages/api-client/tsconfig.json new file mode 100644 index 0000000..45a8df2 --- /dev/null +++ b/packages/api-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@sport/config/typescript/library.json", + "compilerOptions": { + "lib": ["ES2023", "DOM"], + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..97baa82 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sport/config", + "version": "0.0.0", + "private": true, + "description": "Build-time configuration shared across the workspace (TypeScript bases, Tailwind theme). Contains NO runtime code.", + "files": [ + "typescript", + "tailwind" + ], + "exports": { + "./typescript/base.json": "./typescript/base.json", + "./typescript/library.json": "./typescript/library.json", + "./typescript/react-library.json": "./typescript/react-library.json", + "./typescript/nextjs.json": "./typescript/nextjs.json", + "./typescript/nestjs.json": "./typescript/nestjs.json", + "./tailwind/theme.css": "./tailwind/theme.css" + }, + "scripts": { + "lint": "echo 'no lint target'", + "typecheck": "echo 'no typecheck target'" + } +} diff --git a/packages/config/tailwind/theme.css b/packages/config/tailwind/theme.css new file mode 100644 index 0000000..64587a3 --- /dev/null +++ b/packages/config/tailwind/theme.css @@ -0,0 +1,96 @@ +/** + * Sport Store — shared design tokens (Tailwind CSS v4, CSS-first configuration). + * + * This file is the SINGLE source of truth for the visual language of both the + * storefront and the admin dashboard. Apps import it after `@import "tailwindcss"`. + * + * Direction: modern, premium, minimal, sport-oriented (Nike / Gymshark spirit). + * High-contrast neutrals, one energetic accent, generous whitespace, tight type. + */ + +@theme { + /* ---- Brand ------------------------------------------------------------ */ + /* Near-black ink, not pure black: prints and photographs better. */ + --color-ink-50: oklch(0.98 0.002 260); + --color-ink-100: oklch(0.95 0.003 260); + --color-ink-200: oklch(0.89 0.004 260); + --color-ink-300: oklch(0.78 0.005 260); + --color-ink-400: oklch(0.63 0.006 260); + --color-ink-500: oklch(0.51 0.007 260); + --color-ink-600: oklch(0.41 0.008 260); + --color-ink-700: oklch(0.32 0.009 260); + --color-ink-800: oklch(0.23 0.01 260); + --color-ink-900: oklch(0.16 0.011 260); + --color-ink-950: oklch(0.11 0.012 260); + + /* Energetic accent — "volt". Used sparingly: CTAs, price, sale badges. */ + --color-volt-50: oklch(0.97 0.06 125); + --color-volt-100: oklch(0.94 0.11 125); + --color-volt-200: oklch(0.9 0.16 125); + --color-volt-300: oklch(0.86 0.2 125); + --color-volt-400: oklch(0.82 0.23 125); + --color-volt-500: oklch(0.76 0.24 125); + --color-volt-600: oklch(0.66 0.21 125); + --color-volt-700: oklch(0.54 0.17 125); + --color-volt-800: oklch(0.43 0.13 125); + --color-volt-900: oklch(0.35 0.1 125); + + /* Semantic */ + --color-sale: oklch(0.58 0.21 27); + --color-success: oklch(0.65 0.16 155); + --color-warning: oklch(0.78 0.15 80); + --color-danger: oklch(0.58 0.22 27); + + /* ---- Typography ------------------------------------------------------- */ + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-display: var(--font-display), var(--font-inter), ui-sans-serif, system-ui, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; + + --tracking-display: -0.03em; + + /* ---- Layout ----------------------------------------------------------- */ + --spacing-gutter: 1.25rem; + --container-page: 90rem; + + --radius-card: 0.25rem; + --radius-pill: 999px; + + /* ---- Motion ----------------------------------------------------------- */ + --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); + --animate-rise: rise 0.5s var(--ease-out-quint) both; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(0.75rem); + } + to { + opacity: 1; + transform: none; + } +} + +@layer base { + :root { + color-scheme: light; + } + + html { + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + } + + /* Sport/streetwear headings: tight, uppercase-capable, no letterspacing drift. */ + h1, + h2, + h3 { + letter-spacing: var(--tracking-display); + text-wrap: balance; + } + + ::selection { + background-color: var(--color-volt-300); + color: var(--color-ink-950); + } +} diff --git a/packages/config/typescript/base.json b/packages/config/typescript/base.json new file mode 100644 index 0000000..829f018 --- /dev/null +++ b/packages/config/typescript/base.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Sport Store — Base", + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowUnreachableCode": false, + "useUnknownInCatchVariables": true, + + "isolatedModules": true, + "verbatimModuleSyntax": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "incremental": true, + "composite": false, + }, + "exclude": ["node_modules", "dist", "build", ".next", ".turbo", "coverage"], +} diff --git a/packages/config/typescript/library.json b/packages/config/typescript/library.json new file mode 100644 index 0000000..562b84b --- /dev/null +++ b/packages/config/typescript/library.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Sport Store — Compiled Library (CJS + d.ts)", + "extends": "./base.json", + // NOTE: `outDir` and `rootDir` are deliberately NOT set here. Relative paths + // in an extended config resolve against the file that declares them, so they + // would point inside packages/config. Each package sets its own. + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "verbatimModuleSyntax": false, + }, +} diff --git a/packages/config/typescript/nestjs.json b/packages/config/typescript/nestjs.json new file mode 100644 index 0000000..d38963d --- /dev/null +++ b/packages/config/typescript/nestjs.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Sport Store — NestJS App", + "extends": "./base.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + + // NestJS DI relies on `emitDecoratorMetadata`, which needs the *value* of a + // constructor parameter's type to survive compilation. `verbatimModuleSyntax` + // erases such imports and silently breaks injection — it must stay off here. + "verbatimModuleSyntax": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + + // `outDir`/`rootDir` are set by the app — see library.json for why. + "declaration": false, + "declarationMap": false, + }, +} diff --git a/packages/config/typescript/nextjs.json b/packages/config/typescript/nextjs.json new file mode 100644 index 0000000..67b8d3d --- /dev/null +++ b/packages/config/typescript/nextjs.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Sport Store — Next.js App", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "module": "ESNext", + "moduleResolution": "Bundler", + "allowJs": true, + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "plugins": [{ "name": "next" }], + }, +} diff --git a/packages/config/typescript/react-library.json b/packages/config/typescript/react-library.json new file mode 100644 index 0000000..0c9ab57 --- /dev/null +++ b/packages/config/typescript/react-library.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Sport Store — React Source Library (consumed via transpilePackages)", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false, + }, +} diff --git a/packages/eslint-config/base.js b/packages/eslint-config/base.js new file mode 100644 index 0000000..03d59a8 --- /dev/null +++ b/packages/eslint-config/base.js @@ -0,0 +1,102 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier'; +import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; +import importX from 'eslint-plugin-import-x'; +import turbo from 'eslint-plugin-turbo'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +/** + * Base flat config: TypeScript rules + import hygiene + the architectural + * boundary rules that every package in the workspace must obey. + * + * @type {import("eslint").Linter.Config[]} + */ +export const baseConfig = [ + { + ignores: [ + '**/dist/**', + '**/build/**', + '**/.next/**', + '**/.turbo/**', + '**/coverage/**', + '**/node_modules/**', + '**/generated/**', + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + importX.flatConfigs.recommended, + importX.flatConfigs.typescript, + turbo.configs['flat/recommended'], + { + languageOptions: { + ecmaVersion: 2023, + globals: { ...globals.node, ...globals.es2021 }, + }, + settings: { + // TypeScript resolver first: it understands `.ts` extensionless imports, + // path aliases and workspace `exports` maps. The node resolver is the + // fallback for plain JS config files. + 'import-x/resolver-next': [ + createTypeScriptImportResolver({ alwaysTryTypes: true, project: ['*/tsconfig.json'] }), + importX.createNodeResolver(), + ], + }, + rules: { + // --- Type safety ------------------------------------------------- + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'inline-type-imports' }, + ], + // `import type` statements stay with their source group rather than being + // herded to the bottom of the file, where they lose the context of what + // they belong to. + '@typescript-eslint/no-import-type-side-effects': 'error', + '@typescript-eslint/no-non-null-assertion': 'warn', + + // --- Import hygiene ---------------------------------------------- + 'import-x/no-default-export': 'error', + 'import-x/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + pathGroups: [{ pattern: '@sport/**', group: 'internal', position: 'before' }], + pathGroupsExcludedImportTypes: ['type'], + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + 'import-x/no-cycle': ['error', { maxDepth: 4 }], + + // --- General ------------------------------------------------------ + eqeqeq: ['error', 'smart'], + 'no-console': ['error', { allow: ['warn', 'error'] }], + 'prefer-const': 'error', + 'object-shorthand': 'error', + }, + }, + { + // Config files and scripts are allowed default exports and console output. + files: ['**/*.config.{js,mjs,ts}', '**/scripts/**', '**/*.cjs'], + rules: { + 'import-x/no-default-export': 'off', + 'no-console': 'off', + }, + }, + { + files: ['**/*.{test,spec}.{ts,tsx}', '**/test/**'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'no-console': 'off', + }, + }, + prettier, +]; + +export default baseConfig; diff --git a/packages/eslint-config/nest.js b/packages/eslint-config/nest.js new file mode 100644 index 0000000..6f5207c --- /dev/null +++ b/packages/eslint-config/nest.js @@ -0,0 +1,59 @@ +import { baseConfig } from './base.js'; + +/** + * NestJS config. + * + * The two rules that matter architecturally: + * 1. Only the persistence layer may import PrismaService / Redis directly. + * 2. A module must not deep-import another module's internals — cross-module + * access goes through the other module's public surface (`/public`). + * + * @type {import("eslint").Linter.Config[]} + */ +export const nestConfig = [ + ...baseConfig, + { + files: ['**/*.ts'], + rules: { + /** + * OFF, and it must stay off. + * + * Nest resolves constructor dependencies from `emitDecoratorMetadata`, + * which requires the parameter's type to survive as a *value* import. + * This rule cannot tell an injected class from a pure type, so its + * autofix silently rewrites `import { PrismaService }` into + * `import { type PrismaService }` — which compiles cleanly and then + * fails at runtime with "Nest can't resolve dependencies". + * + * The same reasoning is why `verbatimModuleSyntax` is disabled in + * @sport/config/typescript/nestjs.json. + */ + '@typescript-eslint/consistent-type-imports': 'off', + + // Nest relies on parameter decorators and class-based DI. + '@typescript-eslint/no-extraneous-class': 'off', + '@typescript-eslint/no-empty-object-type': 'off', + 'no-console': 'error', + + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@/modules/*/*', '!@/modules/*/public'], + message: + 'Cross-module deep imports are forbidden. Import from `@/modules//public` instead.', + }, + ], + }, + ], + }, + }, + { + // Decorated DTO / entity classes legitimately have empty bodies. + files: ['**/*.dto.ts', '**/*.entity.ts'], + rules: { '@typescript-eslint/no-extraneous-class': 'off' }, + }, +]; + +export default nestConfig; diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js new file mode 100644 index 0000000..3e3a10b --- /dev/null +++ b/packages/eslint-config/next.js @@ -0,0 +1,56 @@ +import { reactConfig } from './react.js'; + +/** + * Next.js App Router config. + * + * Note: the App Router *requires* default exports for `page`/`layout`/`route` + * and friends, so the workspace-wide `no-default-export` rule is relaxed for + * those files only — everywhere else named exports remain mandatory. + * + * @type {import("eslint").Linter.Config[]} + */ +export const nextConfig = [ + ...reactConfig, + { + files: [ + 'src/app/**/{page,layout,template,loading,error,not-found,default,route,global-error,sitemap,robots,opengraph-image,icon,apple-icon,manifest}.{ts,tsx}', + 'src/middleware.ts', + 'next.config.{ts,mjs,js}', + 'instrumentation.ts', + ], + rules: { + 'import-x/no-default-export': 'off', + }, + }, + { + files: ['src/**/*.{ts,tsx}'], + rules: { + // The storefront and admin must reach the backend only through the + // generated API client — never with ad-hoc fetch calls or a DB driver. + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@prisma/client', + message: + 'Frontend apps must never talk to the database. Use @sport/api-client instead.', + }, + { + name: 'ioredis', + message: 'Frontend apps must never talk to Redis. Use @sport/api-client instead.', + }, + ], + patterns: [ + { + group: ['@sport/api/*', '@sport/api'], + message: 'Frontend apps must not import backend code. Use @sport/api-client.', + }, + ], + }, + ], + }, + }, +]; + +export default nextConfig; diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json new file mode 100644 index 0000000..3608e7a --- /dev/null +++ b/packages/eslint-config/package.json @@ -0,0 +1,33 @@ +{ + "name": "@sport/eslint-config", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Shared ESLint flat configs for the workspace.", + "exports": { + "./base": "./base.js", + "./react": "./react.js", + "./next": "./next.js", + "./nest": "./nest.js" + }, + "scripts": { + "lint": "echo 'no lint target'", + "typecheck": "echo 'no typecheck target'" + }, + "dependencies": { + "@eslint/js": "^9.39.0", + "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.5", + "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-turbo": "^2.10.9", + "globals": "^16.5.0", + "typescript-eslint": "^8.67.0" + }, + "devDependencies": { + "eslint": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/eslint-config/react.js b/packages/eslint-config/react.js new file mode 100644 index 0000000..44ff09a --- /dev/null +++ b/packages/eslint-config/react.js @@ -0,0 +1,34 @@ +import globals from 'globals'; +import a11y from 'eslint-plugin-jsx-a11y'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; + +import { baseConfig } from './base.js'; + +/** @type {import("eslint").Linter.Config[]} */ +export const reactConfig = [ + ...baseConfig, + { + files: ['**/*.{ts,tsx}'], + ...react.configs.flat.recommended, + languageOptions: { + ...react.configs.flat.recommended.languageOptions, + globals: { ...globals.browser, ...globals.serviceworker }, + }, + settings: { react: { version: 'detect' } }, + }, + { + files: ['**/*.{ts,tsx}'], + plugins: { 'react-hooks': reactHooks, 'jsx-a11y': a11y }, + rules: { + ...reactHooks.configs.recommended.rules, + ...a11y.flatConfigs.recommended.rules, + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + 'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }], + 'react/self-closing-comp': 'error', + }, + }, +]; + +export default reactConfig; diff --git a/packages/types/eslint.config.mjs b/packages/types/eslint.config.mjs new file mode 100644 index 0000000..5eba426 --- /dev/null +++ b/packages/types/eslint.config.mjs @@ -0,0 +1,3 @@ +import { baseConfig } from '@sport/eslint-config/base'; + +export default baseConfig; diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..b5c41ca --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,30 @@ +{ + "name": "@sport/types", + "version": "0.0.0", + "private": true, + "description": "Framework-free domain and API contract types shared by api, storefront and admin.", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", + "clean": "rm -rf dist .turbo *.tsbuildinfo", + "lint": "eslint src", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@sport/config": "workspace:*", + "@sport/eslint-config": "workspace:*", + "eslint": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/types/src/api/envelope.ts b/packages/types/src/api/envelope.ts new file mode 100644 index 0000000..5843289 --- /dev/null +++ b/packages/types/src/api/envelope.ts @@ -0,0 +1,45 @@ +import type { IsoDateTime } from '../primitives'; + +import type { ApiErrorCode } from './error-codes'; + +/** + * Every REST response uses one of these two shapes. No exceptions, including + * 500s — the global exception filter guarantees it. Clients can therefore + * branch on `success` alone and never on the HTTP status code. + */ + +export interface ApiMeta { + /** Correlates client logs, server logs and traces. Echoed as `x-request-id`. */ + readonly requestId: string; + readonly timestamp: IsoDateTime; +} + +export interface ApiSuccessResponse { + readonly success: true; + readonly data: TData; + readonly meta: ApiMeta; +} + +/** Field-level validation problems, keyed by dotted path (`items.0.quantity`). */ +export type ApiFieldErrors = Readonly>; + +export interface ApiErrorBody { + readonly code: ApiErrorCode; + /** Safe to show to end users; already localised by the backend. */ + readonly message: string; + readonly fields?: ApiFieldErrors; + /** Present only outside production. */ + readonly stack?: string; +} + +export interface ApiErrorResponse { + readonly success: false; + readonly error: ApiErrorBody; + readonly meta: ApiMeta; +} + +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +export function isApiError(response: ApiResponse): response is ApiErrorResponse { + return response.success === false; +} diff --git a/packages/types/src/api/error-codes.ts b/packages/types/src/api/error-codes.ts new file mode 100644 index 0000000..ce38134 --- /dev/null +++ b/packages/types/src/api/error-codes.ts @@ -0,0 +1,61 @@ +/** + * Stable, machine-readable error codes. + * + * Contract: a code is NEVER renamed or repurposed once shipped — clients, + * mobile apps and partner integrations branch on these strings. Adding a new + * code is always safe; changing one is a breaking API change. + * + * Format: `_`. + */ +export const API_ERROR_CODES = { + // --- Generic / transport ------------------------------------------------ + BAD_REQUEST: 'BAD_REQUEST', + VALIDATION_FAILED: 'VALIDATION_FAILED', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + INTERNAL_ERROR: 'INTERNAL_ERROR', + SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE', + + // --- Authentication / authorization ------------------------------------- + UNAUTHENTICATED: 'UNAUTHENTICATED', + INVALID_CREDENTIALS: 'INVALID_CREDENTIALS', + TOKEN_EXPIRED: 'TOKEN_EXPIRED', + TOKEN_INVALID: 'TOKEN_INVALID', + FORBIDDEN: 'FORBIDDEN', + PERMISSION_DENIED: 'PERMISSION_DENIED', + ACCOUNT_DISABLED: 'ACCOUNT_DISABLED', + + // --- Catalog ------------------------------------------------------------- + PRODUCT_NOT_FOUND: 'PRODUCT_NOT_FOUND', + VARIANT_NOT_FOUND: 'VARIANT_NOT_FOUND', + VARIANT_UNAVAILABLE: 'VARIANT_UNAVAILABLE', + + // --- Inventory ----------------------------------------------------------- + INSUFFICIENT_STOCK: 'INSUFFICIENT_STOCK', + RESERVATION_EXPIRED: 'RESERVATION_EXPIRED', + + // --- Cart / checkout / order -------------------------------------------- + CART_NOT_FOUND: 'CART_NOT_FOUND', + CART_EMPTY: 'CART_EMPTY', + CHECKOUT_EXPIRED: 'CHECKOUT_EXPIRED', + ORDER_NOT_FOUND: 'ORDER_NOT_FOUND', + ORDER_NOT_CANCELLABLE: 'ORDER_NOT_CANCELLABLE', + + // --- Promotions ---------------------------------------------------------- + COUPON_INVALID: 'COUPON_INVALID', + COUPON_EXPIRED: 'COUPON_EXPIRED', + COUPON_USAGE_EXCEEDED: 'COUPON_USAGE_EXCEEDED', + + // --- Payment ------------------------------------------------------------- + PAYMENT_FAILED: 'PAYMENT_FAILED', + PAYMENT_PROVIDER_ERROR: 'PAYMENT_PROVIDER_ERROR', + PAYMENT_SIGNATURE_INVALID: 'PAYMENT_SIGNATURE_INVALID', + + // --- Media --------------------------------------------------------------- + UPLOAD_REJECTED: 'UPLOAD_REJECTED', + FILE_TOO_LARGE: 'FILE_TOO_LARGE', + UNSUPPORTED_MEDIA_TYPE: 'UNSUPPORTED_MEDIA_TYPE', +} as const; + +export type ApiErrorCode = (typeof API_ERROR_CODES)[keyof typeof API_ERROR_CODES]; diff --git a/packages/types/src/api/pagination.ts b/packages/types/src/api/pagination.ts new file mode 100644 index 0000000..c200cf6 --- /dev/null +++ b/packages/types/src/api/pagination.ts @@ -0,0 +1,49 @@ +/** + * Two pagination strategies, chosen per endpoint and never mixed: + * + * - OFFSET — admin tables, where "page 7 of 42" is a real requirement. + * - CURSOR — storefront listings and infinite scroll, where correctness under + * concurrent writes matters more than random access. + */ + +export interface OffsetPageQuery { + page?: number; + perPage?: number; +} + +export interface OffsetPageInfo { + readonly page: number; + readonly perPage: number; + readonly totalItems: number; + readonly totalPages: number; + readonly hasNextPage: boolean; +} + +export interface OffsetPaginated { + readonly items: readonly T[]; + readonly pageInfo: OffsetPageInfo; +} + +export interface CursorPageQuery { + cursor?: string | null; + limit?: number; +} + +export interface CursorPageInfo { + readonly nextCursor: string | null; + readonly hasNextPage: boolean; +} + +export interface CursorPaginated { + readonly items: readonly T[]; + readonly pageInfo: CursorPageInfo; +} + +export type SortDirection = 'asc' | 'desc'; + +export const PAGINATION_DEFAULTS = { + perPage: 24, + maxPerPage: 100, + cursorLimit: 24, + maxCursorLimit: 100, +} as const; diff --git a/packages/types/src/auth/actors.ts b/packages/types/src/auth/actors.ts new file mode 100644 index 0000000..0f142d4 --- /dev/null +++ b/packages/types/src/auth/actors.ts @@ -0,0 +1,38 @@ +/** + * Two distinct actor populations live in this system and they must never be + * merged into one table or one token audience: + * + * - CUSTOMER — self-registered shoppers, authenticated on the storefront. + * - STAFF / ADMIN / SUPER_ADMIN — back-office operators, authenticated on the + * admin dashboard, always subject to RBAC. + * + * A customer token is rejected by admin endpoints purely on audience, before + * any permission check runs. That is a defence-in-depth boundary, not an + * optimisation. + */ +export const USER_TYPES = { + CUSTOMER: 'CUSTOMER', + STAFF: 'STAFF', + ADMIN: 'ADMIN', + SUPER_ADMIN: 'SUPER_ADMIN', +} as const; + +export type UserType = (typeof USER_TYPES)[keyof typeof USER_TYPES]; + +export const BACK_OFFICE_USER_TYPES: readonly UserType[] = [ + USER_TYPES.STAFF, + USER_TYPES.ADMIN, + USER_TYPES.SUPER_ADMIN, +]; + +export function isBackOfficeUser(type: UserType): boolean { + return BACK_OFFICE_USER_TYPES.includes(type); +} + +/** Token audience — encoded in the JWT and validated per guard. */ +export const TOKEN_AUDIENCES = { + STOREFRONT: 'storefront', + ADMIN: 'admin', +} as const; + +export type TokenAudience = (typeof TOKEN_AUDIENCES)[keyof typeof TOKEN_AUDIENCES]; diff --git a/packages/types/src/auth/permissions.ts b/packages/types/src/auth/permissions.ts new file mode 100644 index 0000000..f310f08 --- /dev/null +++ b/packages/types/src/auth/permissions.ts @@ -0,0 +1,104 @@ +/** + * RBAC permission catalog — `.`. + * + * Authorization is expressed ONLY as permission checks. There is deliberately + * no `if (user.role === 'ADMIN')` anywhere in the codebase: roles are data + * (rows in the database, editable by a SUPER_ADMIN), permissions are code. + * + * Adding a capability = add a constant here + attach it to a role in the seed. + * The admin UI reads the same catalog to render menus, so a permission the + * user lacks never renders a dead-end screen. + */ +export const PERMISSIONS = { + // Catalog + PRODUCT_READ: 'product.read', + PRODUCT_CREATE: 'product.create', + PRODUCT_UPDATE: 'product.update', + PRODUCT_DELETE: 'product.delete', + PRODUCT_PUBLISH: 'product.publish', + + CATEGORY_READ: 'category.read', + CATEGORY_MANAGE: 'category.manage', + COLLECTION_READ: 'collection.read', + COLLECTION_MANAGE: 'collection.manage', + BRAND_READ: 'brand.read', + BRAND_MANAGE: 'brand.manage', + + // Inventory + INVENTORY_READ: 'inventory.read', + INVENTORY_UPDATE: 'inventory.update', + + // Sales + ORDER_READ: 'order.read', + ORDER_UPDATE: 'order.update', + ORDER_CANCEL: 'order.cancel', + ORDER_REFUND: 'order.refund', + + PAYMENT_READ: 'payment.read', + PAYMENT_REFUND: 'payment.refund', + + // Customers + CUSTOMER_READ: 'customer.read', + CUSTOMER_UPDATE: 'customer.update', + CUSTOMER_DELETE: 'customer.delete', + + // Marketing + PROMOTION_MANAGE: 'promotion.manage', + COUPON_MANAGE: 'coupon.manage', + REVIEW_MODERATE: 'review.moderate', + + // Content + CMS_READ: 'cms.read', + CMS_MANAGE: 'cms.manage', + MEDIA_READ: 'media.read', + MEDIA_UPLOAD: 'media.upload', + MEDIA_DELETE: 'media.delete', + + // Platform administration + USER_READ: 'user.read', + USER_MANAGE: 'user.manage', + ROLE_READ: 'role.read', + ROLE_MANAGE: 'role.manage', + SETTINGS_MANAGE: 'settings.manage', + AUDIT_LOG_READ: 'audit_log.read', +} as const; + +export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; + +export const ALL_PERMISSIONS: readonly Permission[] = Object.values(PERMISSIONS); + +/** + * Seed roles. These are *starting data*, not hard-coded authorization: an + * operator can create new roles at runtime without a deploy. + */ +export const SYSTEM_ROLES = { + SUPER_ADMIN: 'super_admin', + ADMIN: 'admin', + CATALOG_MANAGER: 'catalog_manager', + ORDER_MANAGER: 'order_manager', + SUPPORT_AGENT: 'support_agent', + CUSTOMER: 'customer', +} as const; + +export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES]; + +export function hasPermission( + granted: readonly Permission[] | undefined, + required: Permission, +): boolean { + return granted?.includes(required) ?? false; +} + +export function hasAllPermissions( + granted: readonly Permission[] | undefined, + required: readonly Permission[], +): boolean { + return required.every((permission) => hasPermission(granted, permission)); +} + +export function hasAnyPermission( + granted: readonly Permission[] | undefined, + required: readonly Permission[], +): boolean { + return required.some((permission) => hasPermission(granted, permission)); +} diff --git a/packages/types/src/auth/tokens.ts b/packages/types/src/auth/tokens.ts new file mode 100644 index 0000000..f5feb2e --- /dev/null +++ b/packages/types/src/auth/tokens.ts @@ -0,0 +1,40 @@ +import type { Id, IsoDateTime } from '../primitives'; + +import type { TokenAudience, UserType } from './actors'; +import type { Permission } from './permissions'; + +/** + * Access token: short-lived (minutes), stateless, carries the permission set so + * that guards need zero database round-trips on the hot path. + * + * Refresh token: long-lived (days), opaque to the client, ROTATED on every use + * and persisted server-side so it can be revoked. Reuse of an already-rotated + * refresh token invalidates the whole family (theft detection). + */ +export interface AccessTokenClaims { + /** Subject — the User id. */ + readonly sub: Id; + readonly aud: TokenAudience; + readonly type: UserType; + readonly permissions: readonly Permission[]; + /** Session/token family id, so a single device can be signed out. */ + readonly sid: Id; + readonly iat: number; + readonly exp: number; +} + +export interface AuthTokens { + readonly accessToken: string; + readonly accessTokenExpiresAt: IsoDateTime; + /** Delivered as an httpOnly, Secure, SameSite=Lax cookie — never in the body. */ + readonly refreshTokenExpiresAt: IsoDateTime; +} + +/** The shape every guard attaches to the request. */ +export interface AuthenticatedActor { + readonly userId: Id; + readonly userType: UserType; + readonly audience: TokenAudience; + readonly permissions: readonly Permission[]; + readonly sessionId: Id; +} diff --git a/packages/types/src/catalog/media.ts b/packages/types/src/catalog/media.ts new file mode 100644 index 0000000..dea8ff9 --- /dev/null +++ b/packages/types/src/catalog/media.ts @@ -0,0 +1,50 @@ +import type { Id, Nullable } from '../primitives'; + +/** + * Binary data NEVER touches PostgreSQL. The database stores an object key plus + * metadata; bytes live in R2/S3 and are served through the CDN. + * + * `url` is derived at read time from `storageKey` + the public media base URL, + * so swapping bucket, CDN domain or provider is a config change and not a + * data migration. + */ +export const MEDIA_KINDS = { + IMAGE: 'IMAGE', + VIDEO: 'VIDEO', + DOCUMENT: 'DOCUMENT', +} as const; + +export type MediaKind = (typeof MEDIA_KINDS)[keyof typeof MEDIA_KINDS]; + +export interface MediaAsset { + readonly id: Id; + readonly kind: MediaKind; + /** Path inside the bucket, e.g. `products/2026/01/xy7.webp`. Never a full URL. */ + readonly storageKey: string; + readonly url: string; + readonly mimeType: string; + readonly sizeBytes: number; + readonly width: Nullable; + readonly height: Nullable; + /** Tiny base64 LQIP so grids never flash empty. */ + readonly blurDataUrl: Nullable; + readonly altText: Nullable; +} + +export interface ImageRef { + readonly id: Id; + readonly url: string; + readonly altText: Nullable; + readonly width: Nullable; + readonly height: Nullable; + readonly blurDataUrl: Nullable; +} + +export interface ProductImage extends ImageRef { + readonly position: number; + /** + * When set, this image belongs to a specific option value (usually a colour), + * which is how the gallery swaps when a shopper picks "Black" vs "White". + */ + readonly optionValueId: Nullable; +} diff --git a/packages/types/src/catalog/product.ts b/packages/types/src/catalog/product.ts new file mode 100644 index 0000000..def1e60 --- /dev/null +++ b/packages/types/src/catalog/product.ts @@ -0,0 +1,127 @@ +import type { Id, IsoDateTime, Metadata, Money, Nullable, Slug } from '../primitives'; + +import type { ProductImage } from './media'; +import type { Brand, Category, Collection, SeoFields } from './taxonomy'; +import type { ProductOption, ProductVariant, StorefrontVariant } from './variant'; + +export const PRODUCT_STATUSES = { + DRAFT: 'DRAFT', + ACTIVE: 'ACTIVE', + ARCHIVED: 'ARCHIVED', +} as const; + +export type ProductStatus = (typeof PRODUCT_STATUSES)[keyof typeof PRODUCT_STATUSES]; + +/** Merchandising axis for /men, /women, /kids. Not a category — a facet. */ +export const GENDER_TARGETS = { + MEN: 'MEN', + WOMEN: 'WOMEN', + KIDS: 'KIDS', + UNISEX: 'UNISEX', +} as const; + +export type GenderTarget = (typeof GENDER_TARGETS)[keyof typeof GENDER_TARGETS]; + +/** Facet behind /sports/running, /sports/gym, … */ +export const SPORT_TYPES = { + RUNNING: 'RUNNING', + FOOTBALL: 'FOOTBALL', + TRAINING: 'TRAINING', + GYM: 'GYM', + BADMINTON: 'BADMINTON', + LIFESTYLE: 'LIFESTYLE', +} as const; + +export type SportType = (typeof SPORT_TYPES)[keyof typeof SPORT_TYPES]; + +/** + * Product is the *marketing* entity: what has a page, a name and a URL. + * It is never the thing you buy — a ProductVariant is. Product therefore holds + * no SKU, no stock and no single price. + */ +export interface Product { + readonly id: Id; + readonly name: string; + readonly slug: Slug; + readonly description: Nullable; + readonly shortDescription: Nullable; + + readonly status: ProductStatus; + readonly publishedAt: Nullable; + + readonly brandId: Nullable; + readonly primaryCategoryId: Nullable; + + readonly genderTargets: readonly GenderTarget[]; + readonly sportTypes: readonly SportType[]; + + readonly options: readonly ProductOption[]; + readonly variants: readonly ProductVariant[]; + readonly images: readonly ProductImage[]; + readonly attributes: readonly ProductAttribute[]; + + readonly seo: SeoFields; + readonly metadata: Metadata; + readonly createdAt: IsoDateTime; + readonly updatedAt: IsoDateTime; +} + +/** + * Free-form specification rows ("Material: 92% polyester", "Fit: Slim"). + * Deliberately generic: merchandisers add specs without a schema migration. + * Anything that must be *filtered on* becomes a first-class facet instead. + */ +export interface ProductAttribute { + readonly id: Id; + readonly key: string; + readonly label: string; + readonly value: string; + readonly group: Nullable; + readonly position: number; + readonly isFilterable: boolean; +} + +/** Full PDP payload. */ +export interface StorefrontProduct extends Omit< + Product, + 'variants' | 'status' | 'metadata' | 'createdAt' | 'updatedAt' +> { + readonly brand: Nullable; + readonly primaryCategory: Nullable; + readonly collections: readonly Collection[]; + readonly variants: readonly StorefrontVariant[]; + readonly priceRange: PriceRange; + readonly rating: Nullable; +} + +/** Trimmed payload for grids — deliberately small, it is fetched 24 at a time. */ +export interface ProductListItem { + readonly id: Id; + readonly name: string; + readonly slug: Slug; + readonly brandName: Nullable; + readonly primaryImage: Nullable; + readonly hoverImage: Nullable; + readonly priceRange: PriceRange; + readonly isOnSale: boolean; + readonly colorSwatches: readonly ColorSwatch[]; + readonly rating: Nullable; +} + +export interface ColorSwatch { + readonly optionValueId: Id; + readonly label: string; + readonly swatchHex: Nullable; + readonly swatchImageUrl: Nullable; +} + +export interface PriceRange { + readonly min: Money; + readonly max: Money; + readonly compareAtMax: Nullable; +} + +export interface ProductRatingSummary { + readonly average: number; + readonly count: number; +} diff --git a/packages/types/src/catalog/taxonomy.ts b/packages/types/src/catalog/taxonomy.ts new file mode 100644 index 0000000..11b6ecd --- /dev/null +++ b/packages/types/src/catalog/taxonomy.ts @@ -0,0 +1,71 @@ +import type { Id, IsoDateTime, Metadata, Nullable, Slug } from '../primitives'; + +import type { ImageRef } from './media'; + +/** + * Three orthogonal ways to group products. Keeping them separate is what lets + * `/men/running` and `/collections/summer-drop` coexist without either one + * hijacking the other's URL space. + * + * - Category — the permanent, hierarchical merchandising tree + * (Men > Running > Shoes). One product has one primary category. + * - Collection— an editorial / campaign grouping, possibly rule-based and + * time-boxed (New Arrivals, Summer Drop, Sale). Many-to-many. + * - Brand — the manufacturer. One product has exactly one. + */ + +export interface Category { + readonly id: Id; + readonly parentId: Nullable; + readonly name: string; + readonly slug: Slug; + readonly description: Nullable; + readonly image: Nullable; + /** Materialised path (`men/running/shoes`) — one query for a whole subtree. */ + readonly path: string; + readonly depth: number; + readonly position: number; + readonly isActive: boolean; + readonly seo: SeoFields; +} + +export interface CategoryNode extends Category { + readonly children: readonly CategoryNode[]; +} + +export const COLLECTION_TYPES = { + MANUAL: 'MANUAL', + /** Membership derived from rules (e.g. "price < 500k AND tag = sale"). */ + AUTOMATED: 'AUTOMATED', +} as const; + +export type CollectionType = (typeof COLLECTION_TYPES)[keyof typeof COLLECTION_TYPES]; + +export interface Collection { + readonly id: Id; + readonly name: string; + readonly slug: Slug; + readonly type: CollectionType; + readonly description: Nullable; + readonly banner: Nullable; + readonly startsAt: Nullable; + readonly endsAt: Nullable; + readonly isActive: boolean; + readonly seo: SeoFields; +} + +export interface Brand { + readonly id: Id; + readonly name: string; + readonly slug: Slug; + readonly logo: Nullable; + readonly description: Nullable; + readonly isActive: boolean; + readonly seo: SeoFields; +} + +export interface SeoFields { + readonly metaTitle: Nullable; + readonly metaDescription: Nullable; + readonly metadata?: Metadata; +} diff --git a/packages/types/src/catalog/variant.ts b/packages/types/src/catalog/variant.ts new file mode 100644 index 0000000..6afe4e9 --- /dev/null +++ b/packages/types/src/catalog/variant.ts @@ -0,0 +1,116 @@ +import type { Id, IsoDateTime, Money, Nullable } from '../primitives'; + +/** + * THE central catalog decision. + * + * Size and colour are NOT fields on Product. A Product owns an ordered list of + * ProductOptions (Colour, Size, …), each option owns ordered ProductOptionValues + * (Black/White, S/M/L), and every purchasable combination is a ProductVariant + * with its own SKU, price and stock. + * + * Running Shirt (Product) + * ├── Option "Colour" → Black, White + * ├── Option "Size" → S, M, L + * └── Variants: Black/S, Black/M, Black/L, White/S, White/M, White/L + * + * Why this and not `sizes: string[]` on Product: + * - Stock, price, barcode and weight are per combination in the real world. + * - Order lines must reference an immutable, sellable unit (the variant id). + * - A third option (width, length, fit) is additive instead of a migration. + * - Marketplaces (Shopee/Lazada/TikTok) and ERP/POS all model variants this + * way, so integrations map 1:1 instead of needing a translation layer. + */ + +export interface ProductOption { + readonly id: Id; + /** Display name shown to shoppers: "Colour", "Size". */ + readonly name: string; + /** Stable machine key: `colour`, `size`. Used by URL params and integrations. */ + readonly key: string; + readonly position: number; + readonly values: readonly ProductOptionValue[]; +} + +export interface ProductOptionValue { + readonly id: Id; + readonly optionId: Id; + /** "Black", "M". */ + readonly label: string; + readonly value: string; + readonly position: number; + /** Swatch hex / image for colour-type options. */ + readonly swatchHex: Nullable; + readonly swatchImageUrl: Nullable; +} + +export const VARIANT_STATUSES = { + ACTIVE: 'ACTIVE', + /** Still referenced by past orders, no longer sellable. Never hard-deleted. */ + ARCHIVED: 'ARCHIVED', +} as const; + +export type VariantStatus = (typeof VARIANT_STATUSES)[keyof typeof VARIANT_STATUSES]; + +export interface ProductVariant { + readonly id: Id; + readonly productId: Id; + + /** Unique across the whole catalog. The identifier ERP/POS/marketplaces use. */ + readonly sku: string; + readonly barcode: Nullable; + + /** Denormalised for display and order snapshots: "Black / M". */ + readonly title: string; + + readonly price: Money; + /** Non-null only while on promotion; the effective price is derived. */ + readonly salePrice: Nullable; + /** What it "was" — for the struck-through reference price. */ + readonly compareAtPrice: Nullable; + /** Landed cost. Admin-only; never serialised to the storefront. */ + readonly costPrice?: Nullable; + + /** Grams. Required by every shipping-rate API. */ + readonly weightGrams: Nullable; + readonly dimensions: Nullable; + + /** Which option value this variant resolves to for each of the product's options. */ + readonly optionValues: readonly VariantOptionValueRef[]; + + readonly status: VariantStatus; + readonly position: number; + readonly createdAt: IsoDateTime; + readonly updatedAt: IsoDateTime; +} + +export interface VariantOptionValueRef { + readonly optionId: Id; + readonly optionKey: string; + readonly optionValueId: Id; + readonly label: string; +} + +export interface VariantDimensions { + readonly lengthMm: number; + readonly widthMm: number; + readonly heightMm: number; +} + +/** What the storefront actually renders on a product card / PDP selector. */ +export interface StorefrontVariant extends Omit< + ProductVariant, + 'costPrice' | 'createdAt' | 'updatedAt' +> { + readonly effectivePrice: Money; + readonly isOnSale: boolean; + readonly availability: VariantAvailability; +} + +export const VARIANT_AVAILABILITY = { + IN_STOCK: 'IN_STOCK', + LOW_STOCK: 'LOW_STOCK', + OUT_OF_STOCK: 'OUT_OF_STOCK', + PREORDER: 'PREORDER', +} as const; + +export type VariantAvailability = (typeof VARIANT_AVAILABILITY)[keyof typeof VARIANT_AVAILABILITY]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..62e7fa0 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,20 @@ +/** + * @sport/types — the shared contract between backend and frontends. + * + * HARD RULE: this package must stay framework-free. No NestJS, no React, no + * Prisma, no Zod, no runtime dependencies at all beyond plain TypeScript. + * Anything that needs a runtime belongs in @sport/validation or @sport/api-client. + */ + +export * from './primitives'; +export * from './api/envelope'; +export * from './api/error-codes'; +export * from './api/pagination'; +export * from './auth/actors'; +export * from './auth/permissions'; +export * from './auth/tokens'; +export * from './catalog/product'; +export * from './catalog/variant'; +export * from './catalog/taxonomy'; +export * from './catalog/media'; +export * from './inventory/stock'; diff --git a/packages/types/src/inventory/stock.ts b/packages/types/src/inventory/stock.ts new file mode 100644 index 0000000..766ef06 --- /dev/null +++ b/packages/types/src/inventory/stock.ts @@ -0,0 +1,63 @@ +import type { Id, IsoDateTime, Nullable } from '../primitives'; + +/** + * Inventory is tracked per (variant, location). Even with a single warehouse on + * day one, the location dimension exists from the start — retrofitting it after + * orders exist is one of the most expensive migrations in e-commerce. + * + * Availability is always computed, never stored: + * available = onHand - reserved + * + * `reserved` is what checkout holds while a payment is in flight, which is what + * prevents overselling the last size M. + */ +export interface StockLevel { + readonly variantId: Id; + readonly locationId: Id; + readonly onHand: number; + readonly reserved: number; + readonly available: number; + readonly reorderPoint: Nullable; + readonly updatedAt: IsoDateTime; +} + +export interface InventoryLocation { + readonly id: Id; + readonly name: string; + readonly code: string; + readonly isDefault: boolean; + readonly isActive: boolean; +} + +/** + * Every quantity change is an append-only movement. The stock level is a + * projection of this ledger, which is what makes discrepancies auditable and + * makes a future extraction of Inventory into its own service tractable. + */ +export const STOCK_MOVEMENT_REASONS = { + PURCHASE_RECEIPT: 'PURCHASE_RECEIPT', + SALE: 'SALE', + RETURN: 'RETURN', + MANUAL_ADJUSTMENT: 'MANUAL_ADJUSTMENT', + STOCK_TAKE: 'STOCK_TAKE', + TRANSFER_IN: 'TRANSFER_IN', + TRANSFER_OUT: 'TRANSFER_OUT', + DAMAGE: 'DAMAGE', +} as const; + +export type StockMovementReason = + (typeof STOCK_MOVEMENT_REASONS)[keyof typeof STOCK_MOVEMENT_REASONS]; + +export interface StockMovement { + readonly id: Id; + readonly variantId: Id; + readonly locationId: Id; + /** Signed: negative for outbound. */ + readonly quantityDelta: number; + readonly reason: StockMovementReason; + /** Order id, return id, purchase-order id… */ + readonly referenceId: Nullable; + readonly note: Nullable; + readonly createdByUserId: Nullable; + readonly createdAt: IsoDateTime; +} diff --git a/packages/types/src/primitives.ts b/packages/types/src/primitives.ts new file mode 100644 index 0000000..2a96d56 --- /dev/null +++ b/packages/types/src/primitives.ts @@ -0,0 +1,31 @@ +/** Opaque-ish branded aliases. Cheap documentation, zero runtime cost. */ +export type Id = string; +export type Slug = string; + +/** Always serialised as ISO-8601 UTC across the API boundary. */ +export type IsoDateTime = string; + +/** ISO-4217. The store launches VND-only but the type never assumes that. */ +export type CurrencyCode = 'VND' | 'USD'; + +/** + * Money is transferred as an INTEGER in the currency's minor unit, never as a + * float. VND has no minor unit (minorUnitScale = 0), USD has two. + * + * Rationale: floating point money bugs are unfixable after the fact, and the + * database stores the same integer, so no conversion happens anywhere. + */ +export interface Money { + readonly amount: number; + readonly currency: CurrencyCode; +} + +export const MINOR_UNIT_SCALE: Readonly> = { + VND: 0, + USD: 2, +}; + +/** Generic key/value bag for extensible, non-queried data. */ +export type Metadata = Record; + +export type Nullable = T | null; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..93c3ef1 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@sport/config/typescript/library.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/ui/eslint.config.mjs b/packages/ui/eslint.config.mjs new file mode 100644 index 0000000..05f60c6 --- /dev/null +++ b/packages/ui/eslint.config.mjs @@ -0,0 +1,3 @@ +import { reactConfig } from '@sport/eslint-config/react'; + +export default reactConfig; diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..e98cd0c --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,31 @@ +{ + "name": "@sport/ui", + "version": "0.0.0", + "private": true, + "description": "Shared, presentational React design-system primitives. Consumed as source via Next.js transpilePackages.", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "eslint src", + "typecheck": "tsc -p tsconfig.json --noEmit", + "clean": "rm -rf .turbo *.tsbuildinfo" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@sport/config": "workspace:*", + "@sport/eslint-config": "workspace:*", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "eslint": "catalog:", + "react": "catalog:", + "typescript": "catalog:" + }, + "peerDependencies": { + "react": "^19.0.0" + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..792f66f --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1,27 @@ +/** + * @sport/ui — the design system. + * + * WHAT BELONGS HERE + * Presentational primitives that are identical in the storefront and the + * admin: buttons, inputs, badges, skeletons, typography, layout helpers. + * + * WHAT MUST NEVER BE HERE + * - Data fetching or any @sport/api-client import. + * - App state (cart, auth session, filters). + * - Domain components. A `` knows about pricing, sale badges and + * variant swatches — that is storefront business UI and lives in + * `apps/storefront/src/features/product/`. Sharing it would couple two apps + * that must be free to diverge. + * + * The test: if the component would be meaningless in the admin dashboard, it + * does not go in this package. + */ + +export { cn } from './lib/cn'; +export { Button, buttonVariants } from './primitives/button'; +export type { ButtonProps } from './primitives/button'; +export { Badge, badgeVariants } from './primitives/badge'; +export type { BadgeProps } from './primitives/badge'; +export { Input } from './primitives/input'; +export type { InputProps } from './primitives/input'; +export { Skeleton } from './primitives/skeleton'; diff --git a/packages/ui/src/lib/cn.ts b/packages/ui/src/lib/cn.ts new file mode 100644 index 0000000..8508116 --- /dev/null +++ b/packages/ui/src/lib/cn.ts @@ -0,0 +1,7 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +/** Conditional classes + last-wins conflict resolution for Tailwind utilities. */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/packages/ui/src/primitives/badge.tsx b/packages/ui/src/primitives/badge.tsx new file mode 100644 index 0000000..ec1cb80 --- /dev/null +++ b/packages/ui/src/primitives/badge.tsx @@ -0,0 +1,29 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { HTMLAttributes } from 'react'; + +import { cn } from '../lib/cn'; + +export const badgeVariants = cva( + 'inline-flex items-center gap-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest', + { + variants: { + variant: { + neutral: 'bg-ink-100 text-ink-700', + solid: 'bg-ink-950 text-white', + sale: 'bg-sale text-white', + new: 'bg-volt-500 text-ink-950', + success: 'bg-success text-white', + warning: 'bg-warning text-ink-950', + outline: 'border border-ink-300 text-ink-700', + }, + }, + defaultVariants: { variant: 'neutral' }, + }, +); + +export interface BadgeProps + extends HTMLAttributes, VariantProps {} + +export function Badge({ className, variant, ...props }: BadgeProps) { + return ; +} diff --git a/packages/ui/src/primitives/button.tsx b/packages/ui/src/primitives/button.tsx new file mode 100644 index 0000000..c25af46 --- /dev/null +++ b/packages/ui/src/primitives/button.tsx @@ -0,0 +1,56 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ButtonHTMLAttributes, Ref } from 'react'; + +import { cn } from '../lib/cn'; + +export const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors ' + + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink-900 focus-visible:ring-offset-2 ' + + 'disabled:pointer-events-none disabled:opacity-40', + { + variants: { + variant: { + primary: 'bg-ink-950 text-white hover:bg-ink-800', + secondary: 'bg-ink-100 text-ink-950 hover:bg-ink-200', + outline: + 'border border-ink-950 bg-transparent text-ink-950 hover:bg-ink-950 hover:text-white', + ghost: 'bg-transparent text-ink-950 hover:bg-ink-100', + accent: 'bg-volt-500 text-ink-950 hover:bg-volt-400', + danger: 'bg-danger text-white hover:opacity-90', + }, + size: { + sm: 'h-9 px-4 text-xs uppercase tracking-wide', + md: 'h-11 px-6 text-sm uppercase tracking-wide', + lg: 'h-14 px-8 text-sm uppercase tracking-widest', + icon: 'size-10', + }, + shape: { + square: 'rounded-none', + rounded: 'rounded-card', + pill: 'rounded-pill', + }, + fullWidth: { + true: 'w-full', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + shape: 'square', + }, + }, +); + +export interface ButtonProps + extends ButtonHTMLAttributes, VariantProps { + ref?: Ref; +} + +export function Button({ className, variant, size, shape, fullWidth, ...props }: ButtonProps) { + return ( +