Repository: ZhiXiao-Lin/nestify Branch: main Commit: d884848690a4 Files: 241 Total size: 541.9 KB Directory structure: gitextract_v0ktoj1e/ ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── apps/ │ └── api/ │ ├── DATABASE.md │ ├── REDIS_USAGE.md │ ├── migrations/ │ │ └── 001_create_orders_tables.sql │ ├── nest-cli.json │ ├── package.json │ ├── src/ │ │ ├── app.module.ts │ │ ├── main.ts │ │ ├── modules/ │ │ │ └── order/ │ │ │ ├── application/ │ │ │ │ ├── commands/ │ │ │ │ │ ├── cancel-order/ │ │ │ │ │ │ ├── cancel-order.command.ts │ │ │ │ │ │ ├── cancel-order.dto.ts │ │ │ │ │ │ ├── cancel-order.handler.spec.ts │ │ │ │ │ │ └── cancel-order.handler.ts │ │ │ │ │ ├── confirm-order/ │ │ │ │ │ │ ├── confirm-order.command.ts │ │ │ │ │ │ ├── confirm-order.dto.ts │ │ │ │ │ │ ├── confirm-order.handler.spec.ts │ │ │ │ │ │ └── confirm-order.handler.ts │ │ │ │ │ └── create-order/ │ │ │ │ │ ├── create-order.command.ts │ │ │ │ │ ├── create-order.dto.ts │ │ │ │ │ ├── create-order.handler.spec.ts │ │ │ │ │ └── create-order.handler.ts │ │ │ │ ├── event-handlers/ │ │ │ │ │ ├── order-confirmed.handler.ts │ │ │ │ │ └── order-created.handler.ts │ │ │ │ └── queries/ │ │ │ │ ├── get-order/ │ │ │ │ │ ├── get-order.handler.spec.ts │ │ │ │ │ ├── get-order.handler.ts │ │ │ │ │ ├── get-order.query.ts │ │ │ │ │ └── order.response.dto.ts │ │ │ │ └── list-orders/ │ │ │ │ ├── list-orders.handler.spec.ts │ │ │ │ ├── list-orders.handler.ts │ │ │ │ ├── list-orders.query.ts │ │ │ │ └── order-list.response.dto.ts │ │ │ ├── domain/ │ │ │ │ ├── entities/ │ │ │ │ │ ├── order-item.entity.spec.ts │ │ │ │ │ ├── order-item.entity.ts │ │ │ │ │ ├── order.entity.spec.ts │ │ │ │ │ └── order.entity.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── order-cancelled.event.ts │ │ │ │ │ ├── order-confirmed.event.ts │ │ │ │ │ └── order-created.event.ts │ │ │ │ ├── exceptions/ │ │ │ │ │ ├── invalid-order-state.exception.ts │ │ │ │ │ └── order-not-found.exception.ts │ │ │ │ ├── repositories/ │ │ │ │ │ └── order.repository.interface.ts │ │ │ │ ├── services/ │ │ │ │ │ ├── order-pricing.service.spec.ts │ │ │ │ │ └── order-pricing.service.ts │ │ │ │ └── value-objects/ │ │ │ │ ├── money.vo.spec.ts │ │ │ │ ├── money.vo.ts │ │ │ │ ├── order-id.vo.spec.ts │ │ │ │ ├── order-id.vo.ts │ │ │ │ ├── order-status.vo.spec.ts │ │ │ │ ├── order-status.vo.ts │ │ │ │ ├── quantity.vo.spec.ts │ │ │ │ └── quantity.vo.ts │ │ │ ├── infrastructure/ │ │ │ │ ├── cache/ │ │ │ │ │ └── order-cache.service.ts │ │ │ │ └── persistence/ │ │ │ │ └── kysely-order.repository.ts │ │ │ ├── order.module.ts │ │ │ └── presentation/ │ │ │ └── order.controller.ts │ │ └── shared/ │ │ ├── api-response/ │ │ │ ├── api-response.dto.ts │ │ │ ├── api-response.interceptor.ts │ │ │ ├── api-response.service.ts │ │ │ └── index.ts │ │ ├── api-versioning/ │ │ │ ├── api-versioning.decorator.ts │ │ │ ├── api-versioning.interceptor.ts │ │ │ └── index.ts │ │ ├── application/ │ │ │ ├── dto.base.ts │ │ │ ├── query.interface.ts │ │ │ └── use-case.interface.ts │ │ ├── audit/ │ │ │ ├── audit.decorator.ts │ │ │ ├── audit.interceptor.ts │ │ │ ├── audit.service.ts │ │ │ └── index.ts │ │ ├── auth/ │ │ │ ├── auth.module.ts │ │ │ ├── decorators/ │ │ │ │ ├── current-user.decorator.ts │ │ │ │ └── index.ts │ │ │ ├── dto/ │ │ │ │ ├── auth.dto.ts │ │ │ │ └── index.ts │ │ │ ├── guards/ │ │ │ │ ├── index.ts │ │ │ │ ├── jwt-auth.guard.ts │ │ │ │ ├── permissions.guard.ts │ │ │ │ └── roles.guard.ts │ │ │ ├── index.ts │ │ │ ├── jwt/ │ │ │ │ ├── index.ts │ │ │ │ ├── jwt.service.ts │ │ │ │ └── jwt.types.ts │ │ │ └── rbac/ │ │ │ ├── index.ts │ │ │ └── rbac.service.ts │ │ ├── base/ │ │ │ ├── base.entity.ts │ │ │ ├── base.service.ts │ │ │ ├── index.ts │ │ │ └── pagination.dto.ts │ │ ├── cache/ │ │ │ ├── cache.decorator.ts │ │ │ ├── cache.interceptor.ts │ │ │ ├── cache.service.ts │ │ │ └── index.ts │ │ ├── circuit-breaker/ │ │ │ ├── circuit-breaker.decorator.ts │ │ │ ├── circuit-breaker.module.ts │ │ │ ├── circuit-breaker.service.ts │ │ │ └── index.ts │ │ ├── database/ │ │ │ ├── database.module.ts │ │ │ ├── database.types.ts │ │ │ └── index.ts │ │ ├── domain/ │ │ │ ├── aggregate-root.ts │ │ │ ├── domain-event-publisher.ts │ │ │ ├── domain-event.ts │ │ │ ├── entity.ts │ │ │ ├── index.ts │ │ │ └── value-object.ts │ │ ├── errors/ │ │ │ ├── business.exception.ts │ │ │ ├── error-codes.ts │ │ │ ├── error.filter.ts │ │ │ └── index.ts │ │ ├── feature-flags/ │ │ │ ├── feature-flags.decorator.ts │ │ │ ├── feature-flags.guard.ts │ │ │ ├── feature-flags.service.ts │ │ │ └── index.ts │ │ ├── file-upload/ │ │ │ ├── file-upload.decorator.ts │ │ │ ├── file-upload.interceptor.ts │ │ │ ├── file-upload.service.ts │ │ │ └── index.ts │ │ ├── health/ │ │ │ ├── health.controller.ts │ │ │ ├── health.module.ts │ │ │ ├── index.ts │ │ │ └── indicators/ │ │ │ ├── database.indicator.ts │ │ │ ├── nats.indicator.ts │ │ │ ├── redis.indicator.ts │ │ │ └── rustfs.indicator.ts │ │ ├── infrastructure/ │ │ │ ├── messaging/ │ │ │ │ ├── event-bus.interface.ts │ │ │ │ ├── event-bus.service.ts │ │ │ │ └── messaging.interface.ts │ │ │ ├── persistence/ │ │ │ │ ├── repository.interface.ts │ │ │ │ └── unit-of-work.interface.ts │ │ │ └── storage/ │ │ │ └── storage.interface.ts │ │ ├── metrics/ │ │ │ ├── index.ts │ │ │ ├── metrics.controller.ts │ │ │ ├── metrics.interceptor.ts │ │ │ └── metrics.service.ts │ │ ├── openapi/ │ │ │ ├── index.ts │ │ │ ├── openapi-common.dto.ts │ │ │ └── openapi-decorators.ts │ │ ├── presentation/ │ │ │ ├── filters/ │ │ │ │ ├── domain-exception.filter.ts │ │ │ │ └── http-exception.filter.ts │ │ │ └── interceptors/ │ │ │ └── logging.interceptor.ts │ │ ├── rate-limiting/ │ │ │ ├── index.ts │ │ │ ├── rate-limiting.decorator.ts │ │ │ ├── rate-limiting.guard.ts │ │ │ └── rate-limiting.service.ts │ │ ├── redis/ │ │ │ ├── index.ts │ │ │ └── redis.module.ts │ │ ├── retry/ │ │ │ ├── index.ts │ │ │ ├── retry.module.ts │ │ │ └── retry.service.ts │ │ ├── serialization/ │ │ │ ├── example.ts │ │ │ ├── index.ts │ │ │ └── serializer.ts │ │ ├── tenant/ │ │ │ ├── index.ts │ │ │ ├── tenant.decorator.ts │ │ │ ├── tenant.guard.ts │ │ │ ├── tenant.interceptor.ts │ │ │ └── tenant.service.ts │ │ ├── testing/ │ │ │ ├── index.ts │ │ │ └── testing.utils.ts │ │ ├── tracking/ │ │ │ ├── index.ts │ │ │ └── tracking.interceptor.ts │ │ ├── transform/ │ │ │ ├── index.ts │ │ │ └── transform.interceptor.ts │ │ ├── utils/ │ │ │ ├── guard.ts │ │ │ └── result.ts │ │ └── validation/ │ │ ├── index.ts │ │ ├── validation-options.ts │ │ └── validation.pipe.ts │ ├── test/ │ │ └── jest-e2e.json │ ├── tsconfig.build.json │ └── tsconfig.json ├── biome.json ├── docker/ │ ├── Dockerfile │ ├── Dockerfile.dev │ ├── docker-compose.prod.yml │ └── docker-compose.yml ├── docs/ │ ├── architecture.md │ └── ddd-patterns.md ├── nest-cli.json ├── package.json ├── packages/ │ ├── bullmq/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── bullmq.module-definition.ts │ │ │ ├── bullmq.module.ts │ │ │ ├── bullmq.service.ts │ │ │ ├── bullmq.types.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── etcd/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── config.service.ts │ │ │ ├── etcd.module.ts │ │ │ ├── etcd.service.ts │ │ │ ├── etcd.types.ts │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── kysely/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __tests__/ │ │ │ │ ├── kysely.logger.spec.ts │ │ │ │ └── kysely.module.spec.ts │ │ │ ├── index.ts │ │ │ ├── kysely-module-options.interface.ts │ │ │ ├── kysely.logger.ts │ │ │ ├── kysely.module-definition.ts │ │ │ ├── kysely.module.ts │ │ │ └── kysely.service.ts │ │ └── tsconfig.json │ ├── logger/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── logger.module-definition.ts │ │ │ ├── logger.module.ts │ │ │ ├── logger.service.ts │ │ │ ├── logger.types.ts │ │ │ └── logging.interceptor.ts │ │ └── tsconfig.json │ ├── nats/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── nats.module-definition.ts │ │ │ ├── nats.module.ts │ │ │ ├── nats.service.ts │ │ │ └── nats.types.ts │ │ └── tsconfig.json │ ├── redisson/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── __tests__/ │ │ │ │ ├── redisson.module.spec.ts │ │ │ │ └── types.spec.ts │ │ │ ├── index.ts │ │ │ ├── redisson-module-options.interface.ts │ │ │ ├── redisson.module-definition.ts │ │ │ ├── redisson.module.ts │ │ │ ├── redisson.service.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ └── rustfs/ │ ├── package.json │ ├── src/ │ │ ├── index.ts │ │ ├── rustfs.module-definition.ts │ │ ├── rustfs.module.ts │ │ ├── rustfs.service.ts │ │ └── rustfs.types.ts │ └── tsconfig.json ├── pnpm-workspace.yaml ├── tsconfig.build.json └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Dependencies node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* # Build dist/ build/ *.tsbuildinfo # Environment .env .env.local .env.*.local # IDE .vscode/ .idea/ *.swp *.swo *~ .DS_Store # Testing coverage/ .nyc_output/ *.lcov # Logs logs/ *.log # Database *.sqlite *.db # OS Thumbs.db ================================================ FILE: .npmrc ================================================ shamefully-hoist=true strict-peer-dependencies=false ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 A3S Lab Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Nestify - Production-Ready NestJS Monorepo Template A production-ready NestJS monorepo template with pnpm workspace, implementing Domain-Driven Design (DDD), Clean Architecture, and comprehensive infrastructure for enterprise applications. ## Features ### Core Architecture - **Monorepo Architecture**: pnpm workspace for managing multiple packages and applications - **Clean Architecture**: Clear separation of concerns with Domain, Application, Infrastructure, and Presentation layers - **Domain-Driven Design**: Rich domain models with entities, value objects, aggregates, and domain events - **CQRS Pattern**: Separate command and query handlers using @nestjs/cqrs - **Event-Driven**: Domain events for decoupled communication ### Infrastructure Packages - **Type-Safe SQL**: Kysely query builder with full TypeScript support - **Distributed Caching**: Redis with Redisson for locks, caching, and rate limiting - **Structured Logging**: Pino-based JSON logging with request tracing - **Message Queue**: BullMQ for distributed task processing - **Event Streaming**: NATS with JetStream support - **Object Storage**: S3-compatible RustFS storage - **Distributed Config**: etcd for configuration management with hot-reload ### Application Features - **Authentication**: JWT with access/refresh tokens, RBAC permission system - **API Metrics**: Prometheus metrics with request tracking - **Circuit Breaker**: Fault tolerance with automatic failover - **Retry Logic**: Exponential backoff with jitter - **Rate Limiting**: Redis-based sliding window rate limiting - **Multi-tenancy**: Tenant isolation support - **Audit Logging**: Comprehensive audit trail - **Feature Flags**: Rollout management - **API Versioning**: Header-based API versioning - **File Upload**: Multipart file handling ### Quality Assurance - **Type Safety**: Full TypeScript with strict mode - **API Documentation**: Swagger/OpenAPI integration - **Validation**: class-validator with custom decorators - **Testing**: Unit, integration, and E2E test setup - **Code Quality**: Biome linting and formatting ## Architecture Overview ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ API Application │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Presentation │ Application │ Domain │ Infrastructure │ │ - Controllers │ - Commands │ - Entities │ - Kysely (PostgreSQL) │ │ - DTOs │ - Queries │ - Value Obj │ - Redisson (Redis) │ │ - Guards │ - Event Hand. │ - Aggreg. │ - BullMQ (Tasks) │ │ - Interceptors │ - DTOs │ - Events │ - NATS (Messaging) │ │ │ │ - Services │ - RustFS (Storage) │ │ │ │ │ - etcd (Config) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Shared Infrastructure │ │ Auth │ Metrics │ Cache │ CircuitBreaker │ Retry │ RateLimit │ Health │ └─────────────────────────────────────────────────────────────────────────────┘ ``` ## Monorepo Structure ``` nestify/ ├── pnpm-workspace.yaml # Workspace configuration ├── package.json # Root package.json with workspace scripts ├── tsconfig.json # Base TypeScript configuration ├── biome.json # Biome linting/formatting config ├── apps/ │ └── api/ # Main NestJS API application │ ├── src/ │ │ ├── app.module.ts # Root application module │ │ ├── main.ts # Application entry point │ │ ├── modules/ # Business modules (DDD) │ │ └── shared/ # Shared infrastructure modules │ └── package.json └── packages/ ├── kysely/ # @a3s-lab/kysely - Type-safe SQL ├── redisson/ # @a3s-lab/redisson - Redis client ├── logger/ # @a3s-lab/logger - Structured logging ├── bullmq/ # @a3s-lab/bullmq - Task queue ├── nats/ # @a3s-lab/nats - Message broker ├── rustfs/ # @a3s-lab/rustfs - S3 storage └── etcd/ # @a3s-lab/etcd - Config center ``` ## Packages ### @a3s-lab/kysely Type-safe SQL query builder module for NestJS. ```typescript KyselyModule.register({ config: { dialect: new PostgresDialect({ pool: new Pool({ connectionString }) }), }, }) ``` ### @a3s-lab/redisson Redis distributed locks, caching, and rate limiting. ```typescript // Distributed lock await redisson.withLock('resource-key', async () => { // Critical section }); // Cache with TTL await redisson.setJSON('cache-key', data, 3600); // Rate limiting const limited = await rateLimiter.tryAcquire('endpoint-limit'); ``` ### @a3s-lab/logger Structured JSON logging with request tracing. ```typescript LoggerModule.register({ level: 'info', name: 'api', json: true, // JSON format for K8s }); // In services logger.logRequest({ method, url, statusCode, responseTime }); ``` ### @a3s-lab/bullmq Distributed task queue with retry and delayed jobs. ```typescript // Add job await bullmq.addJob('notifications', 'send-email', { to: 'user@example.com' }); // Create worker bullmq.createWorker('notifications', async (job) => { await sendEmail(job.data); return { success: true }; }); ``` ### @a3s-lab/nats High-performance message broker with JetStream. ```typescript // Publish await nats.publish({ subject: 'orders.created', data: orderEvent }); // Subscribe await nats.subscribe$('orders.created', async (data) => { await handleOrderCreated(data); }); // JetStream await nats.jsPublish({ stream: 'ORDERS', subject: 'created', data }); ``` ### @a3s-lab/rustfs S3-compatible object storage. ```typescript // Upload file const result = await rustfs.putObject('bucket', { key: 'uploads/file.pdf', body: fileBuffer, contentType: 'application/pdf', }); // Get presigned URL const url = await rustfs.getPresignedUrl('bucket', { key: 'uploads/file.pdf', expiresIn: 3600, }); ``` ### @a3s-lab/etcd Distributed configuration with hot-reload. ```typescript // Get config const value = await etcd.get('config/feature-flags'); // Watch for changes etcd.watch('config/feature-flags', (event) => { if (event.value) reloadFeatures(event.value); }); ``` ## Shared Modules ### Authentication (auth) JWT-based authentication with RBAC. ```typescript // JWT Token Generation const tokens = jwtService.generateTokenPair({ sub: userId, roles: ['admin'] }); // Protect Routes @UseGuards(JwtAuthGuard) // Role-based Access @Roles('admin') @UseGuards(RolesGuard) // Permission Check @Permissions('users', 'create') @UseGuards(PermissionsGuard) ``` ### Metrics (metrics) Prometheus metrics collection. ```typescript // Automatic HTTP metrics GET /metrics // Prometheus format // Custom metrics metricsService.incGauge('active_users'); metricsService.observeHistogram('request_duration', duration); ``` ### Circuit Breaker (circuit-breaker) Fault tolerance pattern. ```typescript @CircuitBreaker({ timeout: 5000, maxFailures: 5 }) async callExternalService() { return await externalService.get(); } ``` ### Retry (retry) Automatic retry with exponential backoff. ```typescript const result = await retryService.execute(fn, { maxAttempts: 3, initialDelay: 100, backoffMultiplier: 2, retryableErrors: [NetworkError, TimeoutError], }); ``` ### Rate Limiting (rate-limiting) Redis-based sliding window rate limiting. ```typescript @RateLimit({ limit: 100, window: '1m' }) async endpoint() { } ``` ### Health (health) Health check endpoints. ```typescript GET /health // Full health check GET /health/live // Liveness probe GET /health/ready // Readiness probe ``` ### Validation (validation) Custom validators beyond class-validator. ```typescript @IsPassword() // Strong password @IsStrongPassword() // Very strong password @IsUsername() // Alphanumeric with underscores @IsSlug() // URL-safe slug @IsFutureDate() // Future date only @IsInRange(0, 100) // Number in range ``` ### Serialization (serialization) class-transformer integration with groups. ```typescript class UserEntity { } class UserDto { } @Serialize(UserDto, { groups: ['user:read'] }) getUser(): UserEntity { } ``` ## Project Structure ``` apps/api/src/ ├── app.module.ts # Root module ├── main.ts # Bootstrap ├── modules/ # Business modules │ └── order/ # Order bounded context │ ├── domain/ │ │ ├── entities/ # Order, OrderItem │ │ ├── value-objects/ # Money, Quantity │ │ ├── events/ # OrderCreated, OrderConfirmed │ │ ├── repositories/ # IOrderRepository │ │ └── exceptions/ # Domain exceptions │ ├── application/ │ │ ├── commands/ # CreateOrder, CancelOrder │ │ ├── queries/ # GetOrder, ListOrders │ │ └── event-handlers/ # HandleOrderCreated │ ├── infrastructure/ │ │ └── persistence/ # KyselyOrderRepository │ └── presentation/ │ └── order.controller.ts └── shared/ # Shared kernel ├── auth/ # JWT, RBAC, Guards ├── metrics/ # Prometheus ├── cache/ # Caching ├── circuit-breaker/ # Fault tolerance ├── retry/ # Retry logic ├── rate-limiting/ # Rate limit ├── health/ # Health checks ├── validation/ # Custom validators ├── serialization/ # DTO transformation ├── base/ # Base service, entity ├── domain/ # Core DDD ├── errors/ # Error handling ├── utils/ # Utilities └── ... ``` ## Getting Started ### Prerequisites - Node.js 20+ - pnpm 8+ - Docker and Docker Compose - PostgreSQL 15+ - Redis 7+ ### Installation ```bash # Clone and install git clone https://github.com/A3S-Lab/nestify.git cd nestify pnpm install # Start infrastructure cd docker && docker-compose up -d # Build pnpm build # Run pnpm start:dev ``` Access: - API: http://localhost:3000 - Swagger: http://localhost:3000/api/docs - Metrics: http://localhost:3000/metrics ## Scripts ```bash pnpm install # Install dependencies pnpm build # Build all pnpm start:dev # Development mode pnpm test # Run tests pnpm lint # Lint code pnpm format # Format code ``` ## Key Design Patterns ### Domain-Driven Design ``` Domain Layer (innermost, no dependencies) │ ▼ Application Layer (depends on Domain) │ ▼ Infrastructure Layer (implements interfaces) │ ▼ Presentation Layer (depends on all) ``` ### CQRS - **Commands**: `CreateOrder`, `ConfirmOrder` - Write operations - **Queries**: `GetOrder`, `ListOrders` - Read operations - **Events**: `OrderCreated`, `OrderConfirmed` - Decoupled communication ### Fault Tolerance ``` Circuit Breaker States: ┌─────────┐ 5 failures ┌──────┐ timeout ┌───────────┐ │ CLOSED │ ─────────────────▶ │ OPEN │ ──────────────▶ │ HALF_OPEN │ └─────────┘ └──────┘ └───────────┘ ▲ │ │ success │ └────────────────────────────────────────────────────────┘ ``` ## Environment Variables ```env # Database DATABASE_URL=postgresql://user:pass@localhost:5432/nestify # Redis REDIS_HOST=localhost REDIS_PORT=6379 # JWT JWT_ACCESS_SECRET=your-access-secret JWT_REFRESH_SECRET=your-refresh-secret JWT_ACCESS_EXPIRY=15m JWT_REFRESH_EXPIRY=7d # NATS (optional) NATS_SERVERS=nats://localhost:4222 # RustFS (optional) RUSTFS_ENDPOINT=http://localhost:9000 RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin RUSTFS_BUCKET=nestify # etcd (optional) ETCD_ENDPOINTS=http://localhost:2379 ``` ## License MIT ================================================ FILE: apps/api/DATABASE.md ================================================ # Database Setup This project uses PostgreSQL with Kysely for type-safe SQL queries. ## Prerequisites - PostgreSQL 14+ - pnpm 8+ ## Setup ### 1. Start PostgreSQL Using Docker: ```bash cd docker docker-compose up -d postgres ``` Or use your local PostgreSQL installation. ### 2. Create Database ```bash createdb nestify ``` ### 3. Run Migrations ```bash psql -d nestify -f apps/api/migrations/001_create_orders_tables.sql ``` ### 4. Configure Environment Create `.env` file in the root: ```env # Database DB_HOST=localhost DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=postgres DB_DATABASE=nestify # Application NODE_ENV=development PORT=3000 ``` ## Running the Application ```bash # Install dependencies pnpm install # Build packages pnpm build:packages # Start development server pnpm start:dev ``` The API will be available at http://localhost:3000/api ## API Endpoints ### Create Order ```bash curl -X POST http://localhost:3000/api/orders \ -H "Content-Type: application/json" \ -d '{ "customerId": "customer-123", "items": [ { "productId": "product-456", "quantity": 2, "unitPrice": 10.99 } ] }' ``` ### Get Order ```bash curl http://localhost:3000/api/orders/{orderId} ``` ### List Orders by Customer ```bash curl http://localhost:3000/api/orders?customerId=customer-123 ``` ### Confirm Order ```bash curl -X POST http://localhost:3000/api/orders/{orderId}/confirm ``` ### Cancel Order ```bash curl -X POST http://localhost:3000/api/orders/{orderId}/cancel ``` ## Database Schema ### orders table - `id` (UUID, Primary Key) - `customer_id` (VARCHAR) - `status` (VARCHAR: 'pending', 'confirmed', 'cancelled') - `total_amount` (DECIMAL) - `created_at` (TIMESTAMP) - `updated_at` (TIMESTAMP) ### order_items table - `id` (UUID, Primary Key) - `order_id` (UUID, Foreign Key) - `product_id` (VARCHAR) - `quantity` (INTEGER) - `unit_price` (DECIMAL) - `subtotal` (DECIMAL) - `created_at` (TIMESTAMP) ## Using Kysely in Your Code ```typescript import { Injectable } from '@nestjs/common'; import { KyselyService } from '@a3s-lab/kysely'; import { Database } from '@/shared/database/database.types'; @Injectable() export class MyRepository { constructor(private readonly db: KyselyService) {} async findAll() { return this.db .selectFrom('orders') .selectAll() .execute(); } async create(data: NewOrder) { return this.db .insertInto('orders') .values(data) .returningAll() .executeTakeFirstOrThrow(); } async update(id: string, data: OrderUpdate) { return this.db .updateTable('orders') .set(data) .where('id', '=', id) .returningAll() .executeTakeFirstOrThrow(); } async delete(id: string) { await this.db .deleteFrom('orders') .where('id', '=', id) .execute(); } } ``` ## Transactions ```typescript async saveWithTransaction(order: Order) { return await this.db.transaction().execute(async (trx) => { // Insert order await trx .insertInto('orders') .values(orderData) .execute(); // Insert order items await trx .insertInto('order_items') .values(itemsData) .execute(); return order; }); } ``` ## Query Logging Kysely logger is enabled in development mode and will show: - SQL queries with syntax highlighting - Query parameters - Execution time with color coding: - Green: < 1ms (excellent) - Yellow: 1-100ms (acceptable) - Red: > 100ms (needs optimization) ================================================ FILE: apps/api/REDIS_USAGE.md ================================================ # Redis & Redisson Usage Guide This guide demonstrates how to use the `@a3s-lab/redisson` package for caching, distributed locks, and other Redis operations in the NestJS application. ## Installation The package is already installed as a workspace dependency: ```json { "dependencies": { "@a3s-lab/redisson": "workspace:*" } } ``` ## Configuration ### Environment Variables Add Redis configuration to your `.env` file: ```env # Redis Configuration REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 ``` ### Module Setup The `RedisModule` is configured globally in `src/shared/redis/redis.module.ts`: ```typescript import { Module, Global } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedissonModule } from '@a3s-lab/redisson'; @Global() @Module({ imports: [ RedissonModule.registerAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ host: configService.get('REDIS_HOST', 'localhost'), port: configService.get('REDIS_PORT', 6379), password: configService.get('REDIS_PASSWORD'), db: configService.get('REDIS_DB', 0), }), inject: [ConfigService], }), ], exports: [RedissonModule], }) export class RedisModule {} ``` ## Basic Usage ### 1. Inject RedissonService ```typescript import { Injectable } from '@nestjs/common'; import { RedissonService } from '@a3s-lab/redisson'; @Injectable() export class MyService { constructor(private readonly redisson: RedissonService) {} async example() { // Your Redis operations here } } ``` ### 2. Simple Key-Value Operations ```typescript // Set a value await this.redisson.set('key', 'value'); // Set with TTL (time to live in seconds) await this.redisson.set('key', 'value', 3600); // 1 hour // Get a value const value = await this.redisson.get('key'); // Delete a key await this.redisson.delete('key'); // Check if key exists const exists = await this.redisson.exists('key'); ``` ### 3. JSON Operations ```typescript // Store JSON data const user = { id: '123', name: 'John', email: 'john@example.com' }; await this.redisson.setJSON('user:123', user, 3600); // Retrieve JSON data const cachedUser = await this.redisson.getJSON('user:123'); // Update JSON data await this.redisson.setJSON('user:123', { ...user, name: 'Jane' }); ``` ### 4. Cache with Get-or-Set Pattern ```typescript // Get from cache or execute factory function const order = await this.redisson.getOrSet( 'order:123', async () => { // This function only runs if cache miss return await this.orderRepository.findById('123'); }, 3600, // TTL in seconds ); ``` ### 5. Distributed Locks Prevent race conditions with distributed locks: ```typescript // Execute operation with lock const result = await this.redisson.withLock( 'lock:order:123', async () => { // Critical section - only one process can execute this at a time const order = await this.orderRepository.findById('123'); order.confirm(); return await this.orderRepository.save(order); }, 5000, // Wait time (ms) - how long to wait for lock 10000, // Lease time (ms) - how long to hold lock ); ``` ### 6. Counters ```typescript // Increment counter const views = await this.redisson.increment('page:views'); // Increment by specific amount const score = await this.redisson.increment('user:score', 10); // Decrement counter const remaining = await this.redisson.decrement('stock:product:123'); ``` ### 7. Hash Operations ```typescript // Set hash field await this.redisson.hset('user:123', 'name', 'John'); await this.redisson.hset('user:123', 'email', 'john@example.com'); // Get hash field const name = await this.redisson.hget('user:123', 'name'); // Get all hash fields const user = await this.redisson.hgetall('user:123'); // Returns: { name: 'John', email: 'john@example.com' } // Delete hash field await this.redisson.hdel('user:123', 'email'); ``` ### 8. Pattern-Based Deletion ```typescript // Delete all keys matching pattern const deletedCount = await this.redisson.deleteByPattern('cache:order:*'); console.log(`Deleted ${deletedCount} keys`); ``` ### 9. Expiration ```typescript // Set expiration on existing key await this.redisson.expire('key', 3600); // 1 hour ``` ## Real-World Example: Order Cache Service See `src/modules/order/infrastructure/cache/order-cache.service.ts` for a complete implementation: ```typescript import { Injectable, Logger } from '@nestjs/common'; import { RedissonService } from '@a3s-lab/redisson'; @Injectable() export class OrderCacheService { private readonly logger = new Logger(OrderCacheService.name); constructor(private readonly redisson: RedissonService) {} // Cache an order async cacheOrder(order: Order, ttl: number = 3600): Promise { const key = `order:${order.id}`; await this.redisson.setJSON(key, this.serializeOrder(order), ttl); } // Get cached order async getCachedOrder(orderId: string): Promise { const key = `order:${orderId}`; return this.redisson.getJSON(key); } // Get or fetch order with cache async getOrSetOrder( orderId: string, factory: () => Promise, ttl: number = 3600, ): Promise { const key = `order:${orderId}`; return this.redisson.getOrSet( key, async () => { const order = await factory(); return order ? this.serializeOrder(order) : null; }, ttl, ); } // Execute with distributed lock async withOrderLock( orderId: string, operation: () => Promise, ): Promise { const lockKey = `lock:order:${orderId}`; return this.redisson.withLock(lockKey, operation, 5000, 10000); } // Increment view count async incrementOrderViewCount(orderId: string): Promise { const key = `order:views:${orderId}`; return this.redisson.increment(key); } } ``` ## Usage in Query Handlers ### Example: Get Order with Cache ```typescript import { Injectable } from '@nestjs/common'; import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { OrderCacheService } from '../../infrastructure/cache/order-cache.service'; import { OrderRepository } from '../../infrastructure/persistence/kysely-order.repository'; @QueryHandler(GetOrderQuery) export class GetOrderHandler implements IQueryHandler { constructor( private readonly orderRepository: OrderRepository, private readonly cacheService: OrderCacheService, ) {} async execute(query: GetOrderQuery): Promise { // Try cache first const cached = await this.cacheService.getCachedOrder(query.orderId); if (cached) { return this.toDto(cached); } // Cache miss - fetch from database const order = await this.orderRepository.findById(query.orderId); if (!order) { throw new NotFoundException('Order not found'); } // Cache for future requests await this.cacheService.cacheOrder(order); // Increment view count await this.cacheService.incrementOrderViewCount(query.orderId); return this.toDto(order); } } ``` ### Example: Update Order with Lock ```typescript @CommandHandler(ConfirmOrderCommand) export class ConfirmOrderHandler implements ICommandHandler { constructor( private readonly orderRepository: OrderRepository, private readonly cacheService: OrderCacheService, ) {} async execute(command: ConfirmOrderCommand): Promise { // Use distributed lock to prevent race conditions await this.cacheService.withOrderLock(command.orderId, async () => { const order = await this.orderRepository.findById(command.orderId); if (!order) { throw new NotFoundException('Order not found'); } order.confirm(); await this.orderRepository.save(order); // Invalidate cache after update await this.cacheService.invalidateOrder(command.orderId); await this.cacheService.invalidateCustomerOrders(order.customerId); }); } } ``` ## Advanced Features ### Lua Scripts Execute Lua scripts for atomic operations: ```typescript const script = ` local current = redis.call('GET', KEYS[1]) if current and tonumber(current) > tonumber(ARGV[1]) then return redis.call('DECRBY', KEYS[1], ARGV[1]) else return -1 end `; const result = await this.redisson.eval(script, ['stock:product:123'], ['5']); ``` ### Script Caching Load and cache scripts for better performance: ```typescript // Load script once const sha1 = await this.redisson.scriptLoad(script); // Execute cached script multiple times const result = await this.redisson.evalsha(sha1, ['key'], ['arg']); ``` ## Best Practices 1. **Use Appropriate TTL**: Set reasonable expiration times to prevent stale data 2. **Cache Invalidation**: Always invalidate cache after updates 3. **Distributed Locks**: Use locks for critical sections to prevent race conditions 4. **Key Naming**: Use consistent, hierarchical key naming (e.g., `entity:id:field`) 5. **Error Handling**: Always handle Redis errors gracefully with fallbacks 6. **Monitoring**: Log cache hits/misses for performance monitoring ## Testing ### Start Redis with Docker ```bash docker run -d -p 6379:6379 --name redis redis:7-alpine ``` ### Test Connection ```bash redis-cli ping # Should return: PONG ``` ## Troubleshooting ### Connection Issues ```typescript // Check if Redis is connected try { await this.redisson.redis.ping(); console.log('Redis connected'); } catch (error) { console.error('Redis connection failed:', error); } ``` ### Memory Issues ```bash # Check Redis memory usage redis-cli INFO memory # Clear all keys (development only!) redis-cli FLUSHDB ``` ## Performance Tips 1. **Batch Operations**: Use pipelines for multiple operations 2. **Compression**: Compress large JSON objects before caching 3. **Lazy Loading**: Only cache frequently accessed data 4. **TTL Strategy**: Use shorter TTL for frequently changing data 5. **Connection Pooling**: Configure appropriate pool size in production ## References - [Redisson Documentation](https://github.com/redisson/redisson) - [ioredis Documentation](https://github.com/redis/ioredis) - [Redis Commands](https://redis.io/commands) ================================================ FILE: apps/api/migrations/001_create_orders_tables.sql ================================================ -- Create orders table CREATE TABLE IF NOT EXISTS orders ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id VARCHAR(255) NOT NULL, status VARCHAR(50) NOT NULL DEFAULT 'pending', total_amount DECIMAL(10, 2) NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Create order_items table CREATE TABLE IF NOT EXISTS order_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, product_id VARCHAR(255) NOT NULL, quantity INTEGER NOT NULL, unit_price DECIMAL(10, 2) NOT NULL, subtotal DECIMAL(10, 2) NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Create indexes CREATE INDEX IF NOT EXISTS idx_orders_customer_id ON orders(customer_id); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); ================================================ FILE: apps/api/nest-cli.json ================================================ { "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { "builder": "swc", "typeCheck": true, "deleteOutDir": true, "tsConfigPath": "tsconfig.build.json" } } ================================================ FILE: apps/api/package.json ================================================ { "name": "@a3s-lab/api", "version": "1.0.0", "description": "Production-ready NestJS API with Domain-Driven Design and Clean Architecture", "author": "", "private": true, "license": "MIT", "scripts": { "build": "nest build", "format": "biome format --write .", "format:check": "biome format .", "lint": "biome lint --write .", "lint:check": "biome lint .", "start": "nest start", "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { "@a3s-lab/kysely": "workspace:*", "@a3s-lab/redisson": "workspace:*", "@a3s-lab/logger": "workspace:*", "@a3s-lab/bullmq": "workspace:*", "@a3s-lab/nats": "workspace:*", "@a3s-lab/rustfs": "workspace:*", "@a3s-lab/etcd": "workspace:*", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", "@nestjs/cqrs": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.0.0", "@nestjs/terminus": "^10.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "kysely": "^0.28.11", "pg": "^8.18.0", "pino": "^9.0.0", "pino-http": "^10.0.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "uuid": "^9.0.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@swc/cli": "^0.7.10", "@swc/core": "^1.15.11", "@types/express": "^4.17.17", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@types/pg": "^8.16.0", "@types/uuid": "^9.0.0", "jest": "^29.5.0", "source-map-support": "^0.5.21", "supertest": "^6.3.3", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" }, "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" } } } ================================================ FILE: apps/api/src/app.module.ts ================================================ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { OrderModule } from './modules/order/order.module'; import { DatabaseModule } from './shared/database'; import { RedisModule } from './shared/redis'; // Shared infrastructure modules import { LoggerModule } from '@a3s-lab/logger'; import { AuthModule } from './shared/auth'; import { MetricsModule } from './shared/metrics'; import { CacheModule } from './shared/cache'; import { CircuitBreakerModule } from './shared/circuit-breaker'; import { RetryModule } from './shared/retry'; import { HealthModule } from './shared/health'; import { ValidationModule } from './shared/validation'; import { SerializationModule } from './shared/serialization'; import { RateLimitingModule } from './shared/rate-limiting'; import { TenantModule } from './shared/tenant'; import { AuditModule } from './shared/audit'; import { ApiResponseModule } from './shared/api-response'; import { ApiVersioningModule } from './shared/api-versioning'; import { FeatureFlagsModule } from './shared/feature-flags'; import { FileUploadModule } from './shared/file-upload'; import { TransformModule } from './shared/transform'; import { ErrorsModule } from './shared/errors'; import { OpenAPIModule } from './shared/openapi'; import { TrackingModule } from './shared/tracking'; @Module({ imports: [ // NestJS Config ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', }), // Logger (global JSON logging with request tracing) LoggerModule.register({ level: process.env.LOG_LEVEL as any || 'info', name: 'nestify-api', json: true, }), // Database (Kysely + PostgreSQL) DatabaseModule, // Redis (Redisson) RedisModule, // Auth (JWT + RBAC) AuthModule, // Metrics (Prometheus) MetricsModule, // Cache CacheModule, // Circuit Breaker CircuitBreakerModule, // Retry with exponential backoff RetryModule, // Health checks HealthModule, // Validation ValidationModule, // Serialization (class-transformer) SerializationModule, // Rate limiting RateLimitingModule, // Tenant isolation TenantModule, // Audit logging AuditModule, // API response wrapper ApiResponseModule, // API versioning ApiVersioningModule, // Feature flags FeatureFlagsModule, // File upload FileUploadModule, // Transform interceptor TransformModule, // Error handling ErrorsModule, // OpenAPI decorators OpenAPIModule, // Request tracking TrackingModule, // Business modules OrderModule, ], }) export class AppModule {} ================================================ FILE: apps/api/src/main.ts ================================================ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './shared/presentation/filters/http-exception.filter'; import { DomainExceptionFilter } from './shared/presentation/filters/domain-exception.filter'; import { LoggingInterceptor } from './shared/presentation/interceptors/logging.interceptor'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api'); app.useGlobalPipes( new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, }), ); app.useGlobalFilters(new HttpExceptionFilter(), new DomainExceptionFilter()); app.useGlobalInterceptors(new LoggingInterceptor()); const config = new DocumentBuilder() .setTitle('NestJS DDD Template') .setDescription('Production-ready NestJS template with Domain-Driven Design') .setVersion('1.0') .addTag('orders') .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); const port = process.env.APP_PORT || 3000; await app.listen(port); console.log(`Application is running on: http://localhost:${port}`); console.log(`Swagger documentation: http://localhost:${port}/api/docs`); } bootstrap(); ================================================ FILE: apps/api/src/modules/order/application/commands/cancel-order/cancel-order.command.ts ================================================ export class CancelOrderCommand { constructor(public readonly orderId: string) {} } ================================================ FILE: apps/api/src/modules/order/application/commands/cancel-order/cancel-order.dto.ts ================================================ import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class CancelOrderDto { @ApiProperty({ example: 'order-id-123' }) @IsString() orderId: string; } ================================================ FILE: apps/api/src/modules/order/application/commands/cancel-order/cancel-order.handler.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { CancelOrderHandler } from './cancel-order.handler'; import { CancelOrderCommand } from './cancel-order.command'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { IEventBus, EVENT_BUS } from '@/shared/infrastructure/messaging/event-bus.interface'; import { Order } from '../../../domain/entities/order.entity'; import { OrderItem } from '../../../domain/entities/order-item.entity'; import { Money } from '../../../domain/value-objects/money.vo'; import { Quantity } from '../../../domain/value-objects/quantity.vo'; import { OrderNotFoundException } from '../../../domain/exceptions/order-not-found.exception'; import { InvalidOrderStateException } from '../../../domain/exceptions/invalid-order-state.exception'; describe('CancelOrderHandler', () => { let handler: CancelOrderHandler; let orderRepository: jest.Mocked; let eventBus: jest.Mocked; beforeEach(async () => { const mockOrderRepository: Partial = { save: jest.fn(), findById: jest.fn(), findByCustomerId: jest.fn(), delete: jest.fn(), }; const mockEventBus: Partial = { publish: jest.fn(), publishAll: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ CancelOrderHandler, { provide: ORDER_REPOSITORY, useValue: mockOrderRepository, }, { provide: EVENT_BUS, useValue: mockEventBus, }, ], }).compile(); handler = module.get(CancelOrderHandler); orderRepository = module.get(ORDER_REPOSITORY); eventBus = module.get(EVENT_BUS); }); it('should be defined', () => { expect(handler).toBeDefined(); }); describe('execute', () => { it('should cancel existing pending order', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); const command = new CancelOrderCommand(order.id); orderRepository.findById.mockResolvedValue(order); orderRepository.save.mockImplementation(async (o: Order) => o); await handler.execute(command); expect(orderRepository.findById).toHaveBeenCalledWith(order.id); expect(order.status.isCancelled()).toBe(true); expect(orderRepository.save).toHaveBeenCalledWith(order); }); it('should cancel confirmed order', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); order.confirm(); order.clearEvents(); const command = new CancelOrderCommand(order.id); orderRepository.findById.mockResolvedValue(order); orderRepository.save.mockImplementation(async (o: Order) => o); await handler.execute(command); expect(order.status.isCancelled()).toBe(true); }); it('should publish OrderCancelledEvent', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); const command = new CancelOrderCommand(order.id); orderRepository.findById.mockResolvedValue(order); orderRepository.save.mockImplementation(async (o: Order) => o); await handler.execute(command); expect(eventBus.publishAll).toHaveBeenCalledTimes(1); }); it('should throw OrderNotFoundException when order not found', async () => { const command = new CancelOrderCommand('non-existent-id'); orderRepository.findById.mockResolvedValue(null); await expect(handler.execute(command)).rejects.toThrow(OrderNotFoundException); }); it('should throw InvalidOrderStateException when order is already cancelled', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); order.cancel(); // Already cancelled const command = new CancelOrderCommand(order.id); orderRepository.findById.mockResolvedValue(order); await expect(handler.execute(command)).rejects.toThrow(InvalidOrderStateException); }); }); }); ================================================ FILE: apps/api/src/modules/order/application/commands/cancel-order/cancel-order.handler.ts ================================================ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { CancelOrderCommand } from './cancel-order.command'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { OrderNotFoundException } from '../../../domain/exceptions/order-not-found.exception'; import { EVENT_BUS, IEventBus } from '@/shared/infrastructure/messaging/event-bus.interface'; @CommandHandler(CancelOrderCommand) export class CancelOrderHandler implements ICommandHandler { constructor( @Inject(ORDER_REPOSITORY) private readonly orderRepository: IOrderRepository, @Inject(EVENT_BUS) private readonly eventBus: IEventBus, ) {} async execute(command: CancelOrderCommand): Promise { const order = await this.orderRepository.findById(command.orderId); if (!order) { throw new OrderNotFoundException(command.orderId); } order.cancel(); await this.orderRepository.save(order); await this.eventBus.publishAll(order.domainEvents); order.clearEvents(); } } ================================================ FILE: apps/api/src/modules/order/application/commands/confirm-order/confirm-order.command.ts ================================================ export class ConfirmOrderCommand { constructor(public readonly orderId: string) {} } ================================================ FILE: apps/api/src/modules/order/application/commands/confirm-order/confirm-order.dto.ts ================================================ import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class ConfirmOrderDto { @ApiProperty({ example: 'order-id-123' }) @IsString() orderId: string; } ================================================ FILE: apps/api/src/modules/order/application/commands/confirm-order/confirm-order.handler.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { ConfirmOrderHandler } from './confirm-order.handler'; import { ConfirmOrderCommand } from './confirm-order.command'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { IEventBus, EVENT_BUS } from '@/shared/infrastructure/messaging/event-bus.interface'; import { Order } from '../../../domain/entities/order.entity'; import { OrderItem } from '../../../domain/entities/order-item.entity'; import { Money } from '../../../domain/value-objects/money.vo'; import { Quantity } from '../../../domain/value-objects/quantity.vo'; import { OrderNotFoundException } from '../../../domain/exceptions/order-not-found.exception'; import { InvalidOrderStateException } from '../../../domain/exceptions/invalid-order-state.exception'; describe('ConfirmOrderHandler', () => { let handler: ConfirmOrderHandler; let orderRepository: jest.Mocked; let eventBus: jest.Mocked; beforeEach(async () => { const mockOrderRepository: Partial = { save: jest.fn(), findById: jest.fn(), findByCustomerId: jest.fn(), delete: jest.fn(), }; const mockEventBus: Partial = { publish: jest.fn(), publishAll: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ ConfirmOrderHandler, { provide: ORDER_REPOSITORY, useValue: mockOrderRepository, }, { provide: EVENT_BUS, useValue: mockEventBus, }, ], }).compile(); handler = module.get(ConfirmOrderHandler); orderRepository = module.get(ORDER_REPOSITORY); eventBus = module.get(EVENT_BUS); }); it('should be defined', () => { expect(handler).toBeDefined(); }); describe('execute', () => { it('should confirm existing pending order', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); const command = new ConfirmOrderCommand(order.id); orderRepository.findById.mockResolvedValue(order); orderRepository.save.mockImplementation(async (o: Order) => o); await handler.execute(command); expect(orderRepository.findById).toHaveBeenCalledWith(order.id); expect(order.status.isConfirmed()).toBe(true); expect(orderRepository.save).toHaveBeenCalledWith(order); }); it('should publish OrderConfirmedEvent', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); const command = new ConfirmOrderCommand(order.id); orderRepository.findById.mockResolvedValue(order); orderRepository.save.mockImplementation(async (o: Order) => o); await handler.execute(command); expect(eventBus.publishAll).toHaveBeenCalledTimes(1); const publishedEvents = eventBus.publishAll.mock.calls[0][0]; expect(publishedEvents.length).toBeGreaterThan(0); }); it('should throw OrderNotFoundException when order not found', async () => { const command = new ConfirmOrderCommand('non-existent-id'); orderRepository.findById.mockResolvedValue(null); await expect(handler.execute(command)).rejects.toThrow(OrderNotFoundException); await expect(handler.execute(command)).rejects.toThrow('non-existent-id'); }); it('should throw InvalidOrderStateException when order is not pending', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); order.confirm(); // Already confirmed const command = new ConfirmOrderCommand(order.id); orderRepository.findById.mockResolvedValue(order); await expect(handler.execute(command)).rejects.toThrow(InvalidOrderStateException); }); it('should not save order if confirmation fails', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); order.confirm(); // Already confirmed const command = new ConfirmOrderCommand(order.id); orderRepository.findById.mockResolvedValue(order); await expect(handler.execute(command)).rejects.toThrow(); expect(orderRepository.save).not.toHaveBeenCalled(); }); }); }); ================================================ FILE: apps/api/src/modules/order/application/commands/confirm-order/confirm-order.handler.ts ================================================ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { ConfirmOrderCommand } from './confirm-order.command'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { OrderNotFoundException } from '../../../domain/exceptions/order-not-found.exception'; import { EVENT_BUS, IEventBus } from '@/shared/infrastructure/messaging/event-bus.interface'; @CommandHandler(ConfirmOrderCommand) export class ConfirmOrderHandler implements ICommandHandler { constructor( @Inject(ORDER_REPOSITORY) private readonly orderRepository: IOrderRepository, @Inject(EVENT_BUS) private readonly eventBus: IEventBus, ) {} async execute(command: ConfirmOrderCommand): Promise { const order = await this.orderRepository.findById(command.orderId); if (!order) { throw new OrderNotFoundException(command.orderId); } order.confirm(); await this.orderRepository.save(order); await this.eventBus.publishAll(order.domainEvents); order.clearEvents(); } } ================================================ FILE: apps/api/src/modules/order/application/commands/create-order/create-order.command.ts ================================================ export class CreateOrderCommand { constructor( public readonly customerId: string, public readonly items: Array<{ productId: string; quantity: number; unitPrice: number; }>, ) {} } ================================================ FILE: apps/api/src/modules/order/application/commands/create-order/create-order.dto.ts ================================================ import { IsString, IsArray, ValidateNested, IsNumber, Min } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; export class CreateOrderItemDto { @ApiProperty({ example: 'product-123' }) @IsString() productId: string; @ApiProperty({ example: 2 }) @IsNumber() @Min(1) quantity: number; @ApiProperty({ example: 10.99 }) @IsNumber() @Min(0) unitPrice: number; } export class CreateOrderDto { @ApiProperty({ example: 'customer-456' }) @IsString() customerId: string; @ApiProperty({ type: [CreateOrderItemDto] }) @IsArray() @ValidateNested({ each: true }) @Type(() => CreateOrderItemDto) items: CreateOrderItemDto[]; } ================================================ FILE: apps/api/src/modules/order/application/commands/create-order/create-order.handler.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { CreateOrderHandler } from './create-order.handler'; import { CreateOrderCommand } from './create-order.command'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { IEventBus, EVENT_BUS } from '@/shared/infrastructure/messaging/event-bus.interface'; import { Order } from '../../../domain/entities/order.entity'; describe('CreateOrderHandler', () => { let handler: CreateOrderHandler; let orderRepository: jest.Mocked; let eventBus: jest.Mocked; beforeEach(async () => { const mockOrderRepository: Partial = { save: jest.fn(), findById: jest.fn(), findByCustomerId: jest.fn(), delete: jest.fn(), }; const mockEventBus: Partial = { publish: jest.fn(), publishAll: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ CreateOrderHandler, { provide: ORDER_REPOSITORY, useValue: mockOrderRepository, }, { provide: EVENT_BUS, useValue: mockEventBus, }, ], }).compile(); handler = module.get(CreateOrderHandler); orderRepository = module.get(ORDER_REPOSITORY); eventBus = module.get(EVENT_BUS); }); it('should be defined', () => { expect(handler).toBeDefined(); }); describe('execute', () => { it('should create order with valid command', async () => { const command = new CreateOrderCommand('customer-1', [ { productId: 'product-1', quantity: 2, unitPrice: 10 }, { productId: 'product-2', quantity: 1, unitPrice: 20 }, ]); orderRepository.save.mockImplementation(async (order: Order) => order); const orderId = await handler.execute(command); expect(orderId).toBeDefined(); expect(typeof orderId).toBe('string'); expect(orderRepository.save).toHaveBeenCalledTimes(1); }); it('should save order with correct properties', async () => { const command = new CreateOrderCommand('customer-1', [ { productId: 'product-1', quantity: 2, unitPrice: 10 }, ]); orderRepository.save.mockImplementation(async (order: Order) => order); await handler.execute(command); expect(orderRepository.save).toHaveBeenCalledWith( expect.objectContaining({ customerId: 'customer-1', }), ); const savedOrder = orderRepository.save.mock.calls[0][0]; expect(savedOrder.items.length).toBe(1); expect(savedOrder.items[0].productId).toBe('product-1'); expect(savedOrder.items[0].quantity.value).toBe(2); expect(savedOrder.items[0].unitPrice.amount).toBe(10); }); it('should publish domain events after saving', async () => { const command = new CreateOrderCommand('customer-1', [ { productId: 'product-1', quantity: 1, unitPrice: 10 }, ]); orderRepository.save.mockImplementation(async (order: Order) => order); await handler.execute(command); expect(eventBus.publishAll).toHaveBeenCalledTimes(1); expect(eventBus.publishAll).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ customerId: 'customer-1', }), ]), ); }); it('should clear events after publishing', async () => { const command = new CreateOrderCommand('customer-1', [ { productId: 'product-1', quantity: 1, unitPrice: 10 }, ]); let savedOrder: Order; orderRepository.save.mockImplementation(async (order: Order) => { savedOrder = order; return order; }); await handler.execute(command); // Events should be cleared after publishing expect(savedOrder!.domainEvents.length).toBe(0); }); it('should create order with multiple items', async () => { const command = new CreateOrderCommand('customer-1', [ { productId: 'product-1', quantity: 2, unitPrice: 10 }, { productId: 'product-2', quantity: 1, unitPrice: 20 }, { productId: 'product-3', quantity: 3, unitPrice: 5 }, ]); orderRepository.save.mockImplementation(async (order: Order) => order); await handler.execute(command); const savedOrder = orderRepository.save.mock.calls[0][0]; expect(savedOrder.items.length).toBe(3); }); it('should handle repository errors', async () => { const command = new CreateOrderCommand('customer-1', [ { productId: 'product-1', quantity: 1, unitPrice: 10 }, ]); orderRepository.save.mockRejectedValue(new Error('Database error')); await expect(handler.execute(command)).rejects.toThrow('Database error'); }); }); }); ================================================ FILE: apps/api/src/modules/order/application/commands/create-order/create-order.handler.ts ================================================ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { CreateOrderCommand } from './create-order.command'; import { Order } from '../../../domain/entities/order.entity'; import { OrderItem } from '../../../domain/entities/order-item.entity'; import { Money } from '../../../domain/value-objects/money.vo'; import { Quantity } from '../../../domain/value-objects/quantity.vo'; import { OrderId } from '../../../domain/value-objects/order-id.vo'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { EVENT_BUS, IEventBus } from '@/shared/infrastructure/messaging/event-bus.interface'; @CommandHandler(CreateOrderCommand) export class CreateOrderHandler implements ICommandHandler { constructor( @Inject(ORDER_REPOSITORY) private readonly orderRepository: IOrderRepository, @Inject(EVENT_BUS) private readonly eventBus: IEventBus, ) {} async execute(command: CreateOrderCommand): Promise { const orderItems = command.items.map(item => OrderItem.create({ id: OrderId.create().value, productId: item.productId, quantity: Quantity.create(item.quantity), unitPrice: Money.create(item.unitPrice), }), ); const order = Order.create(command.customerId, orderItems); await this.orderRepository.save(order); await this.eventBus.publishAll(order.domainEvents); order.clearEvents(); return order.id; } } ================================================ FILE: apps/api/src/modules/order/application/event-handlers/order-confirmed.handler.ts ================================================ import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { Logger } from '@nestjs/common'; import { OrderConfirmedEvent } from '../../domain/events/order-confirmed.event'; @EventsHandler(OrderConfirmedEvent) export class OrderConfirmedHandler implements IEventHandler { private readonly logger = new Logger(OrderConfirmedHandler.name); async handle(event: OrderConfirmedEvent) { this.logger.log(`Order confirmed: ${event.orderId}`); } } ================================================ FILE: apps/api/src/modules/order/application/event-handlers/order-created.handler.ts ================================================ import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; import { Logger } from '@nestjs/common'; import { OrderCreatedEvent } from '../../domain/events/order-created.event'; @EventsHandler(OrderCreatedEvent) export class OrderCreatedHandler implements IEventHandler { private readonly logger = new Logger(OrderCreatedHandler.name); async handle(event: OrderCreatedEvent) { this.logger.log( `Order created: ${event.orderId} for customer ${event.customerId} with total ${event.totalAmount.amount}`, ); } } ================================================ FILE: apps/api/src/modules/order/application/queries/get-order/get-order.handler.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { GetOrderHandler } from './get-order.handler'; import { GetOrderQuery } from './get-order.query'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { Order } from '../../../domain/entities/order.entity'; import { OrderItem } from '../../../domain/entities/order-item.entity'; import { Money } from '../../../domain/value-objects/money.vo'; import { Quantity } from '../../../domain/value-objects/quantity.vo'; import { OrderNotFoundException } from '../../../domain/exceptions/order-not-found.exception'; describe('GetOrderHandler', () => { let handler: GetOrderHandler; let orderRepository: jest.Mocked; beforeEach(async () => { const mockOrderRepository: Partial = { save: jest.fn(), findById: jest.fn(), findByCustomerId: jest.fn(), delete: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ GetOrderHandler, { provide: ORDER_REPOSITORY, useValue: mockOrderRepository, }, ], }).compile(); handler = module.get(GetOrderHandler); orderRepository = module.get(ORDER_REPOSITORY); }); it('should be defined', () => { expect(handler).toBeDefined(); }); describe('execute', () => { it('should return order DTO for existing order', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10), }), OrderItem.create({ id: 'item-2', productId: 'product-2', quantity: Quantity.create(1), unitPrice: Money.create(20), }), ]; const order = Order.create('customer-1', items); const query = new GetOrderQuery(order.id); orderRepository.findById.mockResolvedValue(order); const result = await handler.execute(query); expect(result).toBeDefined(); expect(result.id).toBe(order.id); expect(result.customerId).toBe('customer-1'); expect(result.items.length).toBe(2); expect(result.status).toBe('PENDING'); expect(result.totalAmount).toBe(40); }); it('should map order items correctly', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(3), unitPrice: Money.create(15), }), ]; const order = Order.create('customer-1', items); const query = new GetOrderQuery(order.id); orderRepository.findById.mockResolvedValue(order); const result = await handler.execute(query); expect(result.items[0].id).toBe('item-1'); expect(result.items[0].productId).toBe('product-1'); expect(result.items[0].quantity).toBe(3); expect(result.items[0].unitPrice).toBe(15); expect(result.items[0].totalPrice).toBe(45); }); it('should include timestamps in response', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); const query = new GetOrderQuery(order.id); orderRepository.findById.mockResolvedValue(order); const result = await handler.execute(query); expect(result.createdAt).toBeInstanceOf(Date); expect(result.updatedAt).toBeInstanceOf(Date); }); it('should throw OrderNotFoundException when order not found', async () => { const query = new GetOrderQuery('non-existent-id'); orderRepository.findById.mockResolvedValue(null); await expect(handler.execute(query)).rejects.toThrow(OrderNotFoundException); await expect(handler.execute(query)).rejects.toThrow('non-existent-id'); }); it('should handle confirmed order status', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); order.confirm(); const query = new GetOrderQuery(order.id); orderRepository.findById.mockResolvedValue(order); const result = await handler.execute(query); expect(result.status).toBe('CONFIRMED'); }); it('should handle cancelled order status', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const order = Order.create('customer-1', items); order.cancel(); const query = new GetOrderQuery(order.id); orderRepository.findById.mockResolvedValue(order); const result = await handler.execute(query); expect(result.status).toBe('CANCELLED'); }); }); }); ================================================ FILE: apps/api/src/modules/order/application/queries/get-order/get-order.handler.ts ================================================ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { GetOrderQuery } from './get-order.query'; import { OrderResponseDto } from './order.response.dto'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { OrderNotFoundException } from '../../../domain/exceptions/order-not-found.exception'; @QueryHandler(GetOrderQuery) export class GetOrderHandler implements IQueryHandler { constructor( @Inject(ORDER_REPOSITORY) private readonly orderRepository: IOrderRepository, ) {} async execute(query: GetOrderQuery): Promise { const order = await this.orderRepository.findById(query.orderId); if (!order) { throw new OrderNotFoundException(query.orderId); } return { id: order.id, customerId: order.customerId, items: order.items.map(item => ({ id: item.id, productId: item.productId, quantity: item.quantity.value, unitPrice: item.unitPrice.amount, totalPrice: item.getTotalPrice().amount, })), status: order.status.value, totalAmount: order.getTotalAmount().amount, createdAt: order.createdAt, updatedAt: order.updatedAt, }; } } ================================================ FILE: apps/api/src/modules/order/application/queries/get-order/get-order.query.ts ================================================ export class GetOrderQuery { constructor(public readonly orderId: string) {} } ================================================ FILE: apps/api/src/modules/order/application/queries/get-order/order.response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class OrderItemResponseDto { @ApiProperty() id: string; @ApiProperty() productId: string; @ApiProperty() quantity: number; @ApiProperty() unitPrice: number; @ApiProperty() totalPrice: number; } export class OrderResponseDto { @ApiProperty() id: string; @ApiProperty() customerId: string; @ApiProperty({ type: [OrderItemResponseDto] }) items: OrderItemResponseDto[]; @ApiProperty() status: string; @ApiProperty() totalAmount: number; @ApiProperty() createdAt: Date; @ApiProperty() updatedAt: Date; } ================================================ FILE: apps/api/src/modules/order/application/queries/list-orders/list-orders.handler.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { ListOrdersHandler } from './list-orders.handler'; import { ListOrdersQuery } from './list-orders.query'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; import { Order } from '../../../domain/entities/order.entity'; import { OrderItem } from '../../../domain/entities/order-item.entity'; import { Money } from '../../../domain/value-objects/money.vo'; import { Quantity } from '../../../domain/value-objects/quantity.vo'; describe('ListOrdersHandler', () => { let handler: ListOrdersHandler; let orderRepository: jest.Mocked; beforeEach(async () => { const mockOrderRepository: Partial = { save: jest.fn(), findById: jest.fn(), findByCustomerId: jest.fn(), delete: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ ListOrdersHandler, { provide: ORDER_REPOSITORY, useValue: mockOrderRepository, }, ], }).compile(); handler = module.get(ListOrdersHandler); orderRepository = module.get(ORDER_REPOSITORY); }); it('should be defined', () => { expect(handler).toBeDefined(); }); describe('execute', () => { it('should return list of orders for customer', async () => { const items1 = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const items2 = [ OrderItem.create({ id: 'item-2', productId: 'product-2', quantity: Quantity.create(2), unitPrice: Money.create(20), }), ]; const order1 = Order.create('customer-1', items1); const order2 = Order.create('customer-1', items2); const query = new ListOrdersQuery('customer-1'); orderRepository.findByCustomerId.mockResolvedValue([order1, order2]); const result = await handler.execute(query); expect(result.orders.length).toBe(2); expect(result.total).toBe(2); expect(result.orders[0].customerId).toBe('customer-1'); expect(result.orders[1].customerId).toBe('customer-1'); }); it('should return empty list when no orders found', async () => { const query = new ListOrdersQuery('customer-1'); orderRepository.findByCustomerId.mockResolvedValue([]); const result = await handler.execute(query); expect(result.orders.length).toBe(0); expect(result.total).toBe(0); }); it('should map all order properties correctly', async () => { const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(15), }), ]; const order = Order.create('customer-1', items); const query = new ListOrdersQuery('customer-1'); orderRepository.findByCustomerId.mockResolvedValue([order]); const result = await handler.execute(query); expect(result.orders[0].id).toBe(order.id); expect(result.orders[0].customerId).toBe('customer-1'); expect(result.orders[0].items.length).toBe(1); expect(result.orders[0].status).toBe('PENDING'); expect(result.orders[0].totalAmount).toBe(30); }); it('should handle orders with different statuses', async () => { const items1 = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(10), }), ]; const items2 = [ OrderItem.create({ id: 'item-2', productId: 'product-2', quantity: Quantity.create(1), unitPrice: Money.create(20), }), ]; const order1 = Order.create('customer-1', items1); const order2 = Order.create('customer-1', items2); order2.confirm(); const query = new ListOrdersQuery('customer-1'); orderRepository.findByCustomerId.mockResolvedValue([order1, order2]); const result = await handler.execute(query); expect(result.orders[0].status).toBe('PENDING'); expect(result.orders[1].status).toBe('CONFIRMED'); }); it('should return empty list when customerId is not provided', async () => { const query = new ListOrdersQuery(); const result = await handler.execute(query); expect(result.orders.length).toBe(0); expect(result.total).toBe(0); expect(orderRepository.findByCustomerId).not.toHaveBeenCalled(); }); }); }); ================================================ FILE: apps/api/src/modules/order/application/queries/list-orders/list-orders.handler.ts ================================================ import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { Inject } from '@nestjs/common'; import { ListOrdersQuery } from './list-orders.query'; import { OrderListResponseDto } from './order-list.response.dto'; import { IOrderRepository, ORDER_REPOSITORY } from '../../../domain/repositories/order.repository.interface'; @QueryHandler(ListOrdersQuery) export class ListOrdersHandler implements IQueryHandler { constructor( @Inject(ORDER_REPOSITORY) private readonly orderRepository: IOrderRepository, ) {} async execute(query: ListOrdersQuery): Promise { const orders = query.customerId ? await this.orderRepository.findByCustomerId(query.customerId) : []; return { orders: orders.map(order => ({ id: order.id, customerId: order.customerId, items: order.items.map(item => ({ id: item.id, productId: item.productId, quantity: item.quantity.value, unitPrice: item.unitPrice.amount, totalPrice: item.getTotalPrice().amount, })), status: order.status.value, totalAmount: order.getTotalAmount().amount, createdAt: order.createdAt, updatedAt: order.updatedAt, })), total: orders.length, }; } } ================================================ FILE: apps/api/src/modules/order/application/queries/list-orders/list-orders.query.ts ================================================ export class ListOrdersQuery { constructor(public readonly customerId?: string) {} } ================================================ FILE: apps/api/src/modules/order/application/queries/list-orders/order-list.response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { OrderResponseDto } from '../get-order/order.response.dto'; export class OrderListResponseDto { @ApiProperty({ type: [OrderResponseDto] }) orders: OrderResponseDto[]; @ApiProperty() total: number; } ================================================ FILE: apps/api/src/modules/order/domain/entities/order-item.entity.spec.ts ================================================ import { OrderItem } from './order-item.entity'; import { Money } from '../value-objects/money.vo'; import { Quantity } from '../value-objects/quantity.vo'; describe('OrderItem Entity', () => { describe('create', () => { it('should create order item with valid properties', () => { const orderItem = OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10.99), }); expect(orderItem.id).toBe('item-1'); expect(orderItem.productId).toBe('product-1'); expect(orderItem.quantity.value).toBe(2); expect(orderItem.unitPrice.amount).toBe(10.99); }); }); describe('getTotalPrice', () => { it('should calculate total price correctly', () => { const orderItem = OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(3), unitPrice: Money.create(10), }); const totalPrice = orderItem.getTotalPrice(); expect(totalPrice.amount).toBe(30); }); it('should calculate total price for single item', () => { const orderItem = OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(1), unitPrice: Money.create(15.99), }); const totalPrice = orderItem.getTotalPrice(); expect(totalPrice.amount).toBe(15.99); }); it('should calculate total price with decimal values', () => { const orderItem = OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10.99), }); const totalPrice = orderItem.getTotalPrice(); expect(totalPrice.amount).toBe(21.98); }); }); describe('updateQuantity', () => { it('should update quantity', () => { const orderItem = OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10), }); orderItem.updateQuantity(Quantity.create(5)); expect(orderItem.quantity.value).toBe(5); }); it('should affect total price after quantity update', () => { const orderItem = OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10), }); orderItem.updateQuantity(Quantity.create(3)); expect(orderItem.getTotalPrice().amount).toBe(30); }); }); describe('equals', () => { it('should return true for same id', () => { const item1 = OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10), }); const item2 = OrderItem.create({ id: 'item-1', productId: 'product-2', quantity: Quantity.create(3), unitPrice: Money.create(20), }); expect(item1.equals(item2)).toBe(true); }); it('should return false for different ids', () => { const item1 = OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10), }); const item2 = OrderItem.create({ id: 'item-2', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10), }); expect(item1.equals(item2)).toBe(false); }); }); }); ================================================ FILE: apps/api/src/modules/order/domain/entities/order-item.entity.ts ================================================ import { Entity } from '@/shared/domain/entity'; import { Money } from '../value-objects/money.vo'; import { Quantity } from '../value-objects/quantity.vo'; export interface OrderItemProps { id: string; productId: string; quantity: Quantity; unitPrice: Money; } export class OrderItem extends Entity { private _productId: string; private _quantity: Quantity; private _unitPrice: Money; get productId(): string { return this._productId; } get quantity(): Quantity { return this._quantity; } get unitPrice(): Money { return this._unitPrice; } private constructor(props: OrderItemProps) { super(props.id); this._productId = props.productId; this._quantity = props.quantity; this._unitPrice = props.unitPrice; } public static create(props: OrderItemProps): OrderItem { return new OrderItem(props); } public getTotalPrice(): Money { return this._unitPrice.multiply(this._quantity.value); } public updateQuantity(quantity: Quantity): void { this._quantity = quantity; } } ================================================ FILE: apps/api/src/modules/order/domain/entities/order.entity.spec.ts ================================================ import { Order } from './order.entity'; import { OrderItem } from './order-item.entity'; import { Money } from '../value-objects/money.vo'; import { Quantity } from '../value-objects/quantity.vo'; import { OrderId } from '../value-objects/order-id.vo'; import { OrderStatus } from '../value-objects/order-status.vo'; import { OrderCreatedEvent } from '../events/order-created.event'; import { OrderConfirmedEvent } from '../events/order-confirmed.event'; import { OrderCancelledEvent } from '../events/order-cancelled.event'; import { InvalidOrderStateException } from '../exceptions/invalid-order-state.exception'; describe('Order Aggregate Root', () => { let orderItems: OrderItem[]; beforeEach(() => { orderItems = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10), }), OrderItem.create({ id: 'item-2', productId: 'product-2', quantity: Quantity.create(1), unitPrice: Money.create(20), }), ]; }); describe('create', () => { it('should create order with valid properties', () => { const order = Order.create('customer-1', orderItems); expect(order.id).toBeDefined(); expect(order.customerId).toBe('customer-1'); expect(order.items.length).toBe(2); expect(order.status.isPending()).toBe(true); expect(order.createdAt).toBeInstanceOf(Date); expect(order.updatedAt).toBeInstanceOf(Date); }); it('should create order with provided id', () => { const orderId = OrderId.create('custom-order-id'); const order = Order.create('customer-1', orderItems, orderId); expect(order.id).toBe('custom-order-id'); }); it('should raise OrderCreatedEvent', () => { const order = Order.create('customer-1', orderItems); expect(order.domainEvents.length).toBe(1); expect(order.domainEvents[0]).toBeInstanceOf(OrderCreatedEvent); expect((order.domainEvents[0] as OrderCreatedEvent).orderId).toBe(order.id); expect((order.domainEvents[0] as OrderCreatedEvent).customerId).toBe('customer-1'); }); it('should create order with empty items', () => { const order = Order.create('customer-1', []); expect(order.items.length).toBe(0); }); }); describe('getTotalAmount', () => { it('should calculate total amount correctly', () => { const order = Order.create('customer-1', orderItems); const total = order.getTotalAmount(); expect(total.amount).toBe(40); // (2 * 10) + (1 * 20) }); it('should return zero for empty order', () => { const order = Order.create('customer-1', []); const total = order.getTotalAmount(); expect(total.amount).toBe(0); }); it('should calculate total for single item', () => { const singleItem = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(3), unitPrice: Money.create(15), }), ]; const order = Order.create('customer-1', singleItem); expect(order.getTotalAmount().amount).toBe(45); }); }); describe('confirm', () => { it('should confirm pending order', () => { const order = Order.create('customer-1', orderItems); order.confirm(); expect(order.status.isConfirmed()).toBe(true); }); it('should raise OrderConfirmedEvent', () => { const order = Order.create('customer-1', orderItems); order.clearEvents(); // Clear creation event order.confirm(); expect(order.domainEvents.length).toBe(1); expect(order.domainEvents[0]).toBeInstanceOf(OrderConfirmedEvent); expect((order.domainEvents[0] as OrderConfirmedEvent).orderId).toBe(order.id); }); it('should update updatedAt timestamp', () => { const order = Order.create('customer-1', orderItems); const originalUpdatedAt = order.updatedAt; // Wait a bit to ensure timestamp difference setTimeout(() => { order.confirm(); expect(order.updatedAt.getTime()).toBeGreaterThanOrEqual(originalUpdatedAt.getTime()); }, 10); }); it('should throw error when confirming non-pending order', () => { const order = Order.create('customer-1', orderItems); order.confirm(); expect(() => order.confirm()).toThrow(InvalidOrderStateException); expect(() => order.confirm()).toThrow('Cannot confirm order'); }); it('should throw error when confirming cancelled order', () => { const order = Order.create('customer-1', orderItems); order.cancel(); expect(() => order.confirm()).toThrow(InvalidOrderStateException); }); }); describe('cancel', () => { it('should cancel pending order', () => { const order = Order.create('customer-1', orderItems); order.cancel(); expect(order.status.isCancelled()).toBe(true); }); it('should cancel confirmed order', () => { const order = Order.create('customer-1', orderItems); order.confirm(); order.cancel(); expect(order.status.isCancelled()).toBe(true); }); it('should raise OrderCancelledEvent', () => { const order = Order.create('customer-1', orderItems); order.clearEvents(); order.cancel(); expect(order.domainEvents.length).toBe(1); expect(order.domainEvents[0]).toBeInstanceOf(OrderCancelledEvent); }); it('should throw error when cancelling already cancelled order', () => { const order = Order.create('customer-1', orderItems); order.cancel(); expect(() => order.cancel()).toThrow(InvalidOrderStateException); expect(() => order.cancel()).toThrow('Cannot cancel order'); }); }); describe('addItem', () => { it('should add item to pending order', () => { const order = Order.create('customer-1', orderItems); const newItem = OrderItem.create({ id: 'item-3', productId: 'product-3', quantity: Quantity.create(1), unitPrice: Money.create(30), }); order.addItem(newItem); expect(order.items.length).toBe(3); expect(order.items[2].id).toBe('item-3'); }); it('should update total amount after adding item', () => { const order = Order.create('customer-1', orderItems); const newItem = OrderItem.create({ id: 'item-3', productId: 'product-3', quantity: Quantity.create(1), unitPrice: Money.create(30), }); order.addItem(newItem); expect(order.getTotalAmount().amount).toBe(70); // 40 + 30 }); it('should throw error when adding item to non-pending order', () => { const order = Order.create('customer-1', orderItems); order.confirm(); const newItem = OrderItem.create({ id: 'item-3', productId: 'product-3', quantity: Quantity.create(1), unitPrice: Money.create(30), }); expect(() => order.addItem(newItem)).toThrow(InvalidOrderStateException); expect(() => order.addItem(newItem)).toThrow('Cannot add items to a non-pending order'); }); }); describe('removeItem', () => { it('should remove item from pending order', () => { const order = Order.create('customer-1', orderItems); order.removeItem('item-1'); expect(order.items.length).toBe(1); expect(order.items[0].id).toBe('item-2'); }); it('should update total amount after removing item', () => { const order = Order.create('customer-1', orderItems); order.removeItem('item-1'); // Remove item worth 20 expect(order.getTotalAmount().amount).toBe(20); }); it('should throw error when removing item from non-pending order', () => { const order = Order.create('customer-1', orderItems); order.confirm(); expect(() => order.removeItem('item-1')).toThrow(InvalidOrderStateException); expect(() => order.removeItem('item-1')).toThrow('Cannot remove items from a non-pending order'); }); it('should handle removing non-existent item', () => { const order = Order.create('customer-1', orderItems); order.removeItem('non-existent-id'); expect(order.items.length).toBe(2); // No change }); }); describe('clearEvents', () => { it('should clear all domain events', () => { const order = Order.create('customer-1', orderItems); expect(order.domainEvents.length).toBeGreaterThan(0); order.clearEvents(); expect(order.domainEvents.length).toBe(0); }); }); describe('reconstitute', () => { it('should reconstitute order from props without raising events', () => { const orderId = OrderId.create('existing-order-id'); const order = Order.reconstitute({ id: orderId, customerId: 'customer-1', items: orderItems, status: OrderStatus.confirmed(), createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-02'), }); expect(order.id).toBe('existing-order-id'); expect(order.status.isConfirmed()).toBe(true); expect(order.domainEvents.length).toBe(0); // No events raised }); }); describe('items immutability', () => { it('should return copy of items array', () => { const order = Order.create('customer-1', orderItems); const items1 = order.items; const items2 = order.items; expect(items1).not.toBe(items2); // Different array instances expect(items1).toEqual(items2); // Same content }); it('should not allow external modification of items', () => { const order = Order.create('customer-1', orderItems); const items = order.items; items.push( OrderItem.create({ id: 'item-3', productId: 'product-3', quantity: Quantity.create(1), unitPrice: Money.create(30), }), ); expect(order.items.length).toBe(2); // Original unchanged }); }); }); ================================================ FILE: apps/api/src/modules/order/domain/entities/order.entity.ts ================================================ import { AggregateRoot } from '@/shared/domain/aggregate-root'; import { OrderId } from '../value-objects/order-id.vo'; import { OrderStatus } from '../value-objects/order-status.vo'; import { Money } from '../value-objects/money.vo'; import { OrderItem } from './order-item.entity'; import { OrderCreatedEvent } from '../events/order-created.event'; import { OrderConfirmedEvent } from '../events/order-confirmed.event'; import { OrderCancelledEvent } from '../events/order-cancelled.event'; import { InvalidOrderStateException } from '../exceptions/invalid-order-state.exception'; export interface OrderProps { id: OrderId; customerId: string; items: OrderItem[]; status: OrderStatus; createdAt: Date; updatedAt: Date; } export class Order extends AggregateRoot { private _customerId: string; private _items: OrderItem[]; private _status: OrderStatus; private _createdAt: Date; private _updatedAt: Date; get customerId(): string { return this._customerId; } get items(): OrderItem[] { return [...this._items]; } get status(): OrderStatus { return this._status; } get createdAt(): Date { return this._createdAt; } get updatedAt(): Date { return this._updatedAt; } private constructor(props: OrderProps) { super(props.id.value); this._customerId = props.customerId; this._items = props.items; this._status = props.status; this._createdAt = props.createdAt; this._updatedAt = props.updatedAt; } public static create(customerId: string, items: OrderItem[], id?: OrderId): Order { const orderId = id || OrderId.create(); const now = new Date(); const order = new Order({ id: orderId, customerId, items, status: OrderStatus.pending(), createdAt: now, updatedAt: now, }); order.addDomainEvent(new OrderCreatedEvent(orderId.value, customerId, order.getTotalAmount())); return order; } public static reconstitute(props: OrderProps): Order { return new Order(props); } public getTotalAmount(): Money { if (this._items.length === 0) { return Money.create(0); } return this._items.reduce((total, item) => total.add(item.getTotalPrice()), Money.create(0)); } public confirm(): void { if (!this._status.isPending()) { throw new InvalidOrderStateException(`Cannot confirm order. Current status: ${this._status.value}`); } this._status = OrderStatus.confirmed(); this._updatedAt = new Date(); this.addDomainEvent(new OrderConfirmedEvent(this.id)); } public cancel(): void { if (this._status.isCancelled() || this._status.isCompleted()) { throw new InvalidOrderStateException(`Cannot cancel order. Current status: ${this._status.value}`); } this._status = OrderStatus.cancelled(); this._updatedAt = new Date(); this.addDomainEvent(new OrderCancelledEvent(this.id)); } public addItem(item: OrderItem): void { if (!this._status.isPending()) { throw new InvalidOrderStateException('Cannot add items to a non-pending order'); } this._items.push(item); this._updatedAt = new Date(); } public removeItem(itemId: string): void { if (!this._status.isPending()) { throw new InvalidOrderStateException('Cannot remove items from a non-pending order'); } this._items = this._items.filter(item => item.id !== itemId); this._updatedAt = new Date(); } } ================================================ FILE: apps/api/src/modules/order/domain/events/order-cancelled.event.ts ================================================ import { DomainEvent } from '@/shared/domain/domain-event'; export class OrderCancelledEvent extends DomainEvent { constructor(public readonly orderId: string) { super(); } getAggregateId(): string { return this.orderId; } } ================================================ FILE: apps/api/src/modules/order/domain/events/order-confirmed.event.ts ================================================ import { DomainEvent } from '@/shared/domain/domain-event'; export class OrderConfirmedEvent extends DomainEvent { constructor(public readonly orderId: string) { super(); } getAggregateId(): string { return this.orderId; } } ================================================ FILE: apps/api/src/modules/order/domain/events/order-created.event.ts ================================================ import { DomainEvent } from '@/shared/domain/domain-event'; import { Money } from '../value-objects/money.vo'; export class OrderCreatedEvent extends DomainEvent { constructor( public readonly orderId: string, public readonly customerId: string, public readonly totalAmount: Money, ) { super(); } getAggregateId(): string { return this.orderId; } } ================================================ FILE: apps/api/src/modules/order/domain/exceptions/invalid-order-state.exception.ts ================================================ import { DomainException } from '@/shared/presentation/filters/domain-exception.filter'; export class InvalidOrderStateException extends DomainException { constructor(message: string) { super(message); } } ================================================ FILE: apps/api/src/modules/order/domain/exceptions/order-not-found.exception.ts ================================================ import { DomainException } from '@/shared/presentation/filters/domain-exception.filter'; export class OrderNotFoundException extends DomainException { constructor(orderId: string) { super(`Order with id ${orderId} not found`); } } ================================================ FILE: apps/api/src/modules/order/domain/repositories/order.repository.interface.ts ================================================ import { Order } from '../entities/order.entity'; export interface IOrderRepository { findById(id: string): Promise; findByCustomerId(customerId: string): Promise; save(order: Order): Promise; delete(id: string): Promise; } export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY'); ================================================ FILE: apps/api/src/modules/order/domain/services/order-pricing.service.spec.ts ================================================ import { OrderPricingService } from './order-pricing.service'; import { Order } from '../entities/order.entity'; import { OrderItem } from '../entities/order-item.entity'; import { Money } from '../value-objects/money.vo'; import { Quantity } from '../value-objects/quantity.vo'; describe('OrderPricingService', () => { let service: OrderPricingService; let order: Order; beforeEach(() => { service = new OrderPricingService(); const items = [ OrderItem.create({ id: 'item-1', productId: 'product-1', quantity: Quantity.create(2), unitPrice: Money.create(10), }), OrderItem.create({ id: 'item-2', productId: 'product-2', quantity: Quantity.create(1), unitPrice: Money.create(20), }), ]; order = Order.create('customer-1', items); }); describe('calculateTotal', () => { it('should calculate total amount for order', () => { const total = service.calculateTotal(order); expect(total.amount).toBe(40); // (2 * 10) + (1 * 20) }); it('should return zero for empty order', () => { const emptyOrder = Order.create('customer-1', []); const total = service.calculateTotal(emptyOrder); expect(total.amount).toBe(0); }); }); describe('applyDiscount', () => { it('should apply discount percentage correctly', () => { const total = Money.create(100); const discounted = service.applyDiscount(total, 10); expect(discounted.amount).toBe(90); }); it('should apply 50% discount', () => { const total = Money.create(100); const discounted = service.applyDiscount(total, 50); expect(discounted.amount).toBe(50); }); it('should apply 100% discount', () => { const total = Money.create(100); const discounted = service.applyDiscount(total, 100); expect(discounted.amount).toBe(0); }); it('should apply 0% discount', () => { const total = Money.create(100); const discounted = service.applyDiscount(total, 0); expect(discounted.amount).toBe(100); }); it('should throw error for negative discount', () => { const total = Money.create(100); expect(() => service.applyDiscount(total, -10)).toThrow('Discount percent must be between 0 and 100'); }); it('should throw error for discount over 100%', () => { const total = Money.create(100); expect(() => service.applyDiscount(total, 101)).toThrow('Discount percent must be between 0 and 100'); }); it('should preserve currency after discount', () => { const total = Money.create(100, 'EUR'); const discounted = service.applyDiscount(total, 10); expect(discounted.currency).toBe('EUR'); }); it('should handle decimal discount percentages', () => { const total = Money.create(100); const discounted = service.applyDiscount(total, 12.5); expect(discounted.amount).toBe(87.5); }); }); }); ================================================ FILE: apps/api/src/modules/order/domain/services/order-pricing.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { Order } from '../entities/order.entity'; import { Money } from '../value-objects/money.vo'; @Injectable() export class OrderPricingService { calculateTotal(order: Order): Money { return order.getTotalAmount(); } applyDiscount(total: Money, discountPercent: number): Money { if (discountPercent < 0 || discountPercent > 100) { throw new Error('Discount percent must be between 0 and 100'); } const discountMultiplier = 1 - discountPercent / 100; return Money.create(total.amount * discountMultiplier, total.currency); } } ================================================ FILE: apps/api/src/modules/order/domain/value-objects/money.vo.spec.ts ================================================ import { Money } from './money.vo'; describe('Money Value Object', () => { describe('create', () => { it('should create money with valid amount', () => { const money = Money.create(10.99, 'USD'); expect(money.amount).toBe(10.99); expect(money.currency).toBe('USD'); }); it('should create money with default currency', () => { const money = Money.create(10.99); expect(money.amount).toBe(10.99); expect(money.currency).toBe('USD'); }); it('should throw error for negative amount', () => { expect(() => Money.create(-10)).toThrow('Money amount cannot be negative'); }); it('should allow zero amount', () => { const money = Money.create(0); expect(money.amount).toBe(0); }); }); describe('add', () => { it('should add money with same currency', () => { const money1 = Money.create(10, 'USD'); const money2 = Money.create(5, 'USD'); const result = money1.add(money2); expect(result.amount).toBe(15); expect(result.currency).toBe('USD'); }); it('should throw error when adding different currencies', () => { const money1 = Money.create(10, 'USD'); const money2 = Money.create(5, 'EUR'); expect(() => money1.add(money2)).toThrow('Cannot add money with different currencies'); }); it('should not mutate original money objects', () => { const money1 = Money.create(10, 'USD'); const money2 = Money.create(5, 'USD'); money1.add(money2); expect(money1.amount).toBe(10); expect(money2.amount).toBe(5); }); }); describe('multiply', () => { it('should multiply money by positive number', () => { const money = Money.create(10, 'USD'); const result = money.multiply(2); expect(result.amount).toBe(20); expect(result.currency).toBe('USD'); }); it('should multiply money by decimal', () => { const money = Money.create(10, 'USD'); const result = money.multiply(1.5); expect(result.amount).toBe(15); }); it('should multiply money by zero', () => { const money = Money.create(10, 'USD'); const result = money.multiply(0); expect(result.amount).toBe(0); }); it('should not mutate original money object', () => { const money = Money.create(10, 'USD'); money.multiply(2); expect(money.amount).toBe(10); }); }); describe('equals', () => { it('should return true for equal money objects', () => { const money1 = Money.create(10, 'USD'); const money2 = Money.create(10, 'USD'); expect(money1.equals(money2)).toBe(true); }); it('should return false for different amounts', () => { const money1 = Money.create(10, 'USD'); const money2 = Money.create(20, 'USD'); expect(money1.equals(money2)).toBe(false); }); it('should return false for different currencies', () => { const money1 = Money.create(10, 'USD'); const money2 = Money.create(10, 'EUR'); expect(money1.equals(money2)).toBe(false); }); it('should return false for null', () => { const money = Money.create(10, 'USD'); expect(money.equals(null as any)).toBe(false); }); it('should return false for undefined', () => { const money = Money.create(10, 'USD'); expect(money.equals(undefined as any)).toBe(false); }); }); }); ================================================ FILE: apps/api/src/modules/order/domain/value-objects/money.vo.ts ================================================ import { ValueObject } from '@/shared/domain/value-object'; import { Guard } from '@/shared/utils/guard'; interface MoneyProps { amount: number; currency: string; } export class Money extends ValueObject { get amount(): number { return this.props.amount; } get currency(): string { return this.props.currency; } private constructor(props: MoneyProps) { super(props); } public static create(amount: number, currency: string = 'USD'): Money { const guardResult = Guard.againstNullOrUndefined(amount, 'amount'); if (!guardResult.succeeded) { throw new Error(guardResult.message); } if (amount < 0) { throw new Error('Money amount cannot be negative'); } return new Money({ amount, currency }); } public add(money: Money): Money { if (this.currency !== money.currency) { throw new Error('Cannot add money with different currencies'); } return Money.create(this.amount + money.amount, this.currency); } public multiply(multiplier: number): Money { return Money.create(this.amount * multiplier, this.currency); } } ================================================ FILE: apps/api/src/modules/order/domain/value-objects/order-id.vo.spec.ts ================================================ import { OrderId } from './order-id.vo'; describe('OrderId Value Object', () => { describe('create', () => { it('should create order id with provided value', () => { const id = 'test-order-id'; const orderId = OrderId.create(id); expect(orderId.value).toBe(id); }); it('should generate UUID when no value provided', () => { const orderId = OrderId.create(); expect(orderId.value).toBeDefined(); expect(orderId.value.length).toBeGreaterThan(0); expect(orderId.value).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); }); it('should generate different UUIDs for each call', () => { const orderId1 = OrderId.create(); const orderId2 = OrderId.create(); expect(orderId1.value).not.toBe(orderId2.value); }); }); describe('toString', () => { it('should return string representation', () => { const id = 'test-order-id'; const orderId = OrderId.create(id); expect(orderId.toString()).toBe(id); }); }); describe('equals', () => { it('should return true for equal order ids', () => { const id = 'test-order-id'; const orderId1 = OrderId.create(id); const orderId2 = OrderId.create(id); expect(orderId1.equals(orderId2)).toBe(true); }); it('should return false for different order ids', () => { const orderId1 = OrderId.create('id-1'); const orderId2 = OrderId.create('id-2'); expect(orderId1.equals(orderId2)).toBe(false); }); }); }); ================================================ FILE: apps/api/src/modules/order/domain/value-objects/order-id.vo.ts ================================================ import { ValueObject } from '@/shared/domain/value-object'; import { v4 as uuidv4 } from 'uuid'; interface OrderIdProps { value: string; } export class OrderId extends ValueObject { get value(): string { return this.props.value; } private constructor(props: OrderIdProps) { super(props); } public static create(id?: string): OrderId { return new OrderId({ value: id || uuidv4() }); } public toString(): string { return this.value; } } ================================================ FILE: apps/api/src/modules/order/domain/value-objects/order-status.vo.spec.ts ================================================ import { OrderStatus, OrderStatusEnum } from './order-status.vo'; describe('OrderStatus Value Object', () => { describe('create', () => { it('should create order status with valid enum value', () => { const status = OrderStatus.create(OrderStatusEnum.PENDING); expect(status.value).toBe(OrderStatusEnum.PENDING); }); }); describe('factory methods', () => { it('should create pending status', () => { const status = OrderStatus.pending(); expect(status.value).toBe(OrderStatusEnum.PENDING); expect(status.isPending()).toBe(true); }); it('should create confirmed status', () => { const status = OrderStatus.confirmed(); expect(status.value).toBe(OrderStatusEnum.CONFIRMED); expect(status.isConfirmed()).toBe(true); }); it('should create cancelled status', () => { const status = OrderStatus.cancelled(); expect(status.value).toBe(OrderStatusEnum.CANCELLED); expect(status.isCancelled()).toBe(true); }); it('should create completed status', () => { const status = OrderStatus.completed(); expect(status.value).toBe(OrderStatusEnum.COMPLETED); expect(status.isCompleted()).toBe(true); }); }); describe('status checks', () => { it('isPending should return false for non-pending status', () => { const status = OrderStatus.confirmed(); expect(status.isPending()).toBe(false); }); it('isConfirmed should return false for non-confirmed status', () => { const status = OrderStatus.pending(); expect(status.isConfirmed()).toBe(false); }); it('isCancelled should return false for non-cancelled status', () => { const status = OrderStatus.pending(); expect(status.isCancelled()).toBe(false); }); it('isCompleted should return false for non-completed status', () => { const status = OrderStatus.pending(); expect(status.isCompleted()).toBe(false); }); }); describe('equals', () => { it('should return true for equal statuses', () => { const status1 = OrderStatus.pending(); const status2 = OrderStatus.pending(); expect(status1.equals(status2)).toBe(true); }); it('should return false for different statuses', () => { const status1 = OrderStatus.pending(); const status2 = OrderStatus.confirmed(); expect(status1.equals(status2)).toBe(false); }); }); }); ================================================ FILE: apps/api/src/modules/order/domain/value-objects/order-status.vo.ts ================================================ import { ValueObject } from '@/shared/domain/value-object'; export enum OrderStatusEnum { PENDING = 'PENDING', CONFIRMED = 'CONFIRMED', CANCELLED = 'CANCELLED', COMPLETED = 'COMPLETED', } interface OrderStatusProps { value: OrderStatusEnum; } export class OrderStatus extends ValueObject { get value(): OrderStatusEnum { return this.props.value; } private constructor(props: OrderStatusProps) { super(props); } public static create(status: OrderStatusEnum): OrderStatus { return new OrderStatus({ value: status }); } public static pending(): OrderStatus { return new OrderStatus({ value: OrderStatusEnum.PENDING }); } public static confirmed(): OrderStatus { return new OrderStatus({ value: OrderStatusEnum.CONFIRMED }); } public static cancelled(): OrderStatus { return new OrderStatus({ value: OrderStatusEnum.CANCELLED }); } public static completed(): OrderStatus { return new OrderStatus({ value: OrderStatusEnum.COMPLETED }); } public isPending(): boolean { return this.value === OrderStatusEnum.PENDING; } public isConfirmed(): boolean { return this.value === OrderStatusEnum.CONFIRMED; } public isCancelled(): boolean { return this.value === OrderStatusEnum.CANCELLED; } public isCompleted(): boolean { return this.value === OrderStatusEnum.COMPLETED; } } ================================================ FILE: apps/api/src/modules/order/domain/value-objects/quantity.vo.spec.ts ================================================ import { Quantity } from './quantity.vo'; describe('Quantity Value Object', () => { describe('create', () => { it('should create quantity with valid value', () => { const quantity = Quantity.create(5); expect(quantity.value).toBe(5); }); it('should throw error for zero quantity', () => { expect(() => Quantity.create(0)).toThrow('Quantity must be at least 1'); }); it('should throw error for negative quantity', () => { expect(() => Quantity.create(-5)).toThrow('Quantity must be at least 1'); }); it('should throw error for non-integer quantity', () => { expect(() => Quantity.create(5.5)).toThrow('Quantity must be an integer'); }); it('should allow large quantities', () => { const quantity = Quantity.create(1000); expect(quantity.value).toBe(1000); }); }); describe('equals', () => { it('should return true for equal quantities', () => { const quantity1 = Quantity.create(5); const quantity2 = Quantity.create(5); expect(quantity1.equals(quantity2)).toBe(true); }); it('should return false for different quantities', () => { const quantity1 = Quantity.create(5); const quantity2 = Quantity.create(10); expect(quantity1.equals(quantity2)).toBe(false); }); it('should return false for null', () => { const quantity = Quantity.create(5); expect(quantity.equals(null as any)).toBe(false); }); }); }); ================================================ FILE: apps/api/src/modules/order/domain/value-objects/quantity.vo.ts ================================================ import { ValueObject } from '@/shared/domain/value-object'; interface QuantityProps { value: number; } export class Quantity extends ValueObject { get value(): number { return this.props.value; } private constructor(props: QuantityProps) { super(props); } public static create(value: number): Quantity { if (value < 1) { throw new Error('Quantity must be at least 1'); } if (!Number.isInteger(value)) { throw new Error('Quantity must be an integer'); } return new Quantity({ value }); } } ================================================ FILE: apps/api/src/modules/order/infrastructure/cache/order-cache.service.ts ================================================ import { Injectable, Logger } from '@nestjs/common'; import { RedissonService } from '@a3s-lab/redisson'; import { Order } from '../../domain/entities/order.entity'; const ORDER_CACHE_PREFIX = 'order:'; const ORDER_LIST_CACHE_PREFIX = 'orders:customer:'; const DEFAULT_TTL = 3600; // 1 hour @Injectable() export class OrderCacheService { private readonly logger = new Logger(OrderCacheService.name); constructor(private readonly redisson: RedissonService) {} /** * Cache an order by ID */ async cacheOrder(order: Order, ttl: number = DEFAULT_TTL): Promise { const key = `${ORDER_CACHE_PREFIX}${order.id}`; const data = this.serializeOrder(order); await this.redisson.setJSON(key, data, ttl); this.logger.debug(`Cached order: ${order.id}`); } /** * Get cached order by ID */ async getCachedOrder(orderId: string): Promise { const key = `${ORDER_CACHE_PREFIX}${orderId}`; return this.redisson.getJSON(key); } /** * Invalidate order cache */ async invalidateOrder(orderId: string): Promise { const key = `${ORDER_CACHE_PREFIX}${orderId}`; await this.redisson.delete(key); this.logger.debug(`Invalidated order cache: ${orderId}`); } /** * Cache order list for a customer */ async cacheCustomerOrders( customerId: string, orders: Order[], ttl: number = DEFAULT_TTL, ): Promise { const key = `${ORDER_LIST_CACHE_PREFIX}${customerId}`; const data = orders.map((order) => this.serializeOrder(order)); await this.redisson.setJSON(key, data, ttl); this.logger.debug(`Cached ${orders.length} orders for customer: ${customerId}`); } /** * Get cached orders for a customer */ async getCachedCustomerOrders(customerId: string): Promise { const key = `${ORDER_LIST_CACHE_PREFIX}${customerId}`; return this.redisson.getJSON(key); } /** * Invalidate customer orders cache */ async invalidateCustomerOrders(customerId: string): Promise { const key = `${ORDER_LIST_CACHE_PREFIX}${customerId}`; await this.redisson.delete(key); this.logger.debug(`Invalidated customer orders cache: ${customerId}`); } /** * Get or set order with cache */ async getOrSetOrder( orderId: string, factory: () => Promise, ttl: number = DEFAULT_TTL, ): Promise { const key = `${ORDER_CACHE_PREFIX}${orderId}`; return this.redisson.getOrSet( key, async () => { const order = await factory(); return order ? this.serializeOrder(order) : null; }, ttl, ); } /** * Execute operation with distributed lock * Prevents race conditions when updating orders */ async withOrderLock( orderId: string, operation: () => Promise, waitTime: number = 5000, leaseTime: number = 10000, ): Promise { const lockKey = `lock:order:${orderId}`; return this.redisson.withLock(lockKey, operation, waitTime, leaseTime); } /** * Increment order view count (for analytics) */ async incrementOrderViewCount(orderId: string): Promise { const key = `order:views:${orderId}`; return this.redisson.increment(key); } /** * Get order view count */ async getOrderViewCount(orderId: string): Promise { const key = `order:views:${orderId}`; const count = await this.redisson.get(key); return count ? parseInt(count, 10) : 0; } private serializeOrder(order: Order): SerializedOrder { return { id: order.id, customerId: order.customerId, status: order.status.value, totalAmount: order.getTotalAmount().amount, items: order.items.map((item) => ({ id: item.id, productId: item.productId, quantity: item.quantity.value, unitPrice: item.unitPrice.amount, subtotal: item.getTotalPrice().amount, })), createdAt: order.createdAt.toISOString(), updatedAt: order.updatedAt.toISOString(), }; } } export interface SerializedOrder { id: string; customerId: string; status: string; totalAmount: number; items: SerializedOrderItem[]; createdAt: string; updatedAt: string; } export interface SerializedOrderItem { id: string; productId: string; quantity: number; unitPrice: number; subtotal: number; } ================================================ FILE: apps/api/src/modules/order/infrastructure/persistence/kysely-order.repository.ts ================================================ import { Injectable } from '@nestjs/common'; import { KyselyService } from '@a3s-lab/kysely'; import { Database, NewOrder, NewOrderItem } from '@/shared/database/database.types'; import { IOrderRepository } from '../../domain/repositories/order.repository.interface'; import { Order } from '../../domain/entities/order.entity'; import { OrderItem } from '../../domain/entities/order-item.entity'; import { OrderId } from '../../domain/value-objects/order-id.vo'; import { OrderStatus, OrderStatusEnum } from '../../domain/value-objects/order-status.vo'; import { Money } from '../../domain/value-objects/money.vo'; import { Quantity } from '../../domain/value-objects/quantity.vo'; @Injectable() export class OrderRepository implements IOrderRepository { constructor(private readonly db: KyselyService) {} async findById(id: string): Promise { const orderRow = await this.db .selectFrom('orders') .where('id', '=', id) .selectAll() .executeTakeFirst(); if (!orderRow) { return null; } const itemRows = await this.db .selectFrom('order_items') .where('order_id', '=', id) .selectAll() .execute(); return this.toDomain(orderRow, itemRows); } async findByCustomerId(customerId: string): Promise { const orderRows = await this.db .selectFrom('orders') .where('customer_id', '=', customerId) .selectAll() .execute(); const orders: Order[] = []; for (const orderRow of orderRows) { const itemRows = await this.db .selectFrom('order_items') .where('order_id', '=', orderRow.id) .selectAll() .execute(); orders.push(this.toDomain(orderRow, itemRows)); } return orders; } async save(order: Order): Promise { return await this.db.transaction().execute(async (trx) => { // Check if order exists const existingOrder = await trx .selectFrom('orders') .where('id', '=', order.id) .select('id') .executeTakeFirst(); const statusValue = this.mapStatusToDb(order.status.value); const orderData: NewOrder = { id: order.id, customer_id: order.customerId, status: statusValue, total_amount: order.getTotalAmount().amount, created_at: order.createdAt.toISOString(), updated_at: order.updatedAt.toISOString(), }; if (existingOrder) { // Update existing order await trx .updateTable('orders') .set({ status: orderData.status, total_amount: orderData.total_amount, updated_at: orderData.updated_at, }) .where('id', '=', order.id) .execute(); // Delete existing items await trx .deleteFrom('order_items') .where('order_id', '=', order.id) .execute(); } else { // Insert new order await trx .insertInto('orders') .values(orderData) .execute(); } // Insert order items if (order.items.length > 0) { const itemsData: NewOrderItem[] = order.items.map((item) => ({ id: item.id, order_id: order.id, product_id: item.productId, quantity: item.quantity.value, unit_price: item.unitPrice.amount, subtotal: item.getTotalPrice().amount, created_at: new Date().toISOString(), })); await trx .insertInto('order_items') .values(itemsData) .execute(); } return order; }); } async delete(id: string): Promise { await this.db.transaction().execute(async (trx) => { await trx .deleteFrom('order_items') .where('order_id', '=', id) .execute(); await trx .deleteFrom('orders') .where('id', '=', id) .execute(); }); } private mapStatusToDb(status: OrderStatusEnum): 'pending' | 'confirmed' | 'cancelled' { switch (status) { case OrderStatusEnum.PENDING: return 'pending'; case OrderStatusEnum.CONFIRMED: return 'confirmed'; case OrderStatusEnum.CANCELLED: return 'cancelled'; default: return 'pending'; } } private mapStatusFromDb(status: string): OrderStatusEnum { switch (status.toLowerCase()) { case 'pending': return OrderStatusEnum.PENDING; case 'confirmed': return OrderStatusEnum.CONFIRMED; case 'cancelled': return OrderStatusEnum.CANCELLED; default: return OrderStatusEnum.PENDING; } } private toDomain( orderRow: { id: string; customer_id: string; status: string; total_amount: number; created_at: Date; updated_at: Date; }, itemRows: Array<{ id: string; order_id: string; product_id: string; quantity: number; unit_price: number; subtotal: number; created_at: Date; }>, ): Order { const items = itemRows.map((itemRow) => OrderItem.create({ id: itemRow.id, productId: itemRow.product_id, quantity: Quantity.create(itemRow.quantity), unitPrice: Money.create(itemRow.unit_price), }), ); return Order.reconstitute({ id: OrderId.create(orderRow.id), customerId: orderRow.customer_id, items, status: OrderStatus.create(this.mapStatusFromDb(orderRow.status)), createdAt: orderRow.created_at, updatedAt: orderRow.updated_at, }); } } ================================================ FILE: apps/api/src/modules/order/order.module.ts ================================================ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { OrderController } from './presentation/order.controller'; import { OrderRepository } from './infrastructure/persistence/kysely-order.repository'; import { OrderCacheService } from './infrastructure/cache/order-cache.service'; import { ORDER_REPOSITORY } from './domain/repositories/order.repository.interface'; import { CreateOrderHandler } from './application/commands/create-order/create-order.handler'; import { ConfirmOrderHandler } from './application/commands/confirm-order/confirm-order.handler'; import { CancelOrderHandler } from './application/commands/cancel-order/cancel-order.handler'; import { GetOrderHandler } from './application/queries/get-order/get-order.handler'; import { ListOrdersHandler } from './application/queries/list-orders/list-orders.handler'; import { OrderCreatedHandler } from './application/event-handlers/order-created.handler'; import { OrderConfirmedHandler } from './application/event-handlers/order-confirmed.handler'; import { OrderPricingService } from './domain/services/order-pricing.service'; import { EventBusService } from '@/shared/infrastructure/messaging/event-bus.service'; import { EVENT_BUS } from '@/shared/infrastructure/messaging/event-bus.interface'; const CommandHandlers = [CreateOrderHandler, ConfirmOrderHandler, CancelOrderHandler]; const QueryHandlers = [GetOrderHandler, ListOrdersHandler]; const EventHandlers = [OrderCreatedHandler, OrderConfirmedHandler]; @Module({ imports: [CqrsModule], controllers: [OrderController], providers: [ ...CommandHandlers, ...QueryHandlers, ...EventHandlers, OrderPricingService, OrderCacheService, { provide: ORDER_REPOSITORY, useClass: OrderRepository, }, { provide: EVENT_BUS, useClass: EventBusService, }, ], exports: [OrderCacheService], }) export class OrderModule {} ================================================ FILE: apps/api/src/modules/order/presentation/order.controller.ts ================================================ import { Controller, Get, Post, Body, Param, Query, HttpCode, HttpStatus } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { CreateOrderDto } from '../application/commands/create-order/create-order.dto'; import { CreateOrderCommand } from '../application/commands/create-order/create-order.command'; import { ConfirmOrderCommand } from '../application/commands/confirm-order/confirm-order.command'; import { CancelOrderCommand } from '../application/commands/cancel-order/cancel-order.command'; import { GetOrderQuery } from '../application/queries/get-order/get-order.query'; import { ListOrdersQuery } from '../application/queries/list-orders/list-orders.query'; import { OrderResponseDto } from '../application/queries/get-order/order.response.dto'; import { OrderListResponseDto } from '../application/queries/list-orders/order-list.response.dto'; @ApiTags('orders') @Controller('orders') export class OrderController { constructor( private readonly commandBus: CommandBus, private readonly queryBus: QueryBus, ) {} @Post() @ApiOperation({ summary: 'Create a new order' }) @ApiResponse({ status: 201, description: 'Order created successfully' }) async createOrder(@Body() dto: CreateOrderDto): Promise<{ orderId: string }> { const command = new CreateOrderCommand(dto.customerId, dto.items); const orderId = await this.commandBus.execute(command); return { orderId }; } @Get(':id') @ApiOperation({ summary: 'Get order by ID' }) @ApiResponse({ status: 200, type: OrderResponseDto }) async getOrder(@Param('id') id: string): Promise { const query = new GetOrderQuery(id); return this.queryBus.execute(query); } @Get() @ApiOperation({ summary: 'List orders' }) @ApiResponse({ status: 200, type: OrderListResponseDto }) async listOrders(@Query('customerId') customerId?: string): Promise { const query = new ListOrdersQuery(customerId); return this.queryBus.execute(query); } @Post(':id/confirm') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Confirm an order' }) @ApiResponse({ status: 204, description: 'Order confirmed successfully' }) async confirmOrder(@Param('id') id: string): Promise { const command = new ConfirmOrderCommand(id); await this.commandBus.execute(command); } @Post(':id/cancel') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Cancel an order' }) @ApiResponse({ status: 204, description: 'Order cancelled successfully' }) async cancelOrder(@Param('id') id: string): Promise { const command = new CancelOrderCommand(id); await this.commandBus.execute(command); } } ================================================ FILE: apps/api/src/shared/api-response/api-response.dto.ts ================================================ // ============================================================================ // API Response DTO - Standardized API response wrapper // ============================================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class ApiResponseDto { @ApiProperty({ description: 'Response code', example: 200 }) code: number; @ApiProperty({ description: 'Response message', example: 'Success' }) message: string; @ApiPropertyOptional({ description: 'Response data' }) data?: T; @ApiPropertyOptional({ description: 'Request ID for tracing' }) requestId?: string; @ApiProperty({ description: 'Timestamp' }) timestamp: string; constructor(partial: Partial>) { Object.assign(this, partial); this.timestamp = this.timestamp || new Date().toISOString(); } } export class PaginatedResponseDto { @ApiProperty({ description: 'Items array', type: () => Array }) items: T[]; @ApiProperty({ description: 'Total count' }) total: number; @ApiProperty({ description: 'Current page' }) page: number; @ApiProperty({ description: 'Page size' }) pageSize: number; @ApiProperty({ description: 'Total pages' }) totalPages: number; @ApiProperty({ description: 'Has next page' }) hasNext: boolean; @ApiProperty({ description: 'Has previous page' }) hasPrevious: boolean; constructor(partial: Partial>) { Object.assign(this, partial); } } export class ApiErrorResponseDto { @ApiProperty({ description: 'Error code', example: 'NOT_FOUND' }) code: string; @ApiProperty({ description: 'Error message', example: 'Resource not found' }) message: string; @ApiPropertyOptional({ description: 'Detailed error info' }) details?: Record; @ApiProperty({ description: 'Timestamp' }) timestamp: string; @ApiPropertyOptional({ description: 'Request ID for tracing' }) requestId?: string; constructor(partial: Partial) { Object.assign(this, partial); this.timestamp = this.timestamp || new Date().toISOString(); } } ================================================ FILE: apps/api/src/shared/api-response/api-response.interceptor.ts ================================================ // ============================================================================ // API Response Interceptor - Wrap all responses in ApiResponseDto // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Request } from 'express'; import { ApiResponseDto } from './api-response.dto'; @Injectable() export class ApiResponseInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const requestId = (request.headers['x-request-id'] as string) || (request.headers['x-correlation-id'] as string); return next.handle().pipe( map((data) => { // If already wrapped in ApiResponseDto, return as is if (data instanceof ApiResponseDto) { return data; } // Return wrapped response return new ApiResponseDto({ code: 200, message: 'Success', data, requestId, timestamp: new Date().toISOString(), }); }), ); } } ================================================ FILE: apps/api/src/shared/api-response/api-response.service.ts ================================================ // ============================================================================ // API Response Service - Factory for creating standardized responses // ============================================================================ import { Injectable, Logger } from '@nestjs/common'; import { Request } from 'express'; import { ApiResponseDto, PaginatedResponseDto, ApiErrorResponseDto, } from './api-response.dto'; @Injectable() export class ApiResponseService { private readonly logger = new Logger(ApiResponseService.name); /** * Create a success response */ success(data?: T, message = 'Success', requestId?: string): ApiResponseDto { return new ApiResponseDto({ code: 200, message, data, requestId, }); } /** * Create a created response (201) */ created(data?: T, message = 'Created', requestId?: string): ApiResponseDto { return new ApiResponseDto({ code: 201, message, data, requestId, }); } /** * Create an accepted response (202) */ accepted(data?: T, message = 'Accepted', requestId?: string): ApiResponseDto { return new ApiResponseDto({ code: 202, message, data, requestId, }); } /** * Create a no content response (204) */ noContent(requestId?: string): ApiResponseDto { return new ApiResponseDto({ code: 204, message: 'No Content', requestId, }); } /** * Create a paginated response */ paginated( items: T[], total: number, page: number, pageSize: number, message = 'Success', requestId?: string, ): PaginatedResponseDto { const totalPages = Math.ceil(total / pageSize); return new PaginatedResponseDto({ items, total, page, pageSize, totalPages, hasNext: page < totalPages, hasPrevious: page > 1, }); } /** * Create an error response */ error( code: string, message: string, details?: Record, requestId?: string, ): ApiErrorResponseDto { return new ApiErrorResponseDto({ code, message, details, requestId, }); } /** * Get request ID from request object */ getRequestId(request: Request): string | undefined { return ( (request.headers['x-request-id'] as string) || (request.headers['x-correlation-id'] as string) || undefined ); } } ================================================ FILE: apps/api/src/shared/api-response/index.ts ================================================ export * from './api-response.dto'; export * from './api-response.service'; export * from './api-response.interceptor'; ================================================ FILE: apps/api/src/shared/api-versioning/api-versioning.decorator.ts ================================================ // ============================================================================ // API Versioning Decorators // ============================================================================ import { SetMetadata } from '@nestjs/common'; export const API_VERSION_KEY = 'api_version'; /** * Set API version for a controller or route */ export const ApiVersion = (version: string) => SetMetadata(API_VERSION_KEY, version); /** * Mark endpoint as deprecated */ export const Deprecated = () => SetMetadata('isDeprecated', true); /** * Set sunset date for endpoint */ export const Sunset = (date: Date) => SetMetadata('sunsetDate', date); ================================================ FILE: apps/api/src/shared/api-versioning/api-versioning.interceptor.ts ================================================ // ============================================================================ // API Versioning - URL and Header based versioning // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; import { Observable } from 'rxjs'; import { Request } from 'express'; export interface ApiVersionOptions { /** Current API version */ version: string; /** Deprecation info */ deprecated?: boolean; /** Sunset date */ sunsetDate?: Date; } /** * Default API version */ export const DEFAULT_API_VERSION = '1'; /** * Extract API version from URL path * Supports /api/v1, /api/v2 patterns */ export function extractVersionFromUrl(url: string): string | null { const match = url.match(/\/api\/v(\d+)/); return match ? match[1] : null; } /** * Extract API version from Accept-Header * Supports: application/vnd.api.v1+json */ export function extractVersionFromHeader(header: string | string[] | undefined): string | null { if (!header) return null; const headerValue = Array.isArray(header) ? header[0] : header; const match = headerValue.match(/v(\d+)/); return match ? match[1] : null; } /** * Extract API version from custom header * Supports: X-API-Version: 1 */ export function extractVersionFromCustomHeader( header: string | string[] | undefined, ): string | null { if (!header) return null; const value = Array.isArray(header) ? header[0] : header; return value || null; } @Injectable() export class ApiVersioningInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); // Try URL first (e.g., /api/v1/users) let version = extractVersionFromUrl(request.url); // Fallback to Accept header if (!version) { version = extractVersionFromHeader(request.headers.accept); } // Fallback to custom header if (!version) { version = extractVersionFromCustomHeader( request.headers['x-api-version'], ); } // Default to v1 if no version specified if (!version) { version = DEFAULT_API_VERSION; } // Attach version to request (request as any).apiVersion = version; return next.handle(); } } ================================================ FILE: apps/api/src/shared/api-versioning/index.ts ================================================ // ============================================================================ // API Versioning Module // ============================================================================ export { ApiVersioningInterceptor } from './api-versioning.interceptor'; export { ApiVersion, Deprecated, Sunset } from './api-versioning.decorator'; ================================================ FILE: apps/api/src/shared/application/dto.base.ts ================================================ export abstract class BaseDto { constructor(partial?: Partial) { if (partial) { Object.assign(this, partial); } } } ================================================ FILE: apps/api/src/shared/application/query.interface.ts ================================================ export interface IQuery { execute(): Promise; } ================================================ FILE: apps/api/src/shared/application/use-case.interface.ts ================================================ export interface IUseCase { execute(request: IRequest): Promise; } ================================================ FILE: apps/api/src/shared/audit/audit.decorator.ts ================================================ // ============================================================================ // Audit Decorators // ============================================================================ import { SetMetadata } from '@nestjs/common'; export const AUDIT_KEY = 'audit'; /** * Mark endpoint to be audited */ export const Audited = (resource?: string) => SetMetadata(AUDIT_KEY, { resource }); ================================================ FILE: apps/api/src/shared/audit/audit.interceptor.ts ================================================ // ============================================================================ // Audit Interceptor - Automatically logs operations // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { AuditService } from './audit.service'; import { Request } from 'express'; export const AUDIT_ACTION_KEY = 'audit_action'; export const AUDIT_RESOURCE_KEY = 'audit_resource'; /** * Decorator to mark endpoint for audit logging */ export function AuditedAction(action: string) { return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { Reflect.defineMetadata(AUDIT_ACTION_KEY, action, descriptor.value); return descriptor; }; } /** * Decorator to specify audit resource */ export function AuditedResource(resource: string) { return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { Reflect.defineMetadata(AUDIT_RESOURCE_KEY, resource, descriptor.value); return descriptor; }; } @Injectable() export class AuditInterceptor implements NestInterceptor { private readonly logger = new Logger(AuditInterceptor.name); constructor(private readonly auditService: AuditService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const user = (request as any).user; const action = this.getAction(context); const resource = this.getResource(context); if (!user?.sub || !action || !resource) { return next.handle(); } const startTime = Date.now(); const ipAddress = this.getClientIp(request); const userAgent = request.headers['user-agent']; return next.handle().pipe( tap(async (response) => { const duration = Date.now() - startTime; await this.auditService.log({ userId: user.sub, organizationId: user.organizationId, action, resource, resourceId: response?.id ?? request.params?.id, metadata: { method: request.method, path: request.path, duration, }, ipAddress, userAgent, status: 'success', }); }), catchError(async (error) => { const duration = Date.now() - startTime; await this.auditService.log({ userId: user.sub, organizationId: user.organizationId, action, resource, resourceId: request.params?.id, metadata: { method: request.method, path: request.path, duration, }, ipAddress, userAgent, status: 'error', errorMessage: error.message, }); throw error; }), ); } private getAction(context: ExecutionContext): string | undefined { return ( Reflect.getMetadata(AUDIT_ACTION_KEY, context.getHandler()) || this.inferAction(context) ); } private getResource(context: ExecutionContext): string | undefined { return ( Reflect.getMetadata(AUDIT_RESOURCE_KEY, context.getHandler()) || this.inferResource(context) ); } private inferAction(context: ExecutionContext): string { const method = context.getHandler().name; const httpMethod = context.switchToHttp().getRequest().method; // Map HTTP methods to CRUD actions const actionMap: Record = { GET: 'read', POST: 'create', PUT: 'update', PATCH: 'update', DELETE: 'delete', }; return actionMap[httpMethod] ?? method; } private inferResource(context: ExecutionContext): string { const controller = context.getClass(); return controller.name.replace('Controller', '').toLowerCase(); } private getClientIp(request: Request): string { const forwarded = request.headers['x-forwarded-for']; if (forwarded) { const ips = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0]; return ips.trim(); } return request.ip ?? request.socket.remoteAddress ?? 'unknown'; } } ================================================ FILE: apps/api/src/shared/audit/audit.service.ts ================================================ // ============================================================================ // Audit Service - Operation logging for compliance // ============================================================================ import { Injectable, Logger } from '@nestjs/common'; import { KyselyService } from '@a3s-lab/kysely'; import { v4 as uuidv4 } from 'uuid'; export interface AuditLogEntry { id?: string; timestamp?: Date; userId: string; organizationId: string; action: string; resource: string; resourceId?: string; changes?: Record; metadata?: Record; ipAddress?: string; userAgent?: string; status: 'success' | 'failure' | 'error'; errorMessage?: string; } export interface AuditQueryOptions { userId?: string; organizationId: string; resource?: string; action?: string; startDate?: Date; endDate?: Date; page?: number; pageSize?: number; } export interface PaginatedAuditResult { items: AuditLogEntry[]; total: number; page: number; pageSize: number; } /** * Audit Service - logs all operations for compliance */ @Injectable() export class AuditService { private readonly logger = new Logger(AuditService.name); private readonly tableName = 'audit_logs'; constructor(private readonly kysely: KyselyService) {} /** * Log an audit entry */ async log(entry: AuditLogEntry): Promise { const auditEntry: Record = { id: entry.id ?? uuidv4(), timestamp: entry.timestamp ?? new Date(), user_id: entry.userId, organization_id: entry.organizationId, action: entry.action, resource: entry.resource, resource_id: entry.resourceId, changes: entry.changes ? JSON.stringify(entry.changes) : null, metadata: entry.metadata ? JSON.stringify(entry.metadata) : null, ip_address: entry.ipAddress, user_agent: entry.userAgent, status: entry.status, error_message: entry.errorMessage, }; try { await this.kysely.insertInto(this.tableName).values(auditEntry).executeTakeFirst(); this.logger.debug(`Audit log created: ${entry.action} on ${entry.resource}`); } catch (error) { // Don't fail the operation if audit logging fails this.logger.error(`Failed to write audit log: ${error}`); } } /** * Log entity creation */ async logCreate( userId: string, organizationId: string, resource: string, resourceId: string, data: Record, metadata?: Record, ): Promise { await this.log({ userId, organizationId, action: 'create', resource, resourceId, changes: Object.fromEntries( Object.entries(data).map(([key, value]) => [key, { before: null, after: value }]), ), metadata, status: 'success', }); } /** * Log entity update */ async logUpdate( userId: string, organizationId: string, resource: string, resourceId: string, before: Record, after: Record, metadata?: Record, ): Promise { const changes: Record = {}; for (const key of Object.keys(after)) { if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) { changes[key] = { before: before[key], after: after[key] }; } } if (Object.keys(changes).length > 0) { await this.log({ userId, organizationId, action: 'update', resource, resourceId, changes, metadata, status: 'success', }); } } /** * Log entity deletion */ async logDelete( userId: string, organizationId: string, resource: string, resourceId: string, data: Record, metadata?: Record, ): Promise { await this.log({ userId, organizationId, action: 'delete', resource, resourceId, changes: Object.fromEntries( Object.entries(data).map(([key, value]) => [key, { before: value, after: null }]), ), metadata, status: 'success', }); } /** * Log failed operation */ async logFailure( userId: string, organizationId: string, action: string, resource: string, errorMessage: string, metadata?: Record, ): Promise { await this.log({ userId, organizationId, action, resource, metadata, status: 'failure', errorMessage, }); } /** * Query audit logs */ async query(options: AuditQueryOptions): Promise { const page = options.page ?? 1; const pageSize = options.pageSize ?? 20; const offset = (page - 1) * pageSize; // eslint-disable-next-line @typescript-eslint/no-explicit-any let query = (this.kysely as any).selectFrom(this.tableName).where( 'organization_id', '=', options.organizationId, ); if (options.userId) { query = query.where('user_id', '=', options.userId); } if (options.resource) { query = query.where('resource', '=', options.resource); } if (options.action) { query = query.where('action', '=', options.action); } if (options.startDate) { query = query.where('timestamp', '>=', options.startDate); } if (options.endDate) { query = query.where('timestamp', '<=', options.endDate); } const countResult = await query .select((eb: any) => eb.fn.countAll().as('count')) .executeTakeFirst(); const total = Number((countResult as { count?: number })?.count ?? 0); const items = await query .orderBy('timestamp', 'desc') .limit(pageSize) .offset(offset) .execute(); return { items: items as AuditLogEntry[], total, page, pageSize, }; } /** * Get audit log by ID */ async getById(id: string, organizationId: string): Promise { const row = await this.kysely .selectFrom(this.tableName) .where('id', '=', id) .where('organization_id', '=', organizationId) .executeTakeFirst(); return (row as AuditLogEntry) || null; } } ================================================ FILE: apps/api/src/shared/audit/index.ts ================================================ // ============================================================================ // Audit Module // ============================================================================ export * from './audit.service'; export * from './audit.interceptor'; export * from './audit.decorator'; ================================================ FILE: apps/api/src/shared/auth/auth.module.ts ================================================ // ============================================================================ // Auth Module - Authentication and Authorization // ============================================================================ import { Module, Global } from '@nestjs/common'; import { JwtService } from './jwt/jwt.service'; import { RbacService } from './rbac/rbac.service'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { RolesGuard } from './guards/roles.guard'; import { PermissionsGuard } from './guards/permissions.guard'; @Global() @Module({ providers: [ JwtService, RbacService, JwtAuthGuard, RolesGuard, PermissionsGuard, ], exports: [ JwtService, RbacService, JwtAuthGuard, RolesGuard, PermissionsGuard, ], }) export class AuthModule {} ================================================ FILE: apps/api/src/shared/auth/decorators/current-user.decorator.ts ================================================ // ============================================================================ // Auth Decorators - Convenience decorators for extracting user info // ============================================================================ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { JwtPayload } from '../jwt/jwt.types'; /** * Get current user from request */ export const CurrentUser = createParamDecorator( (data: keyof JwtPayload | undefined, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user = request.user as JwtPayload; if (!user) { return null; } return data ? user[data] : user; }, ); /** * Get current user ID */ export const CurrentUserId = createParamDecorator( (_data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user = request.user as JwtPayload; return user?.sub; }, ); /** * Get current organization ID */ export const CurrentOrganization = createParamDecorator( (_data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user = request.user as JwtPayload; return user?.organizationId; }, ); /** * Get current user roles */ export const CurrentRoles = createParamDecorator( (_data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user = request.user as JwtPayload; return user?.roles ?? []; }, ); ================================================ FILE: apps/api/src/shared/auth/decorators/index.ts ================================================ // ============================================================================ // Auth Decorators // ============================================================================ export * from './current-user.decorator'; ================================================ FILE: apps/api/src/shared/auth/dto/auth.dto.ts ================================================ // ============================================================================ // Auth DTOs - Data Transfer Objects for authentication // ============================================================================ import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; /** * Login DTO */ export class LoginDto { @IsEmail() @ApiProperty({ description: 'User email', example: 'user@example.com' }) email: string; @IsString() @MinLength(8) @ApiProperty({ description: 'User password', minLength: 8 }) password: string; } /** * Register DTO */ export class RegisterDto { @IsEmail() @ApiProperty({ description: 'User email', example: 'user@example.com' }) email: string; @IsString() @MinLength(3) @MaxLength(30) @ApiProperty({ description: 'Username', minLength: 3, maxLength: 30 }) username: string; @IsString() @MinLength(8) @ApiProperty({ description: 'Password', minLength: 8 }) password: string; @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Display name' }) displayName?: string; @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Organization name (for first user)' }) organizationName?: string; } /** * Refresh Token DTO */ export class RefreshTokenDto { @IsString() @ApiProperty({ description: 'Refresh token' }) refreshToken: string; } /** * Change Password DTO */ export class ChangePasswordDto { @IsString() @ApiProperty({ description: 'Current password' }) currentPassword: string; @IsString() @MinLength(8) @ApiProperty({ description: 'New password', minLength: 8 }) newPassword: string; } /** * Forgot Password DTO */ export class ForgotPasswordDto { @IsEmail() @ApiProperty({ description: 'User email' }) email: string; } /** * Reset Password DTO */ export class ResetPasswordDto { @IsString() @ApiProperty({ description: 'Reset token' }) resetToken: string; @IsString() @MinLength(8) @ApiProperty({ description: 'New password', minLength: 8 }) newPassword: string; } /** * Verify Email DTO */ export class VerifyEmailDto { @IsString() @ApiProperty({ description: 'Verification token' }) verifyToken: string; } /** * Token Response DTO */ export class TokenResponseDto { @ApiProperty({ description: 'Access token' }) accessToken: string; @ApiProperty({ description: 'Refresh token' }) refreshToken: string; @ApiProperty({ description: 'Token type', default: 'Bearer' }) tokenType: string; @ApiProperty({ description: 'Expires in seconds' }) expiresIn: number; } /** * User Response DTO (public info) */ export class UserResponseDto { @ApiProperty({ description: 'User ID' }) id: string; @ApiProperty({ description: 'Email' }) email: string; @ApiProperty({ description: 'Username' }) username: string; @ApiPropertyOptional({ description: 'Display name' }) displayName?: string; @ApiProperty({ description: 'Roles' }) roles: string[]; } ================================================ FILE: apps/api/src/shared/auth/dto/index.ts ================================================ // ============================================================================ // Auth DTOs // ============================================================================ export * from './auth.dto'; ================================================ FILE: apps/api/src/shared/auth/guards/index.ts ================================================ // ============================================================================ // Auth Guards // ============================================================================ export * from './jwt-auth.guard'; export * from './roles.guard'; export * from './permissions.guard'; ================================================ FILE: apps/api/src/shared/auth/guards/jwt-auth.guard.ts ================================================ // ============================================================================ // JWT Auth Guard - Validates JWT tokens // ============================================================================ import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, SetMetadata, } from '@nestjs/common'; import { JwtService } from '../jwt/jwt.service'; import { Request } from 'express'; import { JwtPayload } from '../jwt/jwt.types'; /** * Metadata key for public routes (skip auth) */ export const IS_PUBLIC_KEY = 'isPublic'; /** * Mark a route as public (skip JWT validation) */ export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); @Injectable() export class JwtAuthGuard implements CanActivate { constructor(private readonly jwtService: JwtService) {} async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const token = this.extractTokenFromHeader(request); if (!token) { throw new UnauthorizedException('No token provided'); } try { const payload = this.jwtService.verifyAccessToken(token); // Attach user to request (request as any).user = payload; (request as any).userId = payload.sub; (request as any).organizationId = payload.organizationId; } catch (error) { throw new UnauthorizedException('Invalid or expired token'); } return true; } private extractTokenFromHeader(request: Request): string | undefined { const authHeader = request.headers.authorization; if (!authHeader) { return undefined; } const [type, token] = authHeader.split(' '); return type === 'Bearer' ? token : undefined; } } ================================================ FILE: apps/api/src/shared/auth/guards/permissions.guard.ts ================================================ // ============================================================================ // Permissions Guard - Checks user permissions (RBAC) // ============================================================================ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { RbacService } from '../rbac/rbac.service'; import { JwtPayload } from '../jwt/jwt.types'; /** * Metadata key for required permissions */ export const PERMISSIONS_KEY = 'permissions'; /** * Require specific permissions to access route * Format: 'resource:action' e.g., 'users:read', 'workflows:delete' */ export const Permissions = (...permissions: string[]) => SetMetadata(PERMISSIONS_KEY, permissions); /** * Permissions Guard - checks if user has required permissions */ @Injectable() export class PermissionsGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly rbacService: RbacService, ) {} canActivate(context: ExecutionContext): boolean { const requiredPermissions = this.reflector.getAllAndOverride( PERMISSIONS_KEY, [context.getHandler(), context.getClass()], ); if (!requiredPermissions || requiredPermissions.length === 0) { return true; } const request = context.switchToHttp().getRequest(); const user = request.user as JwtPayload; if (!user || !user.roles) { throw new ForbiddenException('Access denied: No permissions assigned'); } for (const permission of requiredPermissions) { const [resource, action] = permission.split(':'); const hasPermission = this.rbacService.hasAnyPermission( user.roles, resource, action, ); if (!hasPermission) { throw new ForbiddenException( `Access denied: Missing permission '${permission}'`, ); } } return true; } } ================================================ FILE: apps/api/src/shared/auth/guards/roles.guard.ts ================================================ // ============================================================================ // Roles Guard - Checks user roles // ============================================================================ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { RbacService } from '../rbac/rbac.service'; import { JwtPayload } from '../jwt/jwt.types'; /** * Metadata key for required roles */ export const ROLES_KEY = 'roles'; /** * Require specific roles to access route */ export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); /** * Roles Guard - checks if user has required roles */ @Injectable() export class RolesGuard implements CanActivate { constructor( private readonly reflector: Reflector, private readonly rbacService: RbacService, ) {} canActivate(context: ExecutionContext): boolean { const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); if (!requiredRoles || requiredRoles.length === 0) { return true; } const request = context.switchToHttp().getRequest(); const user = request.user as JwtPayload; if (!user || !user.roles) { throw new ForbiddenException('Access denied: No roles assigned'); } const hasRole = this.rbacService.hasAnyRole(user.roles, requiredRoles); if (!hasRole) { throw new ForbiddenException( `Access denied: Required role(s): ${requiredRoles.join(', ')}`, ); } return true; } } ================================================ FILE: apps/api/src/shared/auth/index.ts ================================================ // ============================================================================ // Auth Module - Authentication and Authorization // ============================================================================ export * from './jwt'; export * from './rbac'; export * from './guards'; export * from './decorators'; export * from './dto'; export * from './auth.module'; ================================================ FILE: apps/api/src/shared/auth/jwt/index.ts ================================================ // ============================================================================ // JWT Module // ============================================================================ export * from './jwt.service'; export * from './jwt.types'; ================================================ FILE: apps/api/src/shared/auth/jwt/jwt.service.ts ================================================ // ============================================================================ // JWT Service - Token generation and verification // ============================================================================ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as jwt from 'jsonwebtoken'; import { JwtPayload, TokenPair } from './jwt.types'; @Injectable() export class JwtService { private readonly accessTokenSecret: string; private readonly refreshTokenSecret: string; private readonly accessTokenExpiry: string; private readonly refreshTokenExpiry: string; constructor(private readonly configService: ConfigService) { this.accessTokenSecret = this.configService.get('JWT_ACCESS_SECRET') || 'default-access-secret'; this.refreshTokenSecret = this.configService.get('JWT_REFRESH_SECRET') || 'default-refresh-secret'; this.accessTokenExpiry = this.configService.get('JWT_ACCESS_EXPIRY') || '15m'; this.refreshTokenExpiry = this.configService.get('JWT_REFRESH_EXPIRY') || '7d'; } /** * Generate access token */ generateAccessToken(payload: JwtPayload): string { return jwt.sign(payload as object, this.accessTokenSecret, { expiresIn: this.accessTokenExpiry as jwt.SignOptions['expiresIn'], }); } /** * Generate refresh token */ generateRefreshToken(payload: JwtPayload): string { return jwt.sign(payload as object, this.refreshTokenSecret, { expiresIn: this.refreshTokenExpiry as jwt.SignOptions['expiresIn'], }); } /** * Generate both access and refresh tokens */ generateTokenPair(payload: JwtPayload): TokenPair { return { accessToken: this.generateAccessToken(payload), refreshToken: this.generateRefreshToken(payload), }; } /** * Verify access token */ verifyAccessToken(token: string): JwtPayload { try { return jwt.verify(token, this.accessTokenSecret) as JwtPayload; } catch (error) { throw new UnauthorizedException('Invalid or expired access token'); } } /** * Verify refresh token */ verifyRefreshToken(token: string): JwtPayload { try { return jwt.verify(token, this.refreshTokenSecret) as JwtPayload; } catch (error) { throw new UnauthorizedException('Invalid or expired refresh token'); } } /** * Decode token without verification (for debugging) */ decodeToken(token: string): JwtPayload | null { try { return jwt.decode(token) as JwtPayload; } catch { return null; } } /** * Check if token is about to expire (within 5 minutes) */ isTokenExpiringSoon(token: string): boolean { const decoded = this.decodeToken(token); if (!decoded || !decoded.exp) { return true; } const fiveMinutesFromNow = Math.floor(Date.now() / 1000) + 300; return decoded.exp < fiveMinutesFromNow; } } ================================================ FILE: apps/api/src/shared/auth/jwt/jwt.types.ts ================================================ // ============================================================================ // JWT Types // ============================================================================ /** * JWT Payload - contains user information stored in the token */ export interface JwtPayload { /** User ID */ sub: string; /** User email */ email: string; /** Organization/Tenant ID */ organizationId: string; /** User roles */ roles: string[]; /** Permissions (optional, can be computed from roles) */ permissions?: string[]; /** Token type: access or refresh */ type: 'access' | 'refresh'; /** Issued at timestamp */ iat?: number; /** Expiration timestamp */ exp?: number; } /** * Access Token */ export interface AccessToken { token: string; expiresIn: string; } /** * Refresh Token */ export interface RefreshToken { token: string; expiresIn: string; } /** * Token Pair - both access and refresh tokens */ export interface TokenPair { accessToken: string; refreshToken: string; } /** * Token Response - returned to client after login */ export interface TokenResponse { accessToken: string; refreshToken: string; tokenType: 'Bearer'; expiresIn: number; } ================================================ FILE: apps/api/src/shared/auth/rbac/index.ts ================================================ // ============================================================================ // RBAC Module // ============================================================================ export * from './rbac.service'; ================================================ FILE: apps/api/src/shared/auth/rbac/rbac.service.ts ================================================ // ============================================================================ // RBAC - Role-Based Access Control // ============================================================================ import { Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; /** * Permission definition */ export interface Permission { resource: string; actions: string[]; // e.g., ['create', 'read', 'update', 'delete'] } /** * Role definition */ export interface Role { name: string; permissions: Permission[]; } /** * Default roles and permissions */ export const DEFAULT_PERMISSIONS: Permission[] = [ { resource: 'users', actions: ['create', 'read', 'update', 'delete'] }, { resource: 'organizations', actions: ['create', 'read', 'update', 'delete'] }, { resource: 'agents', actions: ['create', 'read', 'update', 'delete'] }, { resource: 'workflows', actions: ['create', 'read', 'update', 'delete'] }, { resource: 'knowledge', actions: ['create', 'read', 'update', 'delete'] }, { resource: 'repositories', actions: ['create', 'read', 'update', 'delete'] }, { resource: 'pipeline', actions: ['create', 'read', 'update', 'delete'] }, { resource: 'audit', actions: ['read'] }, ]; export const DEFAULT_ROLES: Role[] = [ { name: 'owner', permissions: DEFAULT_PERMISSIONS, }, { name: 'admin', permissions: DEFAULT_PERMISSIONS.filter(p => p.resource !== 'organizations'), }, { name: 'member', permissions: [ { resource: 'users', actions: ['read', 'update'] }, { resource: 'agents', actions: ['create', 'read', 'update'] }, { resource: 'workflows', actions: ['create', 'read', 'update'] }, { resource: 'knowledge', actions: ['create', 'read', 'update'] }, { resource: 'repositories', actions: ['create', 'read', 'update'] }, { resource: 'pipeline', actions: ['read'] }, ], }, { name: 'viewer', permissions: [ { resource: 'users', actions: ['read'] }, { resource: 'agents', actions: ['read'] }, { resource: 'workflows', actions: ['read'] }, { resource: 'knowledge', actions: ['read'] }, { resource: 'repositories', actions: ['read'] }, { resource: 'pipeline', actions: ['read'] }, ], }, ]; /** * RBAC Service - handles permission checking */ @Injectable() export class RbacService { private readonly roles: Map = new Map(); constructor() { // Initialize default roles for (const role of DEFAULT_ROLES) { this.roles.set(role.name, role); } } /** * Get role by name */ getRole(roleName: string): Role | undefined { return this.roles.get(roleName); } /** * Check if a role has a specific permission */ hasPermission(roleName: string, resource: string, action: string): boolean { const role = this.getRole(roleName); if (!role) { return false; } const permission = role.permissions.find(p => p.resource === resource); if (!permission) { return false; } return permission.actions.includes(action) || permission.actions.includes('*'); } /** * Check if any of the roles has a specific permission */ hasAnyPermission(roleNames: string[], resource: string, action: string): boolean { return roleNames.some(roleName => this.hasPermission(roleName, resource, action)); } /** * Check if all roles have a specific permission */ hasAllPermissions(roleNames: string[], resource: string, action: string): boolean { return roleNames.every(roleName => this.hasPermission(roleName, resource, action)); } /** * Get all permissions for a role */ getPermissions(roleName: string): Permission[] { const role = this.getRole(roleName); return role?.permissions ?? []; } /** * Register a custom role */ registerRole(role: Role): void { this.roles.set(role.name, role); } /** * Check if user has role */ hasRole(userRoles: string[], requiredRole: string): boolean { return userRoles.includes(requiredRole); } /** * Check if user has any of the roles */ hasAnyRole(userRoles: string[], requiredRoles: string[]): boolean { return userRoles.some(role => requiredRoles.includes(role)); } } ================================================ FILE: apps/api/src/shared/base/base.entity.ts ================================================ // ============================================================================ // Base Entity Interface - Common properties for all entities // ============================================================================ export interface BaseEntity { id: string; createdAt: Date; updatedAt: Date; } export interface TenantEntity extends BaseEntity { organizationId: string; } export interface SoftDeleteEntity extends BaseEntity { deletedAt?: Date; deletedBy?: string; } export interface VersionedEntity extends BaseEntity { version: number; } ================================================ FILE: apps/api/src/shared/base/base.service.ts ================================================ // ============================================================================ // Base Service - Generic CRUD operations with Kysely // ============================================================================ import { KyselyService } from '@a3s-lab/kysely'; import { NotFoundException } from '@nestjs/common'; import { parsePaginationOptions, PaginationOptions, PaginationQueryDto } from './pagination.dto'; export interface FindOptions { filter?: FilterDto; sort?: SortDto; pagination?: PaginationQueryDto; } export interface PaginatedResult { items: T[]; total: number; page: number; pageSize: number; totalPages: number; } export abstract class BaseService< Entity extends { id: string }, CreateDto, UpdateDto, FilterDto = never, SortDto = never, > { // eslint-disable-next-line @typescript-eslint/no-explicit-any constructor( protected readonly kysely: KyselyService, protected readonly tableName: string, ) {} /** * Create a new entity */ async create(dto: CreateDto, additionalData?: Partial): Promise { const now = new Date(); const entity: Partial = { ...dto, ...additionalData, id: crypto.randomUUID(), createdAt: now, updatedAt: now, } as Partial; // eslint-disable-next-line @typescript-eslint/no-explicit-any await (this.kysely as any) .insertInto(this.tableName) .values(entity as Record) .executeTakeFirstOrThrow(); return entity as Entity; } /** * Find entity by ID */ async findById(id: string): Promise { const row = await (this.kysely as any) .selectFrom(this.tableName) .where('id', '=', id) .executeTakeFirst(); return (row as Entity) || null; } /** * Find entity by ID or throw NotFoundException */ async findByIdOrThrow(id: string): Promise { const entity = await this.findById(id); if (!entity) { throw new NotFoundException(`${this.tableName} with id '${id}' not found`); } return entity; } /** * Find all entities with pagination (optimized - uses SQL COUNT) */ async findAll(options?: FindOptions): Promise> { const paginationOptions = options?.pagination ? parsePaginationOptions(options.pagination) : { page: 1, pageSize: 20, limit: 20, offset: 0 }; // eslint-disable-next-line @typescript-eslint/no-explicit-any let baseQuery = (this.kysely as any).selectFrom(this.tableName); // Apply filters if FilterDto is provided if (options?.filter) { baseQuery = this.applyFilters(baseQuery, options.filter); } // Get total count using efficient SQL COUNT const countResult = await baseQuery .select((eb: any) => eb.fn.countAll().as('count')) .executeTakeFirst(); const total = Number(countResult?.count ?? 0); // Apply pagination and sorting to a separate query let dataQuery = baseQuery.limit(paginationOptions.limit).offset(paginationOptions.offset); if (options?.sort) { dataQuery = this.applySort(dataQuery, options.sort); } const items = await dataQuery.execute(); return { items: items as Entity[], total, page: paginationOptions.page, pageSize: paginationOptions.pageSize, totalPages: Math.ceil(total / paginationOptions.pageSize), }; } /** * Find entities with pagination using cursor-based approach (for large datasets) */ async findAllCursor( cursor: string | null, limit: number, options?: FindOptions, ): Promise<{ items: Entity[]; nextCursor: string | null }> { // eslint-disable-next-line @typescript-eslint/no-explicit-any let query = (this.kysely as any).selectFrom(this.tableName); if (options?.filter) { query = this.applyFilters(query, options.filter); } // Cursor-based pagination using id > cursor if (cursor) { query = query.where('id', '>', cursor); } query = query.orderBy('id', 'asc').limit(limit + 1); // Fetch one extra to check if there's more const items = await query.execute(); const hasMore = items.length > limit; const result = hasMore ? items.slice(0, -1) : items; return { items: result as Entity[], nextCursor: hasMore ? (result[result.length - 1] as Entity).id : null, }; } /** * Update entity by ID */ async update(id: string, dto: UpdateDto): Promise { const existing = await this.findByIdOrThrow(id); const updated: Partial = { ...existing, ...dto, id: existing.id, createdAt: (existing as any).createdAt, updatedAt: new Date(), } as Partial; await (this.kysely as any) .updateTable(this.tableName) .set(updated as Record) .where('id', '=', id) .executeTakeFirst(); return updated as Entity; } /** * Update entities by filter (batch update) */ async updateMany(filter: FilterDto, dto: Partial): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any let query = (this.kysely as any).updateTable(this.tableName).set({ ...dto, updatedAt: new Date(), } as Record); query = this.applyFilters(query, filter); const result = await query.execute(); return result.numUpdatedRows ?? 0; } /** * Delete entity by ID (hard delete) */ async delete(id: string): Promise { const existing = await this.findByIdOrThrow(id); await (this.kysely as any) .deleteFrom(this.tableName) .where('id', '=', id) .executeTakeFirst(); } /** * Soft delete entity by ID */ async softDelete(id: string, deletedBy?: string): Promise { await this.findByIdOrThrow(id); await (this.kysely as any) .updateTable(this.tableName) .set({ deletedAt: new Date(), deletedBy, updatedAt: new Date(), } as Record) .where('id', '=', id) .executeTakeFirst(); } /** * Soft delete entities by filter (batch) */ async softDeleteMany(filter: FilterDto, deletedBy?: string): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any let query = (this.kysely as any).updateTable(this.tableName).set({ deletedAt: new Date(), deletedBy, updatedAt: new Date(), } as Record); query = this.applyFilters(query, filter); const result = await query.execute(); return result.numUpdatedRows ?? 0; } /** * Check if entity exists */ async exists(id: string): Promise { const entity = await (this.kysely as any) .selectFrom(this.tableName) .select('id') .where('id', '=', id) .executeTakeFirst(); return !!entity; } /** * Count entities with optional filters (efficient SQL COUNT) */ async count(filter?: FilterDto): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any let query = (this.kysely as any) .selectFrom(this.tableName) .select((eb: any) => eb.fn.countAll().as('count')); if (filter) { query = this.applyFilters(query, filter); } const result = await query.executeTakeFirst(); return Number(result?.count ?? 0); } /** * Insert many entities (batch insert) */ async createMany(dtos: CreateDto[]): Promise { if (dtos.length === 0) return []; const now = new Date(); const entities = dtos.map((dto) => ({ ...dto, id: crypto.randomUUID(), createdAt: now, updatedAt: now, })) as Array>; await (this.kysely as any) .insertInto(this.tableName) .values(entities) .executeTakeFirst(); return entities as unknown as Entity[]; } /** * Apply filters to query (override in subclasses) */ // eslint-disable-next-line @typescript-eslint/no-explicit-any protected applyFilters(qb: any, filter: FilterDto): any { return qb; } /** * Apply sorting to query (override in subclasses) */ // eslint-disable-next-line @typescript-eslint/no-explicit-any protected applySort(qb: any, sort: SortDto): any { return qb; } } ================================================ FILE: apps/api/src/shared/base/index.ts ================================================ export * from './base.entity'; export * from './base.service'; export * from './pagination.dto'; ================================================ FILE: apps/api/src/shared/base/pagination.dto.ts ================================================ // ============================================================================ // Pagination DTO - Standardized pagination parameters // ============================================================================ import { Type } from 'class-transformer'; import { IsOptional, IsInt, Min, Max, IsString, IsIn } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class PaginationQueryDto { @IsOptional() @Type(() => Number) @IsInt() @Min(1) @ApiPropertyOptional({ description: 'Page number', default: 1, minimum: 1 }) page?: number = 1; @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) @ApiPropertyOptional({ description: 'Page size', default: 20, minimum: 1, maximum: 100 }) pageSize?: number = 20; } export class CursorPaginationQueryDto { @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Cursor (last item ID)', required: false }) cursor?: string; @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) @ApiPropertyOptional({ description: 'Page size', default: 20, minimum: 1, maximum: 100 }) limit?: number = 20; @IsOptional() @IsString() @IsIn(['asc', 'desc']) @ApiPropertyOptional({ description: 'Sort order', default: 'asc' }) order?: 'asc' | 'desc' = 'asc'; } export interface PaginationOptions { page: number; pageSize: number; limit: number; offset: number; } export interface CursorPaginationOptions { cursor: string | null; limit: number; order: 'asc' | 'desc'; } export function parsePaginationOptions(query: PaginationQueryDto): PaginationOptions { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const limit = pageSize; const offset = (page - 1) * pageSize; return { page, pageSize, limit, offset }; } export function parseCursorPaginationOptions(query: CursorPaginationQueryDto): CursorPaginationOptions { return { cursor: query.cursor ?? null, limit: query.limit ?? 20, order: query.order ?? 'asc', }; } export class PaginatedResponseDto { @ApiProperty({ description: 'Items in current page' }) items: T[]; @ApiProperty({ description: 'Total number of items' }) total: number; @ApiProperty({ description: 'Current page number' }) page: number; @ApiProperty({ description: 'Number of items per page' }) pageSize: number; @ApiProperty({ description: 'Total number of pages' }) totalPages: number; @ApiProperty({ description: 'Whether there is a next page' }) hasNext: boolean; @ApiProperty({ description: 'Whether there is a previous page' }) hasPrevious: boolean; constructor(partial: Partial>) { Object.assign(this, partial); } } export class CursorPaginatedResponseDto { @ApiProperty({ description: 'Items in current page' }) items: T[]; @ApiProperty({ description: 'Cursor for next page', nullable: true }) nextCursor: string | null; @ApiProperty({ description: 'Whether there is a next page' }) hasMore: boolean; constructor(partial: Partial>) { Object.assign(this, partial); } } ================================================ FILE: apps/api/src/shared/cache/cache.decorator.ts ================================================ // ============================================================================ // Cache Decorator - Method-level caching // ============================================================================ import { SetMetadata } from '@nestjs/common'; import { CacheDecoratorOptions } from './cache.service'; export const CACHE_KEY = 'cache_options'; /** * Cache the result of a method */ export function Cache(options: CacheDecoratorOptions) { return SetMetadata(CACHE_KEY, options); } /** * Cache key prefix decorator */ export const CachePrefix = (prefix: string) => SetMetadata(CACHE_KEY, { prefix }); ================================================ FILE: apps/api/src/shared/cache/cache.interceptor.ts ================================================ // ============================================================================ // Cache Interceptor - Automatically caches method results // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger, } from '@nestjs/common'; import { Observable, of } from 'rxjs'; import { tap } from 'rxjs/operators'; import { Reflector } from '@nestjs/core'; import { CacheService, CacheDecoratorOptions } from './cache.service'; import { CACHE_KEY } from './cache.decorator'; @Injectable() export class CacheInterceptor implements NestInterceptor { private readonly logger = new Logger(CacheInterceptor.name); constructor( private readonly cacheService: CacheService, private readonly reflector: Reflector, ) {} async intercept(context: ExecutionContext, next: CallHandler): Promise> { const cacheOptions = this.reflector.get( CACHE_KEY, context.getHandler(), ); // Skip if no cache options if (!cacheOptions) { return next.handle(); } const cacheKey = this.buildCacheKey(context, cacheOptions); // Try to get from cache const cached = await this.cacheService.get(cacheKey, cacheOptions); if (cached !== null) { this.logger.debug(`Cache hit: ${cacheKey}`); return of(cached); } this.logger.debug(`Cache miss: ${cacheKey}`); // Execute handler and cache result return next.handle().pipe( tap(async (result) => { if (result !== undefined && result !== null) { await this.cacheService.set(cacheKey, result, cacheOptions); this.logger.debug(`Cached: ${cacheKey}`); } }), ); } private buildCacheKey(context: ExecutionContext, options: CacheDecoratorOptions): string { const className = context.getClass().name; const methodName = context.getHandler().name; const prefix = options.keyPrefix ?? `${className}:${methodName}`; // Include route params in key if present const request = context.switchToHttp().getRequest(); const params = request.params ? JSON.stringify(request.params) : ''; return `${prefix}:${params}`; } } ================================================ FILE: apps/api/src/shared/cache/cache.service.ts ================================================ // ============================================================================ // Cache Service - Redis-based caching abstraction // ============================================================================ import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common'; import { RedissonService } from '@a3s-lab/redisson'; export interface CacheOptions { /** Time to live in seconds */ ttl?: number; /** Namespace prefix */ prefix?: string; /** Whether to refresh TTL on access */ touch?: boolean; } export interface CacheStats { hits: number; misses: number; sets: number; deletes: number; hitRate: number; } /** * Decorator options for caching (extends CacheOptions) */ export interface CacheDecoratorOptions extends CacheOptions { /** Key prefix for the cached method */ keyPrefix?: string; /** Whether to skip cache on error */ skipOnError?: boolean; } /** * Default cache options */ const DEFAULT_CACHE_OPTIONS: Required = { ttl: 300, // 5 minutes prefix: 'cache', touch: false, }; /** * Cache Service - provides Redis-based caching with stats tracking */ @Injectable() export class CacheService implements OnModuleDestroy { private readonly logger = new Logger(CacheService.name); private readonly keyPrefix = 'cache:'; private stats = { hits: 0, misses: 0, sets: 0, deletes: 0 }; constructor(private readonly redis: RedissonService) {} /** * Get a value from cache */ async get(key: string, options?: CacheOptions): Promise { const fullKey = this.buildKey(key, options); const data = await this.redis.get(fullKey); if (data) { this.stats.hits++; // Optionally refresh TTL on access if (options?.touch) { const ttl = options?.ttl ?? DEFAULT_CACHE_OPTIONS.ttl; await this.redis.expire(fullKey, ttl); } try { return JSON.parse(data) as T; } catch { return data as unknown as T; } } this.stats.misses++; return null; } /** * Set a value in cache */ async set(key: string, value: T, options?: CacheOptions): Promise { const fullKey = this.buildKey(key, options); const ttl = options?.ttl ?? DEFAULT_CACHE_OPTIONS.ttl; const serialized = typeof value === 'string' ? value : JSON.stringify(value); await this.redis.set(fullKey, serialized, ttl); this.stats.sets++; } /** * Delete a key from cache */ async delete(key: string, options?: CacheOptions): Promise { const fullKey = this.buildKey(key, options); await this.redis.delete(fullKey); this.stats.deletes++; } /** * Delete all keys matching a pattern */ async deleteByPattern(pattern: string): Promise { const fullPattern = `${this.keyPrefix}${pattern}`; const count = await this.redis.deleteByPattern(fullPattern); this.stats.deletes += count; return count; } /** * Check if a key exists */ async has(key: string, options?: CacheOptions): Promise { const fullKey = this.buildKey(key, options); return this.redis.exists(fullKey); } /** * Get or set - fetch from cache or execute factory and cache the result */ async getOrSet( key: string, factory: () => Promise, options?: CacheOptions, ): Promise { const cached = await this.get(key, options); if (cached !== null) { return cached; } const value = await factory(); await this.set(key, value, options); return value; } /** * Get multiple values from cache */ async getMany(keys: string[], options?: CacheOptions): Promise> { const promises = keys.map(key => this.get(key, options)); return Promise.all(promises); } /** * Set multiple values in cache */ async setMany(entries: Array<{ key: string; value: T }>, options?: CacheOptions): Promise { const promises = entries.map(({ key, value }) => this.set(key, value, options)); await Promise.all(promises); } /** * Increment a counter in cache */ async increment(key: string, amount = 1, options?: CacheOptions): Promise { const fullKey = this.buildKey(key, options); const value = await this.redis.increment(fullKey, amount); // Set TTL if not exists const ttl = options?.ttl ?? DEFAULT_CACHE_OPTIONS.ttl; await this.redis.expire(fullKey, ttl); return value; } /** * Decrement a counter in cache */ async decrement(key: string, amount = 1, options?: CacheOptions): Promise { const fullKey = this.buildKey(key, options); const value = await this.redis.decrement(fullKey, amount); return value; } /** * Get cache statistics */ getStats(): CacheStats { const total = this.stats.hits + this.stats.misses; return { ...this.stats, hitRate: total > 0 ? this.stats.hits / total : 0, }; } /** * Reset cache statistics */ resetStats(): void { this.stats = { hits: 0, misses: 0, sets: 0, deletes: 0 }; } /** * Build full cache key */ private buildKey(key: string, options?: CacheOptions): string { const prefix = options?.prefix ?? 'cache'; return `${this.keyPrefix}${prefix}:${key}`; } onModuleDestroy(): void { this.logger.log('Cache service destroyed'); } } ================================================ FILE: apps/api/src/shared/cache/index.ts ================================================ // ============================================================================ // Cache Module // ============================================================================ export { CacheService } from './cache.service'; export { CacheInterceptor } from './cache.interceptor'; export { Cache, CachePrefix } from './cache.decorator'; export type { CacheOptions, CacheStats, CacheDecoratorOptions } from './cache.service'; ================================================ FILE: apps/api/src/shared/circuit-breaker/circuit-breaker.decorator.ts ================================================ // ============================================================================ // Circuit Breaker Decorator // ============================================================================ import { SetMetadata } from '@nestjs/common'; import { CircuitBreakerOptions } from './circuit-breaker.service'; export const CIRCUIT_BREAKER_KEY = 'circuit_breaker'; export const CIRCUIT_BREAKER_OPTIONS = 'circuit_breaker_options'; /** * Decorator to mark a method for circuit breaker protection */ export function CircuitBreaker(options: CircuitBreakerOptions) { return SetMetadata(CIRCUIT_BREAKER_OPTIONS, options); } ================================================ FILE: apps/api/src/shared/circuit-breaker/circuit-breaker.module.ts ================================================ // ============================================================================ // Circuit Breaker Module - Fault tolerance pattern // ============================================================================ import { Module, Global } from '@nestjs/common'; import { CircuitBreakerService } from './circuit-breaker.service'; @Global() @Module({ providers: [CircuitBreakerService], exports: [CircuitBreakerService], }) export class CircuitBreakerModule {} ================================================ FILE: apps/api/src/shared/circuit-breaker/circuit-breaker.service.ts ================================================ // ============================================================================ // Circuit Breaker - Protect external calls from cascading failures // ============================================================================ import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; export enum CircuitState { CLOSED = 'CLOSED', // Normal operation OPEN = 'OPEN', // Failing, reject calls HALF_OPEN = 'HALF_OPEN', // Testing if service recovered } export interface CircuitBreakerOptions { /** Failure threshold to open circuit */ failureThreshold?: number; /** Success threshold to close circuit (from half-open) */ successThreshold?: number; /** Time in ms before attempting recovery */ resetTimeout?: number; /** Name for this circuit breaker */ name?: string; } export interface CircuitBreakerStats { name: string; state: CircuitState; failures: number; successes: number; lastFailure?: Date; lastSuccess?: Date; nextAttempt?: Date; } /** * Default circuit breaker options */ const DEFAULT_OPTIONS: Required = { failureThreshold: 5, successThreshold: 2, resetTimeout: 30000, // 30 seconds name: 'default', }; /** * Circuit Breaker states: * - CLOSED: Normal operation, requests pass through * - OPEN: Circuit is tripped, requests are rejected immediately * - HALF_OPEN: Testing recovery, limited requests pass through */ @Injectable() export class CircuitBreakerService implements OnModuleDestroy { private readonly logger = new Logger(CircuitBreakerService.name); private readonly circuits: Map = new Map(); constructor() {} /** * Get or create a circuit breaker */ getCircuitBreaker(name: string, options?: CircuitBreakerOptions): CircuitBreaker { if (this.circuits.has(name)) { return this.circuits.get(name)!; } const circuit = new CircuitBreaker(name, { ...DEFAULT_OPTIONS, ...options, }); this.circuits.set(name, circuit); return circuit; } /** * Execute a function with circuit breaker protection */ async execute( name: string, fn: () => Promise, options?: CircuitBreakerOptions, ): Promise { const circuit = this.getCircuitBreaker(name, options); if (!circuit.canExecute()) { throw new CircuitBreakerOpenError(name, circuit.getNextAttempt()); } try { const result = await fn(); circuit.recordSuccess(); return result; } catch (error) { circuit.recordFailure(); throw error; } } /** * Get all circuit breaker stats */ getAllStats(): CircuitBreakerStats[] { return Array.from(this.circuits.values()).map(c => c.getStats()); } /** * Get stats for a specific circuit */ getStats(name: string): CircuitBreakerStats | undefined { return this.circuits.get(name)?.getStats(); } /** * Reset a specific circuit */ reset(name: string): void { this.circuits.get(name)?.reset(); } /** * Reset all circuits */ resetAll(): void { for (const circuit of this.circuits.values()) { circuit.reset(); } } onModuleDestroy(): void { this.logger.log('Circuit breaker service destroyed'); } } /** * Individual circuit breaker instance */ export class CircuitBreaker { private state: CircuitState = CircuitState.CLOSED; private failures = 0; private successes = 0; private lastFailure?: Date; private lastSuccess?: Date; private nextAttempt?: Date; private readonly name: string; private readonly options: Required; constructor(name: string, options: Required) { this.name = options.name ?? name; this.options = options; } /** * Check if a request can be executed */ canExecute(): boolean { switch (this.state) { case CircuitState.CLOSED: return true; case CircuitState.OPEN: // Check if reset timeout has passed if (this.nextAttempt && new Date() >= this.nextAttempt) { this.transitionToHalfOpen(); return true; } return false; case CircuitState.HALF_OPEN: // In half-open, allow limited requests through return true; } } /** * Record a successful call */ recordSuccess(): void { this.lastSuccess = new Date(); this.successes++; if (this.state === CircuitState.HALF_OPEN) { if (this.successes >= this.options.successThreshold) { this.transitionToClosed(); } } } /** * Record a failed call */ recordFailure(): void { this.lastFailure = new Date(); this.failures++; if (this.state === CircuitState.CLOSED) { if (this.failures >= this.options.failureThreshold) { this.transitionToOpen(); } } else if (this.state === CircuitState.HALF_OPEN) { // Any failure in half-open immediately opens the circuit this.transitionToOpen(); } } /** * Get current stats */ getStats(): CircuitBreakerStats { return { name: this.name, state: this.state, failures: this.failures, successes: this.successes, lastFailure: this.lastFailure, lastSuccess: this.lastSuccess, nextAttempt: this.nextAttempt, }; } /** * Get next attempt time */ getNextAttempt(): Date | undefined { return this.nextAttempt; } /** * Reset the circuit breaker */ reset(): void { this.state = CircuitState.CLOSED; this.failures = 0; this.successes = 0; this.lastFailure = undefined; this.lastSuccess = undefined; this.nextAttempt = undefined; } /** * Transition to OPEN state */ private transitionToOpen(): void { this.state = CircuitState.OPEN; this.nextAttempt = new Date(Date.now() + this.options.resetTimeout); this.successes = 0; // Reset success count } /** * Transition to HALF_OPEN state */ private transitionToHalfOpen(): void { this.state = CircuitState.HALF_OPEN; this.successes = 0; } /** * Transition to CLOSED state */ private transitionToClosed(): void { this.state = CircuitState.CLOSED; this.failures = 0; this.successes = 0; this.nextAttempt = undefined; } } /** * Error thrown when circuit breaker is open */ export class CircuitBreakerOpenError extends Error { constructor( public readonly circuitName: string, public readonly nextAttempt?: Date, ) { super(`Circuit breaker '${circuitName}' is open. Next attempt: ${nextAttempt?.toISOString() ?? 'unknown'}`); this.name = 'CircuitBreakerOpenError'; } } ================================================ FILE: apps/api/src/shared/circuit-breaker/index.ts ================================================ // ============================================================================ // Circuit Breaker Module // ============================================================================ export { CircuitBreakerService, CircuitBreaker, CircuitBreakerOpenError, CircuitState, } from './circuit-breaker.service'; export type { CircuitBreakerOptions, CircuitBreakerStats } from './circuit-breaker.service'; export { CircuitBreakerModule } from './circuit-breaker.module'; ================================================ FILE: apps/api/src/shared/database/database.module.ts ================================================ import { Module, Global } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { KyselyModule, createKyselyLogger } from '@a3s-lab/kysely'; import { PostgresDialect } from 'kysely'; import { Pool } from 'pg'; @Global() @Module({ imports: [ KyselyModule.registerAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ config: { dialect: new PostgresDialect({ pool: new Pool({ host: configService.get('DB_HOST', 'localhost'), port: configService.get('DB_PORT', 5432), user: configService.get('DB_USERNAME', 'postgres'), password: configService.get('DB_PASSWORD', 'postgres'), database: configService.get('DB_DATABASE', 'nestify'), max: 10, }), }), log: configService.get('NODE_ENV') === 'development' ? createKyselyLogger() : undefined, }, }), inject: [ConfigService], }), ], exports: [KyselyModule], }) export class DatabaseModule { } ================================================ FILE: apps/api/src/shared/database/database.types.ts ================================================ import type { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely'; /** * Database schema types for Kysely */ export interface Database { orders: OrderTable; order_items: OrderItemTable; } /** * Order table schema */ export interface OrderTable { id: Generated; customer_id: string; status: 'pending' | 'confirmed' | 'cancelled'; total_amount: number; created_at: ColumnType; updated_at: ColumnType; } /** * Order item table schema */ export interface OrderItemTable { id: Generated; order_id: string; product_id: string; quantity: number; unit_price: number; subtotal: number; created_at: ColumnType; } /** * Type helpers for database operations */ export type Order = Selectable; export type NewOrder = Insertable; export type OrderUpdate = Updateable; export type OrderItem = Selectable; export type NewOrderItem = Insertable; export type OrderItemUpdate = Updateable; ================================================ FILE: apps/api/src/shared/database/index.ts ================================================ export * from './database.module'; export * from './database.types'; ================================================ FILE: apps/api/src/shared/domain/aggregate-root.ts ================================================ import { Entity } from './entity'; import { DomainEvent } from './domain-event'; export abstract class AggregateRoot extends Entity { private _domainEvents: DomainEvent[] = []; get domainEvents(): DomainEvent[] { return this._domainEvents; } protected addDomainEvent(domainEvent: DomainEvent): void { this._domainEvents.push(domainEvent); } public clearEvents(): void { this._domainEvents = []; } } ================================================ FILE: apps/api/src/shared/domain/domain-event-publisher.ts ================================================ import { DomainEvent } from './domain-event'; export interface IDomainEventPublisher { publish(event: DomainEvent): Promise; publishAll(events: DomainEvent[]): Promise; } export const DOMAIN_EVENT_PUBLISHER = Symbol('DOMAIN_EVENT_PUBLISHER'); ================================================ FILE: apps/api/src/shared/domain/domain-event.ts ================================================ export interface IDomainEvent { occurredOn: Date; getAggregateId(): string; } export abstract class DomainEvent implements IDomainEvent { public readonly occurredOn: Date; constructor() { this.occurredOn = new Date(); } abstract getAggregateId(): string; } ================================================ FILE: apps/api/src/shared/domain/entity.ts ================================================ // ============================================================================ // Entity - Base class for domain entities with identity // ============================================================================ /** * Base class for all domain entities. * Entities have identity - two entities with the same ID are considered equal. */ export abstract class Entity { protected readonly _id: T; constructor(id: T) { this._id = id; } get id(): T { return this._id; } /** * Check equality based on identity */ equals(entity?: Entity): boolean { if (entity === null || entity === undefined) { return false; } if (this === entity) { return true; } if (!(entity instanceof Entity)) { return false; } return this._id === entity._id; } /** * Check equality by ID directly (for primitive IDs) */ equalsById(id: T): boolean { return this._id === id; } /** * Get the entity's identity as a string (for logging, etc.) */ toString(): string { return `${this.constructor.name}:${String(this._id)}`; } /** * Get identity for persistence mapping */ toObject(): { id: T } { return { id: this._id }; } } /** * Interface for entities with audit fields */ export interface IAuditableEntity { createdAt: Date; updatedAt: Date; createdBy?: string; updatedBy?: string; } /** * Interface for soft-deletable entities */ export interface ISoftDeletable { deletedAt?: Date; deletedBy?: string; } /** * Base entity with audit fields */ export abstract class AuditableEntity extends Entity { public readonly createdAt: Date; public readonly updatedAt: Date; public readonly createdBy?: string; public readonly updatedBy?: string; constructor( id: T, createdAt: Date, updatedAt: Date, createdBy?: string, updatedBy?: string, ) { super(id); this.createdAt = createdAt; this.updatedAt = updatedAt; this.createdBy = createdBy; this.updatedBy = updatedBy; } /** * Check if entity was created after a given date */ isCreatedAfter(date: Date): boolean { return this.createdAt > date; } /** * Check if entity was updated after a given date */ isUpdatedAfter(date: Date): boolean { return this.updatedAt > date; } /** * Check if entity was updated by a specific user */ isUpdatedBy(userId: string): boolean { return this.updatedBy === userId; } } /** * Base entity with soft delete support */ export abstract class SoftDeletableEntity extends AuditableEntity { public readonly deletedAt?: Date; public readonly deletedBy?: string; constructor( id: T, createdAt: Date, updatedAt: Date, deletedAt?: Date, deletedBy?: string, createdBy?: string, updatedBy?: string, ) { super(id, createdAt, updatedAt, createdBy, updatedBy); this.deletedAt = deletedAt; this.deletedBy = deletedBy; } /** * Check if entity is deleted */ isDeleted(): boolean { return this.deletedAt !== undefined && this.deletedAt !== null; } /** * Check if deleted by a specific user */ isDeletedBy(userId: string): boolean { return this.deletedBy === userId; } /** * Get days since deletion */ daysSinceDeletion(): number | null { if (!this.deletedAt) return null; const now = new Date(); const diff = now.getTime() - this.deletedAt.getTime(); return Math.floor(diff / (1000 * 60 * 60 * 24)); } } ================================================ FILE: apps/api/src/shared/domain/index.ts ================================================ // ============================================================================ // Domain - DDD core building blocks // ============================================================================ export * from './entity'; export * from './value-object'; export * from './domain-event'; export * from './aggregate-root'; export * from './domain-event-publisher'; ================================================ FILE: apps/api/src/shared/domain/value-object.ts ================================================ export interface ValueObjectProps { [index: string]: any; } export abstract class ValueObject { protected readonly props: T; constructor(props: T) { this.props = Object.freeze(props); } public equals(vo?: ValueObject): boolean { if (vo === null || vo === undefined) { return false; } if (vo.props === undefined) { return false; } return JSON.stringify(this.props) === JSON.stringify(vo.props); } } ================================================ FILE: apps/api/src/shared/errors/business.exception.ts ================================================ // ============================================================================ // Business Exception - Base exception for business logic errors // ============================================================================ import { HttpException, HttpStatus } from '@nestjs/common'; import { ErrorCode, ErrorCodeHttpStatus } from './error-codes'; export interface BusinessExceptionOptions { code: ErrorCode; message: string; details?: Record; httpStatus?: HttpStatus; } export class BusinessException extends HttpException { public readonly code: ErrorCode; public readonly details?: Record; constructor(options: BusinessExceptionOptions) { const httpStatus = options.httpStatus || ErrorCodeHttpStatus[options.code] || 400; super( { code: options.code, message: options.message, details: options.details, }, httpStatus, ); this.code = options.code; this.details = options.details; } getResponse(): Record { return { code: this.code, message: this.message, details: this.details, }; } } // ============================================================================ // Common Business Exceptions // ============================================================================ export class ValidationException extends BusinessException { constructor(message: string, details?: Record) { super({ code: ErrorCode.VALIDATION_ERROR, message, details, }); } } export class NotFoundException extends BusinessException { constructor(resource: string, identifier?: string | number) { const message = identifier ? `${resource} with identifier '${identifier}' not found` : `${resource} not found`; super({ code: ErrorCode.RESOURCE_NOT_FOUND, message, httpStatus: HttpStatus.NOT_FOUND, }); } } export class DuplicateEntryException extends BusinessException { constructor(resource: string, field: string, value: string) { super({ code: ErrorCode.DUPLICATE_ENTRY, message: `${resource} with ${field} '${value}' already exists`, httpStatus: HttpStatus.CONFLICT, }); } } export class ForbiddenException extends BusinessException { constructor(message = 'You do not have permission to perform this action') { super({ code: ErrorCode.PERMISSION_DENIED, message, httpStatus: HttpStatus.FORBIDDEN, }); } } export class UnauthorizedException extends BusinessException { constructor(message = 'Authentication required') { super({ code: ErrorCode.UNAUTHORIZED, message, httpStatus: HttpStatus.UNAUTHORIZED, }); } } export class OperationFailedException extends BusinessException { constructor(operation: string, reason?: string) { super({ code: ErrorCode.OPERATION_FAILED, message: reason ? `${operation} failed: ${reason}` : `${operation} failed`, }); } } ================================================ FILE: apps/api/src/shared/errors/error-codes.ts ================================================ // ============================================================================ // Error Codes - Standardized error codes for the application // ============================================================================ export enum ErrorCode { // 4xx Client Errors BAD_REQUEST = 'BAD_REQUEST', UNAUTHORIZED = 'UNAUTHORIZED', FORBIDDEN = 'FORBIDDEN', NOT_FOUND = 'NOT_FOUND', CONFLICT = 'CONFLICT', GONE = 'GONE', UNPROCESSABLE_ENTITY = 'UNPROCESSABLE_ENTITY', TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', // 5xx Server Errors INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', NOT_IMPLEMENTED = 'NOT_IMPLEMENTED', SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', GATEWAY_TIMEOUT = 'GATEWAY_TIMEOUT', // Business Errors (10xxx) VALIDATION_ERROR = '10001', DUPLICATE_ENTRY = '10002', RESOURCE_NOT_FOUND = '10003', INVALID_OPERATION = '10004', OPERATION_FAILED = '10005', BUSINESS_RULE_VIOLATION = '10006', // Auth Errors (20xxx) TOKEN_EXPIRED = '20001', TOKEN_INVALID = '20002', TOKEN_MISSING = '20003', PERMISSION_DENIED = '20004', ACCOUNT_DISABLED = '20005', // Domain Errors (30xxx) ENTITY_NOT_FOUND = '30001', ENTITY_ALREADY_EXISTS = '30002', ENTITY_CONFLICT = '30003', // External Service Errors (40xxx) EXTERNAL_SERVICE_ERROR = '40001', EXTERNAL_SERVICE_TIMEOUT = '40002', EXTERNAL_SERVICE_UNAVAILABLE = '40003', } export const ErrorCodeHttpStatus: Record = { [ErrorCode.BAD_REQUEST]: 400, [ErrorCode.UNAUTHORIZED]: 401, [ErrorCode.FORBIDDEN]: 403, [ErrorCode.NOT_FOUND]: 404, [ErrorCode.CONFLICT]: 409, [ErrorCode.GONE]: 410, [ErrorCode.UNPROCESSABLE_ENTITY]: 422, [ErrorCode.TOO_MANY_REQUESTS]: 429, [ErrorCode.INTERNAL_SERVER_ERROR]: 500, [ErrorCode.NOT_IMPLEMENTED]: 501, [ErrorCode.SERVICE_UNAVAILABLE]: 503, [ErrorCode.GATEWAY_TIMEOUT]: 504, // Business errors map to 400 by default [ErrorCode.VALIDATION_ERROR]: 400, [ErrorCode.DUPLICATE_ENTRY]: 409, [ErrorCode.RESOURCE_NOT_FOUND]: 404, [ErrorCode.INVALID_OPERATION]: 400, [ErrorCode.OPERATION_FAILED]: 400, [ErrorCode.BUSINESS_RULE_VIOLATION]: 400, // Auth errors [ErrorCode.TOKEN_EXPIRED]: 401, [ErrorCode.TOKEN_INVALID]: 401, [ErrorCode.TOKEN_MISSING]: 401, [ErrorCode.PERMISSION_DENIED]: 403, [ErrorCode.ACCOUNT_DISABLED]: 403, // Domain errors [ErrorCode.ENTITY_NOT_FOUND]: 404, [ErrorCode.ENTITY_ALREADY_EXISTS]: 409, [ErrorCode.ENTITY_CONFLICT]: 409, // External errors [ErrorCode.EXTERNAL_SERVICE_ERROR]: 502, [ErrorCode.EXTERNAL_SERVICE_TIMEOUT]: 504, [ErrorCode.EXTERNAL_SERVICE_UNAVAILABLE]: 503, }; ================================================ FILE: apps/api/src/shared/errors/error.filter.ts ================================================ // ============================================================================ // Global Error Filter - Handle all exceptions and format error responses // ============================================================================ import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger, } from '@nestjs/common'; import { Request, Response } from 'express'; import { BusinessException } from './business.exception'; import { ErrorCode } from './error-codes'; interface ErrorResponse { code: string; message: string; details?: Record; requestId?: string; timestamp: string; path?: string; method?: string; } @Catch() export class GlobalErrorFilter implements ExceptionFilter { private readonly logger = new Logger(GlobalErrorFilter.name); catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const requestId = (request.headers['x-request-id'] as string) || (request.headers['x-correlation-id'] as string); let errorResponse: ErrorResponse; if (exception instanceof BusinessException) { errorResponse = { code: exception.code, message: exception.message, details: exception.details, requestId, timestamp: new Date().toISOString(), path: request.url, method: request.method, }; this.logger.warn( `[${errorResponse.code}] ${errorResponse.message}`, { requestId, path: request.url, method: request.method, }, ); } else if (exception instanceof HttpException) { const status = exception.getStatus(); const exceptionResponse = exception.getResponse(); if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { const resp = exceptionResponse as Record; errorResponse = { code: this.getErrorCode(status), message: (resp.message as string) || exception.message, details: resp.errors as Record, requestId, timestamp: new Date().toISOString(), path: request.url, method: request.method, }; } else { errorResponse = { code: this.getErrorCode(status), message: exception.message, requestId, timestamp: new Date().toISOString(), path: request.url, method: request.method, }; } if (status >= 500) { this.logger.error( `[${errorResponse.code}] ${errorResponse.message}`, exception instanceof Error ? exception.stack : undefined, { requestId, path: request.url, method: request.method }, ); } else { this.logger.warn( `[${errorResponse.code}] ${errorResponse.message}`, { requestId, path: request.url, method: request.method }, ); } } else if (exception instanceof Error) { errorResponse = { code: ErrorCode.INTERNAL_SERVER_ERROR, message: process.env.NODE_ENV === 'production' ? 'Internal server error' : exception.message, requestId, timestamp: new Date().toISOString(), path: request.url, method: request.method, }; this.logger.error( `[${errorResponse.code}] ${exception.message}`, exception.stack, { requestId, path: request.url, method: request.method }, ); } else { errorResponse = { code: ErrorCode.INTERNAL_SERVER_ERROR, message: 'An unexpected error occurred', requestId, timestamp: new Date().toISOString(), path: request.url, method: request.method, }; this.logger.error( `[${errorResponse.code}] Unknown exception`, exception as Error, { requestId, path: request.url, method: request.method }, ); } response.status(errorResponse.code.startsWith('2') ? 200 : (exception instanceof HttpException ? exception.getStatus() : 500)).json({ code: errorResponse.code, message: errorResponse.message, details: errorResponse.details, requestId: errorResponse.requestId, timestamp: errorResponse.timestamp, }); } private getErrorCode(status: number): string { const statusToCode: Record = { 400: ErrorCode.BAD_REQUEST, 401: ErrorCode.UNAUTHORIZED, 403: ErrorCode.FORBIDDEN, 404: ErrorCode.NOT_FOUND, 409: ErrorCode.CONFLICT, 422: ErrorCode.UNPROCESSABLE_ENTITY, 429: ErrorCode.TOO_MANY_REQUESTS, 500: ErrorCode.INTERNAL_SERVER_ERROR, 502: ErrorCode.EXTERNAL_SERVICE_ERROR, 503: ErrorCode.SERVICE_UNAVAILABLE, 504: ErrorCode.GATEWAY_TIMEOUT, }; return statusToCode[status] || ErrorCode.INTERNAL_SERVER_ERROR; } } ================================================ FILE: apps/api/src/shared/errors/index.ts ================================================ export * from './error-codes'; export * from './business.exception'; export * from './error.filter'; ================================================ FILE: apps/api/src/shared/feature-flags/feature-flags.decorator.ts ================================================ // ============================================================================ // Feature Flags Decorators // ============================================================================ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { FeatureFlagsService } from './feature-flags.service'; /** * Check if a feature flag is enabled */ export const FeatureFlag = createParamDecorator( async (flagName: string, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user = request.user; const flagsService = request.featureFlagsService; if (!flagsService) { return false; } return flagsService.isEnabled(flagName); }, ); /** * Get feature flag evaluation result */ export const FeatureFlagEvaluation = createParamDecorator( async (flagName: string, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); const user = request.user; const flagsService = request.featureFlagsService; if (!flagsService) { return { enabled: false, reason: 'Service not available' }; } return flagsService.evaluate(flagName, { userId: user?.sub, organizationId: user?.organizationId, groups: user?.groups, }); }, ); ================================================ FILE: apps/api/src/shared/feature-flags/feature-flags.guard.ts ================================================ // ============================================================================ // Feature Flags Guard - Protects routes based on feature flags // ============================================================================ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, SetMetadata, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { FeatureFlagsService } from './feature-flags.service'; export const FEATURE_FLAG_KEY = 'feature_flag'; /** * Require a feature flag to be enabled */ export const RequiresFeature = (flagName: string) => SetMetadata(FEATURE_FLAG_KEY, flagName); @Injectable() export class FeatureFlagsGuard implements CanActivate { constructor( private readonly featureFlagsService: FeatureFlagsService, private readonly reflector: Reflector, ) {} async canActivate(context: ExecutionContext): Promise { const flagName = this.reflector.get( FEATURE_FLAG_KEY, context.getHandler(), ); if (!flagName) { return true; } const request = context.switchToHttp().getRequest(); const user = request.user; const evaluation = await this.featureFlagsService.evaluate(flagName, { userId: user?.sub, organizationId: user?.organizationId, groups: user?.groups, }); if (!evaluation.enabled) { throw new ForbiddenException( `This feature is currently unavailable: ${flagName}`, ); } return true; } } ================================================ FILE: apps/api/src/shared/feature-flags/feature-flags.service.ts ================================================ // ============================================================================ // Feature Flags Service - Feature toggles and gradual rollouts // ============================================================================ import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common'; import { RedissonService } from '@a3s-lab/redisson'; export interface FeatureFlag { name: string; enabled: boolean; description?: string; /** Rollout percentage (0-100) */ rolloutPercentage?: number; /** User groups that always have access */ allowedGroups?: string[]; /** User groups that never have access */ excludedGroups?: string[]; /** Metadata for the feature */ metadata?: Record; /** When the feature was enabled */ enabledAt?: Date; /** When the feature will be disabled (optional) */ expiresAt?: Date; } export interface FeatureFlagEvaluation { enabled: boolean; reason: string; metadata?: Record; } /** * Default feature flags */ export const DEFAULT_FEATURE_FLAGS: Record> = { 'new-dashboard': { enabled: true, rolloutPercentage: 100 }, 'ai-assistant': { enabled: true, rolloutPercentage: 50 }, 'advanced-analytics': { enabled: false }, 'dark-mode': { enabled: true, rolloutPercentage: 100 }, }; /** * Feature Flags Service */ @Injectable() export class FeatureFlagsService implements OnModuleDestroy { private readonly logger = new Logger(FeatureFlagsService.name); private readonly keyPrefix = 'feature:'; private readonly localCache: Map = new Map(); constructor(private readonly redis: RedissonService) {} /** * Check if a feature flag is enabled */ async isEnabled(flagName: string): Promise { const evaluation = await this.evaluate(flagName); return evaluation.enabled; } /** * Evaluate a feature flag for a specific user/context */ async evaluate( flagName: string, context?: { userId?: string; organizationId?: string; groups?: string[]; attributes?: Record; }, ): Promise { const flag = await this.getFlag(flagName); if (!flag) { return { enabled: false, reason: 'Flag not found' }; } if (!flag.enabled) { return { enabled: false, reason: 'Flag disabled', metadata: flag.metadata }; } // Check expiration if (flag.expiresAt && new Date() > flag.expiresAt) { return { enabled: false, reason: 'Flag expired', metadata: flag.metadata }; } // Check excluded groups if (context?.groups && flag.excludedGroups) { const hasExcludedGroup = context.groups.some(g => flag.excludedGroups!.includes(g)); if (hasExcludedGroup) { return { enabled: false, reason: 'User in excluded group', metadata: flag.metadata }; } } // Check allowed groups if (context?.groups && flag.allowedGroups) { const hasAllowedGroup = context.groups.some(g => flag.allowedGroups!.includes(g)); if (hasAllowedGroup) { return { enabled: true, reason: 'User in allowed group', metadata: flag.metadata }; } } // Check rollout percentage if (flag.rolloutPercentage !== undefined && flag.rolloutPercentage < 100) { const userId = context?.userId ?? context?.organizationId ?? 'anonymous'; const hash = this.hashUserId(userId, flagName); const bucket = hash % 100; if (bucket >= flag.rolloutPercentage) { return { enabled: false, reason: `User not in rollout percentage (${flag.rolloutPercentage}%)`, metadata: flag.metadata, }; } } return { enabled: true, reason: 'Flag enabled', metadata: flag.metadata }; } /** * Get feature flag definition */ async getFlag(flagName: string): Promise { // Check local cache first const cached = this.localCache.get(flagName); if (cached) { return cached; } try { const key = `${this.keyPrefix}${flagName}`; const data = await this.redis.get(key); if (data) { const flag = JSON.parse(data) as FeatureFlag; this.localCache.set(flagName, flag); return flag; } // Fallback to defaults const defaultFlag = DEFAULT_FEATURE_FLAGS[flagName]; if (defaultFlag) { const flag: FeatureFlag = { name: flagName, enabled: defaultFlag.enabled ?? false, rolloutPercentage: defaultFlag.rolloutPercentage, allowedGroups: defaultFlag.allowedGroups, excludedGroups: defaultFlag.excludedGroups, metadata: defaultFlag.metadata, }; this.localCache.set(flagName, flag); return flag; } return null; } catch (error) { this.logger.error(`Error getting feature flag ${flagName}: ${error}`); return null; } } /** * Enable a feature flag */ async enable(flagName: string, metadata?: Record): Promise { const existing = await this.getFlag(flagName); const flag: FeatureFlag = { name: flagName, enabled: true, enabledAt: new Date(), description: existing?.description, rolloutPercentage: existing?.rolloutPercentage, allowedGroups: existing?.allowedGroups, excludedGroups: existing?.excludedGroups, metadata, }; await this.saveFlag(flag); this.logger.log(`Feature flag enabled: ${flagName}`); } /** * Disable a feature flag */ async disable(flagName: string): Promise { const existing = await this.getFlag(flagName); const flag: FeatureFlag = { name: flagName, enabled: false, description: existing?.description, rolloutPercentage: existing?.rolloutPercentage, allowedGroups: existing?.allowedGroups, excludedGroups: existing?.excludedGroups, metadata: existing?.metadata, enabledAt: existing?.enabledAt, }; await this.saveFlag(flag); this.logger.log(`Feature flag disabled: ${flagName}`); } /** * Set rollout percentage */ async setRollout(flagName: string, percentage: number): Promise { const existing = await this.getFlag(flagName); const flag: FeatureFlag = { name: flagName, enabled: true, rolloutPercentage: Math.max(0, Math.min(100, percentage)), description: existing?.description, allowedGroups: existing?.allowedGroups, excludedGroups: existing?.excludedGroups, metadata: existing?.metadata, enabledAt: existing?.enabledAt, }; await this.saveFlag(flag); this.logger.log(`Feature flag rollout set: ${flagName} = ${percentage}%`); } /** * Set expiration date */ async setExpiration(flagName: string, expiresAt: Date): Promise { const existing = await this.getFlag(flagName); const flag: FeatureFlag = { name: flagName, enabled: existing?.enabled ?? false, description: existing?.description, rolloutPercentage: existing?.rolloutPercentage, allowedGroups: existing?.allowedGroups, excludedGroups: existing?.excludedGroups, metadata: existing?.metadata, enabledAt: existing?.enabledAt, expiresAt, }; await this.saveFlag(flag); this.logger.log(`Feature flag expiration set: ${flagName} = ${expiresAt.toISOString()}`); } /** * Get all feature flags */ async getAllFlags(): Promise { try { const redisClient = (this.redis as any).redis; const keys = await redisClient.keys(`${this.keyPrefix}*`); const flags: FeatureFlag[] = []; for (const key of keys) { const data = await this.redis.get(key); if (data) { flags.push(JSON.parse(data)); } } // Add default flags that don't exist in Redis for (const [name, defaultFlag] of Object.entries(DEFAULT_FEATURE_FLAGS)) { if (!flags.find(f => f.name === name)) { flags.push({ name, enabled: defaultFlag.enabled ?? false, rolloutPercentage: defaultFlag.rolloutPercentage, allowedGroups: defaultFlag.allowedGroups, excludedGroups: defaultFlag.excludedGroups, metadata: defaultFlag.metadata, }); } } return flags; } catch (error) { this.logger.error(`Error getting all feature flags: ${error}`); return []; } } /** * Save flag to Redis */ private async saveFlag(flag: FeatureFlag): Promise { const key = `${this.keyPrefix}${flag.name}`; await this.redis.set(key, JSON.stringify(flag)); this.localCache.set(flag.name, flag); } /** * Hash user ID for consistent rollout bucket */ private hashUserId(userId: string, flagName: string): number { const input = `${userId}:${flagName}`; let hash = 0; for (let i = 0; i < input.length; i++) { const char = input.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash); } onModuleDestroy(): void { this.localCache.clear(); } } ================================================ FILE: apps/api/src/shared/feature-flags/index.ts ================================================ // ============================================================================ // Feature Flags Module // ============================================================================ export { FeatureFlagsService } from './feature-flags.service'; export { FeatureFlagsGuard, RequiresFeature } from './feature-flags.guard'; export { FeatureFlag, FeatureFlagEvaluation } from './feature-flags.decorator'; ================================================ FILE: apps/api/src/shared/file-upload/file-upload.decorator.ts ================================================ // ============================================================================ // File Upload Decorators // ============================================================================ import { SetMetadata } from '@nestjs/common'; export const FILE_UPLOAD_KEY = 'file_upload'; /** * Decorator to mark endpoint for file upload handling */ export const FileUpload = (options?: { fieldName?: string; maxSize?: number; allowedTypes?: string[]; allowedExtensions?: string[]; maxCount?: number; }) => SetMetadata(FILE_UPLOAD_KEY, options ?? {}); /** * Upload single file */ export const UploadedFile = () => SetMetadata('isSingleFile', true); /** * Upload multiple files */ export const UploadedFiles = () => SetMetadata('isMultipleFiles', true); ================================================ FILE: apps/api/src/shared/file-upload/file-upload.interceptor.ts ================================================ // ============================================================================ // File Upload Interceptor - Handles multipart file uploads // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, BadRequestException, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { FileUploadService } from './file-upload.service'; export interface UploadedFile { fieldname: string; originalname: string; encoding: string; mimetype: string; size: number; buffer: Buffer; } export interface FileUploadMetadata { /** Field name in the form */ fieldname: string; /** Maximum file size in bytes */ maxSize?: number; /** Allowed MIME types */ allowedTypes?: string[]; /** Allowed extensions */ allowedExtensions?: string[]; /** Whether file is required */ required?: boolean; } /** * Interceptor that processes multipart file uploads */ @Injectable() export class FileUploadInterceptor implements NestInterceptor { constructor(private readonly fileUploadService: FileUploadService) {} async intercept(context: ExecutionContext, next: CallHandler): Promise> { const request = context.switchToHttp().getRequest(); if (!request.body || !request.files) { throw new BadRequestException('No file uploaded'); } // Handle both single and multiple files const files = this.extractFiles(request); // Process and upload files const uploadedFiles = await Promise.all( files.map(async (file) => { return this.fileUploadService.uploadFile( file.buffer, file.originalname, file.mimetype, ); }), ); // Attach uploaded files to request request.uploadedFiles = uploadedFiles; return next.handle().pipe( map((response) => { // If response already contains data, merge uploaded files if (response && typeof response === 'object') { return { ...response, files: uploadedFiles, }; } return { files: uploadedFiles }; }), ); } /** * Extract files from request */ private extractFiles(request: any): UploadedFile[] { const files: UploadedFile[] = []; if (Array.isArray(request.files)) { // Multiple files as array files.push(...request.files); } else if (request.files && typeof request.files === 'object') { // Files as object (fieldname -> file or array) for (const fieldName of Object.keys(request.files)) { const fieldFiles = request.files[fieldName]; if (Array.isArray(fieldFiles)) { files.push(...fieldFiles); } else { files.push(fieldFiles); } } } return files; } } /** * Single file upload interceptor */ @Injectable() export class SingleFileUploadInterceptor implements NestInterceptor { constructor(private readonly fileUploadService: FileUploadService) {} async intercept(context: ExecutionContext, next: CallHandler): Promise> { const request = context.switchToHttp().getRequest(); if (!request.file && !request.files?.file) { throw new BadRequestException('No file uploaded'); } const file = request.file || request.files?.file; const uploadedFile = await this.fileUploadService.uploadFile( file.buffer, file.originalname, file.mimetype, ); request.uploadedFile = uploadedFile; return next.handle().pipe( map((response) => { if (response && typeof response === 'object') { return { ...response, file: uploadedFile, }; } return { file: uploadedFile }; }), ); } } ================================================ FILE: apps/api/src/shared/file-upload/file-upload.service.ts ================================================ // ============================================================================ // File Upload Service - Multipart handling and storage // ============================================================================ import { Injectable, BadRequestException, Logger } from '@nestjs/common'; import { RustFSService } from '../rustfs/rustfs.service'; export interface UploadedFile { /** Original filename */ originalName: string; /** Stored filename (unique) */ storedName: string; /** File MIME type */ mimeType: string; /** File size in bytes */ size: number; /** Full URL to access the file */ url: string; /** SHA256 hash of file content */ hash?: string; /** Upload timestamp */ uploadedAt: Date; } export interface FileValidationOptions { /** Maximum file size in bytes */ maxSize?: number; /** Allowed MIME types (e.g., ['image/png', 'image/jpeg']) */ allowedTypes?: string[]; /** Allowed extensions (e.g., ['.png', '.jpg']) */ allowedExtensions?: string[]; } /** * Default validation options */ export const DEFAULT_FILE_VALIDATION: FileValidationOptions = { maxSize: 10 * 1024 * 1024, // 10MB allowedTypes: [ 'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'application/json', ], }; /** * File Upload Service - handles file uploads with RustFS storage */ @Injectable() export class FileUploadService { private readonly logger = new Logger(FileUploadService.name); constructor(private readonly rustFsService: RustFSService) {} /** * Upload a single file */ async uploadFile( buffer: Buffer, filename: string, mimeType: string, options?: FileValidationOptions, ): Promise { const validation = { ...DEFAULT_FILE_VALIDATION, ...options }; // Validate file this.validateFile(buffer, filename, mimeType, validation); // Generate unique filename const storedName = this.generateStoredName(filename); // Upload to storage const key = `uploads/${storedName}`; await this.rustFsService.upload(key, buffer, { contentType: mimeType }); this.logger.log(`File uploaded: ${filename} -> ${storedName}`); return { originalName: filename, storedName, mimeType, size: buffer.length, url: await this.rustFsService.getSignedUrl(key), uploadedAt: new Date(), }; } /** * Upload multiple files */ async uploadFiles( files: Array<{ buffer: Buffer; filename: string; mimeType: string }>, options?: FileValidationOptions, ): Promise { const results: UploadedFile[] = []; for (const file of files) { const result = await this.uploadFile(file.buffer, file.filename, file.mimeType, options); results.push(result); } return results; } /** * Delete a file */ async deleteFile(storedName: string): Promise { const key = `uploads/${storedName}`; await this.rustFsService.delete(key); this.logger.log(`File deleted: ${storedName}`); } /** * Get file URL */ async getFileUrl(storedName: string, expiresInSeconds = 3600): Promise { const key = `uploads/${storedName}`; return this.rustFsService.getSignedUrl(key, expiresInSeconds); } /** * Check if file exists */ async fileExists(storedName: string): Promise { const key = `uploads/${storedName}`; return this.rustFsService.exists(key); } /** * Validate file */ private validateFile( buffer: Buffer, filename: string, mimeType: string, options: FileValidationOptions, ): void { // Check size if (options.maxSize && buffer.length > options.maxSize) { const maxMB = options.maxSize / (1024 * 1024); throw new BadRequestException(`File size exceeds maximum allowed (${maxMB}MB)`); } // Check MIME type if (options.allowedTypes && !options.allowedTypes.includes(mimeType)) { throw new BadRequestException( `File type '${mimeType}' is not allowed. Allowed types: ${options.allowedTypes.join(', ')}`, ); } // Check extension if (options.allowedExtensions) { const ext = this.getExtension(filename).toLowerCase(); const allowedExts = options.allowedExtensions.map(e => e.toLowerCase()); if (!allowedExts.includes(ext)) { throw new BadRequestException( `File extension '${ext}' is not allowed. Allowed extensions: ${allowedExts.join(', ')}`, ); } } } /** * Generate unique filename */ private generateStoredName(originalName: string): string { const ext = this.getExtension(originalName); const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 15); return `${timestamp}-${random}${ext}`; } /** * Get file extension */ private getExtension(filename: string): string { const lastDot = filename.lastIndexOf('.'); return lastDot !== -1 ? filename.substring(lastDot) : ''; } } ================================================ FILE: apps/api/src/shared/file-upload/index.ts ================================================ // ============================================================================ // File Upload Module // ============================================================================ export { FileUploadService } from './file-upload.service'; export type { UploadedFile, FileValidationOptions } from './file-upload.service'; export { FileUploadInterceptor, SingleFileUploadInterceptor } from './file-upload.interceptor'; ================================================ FILE: apps/api/src/shared/health/health.controller.ts ================================================ // ============================================================================ // Health Controller - Health check endpoints // ============================================================================ import { Controller, Get } from '@nestjs/common'; import { HealthCheck, HealthCheckService, HealthCheckResult, } from '@nestjs/terminus'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { DatabaseHealthIndicator } from './indicators/database.indicator'; import { RedisHealthIndicator } from './indicators/redis.indicator'; @ApiTags('Health') @Controller() export class HealthController { constructor( private readonly health: HealthCheckService, private readonly databaseIndicator: DatabaseHealthIndicator, private readonly redisIndicator: RedisHealthIndicator, ) {} @Get('health') @HealthCheck() @ApiOperation({ summary: 'Health check endpoint' }) @ApiResponse({ status: 200, description: 'Service is healthy' }) @ApiResponse({ status: 503, description: 'Service is unhealthy' }) async check(): Promise { return this.health.check([ () => this.databaseIndicator.isHealthy('database'), () => this.redisIndicator.isHealthy('redis'), ]); } @Get('health/live') @ApiOperation({ summary: 'Liveness probe - is the service running?' }) @ApiResponse({ status: 200, description: 'Service is alive' }) live(): { status: string } { return { status: 'ok' }; } @Get('health/ready') @HealthCheck() @ApiOperation({ summary: 'Readiness probe - is the service ready to accept traffic?' }) @ApiResponse({ status: 200, description: 'Service is ready' }) @ApiResponse({ status: 503, description: 'Service is not ready' }) async ready(): Promise { return this.health.check([ () => this.databaseIndicator.isHealthy('database'), () => this.redisIndicator.isHealthy('redis'), ]); } } ================================================ FILE: apps/api/src/shared/health/health.module.ts ================================================ // ============================================================================ // Health Module // ============================================================================ import { Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { HealthController } from './health.controller'; import { DatabaseHealthIndicator } from './indicators/database.indicator'; import { RedisHealthIndicator } from './indicators/redis.indicator'; import { NatsHealthIndicator } from './indicators/nats.indicator'; import { RustFSHealthIndicator } from './indicators/rustfs.indicator'; @Module({ imports: [TerminusModule], controllers: [HealthController], providers: [ DatabaseHealthIndicator, RedisHealthIndicator, NatsHealthIndicator, RustFSHealthIndicator, ], }) export class HealthModule {} ================================================ FILE: apps/api/src/shared/health/index.ts ================================================ export * from './health.controller'; export * from './health.module'; export * from './indicators/database.indicator'; export * from './indicators/redis.indicator'; ================================================ FILE: apps/api/src/shared/health/indicators/database.indicator.ts ================================================ // ============================================================================ // Health Indicator - Database // ============================================================================ import { Injectable } from '@nestjs/common'; import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus'; import { KyselyService } from '@a3s-lab/kysely'; @Injectable() export class DatabaseHealthIndicator extends HealthIndicator { constructor(private readonly kysely: KyselyService) { super(); } async isHealthy(key: string): Promise { try { await this.kysely.execute('SELECT 1'); return this.getStatus(key, true); } catch (error) { throw new HealthCheckError( 'Database check failed', this.getStatus(key, false, { message: (error as Error).message }), ); } } } ================================================ FILE: apps/api/src/shared/health/indicators/nats.indicator.ts ================================================ // ============================================================================ // Health Indicator - NATS // ============================================================================ import { Injectable } from '@nestjs/common'; import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus'; import { NatsServiceImpl } from '@a3s-lab/nats'; import { IMessagingService } from '../../infrastructure/messaging/messaging.interface'; @Injectable() export class NatsHealthIndicator extends HealthIndicator { constructor(private readonly nats: IMessagingService) { super(); } async isHealthy(key: string): Promise { try { const isHealthy = await this.nats.isHealthy(); if (isHealthy) { return this.getStatus(key, true); } throw new Error('NATS connection not healthy'); } catch (error) { throw new HealthCheckError( 'NATS check failed', this.getStatus(key, false, { message: (error as Error).message }), ); } } } ================================================ FILE: apps/api/src/shared/health/indicators/redis.indicator.ts ================================================ // ============================================================================ // Health Indicator - Redis // ============================================================================ import { Injectable } from '@nestjs/common'; import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus'; import { RedissonService } from '@a3s-lab/redisson'; @Injectable() export class RedisHealthIndicator extends HealthIndicator { constructor(private readonly redis: RedissonService) { super(); } async isHealthy(key: string): Promise { try { await this.redis.ping(); return this.getStatus(key, true); } catch (error) { throw new HealthCheckError( 'Redis check failed', this.getStatus(key, false, { message: (error as Error).message }), ); } } } ================================================ FILE: apps/api/src/shared/health/indicators/rustfs.indicator.ts ================================================ // ============================================================================ // Health Indicator - RustFS // ============================================================================ import { Injectable } from '@nestjs/common'; import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus'; import { RustFSServiceImpl } from '@a3s-lab/rustfs'; import { IStorageService } from '../../infrastructure/storage/storage.interface'; @Injectable() export class RustFSHealthIndicator extends HealthIndicator { constructor(private readonly rustfs: IStorageService) { super(); } async isHealthy(key: string): Promise { try { const isHealthy = await this.rustfs.isHealthy(); if (isHealthy) { return this.getStatus(key, true); } throw new Error('RustFS not healthy'); } catch (error) { throw new HealthCheckError( 'RustFS check failed', this.getStatus(key, false, { message: (error as Error).message }), ); } } } ================================================ FILE: apps/api/src/shared/infrastructure/messaging/event-bus.interface.ts ================================================ import { DomainEvent } from '@/shared/domain/domain-event'; export interface IEventBus { publish(event: DomainEvent): Promise; publishAll(events: DomainEvent[]): Promise; } export const EVENT_BUS = Symbol('EVENT_BUS'); ================================================ FILE: apps/api/src/shared/infrastructure/messaging/event-bus.service.ts ================================================ import { Injectable } from '@nestjs/common'; import { EventBus as NestEventBus } from '@nestjs/cqrs'; import { IEventBus } from './event-bus.interface'; import { DomainEvent } from '@/shared/domain/domain-event'; @Injectable() export class EventBusService implements IEventBus { constructor(private readonly eventBus: NestEventBus) {} async publish(event: DomainEvent): Promise { await this.eventBus.publish(event); } async publishAll(events: DomainEvent[]): Promise { await Promise.all(events.map(event => this.eventBus.publish(event))); } } ================================================ FILE: apps/api/src/shared/infrastructure/messaging/messaging.interface.ts ================================================ // ============================================================================ // Messaging Infrastructure Interface // ============================================================================ import type { JetStreamClient, NatsConnection } from 'nats'; import type { PublishOptions, RequestOptions, SubscribeOptions, NatsMessage, SubscriptionHandler } from '@a3s-lab/nats'; export interface ISubscription { sid: number; subject: string; queue?: string; cancel(): void; isCancelled(): boolean; } export interface IMessagingService { getConnection(): Promise; getJetStream(): Promise; publish(options: PublishOptions): Promise; pubsub(subject: string, data: object): Promise; request(options: RequestOptions): Promise; request$(subject: string, data?: object): Promise; subscribe(options: SubscribeOptions, handler: SubscriptionHandler): Promise; subscribe$(subject: string, handler: (data: unknown) => Promise): Promise; unsubscribe(subscription: ISubscription): void; // Health check isHealthy(): Promise; } ================================================ FILE: apps/api/src/shared/infrastructure/persistence/repository.interface.ts ================================================ export interface IRepository { findById(id: string): Promise; save(entity: T): Promise; delete(id: string): Promise; } ================================================ FILE: apps/api/src/shared/infrastructure/persistence/unit-of-work.interface.ts ================================================ export interface IUnitOfWork { start(): Promise; commit(): Promise; rollback(): Promise; } ================================================ FILE: apps/api/src/shared/infrastructure/storage/storage.interface.ts ================================================ // ============================================================================ // Storage Infrastructure Interface // ============================================================================ import type { Bucket, StorageObject, CreateBucketOptions, BucketAcl, ListObjectsResult } from '@a3s-lab/rustfs'; export interface IStorageService { // Bucket operations createBucket(options: CreateBucketOptions): Promise; listBuckets(): Promise; getBucketAcl(bucketName: string): Promise; setBucketAcl(bucketName: string, acl: BucketAcl): Promise; deleteBucket(bucketName: string): Promise; bucketExists(bucketName: string): Promise; // Object operations putObject(bucketName: string, options: { key: string; body?: Buffer | Uint8Array | string; contentType?: string; contentEncoding?: string; contentDisposition?: string; contentLanguage?: string; metadata?: Record; acl?: string; storageClass?: string; expires?: Date; cacheControl?: string; }): Promise; getObject(bucketName: string, options: { key: string; range?: { start: number; end: number }; ifMatch?: string; ifNoneMatch?: string; ifModifiedSince?: Date; ifUnmodifiedSince?: Date; }): Promise; getObjectMetadata(bucketName: string, key: string): Promise; copyObject(bucketName: string, options: { sourceKey: string; destinationKey: string; sourceBucket?: string; destinationBucket?: string; acl?: string; metadata?: Record; storageClass?: string; }): Promise; deleteObject(bucketName: string, key: string): Promise; deleteObjects(bucketName: string, keys: string[]): Promise; listObjects(bucketName: string, options?: { prefix?: string; delimiter?: string; maxKeys?: number; continuationToken?: string; startAfter?: string; includeOwn?: boolean; }): Promise; // Presigned URLs getPresignedUrl(bucketName: string, options: { key: string; expiresIn?: number; method?: 'GET' | 'PUT' | 'DELETE' | 'POST'; contentType?: string; queryParams?: Record; }): Promise; getPresignedPostUrl(bucketName: string, options: { key: string; expiresIn?: number; conditions?: { contentLengthRange?: { min: number; max: number }; contentType?: string; acl?: string; }; }): Promise<{ url: string; fields: Record }>; // Health check isHealthy(): Promise; } export const STORAGE_SERVICE = Symbol('STORAGE_SERVICE'); ================================================ FILE: apps/api/src/shared/metrics/index.ts ================================================ // ============================================================================ // Metrics Module // ============================================================================ export { MetricsService } from './metrics.service'; export { MetricsInterceptor } from './metrics.interceptor'; export { MetricsController } from './metrics.controller'; ================================================ FILE: apps/api/src/shared/metrics/metrics.controller.ts ================================================ // ============================================================================ // Metrics Controller - Exposes /metrics endpoint for Prometheus // ============================================================================ import { Controller, Get, Header } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { MetricsService } from './metrics.service'; @ApiTags('Metrics') @Controller('metrics') export class MetricsController { constructor(private readonly metricsService: MetricsService) {} @Get() @Header('Content-Type', 'text/plain') @ApiOperation({ summary: 'Prometheus metrics endpoint' }) @ApiResponse({ status: 200, description: 'Metrics in Prometheus format' }) getMetrics(): string { return this.metricsService.toPrometheusFormat(); } @Get('json') @ApiOperation({ summary: 'Metrics in JSON format' }) @ApiResponse({ status: 200, description: 'Metrics in JSON format' }) getMetricsJson(): Record { return this.metricsService.toJSON(); } } ================================================ FILE: apps/api/src/shared/metrics/metrics.interceptor.ts ================================================ // ============================================================================ // Metrics Interceptor - Automatically records HTTP metrics // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { Request, Response } from 'express'; import { MetricsService } from './metrics.service'; @Injectable() export class MetricsInterceptor implements NestInterceptor { private readonly logger = new Logger(MetricsInterceptor.name); constructor(private readonly metricsService: MetricsService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const startTime = process.hrtime.bigint(); const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); // Track active requests this.metricsService.incGauge('http_active_requests'); return next.handle().pipe( tap({ next: () => { const duration = this.getDuration(startTime); this.recordMetrics(request, response, duration); this.metricsService.decGauge('http_active_requests'); }, error: (error) => { const duration = this.getDuration(startTime); // On error, status might not be set, default to 500 const status = error.status || 500; this.recordMetrics(request, response, duration, status); this.metricsService.decGauge('http_active_requests'); }, }), ); } private recordMetrics(request: Request, response: Response, duration: number, status?: number): void { const method = request.method; const path = this.normalizePath(request.route?.path || request.path); const statusCode = status ?? response.statusCode; // Convert duration to seconds const durationSeconds = duration / 1e9; this.metricsService.recordHttpRequest(method, path, statusCode, durationSeconds); // Record request/response size if available const requestSize = parseInt(request.headers['content-length'] as string) || 0; const responseSize = parseInt(response.get('content-length') as string) || 0; if (requestSize > 0) { this.metricsService.observeHistogram('http_request_size_bytes', requestSize, { method, path }); } if (responseSize > 0) { this.metricsService.observeHistogram('http_response_size_bytes', responseSize, { method, path }); } } /** * Normalize path to avoid high cardinality * Replaces dynamic segments like IDs with placeholders */ private normalizePath(path: string): string { // Replace UUIDs path = path.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':id'); // Replace numeric IDs path = path.replace(/\/\d+/g, '/:id'); return path; } /** * Get duration in nanoseconds and convert to number */ private getDuration(startTime: bigint): number { return Number(process.hrtime.bigint() - startTime); } } ================================================ FILE: apps/api/src/shared/metrics/metrics.service.ts ================================================ // ============================================================================ // Metrics Service - Prometheus + OpenTelemetry metrics // ============================================================================ import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common'; /** * Counter metric - for counting events (e.g., requests, errors) */ export interface CounterMetric { name: string; help: string; labelNames?: string[]; } /** * Gauge metric - for current values (e.g., queue size, memory usage) */ export interface GaugeMetric { name: string; help: string; labelNames?: string[]; } /** * Histogram metric - for distributions (e.g., request duration, response size) */ export interface HistogramMetric { name: string; help: string; labelNames?: string[]; buckets?: number[]; } /** * Default buckets for HTTP request duration */ export const DEFAULT_HISTOGRAM_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; /** * Default buckets for HTTP request size in bytes */ export const DEFAULT_SIZE_BUCKETS = [100, 1000, 10000, 100000, 1000000, 10000000]; /** * Metrics Service - provides Prometheus-compatible metrics */ @Injectable() export class MetricsService implements OnModuleDestroy { private readonly logger = new Logger(MetricsService.name); private readonly counters: Map = new Map(); private readonly gauges: Map = new Map(); private readonly histograms: Map = new Map(); private readonly labelValues: Map> = new Map(); // Default metrics private readonly defaultCounters: CounterMetric[] = [ { name: 'http_requests_total', help: 'Total HTTP requests', labelNames: ['method', 'path', 'status'] }, { name: 'http_errors_total', help: 'Total HTTP errors', labelNames: ['method', 'path', 'status'] }, { name: 'db_queries_total', help: 'Total database queries', labelNames: ['operation', 'table'] }, { name: 'cache_hits_total', help: 'Total cache hits', labelNames: ['cache'] }, { name: 'cache_misses_total', help: 'Total cache misses', labelNames: ['cache'] }, ]; private readonly defaultGauges: GaugeMetric[] = [ { name: 'http_active_requests', help: 'Active HTTP requests' }, { name: 'db_connection_pool_size', help: 'Database connection pool size' }, { name: 'db_connection_pool_used', help: 'Database connection pool used' }, { name: 'redis_connection_status', help: 'Redis connection status (1=up, 0=down)' }, ]; private readonly defaultHistograms: HistogramMetric[] = [ { name: 'http_request_duration_seconds', help: 'HTTP request duration', labelNames: ['method', 'path'], buckets: DEFAULT_HISTOGRAM_BUCKETS }, { name: 'http_request_size_bytes', help: 'HTTP request size', labelNames: ['method', 'path'], buckets: DEFAULT_SIZE_BUCKETS }, { name: 'http_response_size_bytes', help: 'HTTP response size', labelNames: ['method', 'path'], buckets: DEFAULT_SIZE_BUCKETS }, { name: 'db_query_duration_seconds', help: 'Database query duration', labelNames: ['operation', 'table'], buckets: DEFAULT_HISTOGRAM_BUCKETS }, ]; constructor() { this.initializeMetrics(); } private initializeMetrics(): void { // Initialize counters for (const counter of this.defaultCounters) { const key = this.getKey(counter.name, counter.labelNames); this.counters.set(key, 0); } // Initialize gauges for (const gauge of this.defaultGauges) { const key = this.getKey(gauge.name, gauge.labelNames); this.gauges.set(key, 0); } // Initialize histograms for (const histogram of this.defaultHistograms) { const key = this.getKey(histogram.name, histogram.labelNames); this.histograms.set(key, []); } this.logger.log('Metrics initialized'); } private getKey(name: string, labelNames?: string[]): string { return labelNames ? `${name}:${labelNames.join(',')}` : name; } private getLabelValues(labelNames: string[], labels: Record): string[] { return labelNames.map(name => labels[name] ?? 'unknown'); } // ========================================================================= // Counter Operations // ========================================================================= /** * Increment a counter */ incCounter(name: string, labels?: Record, amount = 1): void { const key = this.getKey(name, Object.keys(labels ?? {})); const current = this.counters.get(key) ?? 0; this.counters.set(key, current + amount); } /** * Get counter value */ getCounter(name: string, labels?: Record): number { const key = this.getKey(name, Object.keys(labels ?? {})); return this.counters.get(key) ?? 0; } // ========================================================================= // Gauge Operations // ========================================================================= /** * Set a gauge value */ setGauge(name: string, value: number, labels?: Record): void { const key = this.getKey(name, Object.keys(labels ?? {})); this.gauges.set(key, value); } /** * Increment a gauge */ incGauge(name: string, labels?: Record, amount = 1): void { const key = this.getKey(name, Object.keys(labels ?? {})); const current = this.gauges.get(key) ?? 0; this.gauges.set(key, current + amount); } /** * Decrement a gauge */ decGauge(name: string, labels?: Record, amount = 1): void { const key = this.getKey(name, Object.keys(labels ?? {})); const current = this.gauges.get(key) ?? 0; this.gauges.set(key, current - amount); } /** * Get gauge value */ getGauge(name: string, labels?: Record): number { const key = this.getKey(name, Object.keys(labels ?? {})); return this.gauges.get(key) ?? 0; } // ========================================================================= // Histogram Operations // ========================================================================= /** * Observe a value in histogram */ observeHistogram(name: string, value: number, labels?: Record): void { const key = this.getKey(name, Object.keys(labels ?? {})); const values = this.histograms.get(key) ?? []; values.push(value); this.histograms.set(key, values); } /** * Get histogram values */ getHistogram(name: string, labels?: Record): number[] { const key = this.getKey(name, Object.keys(labels ?? {})); return this.histograms.get(key) ?? []; } /** * Calculate histogram statistics */ getHistogramStats(name: string, labels?: Record): { count: number; sum: number; min: number; max: number; avg: number; p50: number; p95: number; p99: number } { const values = this.getHistogram(name, labels); if (values.length === 0) { return { count: 0, sum: 0, min: 0, max: 0, avg: 0, p50: 0, p95: 0, p99: 0 }; } const sorted = [...values].sort((a, b) => a - b); const sum = sorted.reduce((a, b) => a + b, 0); const count = sorted.length; return { count, sum, min: sorted[0], max: sorted[count - 1], avg: sum / count, p50: sorted[Math.floor(count * 0.5)], p95: sorted[Math.floor(count * 0.95)], p99: sorted[Math.floor(count * 0.99)], }; } // ========================================================================= // Convenience Methods // ========================================================================= /** * Record HTTP request */ recordHttpRequest(method: string, path: string, status: number, duration: number): void { const labels = { method, path, status: status.toString() }; this.incCounter('http_requests_total', labels); if (status >= 400) { this.incCounter('http_errors_total', labels); } this.observeHistogram('http_request_duration_seconds', duration, { method, path }); } /** * Record database query */ recordDbQuery(operation: string, table: string, duration: number): void { this.incCounter('db_queries_total', { operation, table }); this.observeHistogram('db_query_duration_seconds', duration, { operation, table }); } /** * Record cache hit */ recordCacheHit(cache: string): void { this.incCounter('cache_hits_total', { cache }); } /** * Record cache miss */ recordCacheMiss(cache: string): void { this.incCounter('cache_misses_total', { cache }); } // ========================================================================= // Export // ========================================================================= /** * Get all metrics in Prometheus format */ toPrometheusFormat(): string { const lines: string[] = []; // Export counters for (const [key, value] of this.counters.entries()) { lines.push(`# HELP ${key} counter`); lines.push(`# TYPE ${key} counter`); lines.push(`${key} ${value}`); } // Export gauges for (const [key, value] of this.gauges.entries()) { lines.push(`# HELP ${key} gauge`); lines.push(`# TYPE ${key} gauge`); lines.push(`${key} ${value}`); } // Export histograms (as summary for simplicity) for (const [key, values] of this.histograms.entries()) { if (values.length === 0) continue; const stats = this.getHistogramStats(key.split(':')[0]); lines.push(`# HELP ${key} histogram`); lines.push(`# TYPE ${key} histogram`); lines.push(`${key}_count ${stats.count}`); lines.push(`${key}_sum ${stats.sum}`); lines.push(`${key}_avg ${stats.avg}`); } return lines.join('\n'); } /** * Get all metrics as JSON */ toJSON(): Record { return { counters: Object.fromEntries(this.counters), gauges: Object.fromEntries(this.gauges), histograms: Object.fromEntries( [...this.histograms.entries()].map(([k, v]) => [k, this.getHistogramStats(k.split(':')[0])]) ), }; } onModuleDestroy(): void { this.logger.log('Metrics service destroyed'); } } ================================================ FILE: apps/api/src/shared/openapi/index.ts ================================================ export * from './openapi-decorators'; export * from './openapi-common.dto'; ================================================ FILE: apps/api/src/shared/openapi/openapi-common.dto.ts ================================================ // ============================================================================ // OpenAPI Common DTOs - Reusable documentation objects // ============================================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class PaginationParamsDto { @ApiPropertyOptional({ description: 'Page number', default: 1, minimum: 1 }) page?: number; @ApiPropertyOptional({ description: 'Page size', default: 20, minimum: 1, maximum: 100 }) pageSize?: number; } export class IdParamDto { @ApiProperty({ description: 'Unique identifier' }) id: string; } export class SlugParamDto { @ApiProperty({ description: 'URL-friendly identifier' }) slug: string; } export class CreatedAtFilterDto { @ApiPropertyOptional({ description: 'Filter by creation date (from)', example: '2024-01-01T00:00:00Z' }) createdFrom?: string; @ApiPropertyOptional({ description: 'Filter by creation date (to)', example: '2024-12-31T23:59:59Z' }) createdTo?: string; } export class StatusFilterDto { @ApiPropertyOptional({ description: 'Filter by status', enum: ['active', 'inactive', 'pending'] }) status?: string; } export class SearchQueryDto { @ApiPropertyOptional({ description: 'Search query', example: 'keyword' }) q?: string; @ApiPropertyOptional({ description: 'Page number', default: 1, minimum: 1 }) page?: number; @ApiPropertyOptional({ description: 'Page size', default: 20, minimum: 1, maximum: 100 }) pageSize?: number; } ================================================ FILE: apps/api/src/shared/openapi/openapi-decorators.ts ================================================ // ============================================================================ // OpenAPI Common Decorators - Reusable API documentation decorators // ============================================================================ import { applyDecorators, HttpStatus } from '@nestjs/common'; import { ApiBearerAuth, ApiUnauthorizedResponse, ApiForbiddenResponse, ApiInternalServerErrorResponse, ApiOperation, ApiResponse, ApiExtraModels, getSchemaPath, } from '@nestjs/swagger'; import { ApiResponseDto, PaginatedResponseDto } from '../api-response'; // ============================================================================ // Auth Decorators // ============================================================================ export function ApiAuth(summary?: string) { return applyDecorators( ApiBearerAuth(), ApiOperation({ summary }), ApiUnauthorizedResponse({ description: 'Unauthorized - Invalid or missing authentication token', schema: { type: 'object', properties: { code: { type: 'string', example: 'UNAUTHORIZED' }, message: { type: 'string', example: 'Authentication required' }, }, }, }), ); } export function ApiPermission(resource: string, action: string, summary?: string) { return applyDecorators( ApiAuth(summary), ApiForbiddenResponse({ description: 'Forbidden - Insufficient permissions', schema: { type: 'object', properties: { code: { type: 'string', example: 'PERMISSION_DENIED' }, message: { type: 'string', example: `Permission denied: ${resource}:${action}` }, }, }, }), ); } // ============================================================================ // Standard Response Decorators // ============================================================================ export function ApiStandardResponse(options: { status?: HttpStatus; summary?: string; description?: string; type?: T; isArray?: boolean; schema?: Record; }) { const { status = 200, summary, description, type, isArray, schema } = options; const code = status; const responseDecorators = [ ApiOperation({ summary, description }), ApiResponse({ status: code, description: description || (code === 200 ? 'Success' : 'Response'), schema: schema || { type: 'object', properties: { code: { type: 'number', example: code }, message: { type: 'string', example: 'Success' }, data: schema ? schema : isArray ? { type: 'array', items: type ? { $ref: getSchemaPath(type as any) } : {} } : type ? { $ref: getSchemaPath(type as any) } : {}, requestId: { type: 'string' }, timestamp: { type: 'string' }, }, }, }), ]; return applyDecorators(...responseDecorators); } export function ApiCreatedResponse(options: { summary?: string; type?: T; description?: string; }) { return ApiStandardResponse({ status: HttpStatus.CREATED, ...options, }); } export function ApiNoContentResponse(summary?: string) { return applyDecorators( ApiOperation({ summary }), ApiResponse({ status: 204, description: 'No Content', }), ); } // ============================================================================ // Paginated Response Decorators // ============================================================================ export function ApiPaginatedResponse(options: { summary?: string; type?: T; description?: string; }) { const { summary, type, description } = options; return applyDecorators( ApiExtraModels(PaginatedResponseDto), ApiOperation({ summary, description }), ApiResponse({ status: 200, description: description || 'Paginated response', schema: { type: 'object', properties: { code: { type: 'number', example: 200 }, message: { type: 'string', example: 'Success' }, data: { type: 'object', properties: { items: { type: 'array', items: type ? { $ref: getSchemaPath(type as any) } : {}, }, total: { type: 'number', example: 100 }, page: { type: 'number', example: 1 }, pageSize: { type: 'number', example: 20 }, totalPages: { type: 'number', example: 5 }, hasNext: { type: 'boolean', example: true }, hasPrevious: { type: 'boolean', example: false }, }, }, requestId: { type: 'string' }, timestamp: { type: 'string' }, }, }, }), ); } // ============================================================================ // Error Response Decorators // ============================================================================ export function ApiBadRequestResponse(description = 'Bad Request - Invalid input') { return ApiResponse({ status: 400, description, schema: { type: 'object', properties: { code: { type: 'string', example: 'BAD_REQUEST' }, message: { type: 'string', example: 'Validation failed' }, details: { type: 'object' }, requestId: { type: 'string' }, timestamp: { type: 'string' }, }, }, }); } export function ApiNotFoundResponse(resource = 'Resource') { return ApiResponse({ status: 404, description: `${resource} not found`, schema: { type: 'object', properties: { code: { type: 'string', example: 'NOT_FOUND' }, message: { type: 'string', example: `${resource} not found` }, requestId: { type: 'string' }, timestamp: { type: 'string' }, }, }, }); } export function ApiConflictResponse(description = 'Conflict - Resource already exists') { return ApiResponse({ status: 409, description, schema: { type: 'object', properties: { code: { type: 'string', example: 'CONFLICT' }, message: { type: 'string', example: description }, requestId: { type: 'string' }, timestamp: { type: 'string' }, }, }, }); } export function ApiServerErrorResponse() { return applyDecorators( ApiResponse({ status: 500, description: 'Internal Server Error', schema: { type: 'object', properties: { code: { type: 'string', example: 'INTERNAL_SERVER_ERROR' }, message: { type: 'string', example: 'An unexpected error occurred' }, requestId: { type: 'string' }, timestamp: { type: 'string' }, }, }, }), ApiInternalServerErrorResponse({ description: 'Internal Server Error', }), ); } ================================================ FILE: apps/api/src/shared/presentation/filters/domain-exception.filter.ts ================================================ import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common'; import { Response } from 'express'; export class DomainException extends Error { constructor(message: string) { super(message); this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } } @Catch(DomainException) export class DomainExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(DomainExceptionFilter.name); catch(exception: DomainException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const errorResponse = { statusCode: HttpStatus.BAD_REQUEST, timestamp: new Date().toISOString(), path: request.url, method: request.method, message: exception.message, type: exception.name, }; this.logger.error(`Domain Exception: ${exception.name} - ${exception.message}`); response.status(HttpStatus.BAD_REQUEST).json(errorResponse); } } ================================================ FILE: apps/api/src/shared/presentation/filters/http-exception.filter.ts ================================================ import { ExceptionFilter, Catch, ArgumentsHost, HttpException, Logger } from '@nestjs/common'; import { Request, Response } from 'express'; @Catch(HttpException) export class HttpExceptionFilter implements ExceptionFilter { private readonly logger = new Logger(HttpExceptionFilter.name); catch(exception: HttpException, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const status = exception.getStatus(); const exceptionResponse = exception.getResponse(); const errorResponse = { statusCode: status, timestamp: new Date().toISOString(), path: request.url, method: request.method, message: typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse as any).message || exception.message, }; this.logger.error(`${request.method} ${request.url} ${status} - ${JSON.stringify(errorResponse.message)}`); response.status(status).json(errorResponse); } } ================================================ FILE: apps/api/src/shared/presentation/interceptors/logging.interceptor.ts ================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly logger = new Logger(LoggingInterceptor.name); intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const { method, url } = request; const now = Date.now(); this.logger.log(`Incoming Request: ${method} ${url}`); return next.handle().pipe( tap(() => { const response = context.switchToHttp().getResponse(); const { statusCode } = response; const delay = Date.now() - now; this.logger.log(`Outgoing Response: ${method} ${url} ${statusCode} - ${delay}ms`); }), ); } } ================================================ FILE: apps/api/src/shared/rate-limiting/index.ts ================================================ // ============================================================================ // Rate Limiting Module // ============================================================================ export { RateLimitingService, RateLimitExceededException } from './rate-limiting.service'; export type { RateLimitConfig, RateLimitResult } from './rate-limiting.service'; export { RateLimitingGuard } from './rate-limiting.guard'; export { RateLimit, RateLimitAuth, RateLimitApi, RateLimitUpload } from './rate-limiting.decorator'; ================================================ FILE: apps/api/src/shared/rate-limiting/rate-limiting.decorator.ts ================================================ // ============================================================================ // Rate Limiting Decorators // ============================================================================ import { SetMetadata } from '@nestjs/common'; import { RateLimitConfig, DEFAULT_RATE_LIMITS } from './rate-limiting.service'; /** * Rate limit decorator options */ export interface RateLimitOptions { /** Maximum requests allowed */ limit: number; /** Window size in seconds */ windowSeconds: number; } export const RATE_LIMIT_KEY = 'rate_limit_config'; /** * Apply rate limiting to a route */ export const RateLimit = (options: RateLimitOptions) => SetMetadata(RATE_LIMIT_KEY, options); /** * Apply strict rate limiting (auth endpoints) */ export const RateLimitAuth = () => SetMetadata(RATE_LIMIT_KEY, DEFAULT_RATE_LIMITS.auth); /** * Apply API rate limiting */ export const RateLimitApi = () => SetMetadata(RATE_LIMIT_KEY, DEFAULT_RATE_LIMITS.api); /** * Apply upload rate limiting */ export const RateLimitUpload = () => SetMetadata(RATE_LIMIT_KEY, DEFAULT_RATE_LIMITS.upload); ================================================ FILE: apps/api/src/shared/rate-limiting/rate-limiting.guard.ts ================================================ // ============================================================================ // Rate Limiting Guard - Guard that enforces rate limits // ============================================================================ import { Injectable, CanActivate, ExecutionContext, SetMetadata, HttpException, HttpStatus, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Request } from 'express'; import { RateLimitingService, RateLimitConfig, DEFAULT_RATE_LIMITS } from './rate-limiting.service'; export const RATE_LIMIT_KEY = 'rate_limit'; export const RATE_LIMIT_CONFIG_KEY = 'rate_limit_config'; export interface RateLimitMetadata { name?: string; config?: RateLimitConfig; } /** * Set rate limit for a route */ export const RateLimit = (config?: RateLimitConfig) => SetMetadata(RATE_LIMIT_CONFIG_KEY, config ?? DEFAULT_RATE_LIMITS.default); export const RateLimitByName = (name: keyof typeof DEFAULT_RATE_LIMITS) => SetMetadata(RATE_LIMIT_CONFIG_KEY, DEFAULT_RATE_LIMITS[name]); @Injectable() export class RateLimitingGuard implements CanActivate { constructor( private readonly rateLimitingService: RateLimitingService, private readonly reflector: Reflector, ) {} async canActivate(context: ExecutionContext): Promise { const config = this.reflector.get( RATE_LIMIT_CONFIG_KEY, context.getHandler(), ); // If no rate limit config, skip if (!config) { return true; } const request = context.switchToHttp().getRequest(); const identifier = this.getIdentifier(request); const result = await this.rateLimitingService.checkLimit(identifier, config); // Add rate limit headers to response const response = context.switchToHttp().getResponse(); response.set({ 'X-RateLimit-Limit': config.limit, 'X-RateLimit-Remaining': result.remaining, 'X-RateLimit-Reset': result.resetAt.toISOString(), }); if (!result.allowed) { response.set('Retry-After', result.retryAfter?.toString() ?? '60'); throw new HttpException( { statusCode: HttpStatus.TOO_MANY_REQUESTS, message: 'Too many requests', error: 'Rate limit exceeded', retryAfter: result.retryAfter, }, HttpStatus.TOO_MANY_REQUESTS, ); } return true; } /** * Get identifier for rate limiting * Uses user ID if authenticated, otherwise uses IP */ private getIdentifier(request: Request): string { const user = (request as any).user; if (user?.sub) { return `user:${user.sub}`; } // Fallback to IP address const ip = this.getClientIp(request); return `ip:${ip}`; } /** * Extract client IP from request */ private getClientIp(request: Request): string { const forwarded = request.headers['x-forwarded-for']; if (forwarded) { const ips = Array.isArray(forwarded) ? forwarded[0] : forwarded.split(',')[0]; return ips.trim(); } return request.ip ?? request.socket.remoteAddress ?? 'unknown'; } } ================================================ FILE: apps/api/src/shared/rate-limiting/rate-limiting.service.ts ================================================ // ============================================================================ // Rate Limiting Service - API throttling and abuse protection // ============================================================================ import { Injectable, OnModuleDestroy, HttpException, HttpStatus } from '@nestjs/common'; import { RedissonService } from '@a3s-lab/redisson'; export interface RateLimitConfig { /** Maximum requests allowed in window */ limit: number; /** Window size in seconds */ windowSeconds: number; /** Key prefix for Redis */ keyPrefix?: string; } export interface RateLimitResult { allowed: boolean; remaining: number; resetAt: Date; retryAfter?: number; } /** * Default rate limit configurations */ export const DEFAULT_RATE_LIMITS: Record = { // 100 requests per minute per user default: { limit: 100, windowSeconds: 60 }, // 10 requests per minute for auth endpoints auth: { limit: 10, windowSeconds: 60 }, // 5 requests per minute for password reset passwordReset: { limit: 5, windowSeconds: 60 }, // 1000 requests per hour for API api: { limit: 1000, windowSeconds: 3600 }, // 100 requests per minute for file uploads upload: { limit: 100, windowSeconds: 60 }, }; @Injectable() export class RateLimitingService implements OnModuleDestroy { private readonly keyPrefix = 'ratelimit:'; private readonly localCache: Map = new Map(); constructor(private readonly redis: RedissonService) {} /** * Check rate limit using sliding window algorithm */ async checkLimit( identifier: string, config: RateLimitConfig = DEFAULT_RATE_LIMITS.default, ): Promise { const key = `${config.keyPrefix ?? this.keyPrefix}${identifier}`; const now = Date.now(); const windowMs = config.windowSeconds * 1000; const windowStart = now - windowMs; try { // Use Redis sorted set for sliding window const redisClient = (this.redis as any).redis; const pipeline = redisClient.pipeline(); // Remove old entries outside window pipeline.zremrangebyscore(key, 0, windowStart); // Add current request pipeline.zadd(key, now.toString(), `${now}-${Math.random()}`); // Count requests in window pipeline.zcard(key); // Set expiry on key pipeline.expire(key, config.windowSeconds + 1); const results = await pipeline.exec(); const count = results[2][1] as number; const allowed = count <= config.limit; const remaining = Math.max(0, config.limit - count); const resetAt = new Date(now + windowMs); if (!allowed) { // Calculate when the oldest request will expire const oldest = await redisClient.zrange(key, 0, 0, 'WITHSCORES'); const oldestTime = oldest.length >= 2 ? parseInt(oldest[1]) : now; const retryAfter = Math.ceil((oldestTime + windowMs - now) / 1000); return { allowed: false, remaining: 0, resetAt, retryAfter, }; } return { allowed: true, remaining, resetAt }; } catch (error) { // Fallback to local cache if Redis fails return this.checkLimitLocal(identifier, config); } } /** * Fallback local rate limiting */ private checkLimitLocal( identifier: string, config: RateLimitConfig, ): RateLimitResult { const key = identifier; const now = Date.now(); const windowMs = config.windowSeconds * 1000; const cached = this.localCache.get(key); if (!cached || cached.resetAt < now) { // Start new window this.localCache.set(key, { count: 1, resetAt: now + windowMs }); return { allowed: true, remaining: config.limit - 1, resetAt: new Date(now + windowMs), }; } cached.count++; const allowed = cached.count <= config.limit; const remaining = Math.max(0, config.limit - cached.count); if (!allowed) { return { allowed: false, remaining: 0, resetAt: new Date(cached.resetAt), retryAfter: Math.ceil((cached.resetAt - now) / 1000), }; } return { allowed: true, remaining, resetAt: new Date(cached.resetAt), }; } /** * Reset rate limit for an identifier */ async resetLimit(identifier: string): Promise { const key = `${this.keyPrefix}${identifier}`; await this.redis.delete(key); this.localCache.delete(identifier); } /** * Get current usage for an identifier */ async getUsage(identifier: string): Promise<{ count: number; resetAt: Date }> { const key = `${this.keyPrefix}${identifier}`; const now = Date.now(); try { const redisClient = (this.redis as any).redis; const count = await redisClient.zcard(key); const ttl = await redisClient.ttl(key); return { count, resetAt: new Date(now + ttl * 1000), }; } catch { const cached = this.localCache.get(identifier); if (cached) { return { count: cached.count, resetAt: new Date(cached.resetAt) }; } return { count: 0, resetAt: new Date(now) }; } } onModuleDestroy(): void { this.localCache.clear(); } } /** * Rate limit exceeded exception */ export class RateLimitExceededException extends HttpException { constructor(retryAfter: number) { super( { statusCode: HttpStatus.TOO_MANY_REQUESTS, message: 'Too many requests', error: 'Rate limit exceeded', retryAfter, }, HttpStatus.TOO_MANY_REQUESTS, ); } } ================================================ FILE: apps/api/src/shared/redis/index.ts ================================================ export * from './redis.module'; ================================================ FILE: apps/api/src/shared/redis/redis.module.ts ================================================ import { Module, Global } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { RedissonModule } from '@a3s-lab/redisson'; @Global() @Module({ imports: [ RedissonModule.registerAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ redis: { options: { host: configService.get('REDIS_HOST', 'localhost'), port: configService.get('REDIS_PORT', 6379), password: configService.get('REDIS_PASSWORD'), db: configService.get('REDIS_DB', 0), }, }, }), inject: [ConfigService], }), ], exports: [RedissonModule], }) export class RedisModule {} ================================================ FILE: apps/api/src/shared/retry/index.ts ================================================ // ============================================================================ // Retry Module // ============================================================================ export { RetryService, RetryExhaustedError, Retry, DEFAULT_RETRYABLE_HTTP_CODES } from './retry.service'; export type { RetryOptions, RetryResult, RetryDecoratorOptions } from './retry.service'; ================================================ FILE: apps/api/src/shared/retry/retry.module.ts ================================================ // ============================================================================ // Retry Module - Automatic retry with exponential backoff // ============================================================================ import { Module, Global } from '@nestjs/common'; import { RetryService } from './retry.service'; @Global() @Module({ providers: [RetryService], exports: [RetryService], }) export class RetryModule {} ================================================ FILE: apps/api/src/shared/retry/retry.service.ts ================================================ // ============================================================================ // Retry Service - Automatic retry with exponential backoff // ============================================================================ import { Injectable, Logger } from '@nestjs/common'; export interface RetryOptions { /** Maximum number of attempts */ maxAttempts?: number; /** Initial delay in ms */ initialDelay?: number; /** Maximum delay in ms */ maxDelay?: number; /** Backoff multiplier */ backoffMultiplier?: number; /** List of errors that should trigger retry */ retryableErrors?: Array Error>; /** Function to determine if error is retryable */ isRetryable?: (error: Error) => boolean; /** Callback on retry */ onRetry?: (attempt: number, error: Error, delay: number) => void; } export interface RetryResult { success: boolean; result?: T; error?: Error; attempts: number; totalDuration: number; } /** * Default retry options */ const DEFAULT_OPTIONS: Required> & { retryableErrors: any[]; isRetryable: (error: Error) => boolean } = { maxAttempts: 3, initialDelay: 100, maxDelay: 30000, backoffMultiplier: 2, retryableErrors: [], isRetryable: () => true, }; /** * Common retryable HTTP errors */ export const DEFAULT_RETRYABLE_HTTP_CODES = [408, 429, 500, 502, 503, 504]; /** * Retry Service - provides automatic retry with exponential backoff */ @Injectable() export class RetryService { private readonly logger = new Logger(RetryService.name); constructor() {} /** * Execute a function with retry logic */ async execute( fn: () => Promise, options?: RetryOptions, ): Promise> { const opts = { ...DEFAULT_OPTIONS, ...options, retryableErrors: options?.retryableErrors ?? DEFAULT_OPTIONS.retryableErrors, isRetryable: options?.isRetryable ?? DEFAULT_OPTIONS.isRetryable, }; const startTime = Date.now(); let lastError: Error | undefined; let attempt = 0; while (attempt < opts.maxAttempts) { attempt++; try { const result = await fn(); return { success: true, result, attempts: attempt, totalDuration: Date.now() - startTime, }; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); // Check if we should retry if (attempt >= opts.maxAttempts) { break; } if (!this.isRetryable(lastError, opts)) { break; } // Calculate delay with exponential backoff const delay = this.calculateDelay(attempt, opts); const jitter = this.calculateJitter(delay); if (opts.onRetry) { opts.onRetry(attempt, lastError, delay + jitter); } this.logger.warn( `Retry attempt ${attempt}/${opts.maxAttempts} after ${delay + jitter}ms due to: ${lastError.message}`, ); // Wait before next attempt await this.sleep(delay + jitter); } } return { success: false, error: lastError, attempts: attempt, totalDuration: Date.now() - startTime, }; } /** * Execute with retry - throws on failure */ async executeOrThrow( fn: () => Promise, options?: RetryOptions, ): Promise { const result = await this.execute(fn, options); if (!result.success) { throw new RetryExhaustedError( result.attempts, result.totalDuration, result.error, ); } return result.result!; } /** * Check if error is retryable */ private isRetryable(error: Error, options: { retryableErrors: any[]; isRetryable: (error: Error) => boolean }): boolean { // Check custom retryable errors if (options.retryableErrors.length > 0) { for (const ErrorClass of options.retryableErrors) { if (error instanceof ErrorClass) { return true; } } } // Check custom function return options.isRetryable(error); } /** * Calculate delay with exponential backoff */ private calculateDelay(attempt: number, options: { initialDelay: number; backoffMultiplier: number; maxDelay: number }): number { const delay = options.initialDelay * Math.pow(options.backoffMultiplier, attempt - 1); return Math.min(delay, options.maxDelay); } /** * Add jitter to prevent thundering herd */ private calculateJitter(delay: number): number { // 0-25% of delay return Math.random() * delay * 0.25; } /** * Sleep for specified milliseconds */ private sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } } /** * Error thrown when all retries are exhausted */ export class RetryExhaustedError extends Error { constructor( public readonly attempts: number, public readonly totalDuration: number, public readonly lastError?: Error, ) { super(`Retry exhausted after ${attempts} attempts (${totalDuration}ms): ${lastError?.message ?? 'Unknown error'}`); this.name = 'RetryExhaustedError'; } } /** * Decorator options */ export interface RetryDecoratorOptions extends RetryOptions { /** Name for logging */ name?: string; } /** * Decorator to automatically retry a method */ export function Retry(options: RetryDecoratorOptions = {}) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; const retryService = new RetryService(); descriptor.value = async function (...args: any[]) { return retryService.executeOrThrow( () => originalMethod.apply(this, args), options, ); }; return descriptor; }; } ================================================ FILE: apps/api/src/shared/serialization/example.ts ================================================ // ============================================================================ // Serializer Examples // ============================================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; /** * Example: User Entity */ interface UserEntity { id: string; email: string; username: string; displayName?: string; avatar?: string; status: 'active' | 'inactive' | 'suspended'; passwordHash?: string; organizationId: string; createdAt: Date; updatedAt: Date; } /** * Example: User DTO for response */ class UserDto { @ApiProperty({ description: 'User ID' }) id: string; @ApiProperty({ description: 'Email address' }) email: string; @ApiProperty({ description: 'Username' }) username: string; @ApiPropertyOptional({ description: 'Display name' }) displayName?: string; @ApiPropertyOptional({ description: 'Avatar URL' }) avatar?: string; @ApiProperty({ description: 'Account status' }) status: string; @ApiProperty({ description: 'Organization ID' }) organizationId: string; @ApiProperty({ description: 'Creation timestamp' }) createdAt: Date; @ApiProperty({ description: 'Last update timestamp' }) updatedAt: Date; } /** * Example: Create User DTO */ class CreateUserDto { @ApiProperty({ description: 'Email address' }) email: string; @ApiProperty({ description: 'Username' }) username: string; @ApiPropertyOptional({ description: 'Display name' }) displayName?: string; @ApiProperty({ description: 'Initial password' }) password: string; } /** * Example: Update User DTO */ class UpdateUserDto { @ApiPropertyOptional({ description: 'Display name' }) displayName?: string; @ApiPropertyOptional({ description: 'Avatar URL' }) avatar?: string; } // ============================================================================ // User Serializer Implementation - Functional Approach (Recommended) // ============================================================================ /** * Convert User entity to UserDto */ function userToDto(user: UserEntity): UserDto { return { id: user.id, email: user.email, username: user.username, displayName: user.displayName, avatar: user.avatar, status: user.status, organizationId: user.organizationId, createdAt: user.createdAt, updatedAt: user.updatedAt, }; } /** * Convert User entity to CreateUserDto (excludes sensitive fields) */ function userToCreateDto(user: UserEntity): CreateUserDto { return { email: user.email, username: user.username, displayName: user.displayName, password: '', // Never expose password hash }; } /** * Convert list of User entities to UserDto[] */ function userListToDto(users: UserEntity[]): UserDto[] { return users.map(userToDto); } // ============================================================================ // User Serializer Implementation - Class-based Approach // ============================================================================ import { Serializer } from './serializer'; class UserSerializer extends Serializer { protected static _instance: UserSerializer; static get instance(): UserSerializer { return this._instance || (this._instance = new UserSerializer()); } toDto(user: UserEntity): UserDto { return { id: user.id, email: user.email, username: user.username, displayName: user.displayName, avatar: user.avatar, status: user.status, organizationId: user.organizationId, createdAt: user.createdAt, updatedAt: user.updatedAt, }; } } class CreateUserSerializer extends Serializer { protected static _instance: CreateUserSerializer; static get instance(): CreateUserSerializer { return this._instance || (this._instance = new CreateUserSerializer()); } toDto(user: UserEntity): CreateUserDto { return { email: user.email, username: user.username, displayName: user.displayName, password: '', // Never expose password hash }; } } // Usage in Service // class UserService { // findAll(): UserDto[] { // const users = await this.userRepository.findAll(); // return users.map(userToDto); // // Or: return UserSerializer.instance.toDtoList(users); // } // // findOne(id: string): UserDto { // const user = await this.userRepository.findById(id); // return userToDto(user); // // Or: return UserSerializer.instance.toDto(user); // } // // create(dto: CreateUserDto): UserDto { // const user = this.userRepository.create(dto); // return userToCreateDto(user); // // Or: return CreateUserSerializer.instance.toDto(user); // } // } export { UserDto, CreateUserDto, UpdateUserDto, userToDto, userToCreateDto, userListToDto, UserSerializer, CreateUserSerializer }; ================================================ FILE: apps/api/src/shared/serialization/index.ts ================================================ export * from './serializer'; export * from './example'; ================================================ FILE: apps/api/src/shared/serialization/serializer.ts ================================================ // ============================================================================ // Serializer - Entity ↔ DTO mapping // ============================================================================ import { instanceToPlain, plainToInstance } from 'class-transformer'; /** * Base serializer class for Entity ↔ DTO mapping * * Usage: * ```typescript * class UserSerializer extends Serializer { * protected static _instance: UserSerializer; * * static get instance(): UserSerializer { * return this._instance || (this._instance = new UserSerializer()); * } * * toDto(entity: User): UserDto { * return { * id: entity.id, * email: entity.email, * ... * }; * } * } * ``` * * Or use the simpler functional approach: * ```typescript * const userToDto = (user: User): UserDto => ({ * id: user.id, * email: user.email, * ... * }); * * const userToDtoList = (users: User[]): UserDto[] => users.map(userToDto); * ``` */ export abstract class Serializer { /** * Convert Entity to DTO */ abstract toDto(entity: Entity): Dto; /** * Convert list of Entities to DTOs */ toDtoList(entities: Entity[]): Dto[] { return entities.map((entity) => this.toDto(entity)); } } /** * Type for class constructors */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ClassType = new (...args: any[]) => T; /** * Transform a plain object to a class instance */ export function transformToInstance( plain: Record, cls: ClassType, options?: { excludeExtraneousValues?: boolean }, ): T { return plainToInstance(cls, plain, { excludeExtraneousValues: options?.excludeExtraneousValues ?? true, enableImplicitConversion: true, }); } /** * Transform an object to a plain JavaScript object */ export function transformToPlain(entity: T, options?: { excludeExtraneousValues?: boolean }): Record { return instanceToPlain(entity, { excludeExtraneousValues: options?.excludeExtraneousValues ?? true, }) as Record; } /** * Transform list of plain objects to class instances */ export function transformListToInstance( plainList: Record[], cls: ClassType, options?: { excludeExtraneousValues?: boolean }, ): T[] { return plainToInstance(cls, plainList, { excludeExtraneousValues: options?.excludeExtraneousValues ?? true, enableImplicitConversion: true, }); } /** * Transform list of objects to plain objects */ export function transformListToPlain(entities: T[]): Record[] { return entities.map((entity) => transformToPlain(entity)); } /** * Simple mapper function type */ export type Mapper = (entity: Entity) => Dto; /** * Create a mapper that converts entity to DTO */ export function toDto(mapper: Mapper): Mapper { return mapper; } /** * Create a mapper that converts list of entities to DTOs */ export function toDtoList(mapper: Mapper): (entities: Entity[]) => Dto[] { return (entities: Entity[]) => entities.map(mapper); } ================================================ FILE: apps/api/src/shared/tenant/index.ts ================================================ // ============================================================================ // Tenant Module - Multi-tenancy support // ============================================================================ export * from './tenant.service'; export * from './tenant.guard'; export * from './tenant.interceptor'; export * from './tenant.decorator'; ================================================ FILE: apps/api/src/shared/tenant/tenant.decorator.ts ================================================ // ============================================================================ // Tenant Decorators // ============================================================================ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; import { TenantService } from './tenant.service'; /** * Get current organization ID */ export const OrganizationId = createParamDecorator( (_data: unknown, ctx: ExecutionContext) => { const tenantService = ctx.switchToHttp().getRequest().tenantService; if (tenantService) { return tenantService.getOrganizationId(); } const request = ctx.switchToHttp().getRequest(); return request.user?.organizationId; }, ); /** * Get current tenant context */ export const Tenant = createParamDecorator( (_data: unknown, ctx: ExecutionContext) => { const tenantService = ctx.switchToHttp().getRequest().tenantService; if (tenantService) { return tenantService.getContext(); } const request = ctx.switchToHttp().getRequest(); return { organizationId: request.user?.organizationId, userId: request.user?.sub, roles: request.user?.roles, }; }, ); ================================================ FILE: apps/api/src/shared/tenant/tenant.guard.ts ================================================ // ============================================================================ // Tenant Guard - Ensures tenant context is present // ============================================================================ import { Injectable, CanActivate, ExecutionContext, ForbiddenException, UnauthorizedException, } from '@nestjs/common'; import { TenantService } from './tenant.service'; /** * Tenant Guard - validates that tenant context exists */ @Injectable() export class TenantGuard implements CanActivate { constructor(private readonly tenantService: TenantService) {} canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const user = request.user; if (!user?.organizationId) { throw new UnauthorizedException('Tenant context not available'); } // Set tenant context this.tenantService.setContext({ organizationId: user.organizationId, userId: user.sub, roles: user.roles, }); return true; } } /** * Tenant Guard with optional context (doesn't throw if no tenant) */ @Injectable() export class OptionalTenantGuard implements CanActivate { constructor(private readonly tenantService: TenantService) {} canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); const user = request.user; if (user?.organizationId) { this.tenantService.setContext({ organizationId: user.organizationId, userId: user.sub, roles: user.roles, }); } return true; } } ================================================ FILE: apps/api/src/shared/tenant/tenant.interceptor.ts ================================================ // ============================================================================ // Tenant Interceptor - Automatically extracts tenant from request // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { TenantService } from './tenant.service'; @Injectable() export class TenantInterceptor implements NestInterceptor { constructor(private readonly tenantService: TenantService) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const user = request.user; if (user?.organizationId) { this.tenantService.setContext({ organizationId: user.organizationId, userId: user.sub, roles: user.roles, }); } return next.handle(); } } ================================================ FILE: apps/api/src/shared/tenant/tenant.service.ts ================================================ // ============================================================================ // Tenant Service - Multi-tenancy context management // ============================================================================ import { Injectable, Scope, Inject, Optional } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { Request } from 'express'; /** * Tenant context - contains current tenant information */ export interface TenantContext { organizationId: string; userId?: string; roles?: string[]; metadata?: Record; } /** * Tenant Service - provides access to current tenant context */ @Injectable({ scope: Scope.REQUEST }) export class TenantService { private context: TenantContext | null = null; constructor(@Optional() @Inject(REQUEST) private readonly request: Request) {} /** * Set tenant context */ setContext(context: TenantContext): void { this.context = context; } /** * Get current organization ID */ getOrganizationId(): string { if (!this.context?.organizationId) { throw new Error('Tenant context not available'); } return this.context.organizationId; } /** * Get current user ID */ getUserId(): string | undefined { return this.context?.userId; } /** * Get tenant context */ getContext(): TenantContext | null { return this.context; } /** * Check if tenant context is available */ hasContext(): boolean { return this.context !== null && !!this.context.organizationId; } /** * Get metadata value */ getMetadata(key: string): T | undefined { return this.context?.metadata?.[key] as T; } } /** * Request-scoped storage for tenant context */ export class TenantStorage { private static instance: TenantContext | null = null; static set(context: TenantContext): void { TenantStorage.instance = context; } static get(): TenantContext | null { return TenantStorage.instance; } static clear(): void { TenantStorage.instance = null; } } ================================================ FILE: apps/api/src/shared/testing/index.ts ================================================ // ============================================================================ // Testing Module // ============================================================================ export { createTestingModule, createMock, createMockInstance, FixtureBuilder, fixture, createPaginatedFixture, mockJwtPayload, createMockRequest, TimeMock, createMockResponse, createMockQueryBuilder, createMockRedis, } from './testing.utils'; ================================================ FILE: apps/api/src/shared/testing/testing.utils.ts ================================================ // ============================================================================ // Testing Utils - Mock factories, fixtures, and test helpers // ============================================================================ import { Test, TestingModule } from '@nestjs/testing'; import { Request } from 'express'; /** * Create a testing module with all required providers */ export async function createTestingModule(options: { imports?: any[]; controllers?: any[]; providers?: any[]; mocks?: Map; globalPipes?: any[]; }): Promise { const { imports = [], controllers = [], providers = [], mocks = new Map(), globalPipes = [] } = options; // Create mock providers from mocks map const mockProviders = Array.from(mocks.entries()).map(([token, mock]) => ({ provide: token, useValue: mock, })); return Test.createTestingModule({ imports, controllers, providers: [...providers, ...mockProviders], }) .compile(); } /** * Create a mock for a class or token */ export function createMock(overrides?: Partial): jest.Mocked { // eslint-disable-next-line @typescript-eslint/no-explicit-any const mock = jest.fn() as any; if (overrides) { Object.keys(overrides).forEach(key => { (mock as any)[key] = overrides[key as keyof T]; }); } return mock; } /** * Create a mock instance with spy methods */ export function createMockInstance(classType: new (...args: any[]) => T): jest.Mocked { const instance = Object.create(classType.prototype); // eslint-disable-next-line @typescript-eslint/no-explicit-any const mock = jest.fn() as any; // Copy all methods from prototype const proto = classType.prototype; Object.getOwnPropertyNames(proto).forEach(key => { if (key !== 'constructor' && typeof (proto as any)[key] === 'function') { (mock as any)[key] = jest.fn(); (instance as any)[key] = (mock as any)[key]; } }); return mock; } /** * Builder for creating test fixtures */ export class FixtureBuilder { private data: Partial = {}; constructor(private defaultData: T) { this.data = { ...defaultData }; } with(key: K, value: T[K]): this { this.data[key] = value; return this; } withPartial(partial: Partial): this { this.data = { ...this.data, ...partial }; return this; } build(): T { return { ...this.defaultData, ...this.data } as T; } buildMany(count: number): T[] { return Array.from({ length: count }, () => this.build()); } } /** * Create a fixture builder */ export function fixture(defaultData: T): FixtureBuilder { return new FixtureBuilder(defaultData); } /** * Create pagination test fixtures */ export function createPaginatedFixture(items: T[], total: number, page = 1, pageSize = 20) { return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize), hasNext: page < Math.ceil(total / pageSize), hasPrevious: page > 1, }; } /** * Mock JWT payload for tests */ export const mockJwtPayload = { sub: 'user-123', email: 'test@example.com', organizationId: 'org-123', roles: ['member'], permissions: ['read', 'write'], type: 'access' as const, }; /** * Mock request with user */ export function createMockRequest(overrides?: Partial): Request { return { user: mockJwtPayload, headers: {}, params: {}, query: {}, body: {}, ...overrides, } as unknown as Request; } /** * Time mocking utilities */ export const TimeMock = { /** Freeze time to a specific date */ freeze(date: Date = new Date()): void { jest.useFakeTimers(); jest.setSystemTime(date); }, /** Use real timers */ useReal(): void { jest.useRealTimers(); }, /** Advance time by ms */ advance(ms: number): void { jest.advanceTimersByTime(ms); }, /** Set a date in the future */ setFuture(days = 1): Date { const date = new Date(); date.setDate(date.getDate() + days); jest.setSystemTime(date); return date; }, }; /** * Create a mock response */ export function createMockResponse() { const res: any = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis(), }; return res; } /** * Create a mock query builder for Kysely */ export function createMockQueryBuilder() { const queryBuilder: any = { selectFrom: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), offset: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue([]), executeTakeFirst: jest.fn().mockResolvedValue(null), executeTakeFirstOrThrow: jest.fn().mockResolvedValue(null), insertInto: jest.fn().mockReturnThis(), values: jest.fn().mockReturnThis(), updateTable: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), deleteFrom: jest.fn().mockReturnThis(), }; return queryBuilder; } /** * Create a mock Redis client */ export function createMockRedis() { return { get: jest.fn().mockResolvedValue(null), set: jest.fn().mockResolvedValue('OK'), setex: jest.fn().mockResolvedValue('OK'), del: jest.fn().mockResolvedValue(1), exists: jest.fn().mockResolvedValue(0), keys: jest.fn().mockResolvedValue([]), expire: jest.fn().mockResolvedValue(1), zcard: jest.fn().mockResolvedValue(0), zrange: jest.fn().mockResolvedValue([]), zadd: jest.fn().mockResolvedValue(1), zremrangebyscore: jest.fn().mockResolvedValue(0), incrby: jest.fn().mockResolvedValue(1), decrby: jest.fn().mockResolvedValue(0), hset: jest.fn().mockResolvedValue(1), hget: jest.fn().mockResolvedValue(null), hgetall: jest.fn().mockResolvedValue({}), hdel: jest.fn().mockResolvedValue(1), pipeline: jest.fn().mockReturnValue({ zremrangebyscore: jest.fn().mockReturnThis(), zadd: jest.fn().mockReturnThis(), zcard: jest.fn().mockReturnThis(), expire: jest.fn().mockReturnThis(), exec: jest.fn().mockResolvedValue([]), }), }; } ================================================ FILE: apps/api/src/shared/tracking/index.ts ================================================ export * from './tracking.interceptor'; ================================================ FILE: apps/api/src/shared/tracking/tracking.interceptor.ts ================================================ // ============================================================================ // Tracking Interceptor - Request ID and Correlation ID injection // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { AsyncLocalStorage } from 'async_hooks'; // Async local storage for request context export const trackingStorage = new AsyncLocalStorage(); export interface TrackingContext { requestId: string; correlationId?: string; userId?: string; organizationId?: string; startTime: number; } @Injectable() export class TrackingInterceptor implements NestInterceptor { intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); // Get or generate request ID const requestId = (request.headers['x-request-id'] as string) || (request.headers['x-correlation-id'] as string) || uuidv4(); // Get correlation ID (for distributed tracing) const correlationId = (request.headers['x-correlation-id'] as string) || requestId; // Set headers for downstream services response.setHeader('x-request-id', requestId); response.setHeader('x-correlation-id', correlationId); // Extract user context if available const userId = request.user?.id; const organizationId = request.user?.organizationId; const trackingContext: TrackingContext = { requestId, correlationId, userId, organizationId, startTime: Date.now(), }; // Store in async local storage trackingStorage.run(trackingContext, () => { return next.handle(); }); return next.handle(); } } // Helper to get current tracking context export function getTrackingContext(): TrackingContext | undefined { return trackingStorage.getStore(); } // Helper to get request ID export function getRequestId(): string | undefined { return trackingStorage.getStore()?.requestId; } // Helper to get correlation ID export function getCorrelationId(): string | undefined { return trackingStorage.getStore()?.correlationId; } ================================================ FILE: apps/api/src/shared/transform/index.ts ================================================ // ============================================================================ // Transform Module // ============================================================================ export { TransformInterceptor, KeyTransformInterceptor, transformKeysToCamelCase, transformKeysToSnakeCase, } from './transform.interceptor'; export type { TransformOptions, ResponseMetadata } from './transform.interceptor'; ================================================ FILE: apps/api/src/shared/transform/transform.interceptor.ts ================================================ // ============================================================================ // Transform Interceptor - Global request/response transformation // ============================================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { Request, Response } from 'express'; export interface TransformOptions { /** Enable request body transformation */ transformRequest?: boolean; /** Enable response transformation */ transformResponse?: boolean; /** Custom response wrapper key */ wrapperKey?: string; /** Metadata to include in response */ includeMetadata?: boolean; } /** * Metadata included in transformed responses */ export interface ResponseMetadata { timestamp: string; path: string; method: string; duration?: number; requestId?: string; } /** * Transform Interceptor - Wraps responses and optionally transforms requests */ @Injectable() export class TransformInterceptor implements NestInterceptor { private readonly logger = new Logger(TransformInterceptor.name); private readonly defaultOptions: Required; constructor(options: TransformOptions = {}) { this.defaultOptions = { transformRequest: options.transformRequest ?? true, transformResponse: options.transformResponse ?? true, wrapperKey: options.wrapperKey ?? 'data', includeMetadata: options.includeMetadata ?? true, }; } intercept(context: ExecutionContext, next: CallHandler): Observable { const startTime = process.hrtime.bigint(); const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); if (!this.defaultOptions.transformResponse) { return next.handle(); } return next.handle().pipe( map((data) => { const duration = this.getDuration(startTime); const metadata = this.buildMetadata(request, response, duration); // If data is already wrapped or is a primitive, return as-is or wrap if (this.isPrimitive(data)) { return this.wrapResponse(data, metadata); } // If data has its own structure (e.g., PaginatedResponse), merge metadata if (this.isWrappedResponse(data)) { return { ...data, _meta: metadata, }; } // Default: wrap in data object return this.wrapResponse(data, metadata); }), ); } /** * Build response metadata */ private buildMetadata(request: Request, response: Response, duration: bigint): ResponseMetadata { return { timestamp: new Date().toISOString(), path: request.path, method: request.method, duration: Number(duration) / 1e6, // Convert to ms requestId: (request as any).id || (request.headers['x-request-id'] as string), }; } /** * Wrap response data */ private wrapResponse(data: any, metadata: ResponseMetadata): any { const response: any = { [this.defaultOptions.wrapperKey]: data, }; if (this.defaultOptions.includeMetadata) { response._meta = metadata; } return response; } /** * Check if value is primitive */ private isPrimitive(value: any): boolean { return value === null || value === undefined || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; } /** * Check if response is already wrapped */ private isWrappedResponse(value: any): boolean { if (!value || typeof value !== 'object') return false; return value.items !== undefined && value.total !== undefined || value.data !== undefined || value._meta !== undefined; } /** * Get duration in nanoseconds */ private getDuration(startTime: bigint): bigint { return process.hrtime.bigint() - startTime; } } /** * Snake case to camel case converter for keys */ export function transformKeysToCamelCase(obj: any): T { if (obj === null || obj === undefined) return obj; if (Array.isArray(obj)) { return obj.map(item => transformKeysToCamelCase(item)) as T; } if (typeof obj === 'object') { return Object.keys(obj).reduce((acc, key) => { const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); acc[camelKey] = transformKeysToCamelCase(obj[key]); return acc; }, {} as any) as T; } return obj; } /** * Camel case to snake case converter for keys */ export function transformKeysToSnakeCase(obj: any): T { if (obj === null || obj === undefined) return obj; if (Array.isArray(obj)) { return obj.map(item => transformKeysToSnakeCase(item)) as T; } if (typeof obj === 'object') { return Object.keys(obj).reduce((acc, key) => { const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); acc[snakeKey] = transformKeysToSnakeCase(obj[key]); return acc; }, {} as any) as T; } return obj; } /** * Request key transformer interceptor */ @Injectable() export class KeyTransformInterceptor implements NestInterceptor { constructor( private readonly toCamelCase: boolean = true, ) {} intercept(context: ExecutionContext, next: CallHandler): Observable { const request = context.switchToHttp().getRequest(); // Transform query params if (request.query) { request.query = this.toCamelCase ? transformKeysToCamelCase(request.query) : transformKeysToSnakeCase(request.query); } // Transform body if (request.body && typeof request.body === 'object') { request.body = this.toCamelCase ? transformKeysToCamelCase(request.body) : transformKeysToSnakeCase(request.body); } return next.handle(); } } ================================================ FILE: apps/api/src/shared/utils/guard.ts ================================================ export class Guard { public static againstNullOrUndefined(argument: any, argumentName: string): Result { if (argument === null || argument === undefined) { return { succeeded: false, message: `${argumentName} is null or undefined` }; } return { succeeded: true }; } public static againstNullOrUndefinedBulk(args: GuardArgument[]): Result { for (const arg of args) { const result = this.againstNullOrUndefined(arg.argument, arg.argumentName); if (!result.succeeded) return result; } return { succeeded: true }; } public static isOneOf(value: any, validValues: any[], argumentName: string): Result { let isValid = false; for (const validValue of validValues) { if (value === validValue) { isValid = true; } } if (isValid) { return { succeeded: true }; } else { return { succeeded: false, message: `${argumentName} isn't oneOf the correct types in ${JSON.stringify( validValues, )}. Got "${value}".`, }; } } public static inRange(num: number, min: number, max: number, argumentName: string): Result { const isInRange = num >= min && num <= max; if (!isInRange) { return { succeeded: false, message: `${argumentName} is not within range ${min} to ${max}.`, }; } return { succeeded: true }; } public static allInRange(numbers: number[], min: number, max: number, argumentName: string): Result { let failingResult: Result | null = null; for (const num of numbers) { const numIsInRangeResult = this.inRange(num, min, max, argumentName); if (!numIsInRangeResult.succeeded) failingResult = numIsInRangeResult; } if (failingResult) { return { succeeded: false, message: `${argumentName} is not within the range.` }; } return { succeeded: true }; } } export interface GuardArgument { argument: any; argumentName: string; } export interface Result { succeeded: boolean; message?: string; } ================================================ FILE: apps/api/src/shared/utils/result.ts ================================================ // ============================================================================ // Result Type - Functional error handling without exceptions // ============================================================================ /** * A result type that represents either a success value or a failure with error. * Inspired by Rust's Result type and fp-ts Either. */ export class Result { public readonly isSuccess: boolean; public readonly isFailure: boolean; public readonly error: string | null; private readonly _value: T | null; private constructor(isSuccess: boolean, error: string | null, value: T | null) { this.isSuccess = isSuccess; this.isFailure = !isSuccess; this.error = error; this._value = value; Object.freeze(this); } /** * Get the value or throw if error */ getValue(): T { if (this.isFailure) { throw new Error(`Result is in failure state: ${this.error}`); } return this._value as T; } /** * Get the value or a default if error */ getValueOrElse(defaultValue: T): T { return this.isSuccess ? (this._value as T) : defaultValue; } /** * Get the value or undefined */ getValueOrUndefined(): T | undefined { return this.isSuccess ? (this._value as T) : undefined; } /** * Map success value to a new Result */ map(fn: (value: T) => U): Result { if (this.isSuccess) { return Result.ok(fn(this._value as T)); } return Result.fail(this.error!); } /** * Map success value to a new Result (async) */ async mapAsync(fn: (value: T) => Promise): Promise> { if (this.isSuccess) { return Result.ok(await fn(this._value as T)); } return Result.fail(this.error!); } /** * FlatMap - chain operations that return Results */ flatMap(fn: (value: T) => Result): Result { if (this.isSuccess) { return fn(this._value as T); } return Result.fail(this.error!); } /** * FlatMap - chain async operations that return Results */ async flatMapAsync(fn: (value: T) => Promise>): Promise> { if (this.isSuccess) { return await fn(this._value as T); } return Result.fail(this.error!); } /** * Fold - handle both success and failure cases */ fold(onSuccess: (value: T) => U, onFailure: (error: string) => U): U { if (this.isSuccess) { return onSuccess(this._value as T); } return onFailure(this.error!); } /** * Fold async - handle both success and failure cases (async) */ async foldAsync( onSuccess: (value: T) => Promise, onFailure: (error: string) => Promise, ): Promise { if (this.isSuccess) { return await onSuccess(this._value as T); } return await onFailure(this.error!); } /** * Tap - execute side effects without changing the result */ tap(fn: (value: T) => void): Result { if (this.isSuccess) { fn(this._value as T); } return this; } /** * Tap async - execute async side effects without changing the result */ async tapAsync(fn: (value: T) => Promise): Promise> { if (this.isSuccess) { await fn(this._value as T); } return this; } /** * Check if result contains a specific value */ contains(value: T): boolean { return this.isSuccess && this._value === value; } /** * Check if result's error matches a predicate */ existsError(predicate: (error: string) => boolean): boolean { return this.isFailure && predicate(this.error!); } // ========================================================================= // Static Constructors // ========================================================================= static ok(value?: U): Result { return new Result(true, null, value ?? null); } static fail(error: string): Result { return new Result(false, error, null); } /** * Create Result from a try/catch */ static fromTry(fn: () => U): Result { try { return Result.ok(fn()); } catch (error) { return Result.fail(error instanceof Error ? error.message : String(error)); } } /** * Create Result from an async try/catch */ static async fromTryAsync(fn: () => Promise): Promise> { try { return Result.ok(await fn()); } catch (error) { return Result.fail(error instanceof Error ? error.message : String(error)); } } /** * Combine multiple Results - fail fast on first failure */ static combine[]>(...results: T): Result<{ [K in keyof T]: UnwrapResult }> { const failures: string[] = []; for (const result of results) { if (result.isFailure) { failures.push(result.error!); } } if (failures.length > 0) { return Result.fail(failures.join('; ')) as any; } return Result.ok(results.map(r => r.getValue())) as any; } /** * Combine multiple Results - collect all failures */ static combineAll(...results: Array>): Result { const failures: string[] = []; const values: T[] = []; for (const result of results) { if (result.isFailure) { failures.push(result.error!); } else { values.push(result.getValue()); } } if (failures.length > 0) { return Result.fail(`Multiple failures (${failures.length}): ${failures.join('; ')}`); } return Result.ok(values); } } /** * Type helper to unwrap Result */ export type UnwrapResult = T extends Result ? U : T; /** * Shorthand for Result */ export type VoidResult = Result; /** * Create a successful void result */ export const voidOk = (): VoidResult => Result.ok(null); ================================================ FILE: apps/api/src/shared/validation/index.ts ================================================ // ============================================================================ // Validation - Common validation decorators and utilities // ============================================================================ export * from './validation.pipe'; export * from './validation-options'; ================================================ FILE: apps/api/src/shared/validation/validation-options.ts ================================================ // ============================================================================ // Validation Options & Decorators // ============================================================================ import { IsString, IsEmail, IsUUID, IsOptional, IsEnum, IsInt, IsPositive, MinLength, MaxLength, IsNotEmpty, IsIn, Matches, IsUrl, IsPhoneNumber, IsDateString, IsBoolean, ValidationOptions, ValidationArguments, registerDecorator, } from 'class-validator'; /** * Common validation messages */ export const ValidationMessage = { REQUIRED: 'This field is required', INVALID_EMAIL: 'Invalid email address', INVALID_UUID: 'Invalid UUID format', INVALID_URL: 'Invalid URL format', MIN_LENGTH: (min: number) => `Minimum length is ${min} characters`, MAX_LENGTH: (max: number) => `Maximum length is ${max} characters`, MIN_VALUE: (min: number) => `Minimum value is ${min}`, MAX_VALUE: (max: number) => `Maximum value is ${max}`, INVALID_ENUM: (enumValues: string[]) => `Must be one of: ${enumValues.join(', ')}`, INVALID_PHONE: 'Invalid phone number format', INVALID_DATE: 'Invalid date format (ISO 8601 expected)', }; // ============================================================================ // String Validators // ============================================================================ /** * Password field with strength requirements * Minimum 8 characters, at least one uppercase, one lowercase, one number */ export function IsPassword(options?: { minLength?: number; ValidationOptions?: ValidationOptions }) { const minLen = options?.minLength ?? 8; return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: options?.ValidationOptions, validator: { validate(value: string) { if (!value || typeof value !== 'string') return false; if (value.length < minLen) return false; if (!/[A-Z]/.test(value)) return false; if (!/[a-z]/.test(value)) return false; if (!/[0-9]/.test(value)) return false; return true; }, defaultMessage() { return `Password must be at least ${minLen} characters with uppercase, lowercase and number`; }, }, }); }; } /** * Strong password field - requires special character */ export function IsStrongPassword(options?: { minLength?: number; ValidationOptions?: ValidationOptions }) { const minLen = options?.minLength ?? 8; return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: options?.ValidationOptions, validator: { validate(value: string) { if (!value || typeof value !== 'string') return false; if (value.length < minLen) return false; if (!/[A-Z]/.test(value)) return false; if (!/[a-z]/.test(value)) return false; if (!/[0-9]/.test(value)) return false; if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(value)) return false; return true; }, defaultMessage() { return `Password must be at least ${minLen} characters with uppercase, lowercase, number and special character`; }, }, }); }; } /** * Username field - alphanumeric with underscores */ export function IsUsername(options?: { minLength?: number; maxLength?: number; ValidationOptions?: ValidationOptions }) { const minLen = options?.minLength ?? 3; const maxLen = options?.maxLength ?? 30; return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: options?.ValidationOptions, validator: { validate(value: string) { if (!value || typeof value !== 'string') return false; if (value.length < minLen || value.length > maxLen) return false; return /^[a-zA-Z0-9_]+$/.test(value); }, defaultMessage() { return `Username must be ${minLen}-${maxLen} alphanumeric characters or underscores`; }, }, }); }; } /** * Slug field - lowercase alphanumeric with hyphens */ export function IsSlug(options?: { maxLength?: number; ValidationOptions?: ValidationOptions }) { const maxLen = options?.maxLength ?? 64; return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: options?.ValidationOptions, validator: { validate(value: string) { if (!value || typeof value !== 'string') return false; if (value.length > maxLen) return false; return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value); }, defaultMessage() { return 'Slug must be lowercase alphanumeric with hyphens (e.g., my-slug)'; }, }, }); }; } /** * JSON string field */ export function IsJsonString(ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: string) { if (!value || typeof value !== 'string') return false; try { JSON.parse(value); return true; } catch { return false; } }, defaultMessage() { return 'Invalid JSON string'; }, }, }); }; } // ============================================================================ // ID Validators // ============================================================================ /** * MongoDB ObjectId field */ export function IsObjectId(ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: string) { if (!value || typeof value !== 'string') return false; return /^[a-fA-F0-9]{24}$/.test(value); }, defaultMessage() { return 'Invalid MongoDB ObjectId format'; }, }, }); }; } /** * Custom ID field with prefix (e.g., user_xxx, org_xxx) */ export function IsPrefixedId(prefix: string, ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: string) { if (!value || typeof value !== 'string') return false; const pattern = new RegExp(`^${prefix}_[a-zA-Z0-9]+$`); return pattern.test(value); }, defaultMessage() { return `ID must start with '${prefix}_' followed by alphanumeric characters`; }, }, }); }; } // ============================================================================ // Array Validators // ============================================================================ /** * Non-empty array */ export function IsNonEmptyArray(ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: unknown[]) { return Array.isArray(value) && value.length > 0; }, defaultMessage() { return 'Array must not be empty'; }, }, }); }; } /** * Array with unique items */ export function IsUniqueArray(ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: unknown[]) { if (!Array.isArray(value)) return false; return new Set(value).size === value.length; }, defaultMessage() { return 'Array must contain only unique items'; }, }, }); }; } // ============================================================================ // Date Validators // ============================================================================ /** * ISO 8601 date string */ export function IsIso8601Date(ValidationOptions?: ValidationOptions) { return IsDateString(undefined, ValidationOptions); } /** * Future date */ export function IsFutureDate(ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: string) { if (!value) return false; const date = new Date(value); return date > new Date(); }, defaultMessage() { return 'Date must be in the future'; }, }, }); }; } /** * Past date */ export function IsPastDate(ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: string) { if (!value) return false; const date = new Date(value); return date < new Date(); }, defaultMessage() { return 'Date must be in the past'; }, }, }); }; } // ============================================================================ // Range Validators // ============================================================================ /** * Number in range (inclusive) */ export function IsInRange(min: number, max: number, ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: number) { return typeof value === 'number' && value >= min && value <= max; }, defaultMessage() { return `Value must be between ${min} and ${max}`; }, }, }); }; } /** * String length in range */ export function IsLengthInRange(min: number, max: number, ValidationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: string) { if (typeof value !== 'string') return false; return value.length >= min && value.length <= max; }, defaultMessage() { return `Length must be between ${min} and ${max} characters`; }, }, }); }; } // ============================================================================ // Conditional Validators // ============================================================================ /** * Match another field exactly */ export function MatchesField( field: string, message?: string, ValidationOptions?: ValidationOptions, ) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: unknown, args: ValidationArguments) { const objectToCompare = args.object as Record; return objectToCompare[field] === value; }, defaultMessage() { return message ?? `Must match '${field}'`; }, }, }); }; } // ============================================================================ // Type Validators // ============================================================================ /** * Instance of specific class */ export function IsInstanceOf unknown>( classType: T, ValidationOptions?: ValidationOptions, ) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: unknown) { return value instanceof classType; }, defaultMessage() { return `Must be an instance of ${classType.name}`; }, }, }); }; } /** * Array of specific type/items */ export function IsArrayOf( itemValidator: (value: unknown) => boolean, ValidationOptions?: ValidationOptions, ) { return function (object: object, propertyName: string) { registerDecorator({ target: object.constructor, propertyName, options: ValidationOptions, validator: { validate(value: unknown[]) { if (!Array.isArray(value)) return false; return value.every(itemValidator); }, defaultMessage() { return 'All items must be valid'; }, }, }); }; } ================================================ FILE: apps/api/src/shared/validation/validation.pipe.ts ================================================ // ============================================================================ // Validation Pipe - Global validation configuration // ============================================================================ import { ValidationPipe, ValidationPipeOptions, BadRequestException, } from '@nestjs/common'; import { ValidatorOptions, ValidationError } from 'class-validator'; /** * Default validator options for class-validator */ export const DEFAULT_VALIDATOR_OPTIONS: ValidatorOptions = { whitelist: true, forbidNonWhitelisted: true, forbidUnknownValues: true, }; /** * Default transform options */ export const DEFAULT_TRANSFORM_OPTIONS = { enableImplicitConversion: true, }; /** * Transform NestJS ValidationError to readable format */ export function formatValidationErrors( errors: ValidationError[], parentProperty = '', ): Array<{ field: string; constraints: string[] }> { const formatted: Array<{ field: string; constraints: string[] }> = []; for (const error of errors) { const field = parentProperty ? `${parentProperty}.${error.property}` : error.property; if (error.constraints) { formatted.push({ field, constraints: Object.values(error.constraints), }); } if (error.children && error.children.length > 0) { formatted.push( ...formatValidationErrors(error.children, field), ); } } return formatted; } /** * Create a ValidationPipe with standardized configuration */ export function createValidationPipe( options: ValidationPipeOptions = {}, ): ValidationPipe { return new ValidationPipe({ ...options, transform: true, transformOptions: DEFAULT_TRANSFORM_OPTIONS, exceptionFactory: (errors: ValidationError[]) => { const formatted = formatValidationErrors(errors); return new BadRequestException({ code: 'VALIDATION_ERROR', message: 'Validation failed', errors: formatted, }); }, }); } /** * Default global validation pipe instance */ export const globalValidationPipe = createValidationPipe({ whitelist: true, forbidNonWhitelisted: true, }); /** * Strict validation pipe (for DTOs that must be exact) */ export const strictValidationPipe = createValidationPipe({ whitelist: true, forbidNonWhitelisted: true, skipMissingProperties: false, }); /** * Partial validation pipe (for optional/update DTOs) */ export const partialValidationPipe = createValidationPipe({ whitelist: true, forbidNonWhitelisted: false, skipMissingProperties: true, }); ================================================ FILE: apps/api/test/jest-e2e.json ================================================ { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "moduleNameMapper": { "^@/(.*)$": "/../src/$1" } } ================================================ FILE: apps/api/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } ================================================ FILE: apps/api/tsconfig.json ================================================ { "compilerOptions": { "module": "commonjs", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2024", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, "noImplicitAny": true, "strictBindCallApply": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["src/*"] } } } ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": true, "defaultBranch": "main", "clientKind": "git", "useIgnoreFile": true }, "files": { "maxSize": 10240000, "ignoreUnknown": false, "experimentalScannerIgnores": [ "**/dist/**", ".vscode/**/*", "node_modules/**", "dist/**", "build/**", "coverage/**", "tsconfig.*" ] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 4 }, "javascript": { "parser": { "unsafeParameterDecoratorsEnabled": true }, "formatter": { "quoteStyle": "single", "arrowParentheses": "asNeeded", "jsxQuoteStyle": "double", "lineWidth": 120 } }, "linter": { "enabled": true, "rules": { "recommended": true, "security": { "noDangerouslySetInnerHtml": "off" }, "suspicious": { "noExplicitAny": "off", "noArrayIndexKey": "off", "noAsyncPromiseExecutor": "off", "noAssignInExpressions": "off", "noConfusingVoidType": "off", "noControlCharactersInRegex": "off" }, "style": { "useConst": "off", "noUselessElse": "off", "useImportType": "off", "noParameterAssign": "off", "noInferrableTypes": "off", "noNonNullAssertion": "off", "useExportType": "off", "useNodejsImportProtocol": "off" }, "performance": { "noDelete": "off", "noAccumulatingSpread": "off" }, "complexity": { "noUselessTypeConstraint": "off", "noStaticOnlyClass": "off", "noUselessFragments": "off", "useOptionalChain": "off", "noExtraBooleanCast": "off", "useLiteralKeys": "off", "noThisInStatic": "off", "noBannedTypes": "off", "noForEach": "off" }, "correctness": { "noEmptyCharacterClassInRegex": "off", "useExhaustiveDependencies": "off", "useJsxKeyInIterable": "off", "noConstructorReturn": "off" }, "a11y": { "useKeyWithClickEvents": "off", "noSvgWithoutTitle": "off", "useAltText": "off", "useButtonType": "off", "useValidAnchor": "off" } } } } ================================================ FILE: docker/Dockerfile ================================================ # ============================================================================= # Stage 1: Dependencies # ============================================================================= FROM node:20-alpine AS deps # Install pnpm RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app # Copy package files for dependency installation COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/api/package.json ./apps/api/ COPY packages/kysely/package.json ./packages/kysely/ COPY packages/redisson/package.json ./packages/redisson/ # Install dependencies (production only for smaller image) RUN pnpm install --frozen-lockfile --prod # ============================================================================= # Stage 2: Builder # ============================================================================= FROM node:20-alpine AS builder # Install pnpm RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app # Copy package files COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/api/package.json ./apps/api/ COPY packages/kysely/package.json ./packages/kysely/ COPY packages/redisson/package.json ./packages/redisson/ # Install all dependencies (including devDependencies for build) RUN pnpm install --frozen-lockfile # Copy source code COPY tsconfig.json tsconfig.build.json ./ COPY apps/api/ ./apps/api/ COPY packages/kysely/ ./packages/kysely/ COPY packages/redisson/ ./packages/redisson/ # Build packages first, then the app RUN pnpm --filter @a3s-lab/kysely build && \ pnpm --filter @a3s-lab/redisson build && \ pnpm --filter api build # ============================================================================= # Stage 3: Runner (Production) # ============================================================================= FROM node:20-alpine AS runner # Install pnpm for runtime RUN corepack enable && corepack prepare pnpm@latest --activate # Add non-root user for security RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nestjs WORKDIR /app # Copy production dependencies from deps stage COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules COPY --from=deps /app/packages/kysely/node_modules ./packages/kysely/node_modules COPY --from=deps /app/packages/redisson/node_modules ./packages/redisson/node_modules # Copy built artifacts from builder stage COPY --from=builder /app/apps/api/dist ./apps/api/dist COPY --from=builder /app/packages/kysely/dist ./packages/kysely/dist COPY --from=builder /app/packages/redisson/dist ./packages/redisson/dist # Copy package.json files for runtime COPY --from=builder /app/package.json ./ COPY --from=builder /app/apps/api/package.json ./apps/api/ COPY --from=builder /app/packages/kysely/package.json ./packages/kysely/ COPY --from=builder /app/packages/redisson/package.json ./packages/redisson/ # Set ownership RUN chown -R nestjs:nodejs /app # Switch to non-root user USER nestjs # Environment variables ENV NODE_ENV=production ENV APP_PORT=3000 EXPOSE 3000 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 # Start the application CMD ["node", "apps/api/dist/main.js"] ================================================ FILE: docker/Dockerfile.dev ================================================ # ============================================================================= # Development Dockerfile # ============================================================================= FROM node:20-alpine # Install pnpm RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app # Copy package files for dependency installation COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/api/package.json ./apps/api/ COPY packages/kysely/package.json ./packages/kysely/ COPY packages/redisson/package.json ./packages/redisson/ # Install all dependencies RUN pnpm install --frozen-lockfile # Copy source code COPY . . # Build packages (required for workspace dependencies) RUN pnpm --filter @a3s-lab/kysely build && \ pnpm --filter @a3s-lab/redisson build # Environment variables ENV NODE_ENV=development ENV APP_PORT=3000 EXPOSE 3000 # Start in development mode with hot reload CMD ["pnpm", "--filter", "api", "start:dev"] ================================================ FILE: docker/docker-compose.prod.yml ================================================ services: # ============================================================================= # PostgreSQL Database (Production) # ============================================================================= postgres: image: postgres:16-alpine container_name: nestify-postgres environment: POSTGRES_USER: ${DB_USERNAME} POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: ${DB_DATABASE} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}"] interval: 10s timeout: 5s retries: 5 start_period: 10s networks: - nestify-network restart: always deploy: resources: limits: memory: 512M # ============================================================================= # Redis Cache (Production) # ============================================================================= redis: image: redis:7-alpine container_name: nestify-redis command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} --maxmemory 256mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s timeout: 5s retries: 5 start_period: 5s networks: - nestify-network restart: always deploy: resources: limits: memory: 256M # ============================================================================= # NestJS Application (Production) # ============================================================================= app: build: context: .. dockerfile: docker/Dockerfile container_name: nestify-app ports: - "${APP_PORT:-3000}:3000" environment: NODE_ENV: production APP_PORT: 3000 # Database DB_HOST: postgres DB_PORT: 5432 DB_USERNAME: ${DB_USERNAME} DB_PASSWORD: ${DB_PASSWORD} DB_DATABASE: ${DB_DATABASE} # Redis REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD} REDIS_DB: ${REDIS_DB:-0} depends_on: postgres: condition: service_healthy redis: condition: service_healthy networks: - nestify-network restart: always deploy: resources: limits: memory: 512M replicas: 1 # ============================================================================= # Networks # ============================================================================= networks: nestify-network: driver: bridge # ============================================================================= # Volumes # ============================================================================= volumes: postgres_data: driver: local redis_data: driver: local ================================================ FILE: docker/docker-compose.yml ================================================ services: # ============================================================================= # PostgreSQL Database # ============================================================================= postgres: image: postgres:16-alpine container_name: nestify-postgres environment: POSTGRES_USER: ${DB_USERNAME:-postgres} POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} POSTGRES_DB: ${DB_DATABASE:-nestify} ports: - "${DB_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data - ../apps/api/migrations:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE:-nestify}"] interval: 10s timeout: 5s retries: 5 start_period: 10s networks: - nestify-network # ============================================================================= # Redis Cache # ============================================================================= redis: image: redis:7-alpine container_name: nestify-redis command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redis123} ports: - "${REDIS_PORT:-6379}:6379" volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redis123}", "ping"] interval: 10s timeout: 5s retries: 5 start_period: 5s networks: - nestify-network # ============================================================================= # NestJS Application (Development) # ============================================================================= app: build: context: .. dockerfile: docker/Dockerfile.dev container_name: nestify-app ports: - "${APP_PORT:-3000}:3000" environment: NODE_ENV: development APP_PORT: 3000 # Database DB_HOST: postgres DB_PORT: 5432 DB_USERNAME: ${DB_USERNAME:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_DATABASE: ${DB_DATABASE:-nestify} # Redis REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD:-redis123} REDIS_DB: ${REDIS_DB:-0} depends_on: postgres: condition: service_healthy redis: condition: service_healthy volumes: # Mount source code for hot reload - ../apps/api/src:/app/apps/api/src:ro - ../packages/kysely/src:/app/packages/kysely/src:ro - ../packages/redisson/src:/app/packages/redisson/src:ro networks: - nestify-network restart: unless-stopped # ============================================================================= # Networks # ============================================================================= networks: nestify-network: driver: bridge # ============================================================================= # Volumes # ============================================================================= volumes: postgres_data: driver: local redis_data: driver: local ================================================ FILE: docs/architecture.md ================================================ # Architecture Guide ## Overview This template implements Clean Architecture and Domain-Driven Design (DDD) principles to create a maintainable, testable, and scalable application structure. ## The Dependency Rule The fundamental rule of Clean Architecture: > Source code dependencies must point only inward, toward higher-level policies. ``` ┌─────────────────────────────────────────┐ │ Presentation Layer │ │ (Controllers, DTOs) │ └──────────────┬──────────────────────────┘ │ depends on ┌──────────────▼──────────────────────────┐ │ Application Layer │ │ (Commands, Queries, Handlers) │ └──────────────┬──────────────────────────┘ │ depends on ┌──────────────▼──────────────────────────┐ │ Domain Layer │ │ (Entities, Value Objects, Events) │ └─────────────────────────────────────────┘ ▲ │ implements ┌──────────────┴──────────────────────────┐ │ Infrastructure Layer │ │ (Repositories, Database, External) │ └─────────────────────────────────────────┘ ``` ## Layer Responsibilities ### 1. Domain Layer (Core) **Purpose**: Contains business logic and rules. This is the heart of the application. **Components**: - **Entities**: Objects with identity that persist over time - Example: `Order`, `OrderItem` - Have unique identifiers - Contain business logic - Can change state through methods - **Value Objects**: Immutable objects defined by their attributes - Example: `Money`, `Quantity`, `OrderStatus` - No identity - Immutable - Compared by value, not reference - **Aggregates**: Clusters of entities and value objects - Example: `Order` (aggregate root) contains `OrderItem` entities - Enforce consistency boundaries - Only aggregate roots can be accessed from outside - **Domain Events**: Represent business occurrences - Example: `OrderCreatedEvent`, `OrderConfirmedEvent` - Immutable - Past tense naming - Contain relevant data - **Domain Services**: Business logic that doesn't belong to a single entity - Example: `OrderPricingService` - Stateless - Operate on multiple entities - **Repository Interfaces**: Contracts for data access - Example: `IOrderRepository` - Defined in domain, implemented in infrastructure - Abstract persistence details **Rules**: - No dependencies on outer layers - No framework dependencies - Pure business logic - Framework-agnostic ### 2. Application Layer (Use Cases) **Purpose**: Orchestrates domain objects to fulfill use cases. **Components**: - **Commands**: Represent write operations - Example: `CreateOrderCommand`, `ConfirmOrderCommand` - Contain data needed for the operation - Handled by command handlers - **Command Handlers**: Execute commands - Example: `CreateOrderHandler` - Orchestrate domain objects - Persist changes - Publish events - **Queries**: Represent read operations - Example: `GetOrderQuery`, `ListOrdersQuery` - Return DTOs, not domain entities - Optimized for reading - **Query Handlers**: Execute queries - Example: `GetOrderHandler` - Fetch data - Transform to DTOs - **DTOs**: Data transfer objects - Example: `CreateOrderDto`, `OrderResponseDto` - Define API contracts - Validation rules - No business logic - **Event Handlers**: React to domain events - Example: `OrderCreatedHandler` - Side effects - Asynchronous processing **Rules**: - Depends only on domain layer - No direct database access - Uses repository interfaces - Coordinates domain objects ### 3. Infrastructure Layer (Technical Details) **Purpose**: Implements technical concerns and external dependencies. **Components**: - **Repository Implementations**: Concrete data access - Example: `OrderRepository` - Implements domain repository interfaces - Uses TypeORM - Maps between domain and persistence models - **Database Schemas**: ORM entities - Example: `OrderSchema`, `OrderItemSchema` - TypeORM entities - Database-specific - **Mappers**: Convert between layers - Example: `OrderMapper` - Domain ↔ Persistence - Isolate domain from infrastructure - **Event Bus**: Publish domain events - Example: `EventBusService` - Uses @nestjs/cqrs - Decouples event producers and consumers - **External Services**: Third-party integrations - Example: Payment gateways, email services - Implement domain interfaces - Isolate external dependencies **Rules**: - Implements domain interfaces - Contains framework-specific code - Handles technical concerns - No business logic ### 4. Presentation Layer (API) **Purpose**: Exposes application functionality through APIs. **Components**: - **Controllers**: HTTP endpoints - Example: `OrderController` - Route requests - Validate input - Return responses - **Filters**: Exception handling - Example: `HttpExceptionFilter`, `DomainExceptionFilter` - Transform exceptions to HTTP responses - Logging - **Interceptors**: Cross-cutting concerns - Example: `LoggingInterceptor` - Logging - Transformation - Caching - **Validation**: Input validation - Uses class-validator - DTOs with decorators - Automatic validation **Rules**: - Depends on application layer - No direct domain access - Uses DTOs for data transfer - Framework-specific ## Data Flow ### Command Flow (Write Operation) ``` 1. Controller receives HTTP request ↓ 2. Validates DTO ↓ 3. Creates Command ↓ 4. CommandBus executes CommandHandler ↓ 5. Handler loads domain entities (via repository) ↓ 6. Handler calls domain methods ↓ 7. Domain entity changes state, raises events ↓ 8. Handler persists entity (via repository) ↓ 9. Handler publishes domain events ↓ 10. EventHandlers react to events ↓ 11. Controller returns response ``` ### Query Flow (Read Operation) ``` 1. Controller receives HTTP request ↓ 2. Creates Query ↓ 3. QueryBus executes QueryHandler ↓ 4. Handler fetches data (via repository) ↓ 5. Handler transforms to DTO ↓ 6. Controller returns DTO ``` ## CQRS Pattern ### Why CQRS? - **Separation of Concerns**: Different models for reads and writes - **Scalability**: Scale reads and writes independently - **Optimization**: Optimize queries without affecting commands - **Clarity**: Clear distinction between state changes and queries ### Implementation **Commands** (Write): - Change state - Validate business rules - Raise domain events - Return minimal data (usually just ID) **Queries** (Read): - Don't change state - Optimized for reading - Return DTOs - Can bypass domain layer for performance ## Event-Driven Architecture ### Domain Events Domain events represent something that happened in the domain: ```typescript export class OrderCreatedEvent extends DomainEvent { constructor( public readonly orderId: string, public readonly customerId: string, public readonly totalAmount: Money, ) { super(); } } ``` ### Event Flow ``` 1. Domain entity raises event ↓ 2. Event stored in aggregate ↓ 3. Handler persists aggregate ↓ 4. Handler publishes events ↓ 5. EventHandlers react ↓ 6. Side effects executed ``` ### Benefits - **Decoupling**: Producers don't know consumers - **Extensibility**: Add new handlers without changing existing code - **Audit Trail**: Events provide history - **Integration**: Easy to integrate with external systems ## Dependency Injection ### Inversion of Control The domain layer defines interfaces, infrastructure implements them: ```typescript // Domain layer (interface) export interface IOrderRepository { findById(id: string): Promise; save(order: Order): Promise; } // Infrastructure layer (implementation) @Injectable() export class OrderRepository implements IOrderRepository { // Implementation using TypeORM } // Application layer (usage) @CommandHandler(CreateOrderCommand) export class CreateOrderHandler { constructor( @Inject(ORDER_REPOSITORY) private readonly orderRepository: IOrderRepository, ) {} } ``` ### Benefits - Domain doesn't depend on infrastructure - Easy to test (mock interfaces) - Easy to swap implementations ## Testing Strategy ### Unit Tests Test domain logic in isolation: ```typescript describe('Order', () => { it('should calculate total amount', () => { const order = Order.create('customer-1', items); expect(order.getTotalAmount().amount).toBe(100); }); it('should not allow confirming cancelled order', () => { order.cancel(); expect(() => order.confirm()).toThrow(InvalidOrderStateException); }); }); ``` ### Integration Tests Test infrastructure components: ```typescript describe('OrderRepository', () => { it('should save and retrieve order', async () => { const order = Order.create('customer-1', items); await repository.save(order); const retrieved = await repository.findById(order.id); expect(retrieved).toBeDefined(); }); }); ``` ### E2E Tests Test complete flows: ```typescript describe('Order API', () => { it('should create order', async () => { const response = await request(app.getHttpServer()) .post('/api/orders') .send(createOrderDto) .expect(201); expect(response.body.orderId).toBeDefined(); }); }); ``` ## Best Practices ### 1. Keep Domain Pure ```typescript // ✅ Good: Pure domain logic export class Order extends AggregateRoot { public confirm(): void { if (!this._status.isPending()) { throw new InvalidOrderStateException('Cannot confirm non-pending order'); } this._status = OrderStatus.confirmed(); } } // ❌ Bad: Infrastructure concerns in domain export class Order extends AggregateRoot { public async confirm(): Promise { await this.repository.save(this); // NO! } } ``` ### 2. Use Value Objects ```typescript // ✅ Good: Value object with validation export class Money extends ValueObject { private constructor(props: MoneyProps) { super(props); } public static create(amount: number): Money { if (amount < 0) { throw new Error('Money cannot be negative'); } return new Money({ amount }); } } // ❌ Bad: Primitive obsession export class Order { private amount: number; // No validation, no behavior } ``` ### 3. Raise Domain Events ```typescript // ✅ Good: Raise events for important occurrences export class Order extends AggregateRoot { public confirm(): void { this._status = OrderStatus.confirmed(); this.addDomainEvent(new OrderConfirmedEvent(this.id)); } } // ❌ Bad: Side effects in domain export class Order extends AggregateRoot { public confirm(): void { this._status = OrderStatus.confirmed(); this.sendEmail(); // NO! } } ``` ### 4. Validate at Boundaries ```typescript // ✅ Good: Validate in DTOs and value objects export class CreateOrderDto { @IsString() @IsNotEmpty() customerId: string; @IsArray() @ValidateNested({ each: true }) items: CreateOrderItemDto[]; } // ❌ Bad: No validation export class CreateOrderDto { customerId: string; items: any[]; } ``` ## Common Pitfalls ### 1. Anemic Domain Model **Problem**: Entities with only getters/setters, logic in services. **Solution**: Put behavior in entities. ### 2. Leaking Infrastructure **Problem**: Domain depends on infrastructure (e.g., TypeORM entities). **Solution**: Use repository interfaces, mappers. ### 3. Fat Controllers **Problem**: Business logic in controllers. **Solution**: Move logic to domain/application layers. ### 4. Ignoring Events **Problem**: Direct coupling between components. **Solution**: Use domain events for side effects. ## Conclusion This architecture provides: - **Maintainability**: Clear separation of concerns - **Testability**: Easy to test each layer - **Flexibility**: Easy to change infrastructure - **Scalability**: CQRS enables independent scaling - **Domain Focus**: Business logic is central and protected The key is following the dependency rule and keeping the domain pure. ================================================ FILE: docs/ddd-patterns.md ================================================ # DDD Patterns Guide This document explains the Domain-Driven Design patterns used in this template. ## Core Building Blocks ### 1. Entity **Definition**: An object with a distinct identity that persists over time. **Characteristics**: - Has a unique identifier - Identity remains constant even if attributes change - Compared by identity, not attributes - Has a lifecycle **Example**: ```typescript export class OrderItem extends Entity { private _productId: string; private _quantity: Quantity; private _unitPrice: Money; private constructor(props: OrderItemProps) { super(props.id); // Identity this._productId = props.productId; this._quantity = props.quantity; this._unitPrice = props.unitPrice; } public static create(props: OrderItemProps): OrderItem { return new OrderItem(props); } // Behavior public getTotalPrice(): Money { return this._unitPrice.multiply(this._quantity.value); } public updateQuantity(quantity: Quantity): void { this._quantity = quantity; } } ``` **When to Use**: - Object needs to be tracked over time - Object has a lifecycle - Object needs to be distinguished from similar objects ### 2. Value Object **Definition**: An immutable object defined by its attributes, not identity. **Characteristics**: - No identity - Immutable - Compared by value - Self-validating - Side-effect free **Example**: ```typescript export class Money extends ValueObject { get amount(): number { return this.props.amount; } get currency(): string { return this.props.currency; } private constructor(props: MoneyProps) { super(props); // Frozen/immutable } public static create(amount: number, currency: string = 'USD'): Money { // Self-validation if (amount < 0) { throw new Error('Money amount cannot be negative'); } return new Money({ amount, currency }); } // Returns new instance (immutable) public add(money: Money): Money { if (this.currency !== money.currency) { throw new Error('Cannot add money with different currencies'); } return Money.create(this.amount + money.amount, this.currency); } public multiply(multiplier: number): Money { return Money.create(this.amount * multiplier, this.currency); } } ``` **When to Use**: - Measuring, quantifying, or describing things - No need to track identity - Immutability is desired - Equality is based on attributes **Common Value Objects**: - Money, Currency - Address, Email, Phone - DateRange, TimeSpan - Quantity, Percentage - Status, State ### 3. Aggregate **Definition**: A cluster of entities and value objects with a defined boundary. **Characteristics**: - Has an aggregate root (entry point) - Enforces consistency boundaries - Only root is accessible from outside - Transactions don't cross aggregate boundaries - Loaded and saved as a whole **Example**: ```typescript export class Order extends AggregateRoot { private _customerId: string; private _items: OrderItem[]; // Child entities private _status: OrderStatus; // Value object // Factory method public static create(customerId: string, items: OrderItem[]): Order { const order = new Order({ id: OrderId.create(), customerId, items, status: OrderStatus.pending(), createdAt: new Date(), updatedAt: new Date(), }); // Raise domain event order.addDomainEvent(new OrderCreatedEvent(order.id, customerId)); return order; } // Business logic - enforces invariants public confirm(): void { if (!this._status.isPending()) { throw new InvalidOrderStateException('Cannot confirm non-pending order'); } this._status = OrderStatus.confirmed(); this.addDomainEvent(new OrderConfirmedEvent(this.id)); } // Protects child entities public addItem(item: OrderItem): void { if (!this._status.isPending()) { throw new InvalidOrderStateException('Cannot modify non-pending order'); } this._items.push(item); } } ``` **Design Rules**: 1. Reference other aggregates by ID only 2. Keep aggregates small 3. Update one aggregate per transaction 4. Use eventual consistency between aggregates ### 4. Domain Event **Definition**: A record of something that happened in the domain. **Characteristics**: - Immutable - Past tense naming - Contains relevant data - Timestamp of occurrence **Example**: ```typescript export class OrderCreatedEvent extends DomainEvent { constructor( public readonly orderId: string, public readonly customerId: string, public readonly totalAmount: Money, ) { super(); // Sets occurredOn timestamp } getAggregateId(): string { return this.orderId; } } ``` **When to Use**: - Something important happened in the domain - Other parts of the system need to react - Audit trail is needed - Integration with external systems **Event Naming**: - Use past tense: `OrderCreated`, `OrderConfirmed`, `PaymentReceived` - Be specific: `OrderShipped` not `OrderUpdated` - Include context: `OrderCancelledByCustomer` vs `OrderCancelledBySystem` ### 5. Domain Service **Definition**: Business logic that doesn't naturally fit in an entity or value object. **Characteristics**: - Stateless - Operates on multiple entities - Named after domain concepts - Contains business logic **Example**: ```typescript @Injectable() export class OrderPricingService { calculateTotal(order: Order): Money { return order.getTotalAmount(); } applyDiscount(total: Money, discountPercent: number): Money { if (discountPercent < 0 || discountPercent > 100) { throw new Error('Invalid discount percentage'); } const discountMultiplier = 1 - discountPercent / 100; return Money.create(total.amount * discountMultiplier, total.currency); } calculateShipping(order: Order, destination: Address): Money { // Complex shipping calculation logic } } ``` **When to Use**: - Logic involves multiple aggregates - Logic doesn't belong to any single entity - Stateless operations - Complex calculations ### 6. Repository **Definition**: Abstraction for data access, providing collection-like interface. **Characteristics**: - Interface defined in domain - Implementation in infrastructure - Hides persistence details - Works with aggregates **Example**: ```typescript // Domain layer - interface export interface IOrderRepository { findById(id: string): Promise; findByCustomerId(customerId: string): Promise; save(order: Order): Promise; delete(id: string): Promise; } // Infrastructure layer - implementation @Injectable() export class OrderRepository implements IOrderRepository { constructor( @InjectRepository(OrderSchema) private readonly orderRepo: Repository, ) {} async findById(id: string): Promise { const schema = await this.orderRepo.findOne({ where: { id } }); if (!schema) return null; return OrderMapper.toDomain(schema); } async save(order: Order): Promise { const schema = OrderMapper.toPersistence(order); await this.orderRepo.save(schema); return order; } } ``` **Repository vs DAO**: - Repository: Works with domain objects, collection-like - DAO: Works with database records, CRUD operations ## Strategic Patterns ### 1. Bounded Context **Definition**: A boundary within which a domain model is defined and applicable. **In This Template**: - `order` module is a bounded context - Has its own domain model - Clear boundaries with other contexts **Structure**: ``` src/modules/ ├── order/ # Order bounded context │ ├── domain/ │ ├── application/ │ ├── infrastructure/ │ └── presentation/ ├── inventory/ # Inventory bounded context (future) └── customer/ # Customer bounded context (future) ``` ### 2. Ubiquitous Language **Definition**: A shared language between developers and domain experts. **Examples in Order Context**: - "Order" not "Purchase" or "Transaction" - "Confirm" not "Approve" or "Accept" - "Cancel" not "Delete" or "Remove" - "OrderItem" not "LineItem" or "OrderLine" **Implementation**: ```typescript // Use domain language in code class Order { confirm(): void { } // Not approve() cancel(): void { } // Not delete() addItem(): void { } // Not addLineItem() } // Use domain language in events class OrderConfirmed { } // Not OrderApproved class OrderCancelled { } // Not OrderDeleted ``` ### 3. Context Mapping **Definition**: Relationships between bounded contexts. **Common Patterns**: - **Shared Kernel**: Shared code between contexts - **Customer-Supplier**: One context depends on another - **Anti-Corruption Layer**: Translate between contexts **Example**: ```typescript // Anti-corruption layer for external payment service export class PaymentServiceAdapter { constructor(private readonly externalPaymentService: ExternalPaymentAPI) {} async processPayment(order: Order): Promise { // Translate domain model to external API const externalRequest = { amount: order.getTotalAmount().amount, currency: order.getTotalAmount().currency, reference: order.id, }; const externalResponse = await this.externalPaymentService.charge(externalRequest); // Translate external response to domain model return new PaymentResult( externalResponse.success, externalResponse.transactionId, ); } } ``` ## Application Patterns ### 1. CQRS (Command Query Responsibility Segregation) **Definition**: Separate models for reading and writing data. **Commands** (Write): ```typescript // Command export class CreateOrderCommand { constructor( public readonly customerId: string, public readonly items: OrderItemDto[], ) {} } // Handler @CommandHandler(CreateOrderCommand) export class CreateOrderHandler { async execute(command: CreateOrderCommand): Promise { const order = Order.create(command.customerId, items); await this.orderRepository.save(order); return order.id; } } ``` **Queries** (Read): ```typescript // Query export class GetOrderQuery { constructor(public readonly orderId: string) {} } // Handler @QueryHandler(GetOrderQuery) export class GetOrderHandler { async execute(query: GetOrderQuery): Promise { const order = await this.orderRepository.findById(query.orderId); return this.mapToDto(order); } } ``` ### 2. Event Sourcing (Optional) **Definition**: Store state as a sequence of events. **Note**: This template uses traditional state storage, but can be extended to event sourcing. ```typescript // Event sourced aggregate (conceptual) class Order extends EventSourcedAggregate { apply(event: DomainEvent): void { if (event instanceof OrderCreated) { this._status = OrderStatus.pending(); } else if (event instanceof OrderConfirmed) { this._status = OrderStatus.confirmed(); } } // Rebuild state from events static fromEvents(events: DomainEvent[]): Order { const order = new Order(); events.forEach(event => order.apply(event)); return order; } } ``` ## Best Practices ### 1. Rich Domain Model ```typescript // ✅ Rich domain model - behavior in entity class Order { confirm(): void { this.validateCanConfirm(); this._status = OrderStatus.confirmed(); this.addDomainEvent(new OrderConfirmed(this.id)); } private validateCanConfirm(): void { if (!this._status.isPending()) { throw new InvalidOrderStateException(); } if (this._items.length === 0) { throw new EmptyOrderException(); } } } // ❌ Anemic domain model - behavior in service class Order { status: string; items: OrderItem[]; } class OrderService { confirm(order: Order): void { if (order.status !== 'PENDING') throw new Error(); order.status = 'CONFIRMED'; } } ``` ### 2. Invariant Protection ```typescript class Order { // Protect invariants through encapsulation private _items: OrderItem[]; get items(): OrderItem[] { return [...this._items]; // Return copy } addItem(item: OrderItem): void { // Validate invariant if (!this._status.isPending()) { throw new InvalidOrderStateException(); } this._items.push(item); } } ``` ### 3. Factory Methods ```typescript class Order { // Use factory methods instead of constructors public static create(customerId: string, items: OrderItem[]): Order { // Validation if (!customerId) throw new Error('Customer ID required'); if (items.length === 0) throw new Error('Order must have items'); // Create with proper initial state const order = new Order({ id: OrderId.create(), customerId, items, status: OrderStatus.pending(), createdAt: new Date(), }); // Raise creation event order.addDomainEvent(new OrderCreated(order.id)); return order; } // For reconstituting from persistence public static reconstitute(props: OrderProps): Order { return new Order(props); // No events, no validation } } ``` ### 4. Specification Pattern ```typescript // For complex business rules interface Specification { isSatisfiedBy(candidate: T): boolean; } class OrderCanBeConfirmedSpec implements Specification { isSatisfiedBy(order: Order): boolean { return order.status.isPending() && order.items.length > 0 && order.getTotalAmount().amount > 0; } } // Usage class Order { confirm(): void { const spec = new OrderCanBeConfirmedSpec(); if (!spec.isSatisfiedBy(this)) { throw new InvalidOrderStateException(); } this._status = OrderStatus.confirmed(); } } ``` ## Summary | Pattern | Purpose | Location | |---------|---------|----------| | Entity | Objects with identity | Domain | | Value Object | Immutable descriptors | Domain | | Aggregate | Consistency boundary | Domain | | Domain Event | Record of occurrence | Domain | | Domain Service | Cross-entity logic | Domain | | Repository | Data access abstraction | Domain (interface), Infrastructure (impl) | | CQRS | Separate read/write | Application | | Bounded Context | Model boundary | Module | These patterns work together to create a maintainable, expressive domain model that captures business logic effectively. ================================================ FILE: nest-cli.json ================================================ { "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { "builder": "swc", "typeCheck": true, "deleteOutDir": true, "tsConfigPath": "tsconfig.build.json" } } ================================================ FILE: package.json ================================================ { "name": "@a3s-lab/nestify", "version": "1.0.0", "description": "Production-ready NestJS monorepo with pnpm workspace, Domain-Driven Design and Clean Architecture", "author": "", "private": true, "license": "MIT", "scripts": { "build": "pnpm -r build", "build:api": "pnpm --filter @a3s-lab/api build", "build:packages": "pnpm --filter \"./packages/**\" build", "format": "biome format --write .", "format:check": "biome format .", "lint": "biome lint --write .", "lint:check": "biome lint .", "start": "pnpm --filter @a3s-lab/api start", "start:dev": "pnpm --filter @a3s-lab/api start:dev", "start:debug": "pnpm --filter @a3s-lab/api start:debug", "start:prod": "pnpm --filter @a3s-lab/api start:prod", "test": "pnpm -r test", "test:api": "pnpm --filter @a3s-lab/api test", "test:packages": "pnpm --filter \"./packages/**\" test", "test:kysely": "pnpm --filter @a3s-lab/kysely test", "test:redisson": "pnpm --filter @a3s-lab/redisson test", "test:cov": "pnpm -r test:cov", "clean": "pnpm -r clean && rm -rf node_modules" }, "devDependencies": { "@biomejs/biome": "^2.3.14", "rimraf": "^6.0.1", "typescript": "^5.1.3" } } ================================================ FILE: packages/bullmq/package.json ================================================ { "name": "@a3s-lab/bullmq", "version": "0.0.1", "description": "BullMQ module for NestJS", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { "build": "tsc", "test": "echo \"No tests specified\" && exit 0" }, "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "bullmq": "^5.0.0" }, "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.0.0" }, "peerDependencies": { "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0" } } ================================================ FILE: packages/bullmq/src/bullmq.module-definition.ts ================================================ // ============================================================================ // BullMQ Module Definition - Configurable module pattern // ============================================================================ import { ConfigurableModuleBuilder } from '@nestjs/common'; import { BullMQModuleOptions, BullMQOptionsFactory } from './bullmq.types'; export const MODULE_OPTIONS_TOKEN = 'BULLMQ_MODULE_OPTIONS'; export const { ConfigurableModuleClass: ConfigurableBullMQModule, MODULE_OPTIONS_TOKEN: BULLMQ_OPTIONS_TOKEN, CONFIG_GLOBAL_MODULE_NAME: BULLMQ_GLOBAL_MODULE_NAME, } = new ConfigurableModuleBuilder({ moduleName: 'BullMQ', global: true, }) .setExtras( { isGlobal: true }, (definition, extras) => ({ ...definition, global: extras.isGlobal ?? definition.global ?? false, }), ) .build(); ================================================ FILE: packages/bullmq/src/bullmq.module.ts ================================================ // ============================================================================ // BullMQ Module - Distributed task queue // ============================================================================ import { Module, Global, DynamicModule, Provider } from '@nestjs/common'; import { BullMQModuleOptions, BullMQOptionsFactory } from './bullmq.types'; import { BullMQService } from './bullmq.service'; @Global() @Module({ providers: [BullMQService], exports: [BullMQService], }) export class BullMQModule { /** * Register BullMQ module with static options */ static register(options: BullMQModuleOptions): DynamicModule { return { module: BullMQModule, providers: [ { provide: BullMQModuleOptions, useValue: options, }, ], exports: [BullMQService], }; } /** * Register BullMQ module asynchronously (for ConfigService-based config) */ static registerAsync(options: { useFactory?: (factory: BullMQOptionsFactory) => Promise | BullMQModuleOptions; inject?: any[]; }): DynamicModule { const asyncProviders: Provider[] = []; if (options.useFactory) { asyncProviders.push({ provide: BullMQModuleOptions, useFactory: options.useFactory, inject: options.inject ?? [], }); } return { module: BullMQModule, imports: [], providers: asyncProviders, exports: [BullMQService], }; } } ================================================ FILE: packages/bullmq/src/bullmq.service.ts ================================================ // ============================================================================ // BullMQ Service - Queue management and job processing // ============================================================================ import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common'; import { Queue, Worker, Job, QueueEvents } from 'bullmq'; import { BullMQModuleOptions } from './bullmq.types'; export interface JobData { [key: string]: unknown; } export interface JobResult { success: boolean; data?: unknown; error?: string; } export type JobProcessor = (job: Job) => Promise; /** * Queue metrics for monitoring */ export interface QueueMetrics { waiting: number; active: number; completed: number; failed: number; delayed: number; } @Injectable() export class BullMQService implements OnModuleDestroy { private readonly queues: Map = new Map(); private readonly workers: Map = new Map(); private readonly queueEvents: Map = new Map(); private readonly logger = new Logger(BullMQService.name); constructor(private readonly options: BullMQModuleOptions) {} /** * Get or create a queue */ getQueue(name: string): Queue { if (this.queues.has(name)) { return this.queues.get(name)!; } const queue = new Queue(name, { connection: this.options.connection, defaultJobOptions: this.options.defaultJobOptions, }); this.queues.set(name, queue); this.logger.log(`Queue '${name}' created`); return queue; } /** * Add a job to a queue */ async addJob( queueName: string, jobName: string, data: T, options?: { priority?: number; attempts?: number; backoff?: { type: 'exponential' | 'fixed'; delay: number }; delay?: number; repeat?: { pattern: string } | { endDate: Date }; }, ): Promise { const queue = this.getQueue(queueName); const job = await queue.add(jobName, data, { priority: options?.priority, attempts: options?.attempts ?? 3, backoff: options?.backoff ?? { type: 'exponential', delay: 1000 }, delay: options?.delay, repeat: options?.repeat, }); this.logger.debug(`Job '${jobName}' added to queue '${queueName}'`); return job; } /** * Add a delayed job (runs after delay) */ async addDelayedJob( queueName: string, jobName: string, data: T, delayMs: number, ): Promise { return this.addJob(queueName, jobName, data, { delay: delayMs }); } /** * Add a repeatable job (cron-like) */ async addRepeatableJob( queueName: string, jobName: string, data: T, pattern: string, // e.g., '*/5 * * * *' ): Promise { return this.addJob(queueName, jobName, data, { repeat: { pattern }, }); } /** * Create a worker for a queue */ createWorker( queueName: string, processor: JobProcessor, options?: { concurrency?: number }, ): Worker { if (this.workers.has(queueName)) { this.logger.warn(`Worker for queue '${queueName}' already exists`); return this.workers.get(queueName)!; } const worker = new Worker( queueName, async (job) => { this.logger.debug(`Processing job '${job.name}' in queue '${queueName}'`); try { const result = await processor(job); if (!result.success) { throw new Error(result.error); } return result; } catch (error) { this.logger.error(`Job '${job.name}' failed: ${error.message}`); throw error; } }, { connection: this.options.connection, concurrency: options?.concurrency ?? 1, }, ); worker.on('completed', (job) => { this.logger.debug(`Job '${job.name}' completed`); }); worker.on('failed', (job, error) => { this.logger.error(`Job '${job?.name}' failed: ${error.message}`); }); this.workers.set(queueName, worker); this.logger.log(`Worker for queue '${queueName}' created`); return worker; } /** * Get queue events for monitoring */ getQueueEvents(queueName: string): QueueEvents { if (this.queueEvents.has(queueName)) { return this.queueEvents.get(queueName)!; } const events = new QueueEvents(queueName, { connection: this.options.connection, }); this.queueEvents.set(queueName, events); return events; } /** * Get queue metrics */ async getQueueMetrics(queueName: string): Promise { const queue = this.getQueue(queueName); const [waiting, active, completed, failed, delayed] = await Promise.all([ queue.getWaitingCount(), queue.getActiveCount(), queue.getCompletedCount(), queue.getFailedCount(), queue.getDelayedCount(), ]); return { waiting, active, completed, failed, delayed }; } /** * Pause a queue */ async pauseQueue(queueName: string): Promise { const queue = this.getQueue(queueName); await queue.pause(); this.logger.log(`Queue '${queueName}' paused`); } /** * Resume a queue */ async resumeQueue(queueName: string): Promise { const queue = this.getQueue(queueName); await queue.resume(); this.logger.log(`Queue '${queueName}' resumed`); } /** * Drain a queue (process all waiting jobs) */ async drainQueue(queueName: string): Promise { const queue = this.getQueue(queueName); await queue.drain(); this.logger.log(`Queue '${queueName}' drained`); } /** * Clean a queue (remove old jobs) */ async cleanQueue( queueName: string, grace: number = 24 * 60 * 60 * 1000, // 24 hours status?: 'completed' | 'failed', ): Promise { const queue = this.getQueue(queueName); return queue.clean(grace, 100, status ?? 'completed'); } /** * Remove a specific job */ async removeJob(queueName: string, jobId: string): Promise { const queue = this.getQueue(queueName); const job = await queue.getJob(jobId); if (job) { await job.remove(); } } /** * Get a job by ID */ async getJob(queueName: string, jobId: string): Promise { const queue = this.getQueue(queueName); return queue.getJob(jobId); } /** * Close all queues and workers */ async onModuleDestroy(): Promise { this.logger.log('Closing BullMQ connections...'); // Close all workers for (const [name, worker] of this.workers) { await worker.close(); this.logger.debug(`Worker '${name}' closed`); } // Close all queues for (const [name, queue] of this.queues) { await queue.close(); this.logger.debug(`Queue '${name}' closed`); } // Close all queue events for (const [, events] of this.queueEvents) { await events.close(); } this.queues.clear(); this.workers.clear(); this.queueEvents.clear(); this.logger.log('BullMQ connections closed'); } } ================================================ FILE: packages/bullmq/src/bullmq.types.ts ================================================ // ============================================================================ // BullMQ Module Options // ============================================================================ export interface BullMQModuleOptions { /** Redis connection URL */ connection: { host: string; port: number; password?: string; db?: number; }; /** Default job options */ defaultJobOptions?: { attempts?: number; backoff?: { type: 'exponential' | 'fixed'; delay?: number; }; removeOnComplete?: boolean | number; removeOnFail?: boolean | number; }; /** Queue prefixes */ prefix?: string; } export interface BullMQOptionsFactory { createBullMQOptions(): Promise | BullMQModuleOptions; } ================================================ FILE: packages/bullmq/src/index.ts ================================================ export * from './bullmq.module'; export * from './bullmq.service'; export * from './bullmq.types'; export * from './bullmq.module-definition'; ================================================ FILE: packages/bullmq/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/__tests__"] } ================================================ FILE: packages/etcd/package.json ================================================ { "name": "@a3s-lab/etcd", "version": "0.0.1", "description": "Etcd distributed configuration center for NestJS", "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { "build": "tsc", "test": "jest", "lint": "biome lint src", "format": "biome format --write src" }, "dependencies": { "etcd3": "^1.2.0", "reflect-metadata": "^0.1.13" }, "peerDependencies": { "@nestjs/common": ">=10.0.0", "@nestjs/config": ">=3.0.0" }, "devDependencies": { "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.0.0", "@types/node": "^20.0.0", "typescript": "^5.0.0" }, "files": [ "dist" ] } ================================================ FILE: packages/etcd/src/config.service.ts ================================================ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { EtcdService } from './etcd.service'; export type ConfigSubscriber = (value: unknown) => void; /** * Distributed configuration service with hot-reload support */ @Injectable() export class EtcdConfigService implements OnModuleInit { private readonly logger = new Logger(EtcdConfigService.name); private subscribers: Map> = new Map(); private unsubscribers: Map void> = new Map(); private cache: Map = new Map(); private cacheTtl = 5000; constructor(private readonly etcd: EtcdService) {} async onModuleInit() { this.logger.log('EtcdConfigService initialized'); } // ==================== Get Configuration ==================== async get(key: string, useCache = true): Promise { if (useCache) { const cached = this.cache.get(key); if (cached && Date.now() - cached.timestamp < this.cacheTtl) { return cached.value as T; } } const value = await this.etcd.get(key); if (value !== null) { this.cache.set(key, { value, timestamp: Date.now() }); } return value; } async getJSON(key: string, useCache = true): Promise { if (useCache) { const cached = this.cache.get(key); if (cached && Date.now() - cached.timestamp < this.cacheTtl) { return cached.value as T; } } const value = await this.etcd.getJSON(key); if (value !== null) { this.cache.set(key, { value, timestamp: Date.now() }); } return value; } async getByPrefix(prefix: string): Promise> { return await this.etcd.getEntriesAsJSON(prefix); } // ==================== Set Configuration ==================== async set(key: string, value: string | number | boolean | object, options?: { ttl?: number }): Promise { await this.etcd.set(key, value, options); this.cache.set(key, { value, timestamp: Date.now() }); } async setJSON(key: string, value: T, options?: { ttl?: number }): Promise { await this.set(key, JSON.stringify(value), options); } // ==================== Delete Configuration ==================== async delete(key: string): Promise { const result = await this.etcd.delete(key); this.cache.delete(key); return result; } async deleteByPrefix(prefix: string): Promise { const count = await this.etcd.deleteByPrefix(prefix); for (const key of this.cache.keys()) { if (key.startsWith(prefix)) { this.cache.delete(key); } } return count; } // ==================== Hot Reload ==================== subscribe(key: string, callback: (value: T) => void): () => void { if (!this.subscribers.has(key)) { this.subscribers.set(key, new Set()); const unsubscribe = this.etcd.watch(key, (event) => { const subs = this.subscribers.get(key); if (subs) { if (event.value !== null) { this.cache.set(key, { value: event.value, timestamp: Date.now() }); subs.forEach((cb) => cb(event.value as T)); } } }); this.unsubscribers.set(key, unsubscribe); } this.subscribers.get(key)!.add(callback as ConfigSubscriber); return () => { const subs = this.subscribers.get(key); if (subs) { subs.delete(callback as ConfigSubscriber); if (subs.size === 0) { const unsub = this.unsubscribers.get(key); if (unsub) { unsub(); this.unsubscribers.delete(key); } this.subscribers.delete(key); } } }; } subscribePrefix(prefix: string, callback: (event: { key: string; value: T | null }) => void): () => void { const unsubscribe = this.etcd.watchPrefix(prefix, (event) => { callback({ key: event.key, value: event.value }); }); return unsubscribe; } // ==================== Utility ==================== async exists(key: string): Promise { return await this.etcd.exists(key); } clearCache(): void { this.cache.clear(); } setCacheTtl(ttl: number): void { this.cacheTtl = ttl; } } ================================================ FILE: packages/etcd/src/etcd.module.ts ================================================ import { Module, Global, DynamicModule, Provider } from '@nestjs/common'; import { EtcdModuleOptions } from './etcd.types'; import { EtcdService } from './etcd.service'; import { EtcdConfigService } from './config.service'; @Global() @Module({ providers: [EtcdService, EtcdConfigService], exports: [EtcdService, EtcdConfigService], }) export class EtcdModule { static register(options: EtcdModuleOptions): DynamicModule { return { module: EtcdModule, providers: [ { provide: EtcdModuleOptions, useValue: options, }, ], exports: [EtcdService, EtcdConfigService], }; } static registerAsync(options: { useFactory?: () => Promise | EtcdModuleOptions; inject?: any[]; }): DynamicModule { const asyncProviders: Provider[] = []; if (options.useFactory) { asyncProviders.push({ provide: EtcdModuleOptions, useFactory: options.useFactory, inject: options.inject ?? [], }); } return { module: EtcdModule, imports: [], providers: asyncProviders, exports: [EtcdService, EtcdConfigService], }; } } ================================================ FILE: packages/etcd/src/etcd.service.ts ================================================ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Etcd3, EtcdOptions } from 'etcd3'; import type { EtcdModuleOptions, WatchEvent, WatchCallback, ConfigEntry, LeaseInfo, HealthResult } from './etcd.types'; @Injectable() export class EtcdService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(EtcdService.name); private client: Etcd3; private watchers: Map> = new Map(); constructor(private readonly options: EtcdModuleOptions) { const etcdOptions: EtcdOptions = { hosts: this.options.endpoints, credentials: this.options.tls ? { cert: this.options.tls.cert, key: this.options.tls.key, ca: this.options.tls.ca, } : undefined, username: this.options.auth?.username, password: this.options.auth?.password, }; this.client = new Etcd3(etcdOptions); } async onModuleInit() { try { const health = await this.healthCheck(); if (health.healthy) { this.logger.log(`Successfully connected to etcd: ${this.options.endpoints.join(', ')}`); } else { throw new Error('Etcd health check failed'); } } catch (error) { this.logger.error('Failed to connect to etcd', error); throw error; } } async onModuleDestroy() { try { for (const [key, watcher] of this.watchers) { this.logger.debug(`Canceling watcher: ${key}`); watcher.cancel(); } this.watchers.clear(); await this.client.close(); this.logger.log('Etcd connection closed'); } catch (error) { this.logger.error('Error closing etcd connection', error); } } // ==================== Key-Value Operations ==================== async get(key: string): Promise { try { return (await this.client.get(key).string()) as T; } catch (error: unknown) { if ((error as { code?: string })?.code === 'KEY_NOT_FOUND') { return null; } throw error; } } async getJSON(key: string): Promise { const value = await this.get(key); if (!value) return null; try { return JSON.parse(value) as T; } catch { return null; } } async set(key: string, value: string | number | boolean | object, options?: { ttl?: number; lease?: string }): Promise { if (typeof value === 'object') { value = JSON.stringify(value); } if (options?.ttl && !options?.lease) { await this.client.put(key).value(value as string).ttl(options.ttl); } else if (options?.lease) { await this.client.put(key).value(value as string).lease(options.lease); } else { await this.client.put(key).value(value as string); } } async delete(key: string): Promise { const result = await this.client.delete().key(key).exec(); return result.deleted > 0; } async deleteByPrefix(prefix: string): Promise { const result = await this.client.delete().key(prefix).prefix().exec(); return result.deleted; } async exists(key: string): Promise { return await this.client.get(key).exists(); } async getKeysByPrefix(prefix: string): Promise { return await this.client.getKeys(prefix); } // ==================== Directory Operations ==================== async getEntries(prefix: string): Promise[]> { const results: ConfigEntry[] = []; const pairs = await this.client.getPrefix(prefix); for (const pair of pairs) { results.push({ key: pair.key, value: pair.value as T, version: pair.version, revision: pair.modRevision, created: pair.created, }); } return results; } async getEntriesAsJSON(prefix: string): Promise> { const entries = await this.getEntries(prefix); const result = new Map(); for (const entry of entries) { try { result.set(entry.key, JSON.parse(entry.value) as T); } catch { result.set(entry.key, entry.value as unknown as T); } } return result; } // ==================== Lease Operations ==================== async createLease(ttl: number): Promise { const lease = this.client.lease(ttl); const id = await lease.id(); return { id, ttl, remainingTTL: ttl }; } async grantLease(ttl: number): Promise { const lease = await this.client.grant(ttl); return lease; } async keepAlive(leaseId: string): Promise { const lease = this.client.lease(0, { ID: leaseId }); await lease.refresh(); } async revokeLease(leaseId: string): Promise { await this.client.revoke(leaseId); } // ==================== Watch Operations ==================== watch(key: string, callback: WatchCallback): () => void { const watcher = this.client.watch().key(key).create(); watcher.on('put', (event: { kv?: { key: string; value: string; version: number; mod_revision: number } }) => { if (event.kv) { callback({ type: 'put', key: event.kv.key, value: event.kv.value as T, version: event.kv.version, modRevision: event.kv.mod_revision, }); } }); watcher.on('delete', (event: { kv?: { key: string; version: number; mod_revision: number } }) => { if (event.kv) { callback({ type: 'delete', key: event.kv.key, value: null, version: event.kv.version, modRevision: event.kv.mod_revision, }); } }); watcher.on('error', (error: Error) => { this.logger.error(`Watch error for key ${key}:`, error); }); this.watchers.set(key, watcher); return () => { watcher.cancel(); this.watchers.delete(key); }; } watchPrefix(prefix: string, callback: WatchCallback): () => void { const watcher = this.client.watch().prefix(prefix).create(); watcher.on('put', (event: { kv?: { key: string; value: string; version: number; mod_revision: number } }) => { if (event.kv) { callback({ type: 'put', key: event.kv.key, value: event.kv.value as T, version: event.kv.version, modRevision: event.kv.mod_revision, }); } }); watcher.on('delete', (event: { kv?: { key: string; version: number; mod_revision: number } }) => { if (event.kv) { callback({ type: 'delete', key: event.kv.key, value: null, version: event.kv.version, modRevision: event.kv.mod_revision, }); } }); watcher.on('error', (error: Error) => { this.logger.error(`Watch error for prefix ${prefix}:`, error); }); this.watchers.set(prefix, watcher); return () => { watcher.cancel(); this.watchers.delete(prefix); }; } // ==================== Cluster Operations ==================== async healthCheck(): Promise { try { const status = await this.client.status(); return { healthy: true, leader: status.leader, etcdVersion: status.version, }; } catch { return { healthy: false }; } } async getMembers(): Promise { const memberList = await this.client.memberList(); return memberList.map((m) => m.name); } async getLeader(): Promise { try { const status = await this.client.status(); return status.leader || null; } catch { return null; } } // ==================== Transaction Operations ==================== async compareAndSet( key: string, expectedValue: string | null, newValue: string, options?: { ttl?: number }, ): Promise { const tx = this.client.transaction(); if (expectedValue === null) { tx.compare.notExists(key); } else { tx.compare.value(key, '==', expectedValue); } tx.then(this.client.put(key).value(newValue)); if (options?.ttl) { tx.then(this.client.put(key).value(newValue).ttl(options.ttl)); } const result = await tx.exec(); return result.succeeded; } getClient(): Etcd3 { return this.client; } } ================================================ FILE: packages/etcd/src/etcd.types.ts ================================================ /** * Etcd configuration options */ export interface EtcdModuleOptions { /** Etcd endpoints */ endpoints: string[]; /** Authentication */ auth?: { username?: string; password?: string; }; /** TLS configuration */ tls?: { cert?: string; key?: string; ca?: string; }; /** Default request options */ requestOptions?: { timeout?: number; retry?: number; }; } /** * Watch event types */ export type WatchEventType = 'put' | 'delete'; /** * Watch event from etcd */ export interface WatchEvent { type: WatchEventType; key: string; value: T | null; version: number; modRevision?: number; } /** * Watch callback function */ export type WatchCallback = (event: WatchEvent) => void; /** * Configuration entry */ export interface ConfigEntry { key: string; value: T; version: number; revision: number; created?: boolean; } /** * Lease info */ export interface LeaseInfo { id: number; ttl: number; remainingTTL: number; } /** * Health check result */ export interface HealthResult { healthy: boolean; leader?: string; etcdVersion?: string; } ================================================ FILE: packages/etcd/src/index.ts ================================================ export * from './etcd.module'; export * from './etcd.service'; export * from './config.service'; export * from './etcd.types'; ================================================ FILE: packages/etcd/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/__tests__"] } ================================================ FILE: packages/kysely/package.json ================================================ { "name": "@a3s-lab/kysely", "version": "1.0.0", "description": "NestJS module for Kysely SQL query builder with syntax-highlighted logging", "author": "ZhiXiao-Lin", "license": "MIT", "homepage": "https://github.com/ZhiXiao-Lin/nestify/tree/main/packages/kysely#readme", "repository": { "type": "git", "url": "git+https://github.com/ZhiXiao-Lin/nestify.git", "directory": "packages/kysely" }, "bugs": { "url": "https://github.com/ZhiXiao-Lin/nestify/issues" }, "keywords": [ "nestjs", "kysely", "sql", "query-builder", "database", "postgresql", "mysql", "sqlite", "typescript", "logger" ], "source": "./src/index.ts", "main": "./dist/index.js", "types": "./dist/index.d.ts", "sideEffects": false, "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "scripts": { "build": "tsc", "test": "jest", "test:cov": "jest --coverage", "clean": "rimraf dist && rimraf node_modules", "prepublishOnly": "pnpm run build" }, "dependencies": { "dayjs": "^1.11.13", "kysely": "^0.28.9", "picocolors": "^1.1.1" }, "devDependencies": { "@biomejs/biome": "^2.3.14", "@nestjs/common": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/jest": "^29.5.0", "jest": "^29.5.0", "reflect-metadata": "^0.1.13", "rimraf": "^6.0.1", "rxjs": "^7.8.1", "ts-jest": "^29.1.0", "typescript": "^5.1.3" }, "peerDependencies": { "@nestjs/common": "^10.0.0" }, "publishConfig": { "access": "public" }, "jest": { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": ["**/*.(t|j)s", "!**/__tests__/**"], "coverageDirectory": "../coverage", "testEnvironment": "node" } } ================================================ FILE: packages/kysely/src/__tests__/kysely.logger.spec.ts ================================================ import { createKyselyLogger } from '../kysely.logger'; describe('KyselyLogger', () => { let consoleSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance; beforeEach(() => { consoleSpy = jest.spyOn(console, 'log').mockImplementation(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); }); afterEach(() => { consoleSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); describe('createKyselyLogger', () => { it('should return a function', () => { const logger = createKyselyLogger(); expect(typeof logger).toBe('function'); }); it('should log query events', () => { const logger = createKyselyLogger(); // Use any to bypass strict type checking for test purposes const event: any = { level: 'query', queryDurationMillis: 5.5, query: { sql: 'SELECT * FROM users WHERE id = $1', parameters: ['123'], }, }; logger(event); expect(consoleSpy).toHaveBeenCalled(); }); it('should log error events', () => { const logger = createKyselyLogger(); const event: any = { level: 'error', queryDurationMillis: 10.2, query: { sql: 'SELECT * FROM invalid_table', parameters: [], }, error: new Error('Table not found'), }; logger(event); expect(consoleErrorSpy).toHaveBeenCalled(); }); it('should handle queries without parameters', () => { const logger = createKyselyLogger(); const event: any = { level: 'query', queryDurationMillis: 1.0, query: { sql: 'SELECT COUNT(*) FROM users', parameters: [], }, }; expect(() => logger(event)).not.toThrow(); }); it('should handle various parameter types', () => { const logger = createKyselyLogger(); const event: any = { level: 'query', queryDurationMillis: 2.5, query: { sql: 'INSERT INTO users (name, age, active, created_at) VALUES ($1, $2, $3, $4)', parameters: ['John', 30, true, new Date('2024-01-01')], }, }; expect(() => logger(event)).not.toThrow(); }); it('should handle null and undefined parameters', () => { const logger = createKyselyLogger(); const event: any = { level: 'query', queryDurationMillis: 1.5, query: { sql: 'UPDATE users SET name = $1, email = $2 WHERE id = $3', parameters: [null, undefined, '123'], }, }; expect(() => logger(event)).not.toThrow(); }); }); }); ================================================ FILE: packages/kysely/src/__tests__/kysely.module.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { KyselyModule } from '../kysely.module'; import { KyselyService } from '../kysely.service'; import { MODULE_OPTIONS_TOKEN } from '../kysely.module-definition'; // Mock Kysely jest.mock('kysely', () => { return { Kysely: jest.fn().mockImplementation(() => ({ destroy: jest.fn().mockResolvedValue(undefined), selectFrom: jest.fn().mockReturnThis(), insertInto: jest.fn().mockReturnThis(), updateTable: jest.fn().mockReturnThis(), deleteFrom: jest.fn().mockReturnThis(), })), }; }); describe('KyselyModule', () => { let module: TestingModule; const mockOptions = { config: { dialect: {} as any, }, }; beforeEach(async () => { module = await Test.createTestingModule({ imports: [KyselyModule.register(mockOptions)], }).compile(); }); afterEach(async () => { if (module) { await module.close(); } }); it('should be defined', () => { expect(module).toBeDefined(); }); it('should provide KyselyService', () => { const service = module.get>(KyselyService); expect(service).toBeDefined(); }); it('should inject module options', () => { const options = module.get(MODULE_OPTIONS_TOKEN); expect(options).toEqual(mockOptions); }); }); describe('KyselyModule.registerAsync', () => { let module: TestingModule; const mockOptions = { config: { dialect: {} as any, }, }; beforeEach(async () => { module = await Test.createTestingModule({ imports: [ KyselyModule.registerAsync({ useFactory: () => mockOptions, }), ], }).compile(); }); afterEach(async () => { if (module) { await module.close(); } }); it('should be defined', () => { expect(module).toBeDefined(); }); it('should provide KyselyService with async config', () => { const service = module.get>(KyselyService); expect(service).toBeDefined(); }); }); ================================================ FILE: packages/kysely/src/index.ts ================================================ export * from "./kysely.module"; export * from "./kysely.service"; export * from "./kysely.logger"; export * from "./kysely-module-options.interface"; export * from "./kysely.module-definition"; ================================================ FILE: packages/kysely/src/kysely-module-options.interface.ts ================================================ import type { ModuleMetadata, Type } from "@nestjs/common"; import type { Kysely, KyselyConfig } from "kysely"; export interface KyselyModuleOptions { config: KyselyConfig; /** * Optional existing Kysely instance to use instead of creating a new one */ instance?: Kysely; } export interface KyselyModuleOptionsFactory { createKyselyModuleOptions(): | Promise> | KyselyModuleOptions; } export interface KyselyModuleAsyncOptions extends Pick { useExisting?: Type>; useClass?: Type>; useFactory?: ( ...args: unknown[] ) => Promise> | KyselyModuleOptions; inject?: unknown[]; } ================================================ FILE: packages/kysely/src/kysely.logger.ts ================================================ import type { LogEvent } from 'kysely'; import * as dayjsModule from 'dayjs'; import * as pcModule from 'picocolors'; // Handle both ESM and CJS imports const dayjs = (dayjsModule as any).default || dayjsModule; const pc = (pcModule as any).default || pcModule; /** * SQL keywords for syntax highlighting */ const SQL_KEYWORDS = [ 'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN', 'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TABLE', 'INDEX', 'PRIMARY KEY', 'FOREIGN KEY', 'CONSTRAINT', 'GROUP BY', 'ORDER BY', 'HAVING', 'LIMIT', 'OFFSET', 'UNION', 'DISTINCT', 'AS', 'ON', 'IN', 'NOT', 'AND', 'OR', 'LIKE', 'BETWEEN', 'NULL', 'IS', 'COUNT', 'SUM', 'AVG', 'MAX', 'MIN', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', ] as const; /** * Regular expressions cache for better performance */ const regexCache = new Map(); /** * Get or create a cached regex pattern */ const getCachedRegex = (pattern: string, flags: string): RegExp => { const key = `${pattern}:${flags}`; let regex = regexCache.get(key); if (!regex) { regex = new RegExp(pattern, flags); regexCache.set(key, regex); } return regex; }; /** * Highlights SQL query with color-coded syntax * @param sql - The SQL query string to highlight * @returns Highlighted SQL string with ANSI color codes */ const highlightSql = (sql: string): string => { let highlightedSql = sql; // Highlight keywords for (const keyword of SQL_KEYWORDS) { const regex = getCachedRegex(`\\b${keyword}\\b`, 'gi'); highlightedSql = highlightedSql.replace(regex, pc.bold(pc.blue(keyword.toUpperCase()))); } // Highlight string literals highlightedSql = highlightedSql.replace(/'([^'\\]|\\.)*'/g, pc.green('$&')); // Highlight numbers highlightedSql = highlightedSql.replace(/\b\d+(?:\.\d+)?\b/g, pc.yellow('$&')); // Highlight identifiers with backticks highlightedSql = highlightedSql.replace(/`([^`]+)`/g, pc.cyan('$&')); return highlightedSql; }; /** * Formats query execution duration with color coding based on performance * - Green: < 1ms (excellent) * - Yellow: 1-100ms (acceptable) * - Red: > 100ms (slow, needs optimization) * @param duration - Query duration in milliseconds * @returns Formatted duration string with color coding */ const formatDuration = (duration: number): string => { const formatted = `${duration.toFixed(2)}ms`; if (duration < 1) { return pc.green(formatted); } if (duration < 100) { return pc.yellow(formatted); } return pc.red(pc.bold(formatted)); }; /** * Formats query parameters with type-specific color coding * @param params - Array of query parameters * @returns Formatted parameters string */ const formatParameters = (params: readonly unknown[]): string => { return params .map((param, index) => { let formattedParam: string; if (param === null || param === undefined) { formattedParam = pc.gray('NULL'); } else if (typeof param === 'string') { formattedParam = pc.green(`'${param}'`); } else if (typeof param === 'number') { formattedParam = pc.yellow(String(param)); } else if (typeof param === 'boolean') { formattedParam = pc.magenta(String(param)); } else if (param instanceof Date) { formattedParam = pc.cyan(param.toISOString()); } else { formattedParam = pc.white(JSON.stringify(param)); } return `${pc.dim(`$${index + 1}:`)} ${formattedParam}`; }) .join(', '); }; /** * Type guard to check if error is an Error instance */ const isError = (error: unknown): error is Error => { return error instanceof Error; }; /** * Creates a Kysely logger function with enhanced formatting and syntax highlighting * @returns Logger function compatible with Kysely's log configuration */ export const createKyselyLogger = () => { return (event: LogEvent) => { const timestamp = dayjs().format('YYYY-MM-DD HH:mm:ss'); const formattedTimestamp = pc.dim(`[${timestamp}]`); if (event.level === 'query') { const duration = formatDuration(event.queryDurationMillis); // Query log header console.log(`${formattedTimestamp} ${pc.bold(pc.cyan('[KYSELY QUERY]'))} ${duration}`); // Highlighted SQL query const formattedSql = highlightSql(event.query.sql); console.log(`${pc.dim('┌─')} ${formattedSql}`); // Parameters (if any) if (event.query.parameters && event.query.parameters.length > 0) { console.log( `${pc.dim('├─')} ${pc.bold(pc.magenta('Parameters:'))} ${formatParameters(event.query.parameters)}`, ); } // Footer console.log(pc.dim('└─────────────────────────────────────────────────────────────────')); } else if (event.level === 'error') { const duration = formatDuration(event.queryDurationMillis); // Error log header console.error(`${formattedTimestamp} ${pc.bold(pc.red('[KYSELY ERROR]'))} ${duration}`); // SQL query that caused the error const formattedSql = highlightSql(event.query.sql); console.error(`${pc.dim('┌─')} ${pc.red('Query:')} ${formattedSql}`); // Parameters (if any) if (event.query.parameters && event.query.parameters.length > 0) { console.error( `${pc.dim('├─')} ${pc.bold(pc.magenta('Parameters:'))} ${formatParameters(event.query.parameters)}`, ); } // Error details const error = event.error; if (isError(error)) { console.error(`${pc.dim('├─')} ${pc.red('Error:')} ${pc.bold(pc.red(error.message))}`); if (error.stack) { console.error(`${pc.dim('├─')} ${pc.red('Stack Trace:')}`); console.error(`${pc.dim('│ ')} ${pc.gray(error.stack)}`); } } else { console.error(`${pc.dim('├─')} ${pc.red('Error:')} ${pc.bold(pc.red(String(error)))}`); } console.error(pc.dim('└─────────────────────────────────────────────────────────────────')); } }; }; ================================================ FILE: packages/kysely/src/kysely.module-definition.ts ================================================ import { ConfigurableModuleBuilder } from "@nestjs/common"; import { KyselyModuleOptions } from "./kysely-module-options.interface"; /** * Configurable module builder for KyselyModule * Provides both synchronous and asynchronous registration methods * with optional global module configuration */ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE, } = new ConfigurableModuleBuilder() .setExtras( { isGlobal: true, }, (definition, extras) => ({ ...definition, global: extras.isGlobal, }), ) .build(); ================================================ FILE: packages/kysely/src/kysely.module.ts ================================================ import { DynamicModule, Module } from "@nestjs/common"; import { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, OPTIONS_TYPE, } from "./kysely.module-definition"; import { KyselyService } from "./kysely.service"; @Module({}) export class KyselyModule extends ConfigurableModuleClass { static register(options: typeof OPTIONS_TYPE): DynamicModule { const dynamicModule = super.register(options); return { ...dynamicModule, providers: [...(dynamicModule.providers || []), KyselyService], exports: [KyselyService], }; } static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule { const dynamicModule = super.registerAsync(options); return { ...dynamicModule, providers: [...(dynamicModule.providers || []), KyselyService], exports: [KyselyService], }; } } ================================================ FILE: packages/kysely/src/kysely.service.ts ================================================ import { Inject, Injectable, OnModuleDestroy } from "@nestjs/common"; import { Kysely } from "kysely"; import type { KyselyModuleOptions } from "./kysely-module-options.interface"; import { MODULE_OPTIONS_TOKEN } from "./kysely.module-definition"; @Injectable() export class KyselyService extends Kysely implements OnModuleDestroy { /** * Creates a new KyselyService instance * @param options - Kysely configuration options injected by NestJS * @throws {Error} If options are not provided */ constructor( @Inject(MODULE_OPTIONS_TOKEN) options: KyselyModuleOptions, ) { if (!options) { throw new Error( "KyselyModuleOptions is not defined. Ensure KyselyModule is properly configured.", ); } super(options.config); } /** * Cleanup method called when the module is destroyed. * Properly closes database connections to prevent leaks. */ async onModuleDestroy(): Promise { await this.destroy(); } } ================================================ FILE: packages/kysely/tsconfig.json ================================================ { "compilerOptions": { "module": "Node16", "moduleResolution": "Node16", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "sourceMap": true, "incremental": true, "isolatedModules": true, "strictNullChecks": false, "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, "jsx": "react-jsx", "outDir": "dist", "target": "ES2021", "lib": ["DOM", "DOM.Iterable", "ES2021", "ES2022.Error"], "noErrorTruncation": true, "strict": true, "skipLibCheck": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*"] } ================================================ FILE: packages/logger/package.json ================================================ { "name": "@a3s-lab/logger", "version": "0.0.1", "description": "NestJS module for structured logging with request tracing and JSON format", "author": "A3S Lab", "license": "MIT", "keywords": [ "nestjs", "logger", "logging", "tracing", "structure" ], "source": "./src/index.ts", "main": "./dist/index.js", "types": "./dist/index.d.ts", "sideEffects": false, "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "scripts": { "build": "tsc", "test": "jest", "test:cov": "jest --coverage", "clean": "rimraf dist && rimraf node_modules", "prepublishOnly": "pnpm run build" }, "dependencies": { "pino": "^9.0.0", "pino-http": "^10.0.0" }, "devDependencies": { "@biomejs/biome": "^2.3.14", "@nestjs/common": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/jest": "^29.5.0", "jest": "^29.5.0", "reflect-metadata": "^0.1.13", "rimraf": "^6.0.1", "rxjs": "^7.8.1", "ts-jest": "^29.1.0", "typescript": "^5.1.3" }, "peerDependencies": { "@nestjs/common": "^10.0.0" }, "publishConfig": { "access": "public" }, "jest": { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": ["**/*.(t|j)s", "!**/__tests__/**"], "coverageDirectory": "../coverage", "testEnvironment": "node" } } ================================================ FILE: packages/logger/src/index.ts ================================================ export * from './logger.module'; export * from './logger.service'; export * from './logger.types'; export * from './logger.module-definition'; export * from './logging.interceptor'; ================================================ FILE: packages/logger/src/logger.module-definition.ts ================================================ import { ConfigurableModuleBuilder } from '@nestjs/common'; import { LoggerModuleOptions } from './logger.types'; export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE, } = new ConfigurableModuleBuilder() .setExtras( { isGlobal: true, }, (definition, extras) => ({ ...definition, global: extras.isGlobal, }), ) .build(); ================================================ FILE: packages/logger/src/logger.module.ts ================================================ import { Module, Global } from '@nestjs/common'; import { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, OPTIONS_TYPE, } from './logger.module-definition'; import { LoggerServiceImpl, Logger } from './logger.service'; import { LoggingInterceptor } from './logging.interceptor'; @Global() @Module({}) export class LoggerModule extends ConfigurableModuleClass { static register(options: typeof OPTIONS_TYPE) { const dynamicModule = super.register(options); return { ...dynamicModule, providers: [ ...(dynamicModule.providers || []), LoggerServiceImpl, { provide: Logger, useFactory: (opts: any) => new LoggerServiceImpl(opts), inject: [OPTIONS_TYPE], }, LoggingInterceptor, ], exports: [LoggerServiceImpl, Logger, LoggingInterceptor], }; } static registerAsync(options: typeof ASYNC_OPTIONS_TYPE) { const dynamicModule = super.registerAsync(options); return { ...dynamicModule, providers: [ ...(dynamicModule.providers || []), LoggerServiceImpl, { provide: Logger, useFactory: (opts: any) => new LoggerServiceImpl(opts), inject: [OPTIONS_TYPE], }, LoggingInterceptor, ], exports: [LoggerServiceImpl, Logger, LoggingInterceptor], }; } } ================================================ FILE: packages/logger/src/logger.service.ts ================================================ import { Injectable, LoggerService as NestLoggerService, Scope } from '@nestjs/common'; import pino, { Logger as PinoLogger, BaseLogger } from 'pino'; import { AsyncLocalStorage } from 'async_hooks'; import { LoggerModuleOptions, LogLevel, LogContext, LogEntry, } from './logger.types'; // Async local storage for request context const asyncLocalStorage = new AsyncLocalStorage(); @Injectable({ scope: Scope.TRANSIENT }) export class LoggerServiceImpl implements NestLoggerService { private logger: BaseLogger; private name: string; private baseContext: Partial; constructor(options: LoggerModuleOptions = {}) { this.name = options.name || 'app'; this.baseContext = options.base || {}; const pinoOptions: pino.LoggerOptions = { level: options.level || 'info', name: this.name, base: { service: this.name, ...this.baseContext, }, timestamp: pino.stdTimeFunctions.isoTime, formatters: { level: (label: string) => ({ level: label }), }, ...(options.json !== false && { // Default to JSON for K8s stdout baseCrypter: options.redact ? pino.stdSerializers.noop : undefined, }), }; if (options.prettyPrint || process.env.NODE_ENV === 'development') { pinoOptions.transport = { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname', }, }; } this.logger = pino(pinoOptions); } // ========================================================================= // Basic Logging Methods // ========================================================================= log(message: string, context?: string): void; log(level: LogLevel, message: string, context?: string): void; log(levelOrMessage: string | LogLevel, messageOrContext?: string | LogContext, context?: string): void { if (typeof levelOrMessage === 'string' && this.isLogLevel(levelOrMessage)) { // overload: (level, message, context?) const level = levelOrMessage; const message = messageOrContext as string; this.logAtLevel(level, message); } else if (typeof levelOrMessage === 'string') { // overload: (message, context?) const message = levelOrMessage; const ctx = messageOrContext as LogContext | undefined; this.logAtLevel('info', message, ctx); } else { this.logAtLevel('info', levelOrMessage); } } fatal(message: string, context?: Partial): void { this.logAtLevel('fatal', message, context); } error(message: string, context?: Partial): void; error(error: Error, context?: Partial): void; error(errorOrMessage: Error | string, context?: Partial): void { if (errorOrMessage instanceof Error) { this.logError(errorOrMessage, context); } else { this.logAtLevel('error', errorOrMessage, context); } } warn(message: string, context?: Partial): void { this.logAtLevel('warn', message, context); } info(message: string, context?: Partial): void { this.logAtLevel('info', message, context); } debug(message: string, context?: Partial): void { this.logAtLevel('debug', message, context); } trace(message: string, context?: Partial): void { this.logAtLevel('trace', message, context); } verbose(message: string, context?: Partial): void { this.logAtLevel('trace', message, context); } // ========================================================================= // Child Logger // ========================================================================= child(context: Partial): LoggerService { const childLogger = new LoggerServiceImpl({ name: this.name, base: { ...this.baseContext, ...this.getMergedContext(context), }, }); return childLogger; } // ========================================================================= // Request Context // ========================================================================= static getRequestContext(): LogContext | undefined { return asyncLocalStorage.getStore(); } static runWithContext(context: LogContext, fn: () => T): T { return asyncLocalStorage.run(context, fn); } static setRequestContext(context: LogContext): void { asyncLocalStorage.enterWith(context); } // ========================================================================= // HTTP Interceptor Support // ========================================================================= logRequest(options: { method: string; url: string; headers?: Record; body?: unknown; requestId?: string; startTime: number; statusCode?: number; error?: Error; }): void { const { method, url, requestId, startTime, statusCode, error, body } = options; const context: Partial = { requestId, method, url, statusCode, responseTime: Date.now() - startTime, }; if (error) { this.error(error, context); } else if (statusCode && statusCode >= 400) { this.warn(`${method} ${url} ${statusCode}`, context); } else { this.info(`${method} ${url} ${statusCode}`, context); } } // ========================================================================= // Private Methods // ========================================================================= private logAtLevel(level: LogLevel, message: string, context?: Partial): void { const mergedContext = this.getMergedContext(context); const logFn = this.logger[level as keyof typeof this.logger] as (msg: string, obj?: Record) => void; if (logFn) { logFn.call(this.logger, message, mergedContext); } else { this.logger.info({ ...mergedContext, msg: message, level }); } } private logError(error: Error, context?: Partial): void { const mergedContext = this.getMergedContext(context); const errorLog = { message: error.message, name: error.name, stack: error.stack, code: (error as any).code, cause: error.cause instanceof Error ? error.cause.message : undefined, }; this.logger.error( { ...mergedContext, err: errorLog }, error.message, ); } private getMergedContext(context?: Partial): Record { const storeContext = asyncLocalStorage.getStore(); return { ...this.baseContext, ...storeContext, ...context, }; } private isLogLevel(value: string): value is LogLevel { return ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'].includes(value); } } // Re-export for convenience export { LoggerServiceImpl as Logger }; ================================================ FILE: packages/logger/src/logger.types.ts ================================================ // ============================================================================ // Logger Types - Structured logging with request tracing // ============================================================================ export interface LoggerModuleOptions { level?: LogLevel; name?: string; prettyPrint?: boolean; json?: boolean; redact?: string[]; base?: Record; } export type LogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; export interface LogContext { requestId?: string; userId?: string; organizationId?: string; correlationId?: string; userAgent?: string; ip?: string; method?: string; url?: string; statusCode?: number; responseTime?: number; [key: string]: unknown; } export interface LogEntry { level: LogLevel; time: string; name: string; msg: string; context?: LogContext; err?: ErrorLog; stack?: string; } export interface ErrorLog { message: string; name: string; stack?: string; cause?: string; code?: string; } export interface RequestLoggingOptions { excludePaths?: string[]; includeBody?: boolean; excludeBody?: boolean; headerName?: string; requestIdHeader?: string; } export interface LogInterceptorOptions { excludePaths?: string[]; logRequestBody?: boolean; logResponseBody?: boolean; logRequestHeaders?: boolean; } ================================================ FILE: packages/logger/src/logging.interceptor.ts ================================================ import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; import { Request, Response } from 'express'; import { LoggerServiceImpl } from './logger.service'; import { LogInterceptorOptions } from './logger.types'; @Injectable() export class LoggingInterceptor implements NestInterceptor { private readonly defaultOptions: Required = { excludePaths: ['/health', '/healthz', '/ready', '/metrics'], logRequestBody: false, logResponseBody: false, logRequestHeaders: false, }; constructor( private readonly logger: LoggerServiceImpl, private readonly options: LogInterceptorOptions = {}, ) { this.options = { ...this.defaultOptions, ...options }; } intercept(context: ExecutionContext, next: CallHandler): Observable { const ctx = context.switchToHttp(); const request = ctx.getRequest(); const response = ctx.getResponse(); const { method, url, headers, body } = request; const requestId = (headers['x-request-id'] || headers['x-correlation-id'] || crypto.randomUUID()) as string; const startTime = Date.now(); // Skip excluded paths if (this.isExcludedPath(url)) { return next.handle(); } // Set request context LoggerServiceImpl.setRequestContext({ requestId, method, url, userAgent: headers['user-agent'] as string, ip: this.getClientIp(request), ...(this.options.logRequestHeaders && { headers }), ...(this.options.logRequestBody && body && { requestBody: body }), }); const logRequest = () => { const statusCode = response.statusCode; const responseTime = Date.now() - startTime; this.logger.logRequest({ method, url, requestId, startTime, statusCode, }); }; return next.handle().pipe( tap(() => { logRequest(); }), catchError((error) => { logRequest(); this.logger.error(error, { requestId, method, url, statusCode: error.status || 500, }); throw error; }), ); } private isExcludedPath(url: string): boolean { return this.options.excludePaths.some( (path) => url === path || url.startsWith(path + '/'), ); } private getClientIp(request: Request): string { return ( (request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || (request.headers['x-real-ip'] as string) || request.socket?.remoteAddress || 'unknown', ); } } // Re-export export { LoggingInterceptor }; ================================================ FILE: packages/logger/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/__tests__"] } ================================================ FILE: packages/nats/package.json ================================================ { "name": "@a3s-lab/nats", "version": "0.0.1", "description": "NestJS module for NATS message queue - publish/subscribe and request/reply patterns", "author": "A3S Lab", "license": "MIT", "keywords": [ "nestjs", "nats", "message-queue", "messaging", "pubsub", "request-reply" ], "source": "./src/index.ts", "main": "./dist/index.js", "types": "./dist/index.d.ts", "sideEffects": false, "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "scripts": { "build": "tsc", "test": "jest", "test:cov": "jest --coverage", "clean": "rimraf dist && rimraf node_modules", "prepublishOnly": "pnpm run build" }, "dependencies": { "nats": "^2.28.0" }, "devDependencies": { "@biomejs/biome": "^2.3.14", "@nestjs/common": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/jest": "^29.5.0", "jest": "^29.5.0", "reflect-metadata": "^0.1.13", "rimraf": "^6.0.1", "rxjs": "^7.8.1", "ts-jest": "^29.1.0", "typescript": "^5.1.3" }, "peerDependencies": { "@nestjs/common": "^10.0.0" }, "publishConfig": { "access": "public" }, "jest": { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": ["**/*.(t|j)s", "!**/__tests__/**"], "coverageDirectory": "../coverage", "testEnvironment": "node" } } ================================================ FILE: packages/nats/src/index.ts ================================================ export * from './nats.module'; export * from './nats.service'; export * from './nats.types'; export * from './nats.module-definition'; ================================================ FILE: packages/nats/src/nats.module-definition.ts ================================================ import { ConfigurableModuleBuilder } from '@nestjs/common'; import { NatsPackageOptions } from './nats.types'; /** * Configurable module builder for NatsModule * Provides both synchronous and asynchronous registration methods * with optional global module configuration */ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE, } = new ConfigurableModuleBuilder() .setExtras( { isGlobal: true, }, (definition, extras) => ({ ...definition, global: extras.isGlobal, }), ) .build(); ================================================ FILE: packages/nats/src/nats.module.ts ================================================ import { DynamicModule, Module } from '@nestjs/common'; import { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, OPTIONS_TYPE, } from './nats.module-definition'; import { NatsServiceImpl } from './nats.service'; @Module({}) export class NatsModule extends ConfigurableModuleClass { static register(options: typeof OPTIONS_TYPE): DynamicModule { const dynamicModule = super.register(options); return { ...dynamicModule, providers: [...(dynamicModule.providers || []), NatsServiceImpl], exports: [NatsServiceImpl], }; } static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule { const dynamicModule = super.registerAsync(options); return { ...dynamicModule, providers: [...(dynamicModule.providers || []), NatsServiceImpl], exports: [NatsServiceImpl], }; } } ================================================ FILE: packages/nats/src/nats.service.ts ================================================ import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common'; import { connect, NatsConnection, JetStreamClient, StringCodec, Msg, Subscription as NatsSubscription } from 'nats'; import { NatsPackageOptions, NatsConnectionState, PublishOptions, RequestOptions, SubscribeOptions, NatsMessage, SubscriptionHandler, JetStreamPublishOptions, JetStreamSubscribeOptions, NatsError, NatsConnectionError, NatsPublishError, NatsSubscribeError, NatsRequestError, } from './nats.types'; // Local type for subscription return values (not an interface - no implementation contract) export interface Subscription { sid: number; subject: string; queue?: string; cancel(): void; isCancelled(): boolean; } @Injectable() export class NatsServiceImpl implements OnModuleInit, OnModuleDestroy { private connection: NatsConnection | null = null; private jetStream: JetStreamClient | null = null; private subscriptions: Map = new Map(); private connectionState: NatsConnectionState; private readonly logger = new Logger(NatsServiceImpl.name); private readonly stringCodec = StringCodec(); constructor(private readonly options: NatsPackageOptions) { this.connectionState = { connected: false, server: '', reconnectCount: 0, }; } async onModuleInit() { await this.connect(); } async onModuleDestroy() { await this.disconnect(); } // ========================================================================= // Connection Management // ========================================================================= private async connect(): Promise { try { const servers = this.options.servers || ['nats://localhost:4222']; this.connection = await connect({ servers, name: this.options.name || 'nestjs-nats', user: this.options.user, password: this.options.pass, token: this.options.token, maxReconnectAttempts: this.options.maxReconnectAttempts ?? -1, reconnectTimeWait: this.options.reconnectTimeWait ?? 2000, timeout: this.options.timeout ?? 10000, pingInterval: this.options.pingInterval ?? 60000, maxPingOut: this.options.maxPingOut ?? 2, ...(this.options.tls && { tls: { certFile: this.options.tls.certFile, keyFile: this.options.tls.keyFile, caFile: this.options.tls.caFile, verify: this.options.tls.verify ?? true, }, }), }); this.connectionState = { connected: true, server: this.connection.getServer(), reconnectCount: 0, }; this.connection.closed().then((err) => { if (err) { this.logger.error(`NATS connection closed with error: ${err.message}`); } this.connectionState.connected = false; }); this.connection.on('reconnect', () => { this.connectionState.connected = true; this.connectionState.server = this.connection?.getServer() || ''; this.connectionState.reconnectCount++; this.logger.log(`NATS reconnected to ${this.connectionState.server}`); }); this.connection.on('error', (err) => { this.logger.error(`NATS connection error: ${err.message}`); this.connectionState.lastError = err.message; }); if (this.options.jetstream?.enabled !== false) { this.jetStream = this.connection.jetstream(); } this.logger.log(`Connected to NATS at ${this.connectionState.server}`); } catch (error) { const err = error as Error; this.connectionState.lastError = err.message; this.connectionState.connected = false; throw new NatsConnectionError( this.options.servers?.[0] || 'localhost:4222', err.message, ); } } private async disconnect(): Promise { try { for (const [sid, sub] of this.subscriptions) { sub.unsubscribe(); this.subscriptions.delete(sid); } if (this.connection) { await this.connection.close(); this.connection = null; this.jetStream = null; this.connectionState.connected = false; this.logger.log('NATS connection closed'); } } catch (error) { this.logger.error(`Error closing NATS connection: ${(error as Error).message}`); } } async getConnection(): Promise { if (!this.connection || !this.connectionState.connected) { await this.connect(); } return this.connection!; } async getJetStream(): Promise { if (!this.jetStream) { await this.getConnection(); } return this.jetStream!; } getState(): NatsConnectionState { return { ...this.connectionState }; } async isHealthy(): Promise { try { if (!this.connection || this.connectionState.connected === false) { return false; } return true; } catch { return false; } } // ========================================================================= // Publish / Subscribe // ========================================================================= async publish(options: PublishOptions): Promise { try { const conn = await this.getConnection(); const data = this.encodeData(options.data); conn.publish(options.subject, data, { ...(options.headers && { headers: options.headers }), ...(options.reply && { reply: options.reply }), }); this.logger.debug(`Published to ${options.subject}`); } catch (error) { throw new NatsPublishError( options.subject, (error as Error).message, ); } } async pubsub(subject: string, data: object): Promise { await this.publish({ subject, data, }); } // ========================================================================= // Request / Reply // ========================================================================= async request(options: RequestOptions): Promise { try { const conn = await this.getConnection(); const data = this.encodeData(options.data); const timeout = options.timeout ?? 5000; const msg = await conn.request(options.subject, data, { timeout, ...(options.headers && { headers: options.headers }), }); return this.convertMessage(msg); } catch (error) { throw new NatsRequestError( options.subject, (error as Error).message, ); } } async request$(subject: string, data?: object): Promise { const msg = await this.request({ subject, data, }); return this.decodeData(msg.data) as T; } // ========================================================================= // Subscribe // ========================================================================= async subscribe( options: SubscribeOptions, handler: SubscriptionHandler, ): Promise { try { const conn = await this.getConnection(); const sub = conn.subscribe(options.subject, { queue: options.queue, ...(options.config && { config: options.config }), ...(options.durable && { durable: options.durable }), }); this.subscriptions.set(sub.sid, sub); this.handleSubscription(sub, handler, options); this.logger.log(`Subscribed to ${options.subject}${options.queue ? ` (queue: ${options.queue})` : ''}`); return { sid: sub.sid, subject: options.subject, queue: options.queue, cancel: () => { sub.unsubscribe(); this.subscriptions.delete(sub.sid); }, isCancelled: () => sub.isCancelled(), }; } catch (error) { throw new NatsSubscribeError( options.subject, (error as Error).message, ); } } async subscribe$( subject: string, handler: (data: unknown) => Promise, ): Promise { return this.subscribe( { subject }, async (msg: NatsMessage) => { const data = this.decodeData(msg.data); await handler(data); }, ); } unsubscribe(subscription: Subscription): void { const sub = this.subscriptions.get(subscription.sid); if (sub) { sub.unsubscribe(); this.subscriptions.delete(subscription.sid); } } private async handleSubscription( sub: NatsSubscription, handler: SubscriptionHandler, options: SubscribeOptions, ): Promise { (async () => { for await (const msg of sub) { const natsMsg = this.convertMessage(msg); try { await handler(natsMsg); if (!options.manualAck) { msg.ack(); } } catch (error) { this.logger.error(`Error handling message on ${options.subject}: ${(error as Error).message}`); if (!options.manualAck) { msg.ack(); } } } })(); } // ========================================================================= // JetStream // ========================================================================= async jsPublish(options: JetStreamPublishOptions): Promise { try { const js = await this.getJetStream(); const data = this.encodeData(options.data); const jsm = js as unknown as { publish( subject: string, data?: Uint8Array, options?: { timeout?: number; headers?: Record; wait?: boolean; }, ): Promise; }; const pubAck = await jsm.publish(options.subject, data, { timeout: options.timeout ?? 5000, headers: options.headers, wait: options.wait ?? true, }); this.logger.debug(`JetStream published to ${options.subject} in stream ${options.stream}`); return pubAck; } catch (error) { throw new NatsPublishError( `${options.stream}:${options.subject}`, (error as Error).message, ); } } async jsSubscribe( options: JetStreamSubscribeOptions, handler: SubscriptionHandler, ): Promise { try { const js = await this.getJetStream(); const opts = { stream: options.stream, ...(options.deliverSubject && { deliverSubject: options.deliverSubject }), ...(options.config && { config: options.config }), ...(options.durable && { durable: options.durable }), ...(options.queue && { queue: options.queue }), }; const sub = (js as unknown as { subscribe( subject: string, opts?: { stream?: string; deliverSubject?: string; durable?: string; queue?: string; config?: Record; }, ): NatsSubscription; }).subscribe(options.subject, opts); this.subscriptions.set(sub.sid, sub); this.handleSubscription(sub, handler, options); this.logger.log(`JetStream subscribed to ${options.subject} in stream ${options.stream}`); return { sid: sub.sid, subject: options.subject, queue: options.queue, cancel: () => { sub.unsubscribe(); this.subscriptions.delete(sub.sid); }, isCancelled: () => sub.isCancelled(), }; } catch (error) { throw new NatsSubscribeError( `${options.stream}:${options.subject}`, (error as Error).message, ); } } // ========================================================================= // Helpers // ========================================================================= private encodeData(data?: Uint8Array | string | object): Uint8Array { if (!data) { return new Uint8Array(0); } if (data instanceof Uint8Array) { return data; } if (typeof data === 'string') { return this.stringCodec.encode(data); } return this.stringCodec.encode(JSON.stringify(data)); } private decodeData(data: Uint8Array): unknown { if (!data || data.length === 0) { return null; } try { const str = this.stringCodec.decode(data); try { return JSON.parse(str); } catch { return str; } } catch { return null; } } private convertMessage(msg: Msg): NatsMessage { const headers: Record = {}; if (msg.headers) { msg.headers.forEach((value, key) => { headers[key] = value; }); } return { subject: msg.subject, sid: 0, data: msg.data, headers, reply: msg.reply, timestamp: Date.now(), }; } } ================================================ FILE: packages/nats/src/nats.types.ts ================================================ // ============================================================================ // NATS Types - Re-exported from module-definition for convenience // ============================================================================ import type { JetStreamClient, NatsConnection, PubAck } from 'nats'; export interface NatsPackageOptions { servers?: string[]; name?: string; user?: string; pass?: string; token?: string; maxReconnectAttempts?: number; reconnectTimeWait?: number; timeout?: number; pingInterval?: number; maxPingOut?: number; tls?: TlsOptions; auth?: AuthOptions; jetstream?: JetStreamOptions; } /** @deprecated Use NatsPackageOptions instead */ export type NatsModuleOptions = NatsPackageOptions; export interface TlsOptions { certFile?: string; keyFile?: string; caFile?: string; verify?: boolean; } export interface AuthOptions { user?: string; pass?: string; token?: string; } export interface JetStreamOptions { enabled?: boolean; domain?: string; prefix?: string; } export interface NatsConnectionState { connected: boolean; server: string; lastError?: string; reconnectCount: number; } // ============================================================================ // Publisher Types // ============================================================================ export interface PublishOptions { subject: string; data?: Uint8Array | string | object; headers?: Record; reply?: string; timeout?: number; } export interface RequestOptions extends PublishOptions { expectedResponseCount?: number; headers?: Record; } export interface PubAckPromise { promise: Promise; subject: string; data?: Uint8Array | string | object; } // ============================================================================ // Subscriber Types // ============================================================================ export interface SubscribeOptions { subject: string; queue?: string; stream?: string; durable?: string; manualAck?: boolean; config?: SubscriptionConfig; } export interface SubscriptionConfig { deliverPolicy?: DeliverPolicy; ackPolicy?: AckPolicy; ackWait?: number; maxDeliver?: number; maxAckPending?: number; replayPolicy?: ReplayPolicy; rateLimit?: number; samplingRate?: number; headersOnly?: boolean; maxMessages?: number; } export type DeliverPolicy = | 'all' | 'last' | 'new' | 'first' | 'last_per_subject' | 'by_start_sequence' | 'by_start_time'; export type AckPolicy = | 'none' | 'all' | 'explicit' | 'allInclusive'; export type ReplayPolicy = | 'instant' | 'original' | 'by_start_time' | 'last'; export interface NatsMessage { subject: string; sid: number; data: Uint8Array; headers?: Record; reply?: string; timestamp: number; } export interface SubscriptionHandler { (message: NatsMessage): Promise | void; } // ============================================================================ // JetStream Types // ============================================================================ export interface JetStreamPublishOptions { stream: string; subject: string; data?: Uint8Array | string | object; headers?: Record; timeout?: number; wait?: boolean; } export interface JetStreamSubscribeOptions extends SubscribeOptions { stream: string; deliverSubject?: string; config?: StreamSubscriptionConfig; } export interface StreamSubscriptionConfig extends SubscriptionConfig { filterSubject?: string; subjectTransform?: { src: string; dest: string; }; idleHeartbeat?: number; flowControl?: boolean; active?: boolean; startSeq?: number; startTime?: Date; } export interface StreamInfo { config: StreamConfig; state: StreamState; created: Date; cluster?: ClusterInfo; } export interface StreamConfig { name: string; subjects?: string[]; retention?: RetentionPolicy; maxConsumers?: number; maxMsgs?: number; maxBytes?: number; maxAge?: number; storage?: StorageType; replicas?: number; template?: string; denyDelete?: boolean; denyPurge?: boolean; allowRollup?: boolean; } export type RetentionPolicy = | 'limits' | 'interest' | 'workqueue'; export type StorageType = | 'file' | 'memory'; export interface StreamState { messages: number; bytes: number; firstSeq: number; firstTs: Date; lastSeq: number; lastTs: Date; consumerCount: number; numSubjects?: number; } export interface ClusterInfo { leader: string; replicas: PeerInfo[]; } export interface PeerInfo { name: string; current: boolean; offline: boolean; active: number; lag?: number; } // ============================================================================ // Errors // ============================================================================ export class NatsError extends Error { constructor( message: string, public code: string, public statusCode: number = 500, ) { super(message); this.name = 'NatsError'; } } export class NatsConnectionError extends NatsError { constructor(server: string, reason?: string) { super( `Failed to connect to NATS server ${server}: ${reason || 'Unknown error'}`, 'NATS_CONNECTION_ERROR', 503, ); } } export class NatsPublishError extends NatsError { constructor(subject: string, reason?: string) { super( `Failed to publish to ${subject}: ${reason || 'Unknown error'}`, 'NATS_PUBLISH_ERROR', 500, ); } } export class NatsSubscribeError extends NatsError { constructor(subject: string, reason?: string) { super( `Failed to subscribe to ${subject}: ${reason || 'Unknown error'}`, 'NATS_SUBSCRIBE_ERROR', 500, ); } } export class NatsRequestError extends NatsError { constructor(subject: string, reason?: string) { super( `Request to ${subject} failed: ${reason || 'Unknown error'}`, 'NATS_REQUEST_ERROR', 504, ); } } ================================================ FILE: packages/nats/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/__tests__"] } ================================================ FILE: packages/redisson/package.json ================================================ { "name": "@a3s-lab/redisson", "version": "1.0.0", "description": "NestJS module for Redis distributed locks and caching using Redisson", "author": "ZhiXiao-Lin", "license": "MIT", "homepage": "https://github.com/ZhiXiao-Lin/nestify/tree/main/packages/redisson#readme", "repository": { "type": "git", "url": "git+https://github.com/ZhiXiao-Lin/nestify.git", "directory": "packages/redisson" }, "bugs": { "url": "https://github.com/ZhiXiao-Lin/nestify/issues" }, "keywords": [ "nestjs", "redis", "redisson", "distributed-lock", "cache", "ioredis", "typescript", "mutex", "semaphore" ], "source": "./src/index.ts", "main": "./dist/index.js", "types": "./dist/index.d.ts", "sideEffects": false, "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "scripts": { "build": "tsc", "test": "jest", "test:cov": "jest --coverage", "clean": "rimraf dist && rimraf node_modules", "prepublishOnly": "pnpm run build" }, "dependencies": { "ioredis": "^5.9.1", "node-redisson": "^1.0.3" }, "devDependencies": { "@biomejs/biome": "^2.3.14", "@nestjs/common": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/jest": "^29.5.0", "jest": "^29.5.0", "reflect-metadata": "^0.1.13", "rimraf": "^6.0.1", "rxjs": "^7.8.1", "ts-jest": "^29.1.0", "typescript": "^5.1.3" }, "peerDependencies": { "@nestjs/common": "^10.0.0" }, "publishConfig": { "access": "public" }, "jest": { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": ["**/*.(t|j)s", "!**/__tests__/**"], "coverageDirectory": "../coverage", "testEnvironment": "node" } } ================================================ FILE: packages/redisson/src/__tests__/redisson.module.spec.ts ================================================ import { Test, TestingModule } from '@nestjs/testing'; import { RedissonModule } from '../redisson.module'; import { MODULE_OPTIONS_TOKEN } from '../redisson.module-definition'; describe('RedissonModule', () => { describe('register', () => { it('should create module with options', async () => { const mockOptions = { redis: { options: { host: 'localhost', port: 6379, }, }, }; const dynamicModule = RedissonModule.register(mockOptions); expect(dynamicModule).toBeDefined(); expect(dynamicModule.module).toBe(RedissonModule); expect(dynamicModule.providers).toBeDefined(); expect(dynamicModule.exports).toBeDefined(); }); }); describe('registerAsync', () => { it('should create module with async options', async () => { const mockOptions = { redis: { options: { host: 'localhost', port: 6379, }, }, }; const dynamicModule = RedissonModule.registerAsync({ useFactory: () => mockOptions, }); expect(dynamicModule).toBeDefined(); expect(dynamicModule.module).toBe(RedissonModule); expect(dynamicModule.providers).toBeDefined(); expect(dynamicModule.exports).toBeDefined(); }); it('should support inject option', async () => { const mockOptions = { redis: { options: { host: 'localhost', port: 6379, }, }, }; const dynamicModule = RedissonModule.registerAsync({ useFactory: () => mockOptions, inject: [], }); expect(dynamicModule).toBeDefined(); }); }); }); ================================================ FILE: packages/redisson/src/__tests__/types.spec.ts ================================================ import { CacheOptions, LockOptions, CacheMetadata, LockMetadata, BatchResult } from '../types'; describe('Types', () => { describe('CacheOptions', () => { it('should allow valid cache options', () => { const options: CacheOptions = { ttl: 3600, prefix: 'cache:', }; expect(options.ttl).toBe(3600); expect(options.prefix).toBe('cache:'); }); it('should allow partial cache options', () => { const options: CacheOptions = {}; expect(options.ttl).toBeUndefined(); expect(options.prefix).toBeUndefined(); }); }); describe('LockOptions', () => { it('should allow valid lock options', () => { const options: LockOptions = { waitTime: 5000, leaseTime: 10000, }; expect(options.waitTime).toBe(5000); expect(options.leaseTime).toBe(10000); }); }); describe('CacheMetadata', () => { it('should require key property', () => { const metadata: CacheMetadata = { key: 'test-key', ttl: 3600, }; expect(metadata.key).toBe('test-key'); expect(metadata.ttl).toBe(3600); }); }); describe('LockMetadata', () => { it('should require key property', () => { const metadata: LockMetadata = { key: 'lock-key', waitTime: 5000, leaseTime: 10000, }; expect(metadata.key).toBe('lock-key'); }); }); describe('BatchResult', () => { it('should track succeeded and failed items', () => { const result: BatchResult = { succeeded: ['item1', 'item2'], failed: [{ item: 'item3', error: new Error('Failed') }], }; expect(result.succeeded).toHaveLength(2); expect(result.failed).toHaveLength(1); expect(result.failed[0].error.message).toBe('Failed'); }); }); }); ================================================ FILE: packages/redisson/src/index.ts ================================================ export * from 'node-redisson'; export * from 'ioredis'; export * from './redisson-module-options.interface'; export * from './redisson.module'; export * from './redisson.service'; export * from './types'; ================================================ FILE: packages/redisson/src/redisson-module-options.interface.ts ================================================ import { IRedissonConfig } from 'node-redisson'; export interface RedissonModuleOptions extends IRedissonConfig {} ================================================ FILE: packages/redisson/src/redisson.module-definition.ts ================================================ import { ConfigurableModuleBuilder } from '@nestjs/common'; import { RedissonModuleOptions } from './redisson-module-options.interface'; export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = new ConfigurableModuleBuilder() .setExtras( { isGlobal: true, }, (definition, extras) => ({ ...definition, global: extras.isGlobal, }), ) .build(); ================================================ FILE: packages/redisson/src/redisson.module.ts ================================================ import { DynamicModule, Module } from '@nestjs/common'; import { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, OPTIONS_TYPE } from './redisson.module-definition'; import { RedissonService } from './redisson.service'; @Module({}) export class RedissonModule extends ConfigurableModuleClass { static register(options: typeof OPTIONS_TYPE): DynamicModule { const dynamicModule = super.register(options); return { ...dynamicModule, providers: [...(dynamicModule.providers || []), RedissonService], exports: [RedissonService], }; } static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule { const dynamicModule = super.registerAsync(options); return { ...dynamicModule, providers: [...(dynamicModule.providers || []), RedissonService], exports: [RedissonService], }; } } ================================================ FILE: packages/redisson/src/redisson.service.ts ================================================ import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Redisson } from 'node-redisson'; import { RedissonModuleOptions } from './redisson-module-options.interface'; import { MODULE_OPTIONS_TOKEN } from './redisson.module-definition'; @Injectable() export class RedissonService extends Redisson implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(RedissonService.name); constructor( @Inject(MODULE_OPTIONS_TOKEN) private readonly options: RedissonModuleOptions, ) { if (!options) { throw new Error('RedissonModuleOptions is not defined'); } super(options); } async onModuleInit() { try { // Test connection by pinging Redis await this.redis.ping(); this.logger.log('Successfully connected to Redis via Redisson'); } catch (error) { this.logger.error('Failed to connect to Redis', error); throw error; } } async onModuleDestroy() { try { await this.quit(); this.logger.log('Redis connection closed'); } catch (error) { this.logger.error('Error closing Redis connection', error); } } /** * 执行带锁的操作 * @param key 锁的键名 * @param callback 需要执行的回调函数 * @param waitTime 等待时间(毫秒) * @param leaseTime 锁的租期(毫秒) * @returns 回调函数的返回值 */ async withLock(key: string, callback: () => Promise | T, waitTime = 5000, leaseTime = 10000): Promise { const lock = this.getLock(key); const acquired = await lock.tryLock(waitTime, leaseTime); if (!acquired) { throw new Error(`Failed to acquire lock: ${key}`); } try { this.logger.debug(`Lock acquired: ${key}`); return await callback(); } finally { await lock.unlock(); this.logger.debug(`Lock released: ${key}`); } } /** * 缓存装饰器辅助方法 - 获取缓存或执行函数 * @param key 缓存键 * @param factory 数据工厂函数 * @param ttl 过期时间(秒) * @returns 缓存的数据或新数据 */ async getOrSet(key: string, factory: () => Promise | T, ttl?: number): Promise { // 尝试从缓存获取 const cached = await this.redis.get(key); if (cached) { try { return JSON.parse(cached) as T; } catch { // 如果解析失败,返回原始值 return cached as unknown as T; } } // 执行工厂函数获取数据 const data = await factory(); // 存储到缓存 const value = typeof data === 'string' ? data : JSON.stringify(data); if (ttl) { await this.redis.setex(key, ttl, value); } else { await this.redis.set(key, value); } return data; } /** * 批量删除匹配模式的键 * @param pattern 键的匹配模式 * @returns 删除的键数量 */ async deleteByPattern(pattern: string): Promise { const keys = await this.redis.keys(pattern); if (keys.length === 0) { return 0; } await this.redis.del(...keys); this.logger.debug(`Deleted ${keys.length} keys matching pattern: ${pattern}`); return keys.length; } /** * 设置带过期时间的 JSON 数据 * @param key 键名 * @param value 值(会自动序列化为 JSON) * @param ttl 过期时间(秒) */ async setJSON(key: string, value: T, ttl?: number): Promise { const serialized = JSON.stringify(value); if (ttl) { await this.redis.setex(key, ttl, serialized); } else { await this.redis.set(key, serialized); } } /** * 获取 JSON 数据 * @param key 键名 * @returns 反序列化的数据或 null */ async getJSON(key: string): Promise { const value = await this.redis.get(key); if (!value) { return null; } try { return JSON.parse(value) as T; } catch (error) { this.logger.error(`Failed to parse JSON for key: ${key}`, error); return null; } } /** * 检查键是否存在 * @param key 键名 * @returns 是否存在 */ async exists(key: string): Promise { const result = await this.redis.exists(key); return result === 1; } /** * 设置键的过期时间 * @param key 键名 * @param ttl 过期时间(秒) * @returns 是否成功 */ async expire(key: string, ttl: number): Promise { const result = await this.redis.expire(key, ttl); return result === 1; } /** * 删除键 * @param keys 要删除的键 * @returns 删除的键数量 */ async delete(...keys: string[]): Promise { return await this.redis.del(...keys); } /** * 增量操作 * @param key 键名 * @param increment 增量值(默认为 1) * @returns 增量后的值 */ async increment(key: string, increment = 1): Promise { return await this.redis.incrby(key, increment); } /** * 减量操作 * @param key 键名 * @param decrement 减量值(默认为 1) * @returns 减量后的值 */ async decrement(key: string, decrement = 1): Promise { return await this.redis.decrby(key, decrement); } /** * 获取键值 * @param key 键名 * @returns 键值或 null */ async get(key: string): Promise { return await this.redis.get(key); } /** * 设置键值 * @param key 键名 * @param value 值 * @param ttl 过期时间(秒),可选 */ async set(key: string, value: string, ttl?: number): Promise { if (ttl) { await this.redis.setex(key, ttl, value); } else { await this.redis.set(key, value); } } /** * 设置哈希字段 * @param key 哈希键 * @param field 字段名 * @param value 字段值 */ async hset(key: string, field: string, value: string): Promise { await this.redis.hset(key, field, value); } /** * 获取哈希字段 * @param key 哈希键 * @param field 字段名 * @returns 字段值或 null */ async hget(key: string, field: string): Promise { return await this.redis.hget(key, field); } /** * 获取整个哈希 * @param key 哈希键 * @returns 哈希对象 */ async hgetall(key: string): Promise> { return await this.redis.hgetall(key); } /** * 删除哈希字段 * @param key 哈希键 * @param fields 要删除的字段 * @returns 删除的字段数量 */ async hdel(key: string, ...fields: string[]): Promise { return await this.redis.hdel(key, ...fields); } /** * 执行 Lua 脚本 * @param script Lua 脚本内容 * @param keys 键数组 * @param args 参数数组 * @returns 脚本执行结果 */ async eval(script: string, keys: string[], args: string[]): Promise { const numKeys = keys.length; return await this.redis.eval(script, numKeys, ...keys, ...args); } /** * 执行已缓存的 Lua 脚本(通过 SHA1) * @param sha1 脚本的 SHA1 哈希 * @param keys 键数组 * @param args 参数数组 * @returns 脚本执行结果 */ async evalsha(sha1: string, keys: string[], args: string[]): Promise { const numKeys = keys.length; return await this.redis.evalsha(sha1, numKeys, ...keys, ...args); } /** * 加载 Lua 脚本并返回 SHA1 * @param script Lua 脚本内容 * @returns 脚本的 SHA1 哈希 */ async scriptLoad(script: string): Promise { return (await this.redis.call('SCRIPT', 'LOAD', script)) as string; } /** * 获取底层 Redis 客户端 (IORedis) * 用于需要直接访问 Redis 的高级操作 * @returns IORedis 客户端实例 */ getRedis(): ReturnType { return this.redis; } /** * 尝试获取分布式锁 * @param key 锁的键名 * @param waitTime 等待时间(毫秒) * @param leaseTime 锁的租期(毫秒) * @returns 是否成功获取锁 */ async tryLock(key: string, waitTime = 5000, leaseTime = 10000): Promise { const lock = this.getLock(key); return await lock.tryLock(waitTime, leaseTime); } /** * 释放分布式锁 * @param key 锁的键名 */ async unlock(key: string): Promise { const lock = this.getLock(key); await lock.unlock(); } } ================================================ FILE: packages/redisson/src/types.ts ================================================ /** * Redis 键值对操作的通用类型 */ export interface CacheOptions { /** 过期时间(秒) */ ttl?: number; /** 键的前缀 */ prefix?: string; } /** * 分布式锁配置选项 */ export interface LockOptions { /** 等待获取锁的时间(毫秒) */ waitTime?: number; /** 锁的租期时间(毫秒) */ leaseTime?: number; } /** * 缓存装饰器元数据 */ export interface CacheMetadata { /** 缓存键 */ key: string; /** 过期时间(秒) */ ttl?: number; } /** * 分布式锁装饰器元数据 */ export interface LockMetadata { /** 锁的键名 */ key: string; /** 等待时间(毫秒) */ waitTime?: number; /** 租期时间(毫秒) */ leaseTime?: number; } /** * 批量操作结果 */ export interface BatchResult { /** 成功的项 */ succeeded: T[]; /** 失败的项 */ failed: Array<{ item: T; error: Error }>; } ================================================ FILE: packages/redisson/tsconfig.json ================================================ { "compilerOptions": { "module": "Node16", "moduleResolution": "Node16", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "sourceMap": true, "incremental": true, "isolatedModules": true, "strictNullChecks": false, "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, "jsx": "react-jsx", "outDir": "dist", "target": "ES2021", "lib": ["DOM", "DOM.Iterable", "ES2021", "ES2022.Error"], "noErrorTruncation": true, "strict": true, "skipLibCheck": true, "baseUrl": ".", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*"] } ================================================ FILE: packages/rustfs/package.json ================================================ { "name": "@a3s-lab/rustfs", "version": "0.0.1", "description": "NestJS module for RustFS S3-compatible storage - object storage with bucket and file operations", "author": "A3S Lab", "license": "MIT", "keywords": [ "nestjs", "rustfs", "s3", "storage", "object-storage", "bucket" ], "source": "./src/index.ts", "main": "./dist/index.js", "types": "./dist/index.d.ts", "sideEffects": false, "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "scripts": { "build": "tsc", "test": "jest", "test:cov": "jest --coverage", "clean": "rimraf dist && rimraf node_modules", "prepublishOnly": "pnpm run build" }, "dependencies": { "@aws-sdk/client-s3": "^3.600.0", "@aws-sdk/s3-request-presigner": "^3.600.0" }, "devDependencies": { "@biomejs/biome": "^2.3.14", "@nestjs/common": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/jest": "^29.5.0", "jest": "^29.5.0", "reflect-metadata": "^0.1.13", "rimraf": "^6.0.1", "rxjs": "^7.8.1", "ts-jest": "^29.1.0", "typescript": "^5.1.3" }, "peerDependencies": { "@nestjs/common": "^10.0.0" }, "publishConfig": { "access": "public" }, "jest": { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "collectCoverageFrom": ["**/*.(t|j)s", "!**/__tests__/**"], "coverageDirectory": "../coverage", "testEnvironment": "node" } } ================================================ FILE: packages/rustfs/src/index.ts ================================================ export * from './rustfs.module'; export * from './rustfs.service'; export * from './rustfs.types'; export * from './rustfs.module-definition'; ================================================ FILE: packages/rustfs/src/rustfs.module-definition.ts ================================================ import { ConfigurableModuleBuilder } from '@nestjs/common'; import { RustFSPackageOptions } from './rustfs.types'; /** * Configurable module builder for RustFSModule * Provides both synchronous and asynchronous registration methods * with optional global module configuration */ export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE, } = new ConfigurableModuleBuilder() .setExtras( { isGlobal: true, }, (definition, extras) => ({ ...definition, global: extras.isGlobal, }), ) .build(); ================================================ FILE: packages/rustfs/src/rustfs.module.ts ================================================ import { DynamicModule, Module } from '@nestjs/common'; import { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, OPTIONS_TYPE, } from './rustfs.module-definition'; import { RustFSServiceImpl } from './rustfs.service'; @Module({}) export class RustFSModule extends ConfigurableModuleClass { static register(options: typeof OPTIONS_TYPE): DynamicModule { const dynamicModule = super.register(options); return { ...dynamicModule, providers: [...(dynamicModule.providers || []), RustFSServiceImpl], exports: [RustFSServiceImpl], }; } static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule { const dynamicModule = super.registerAsync(options); return { ...dynamicModule, providers: [...(dynamicModule.providers || []), RustFSServiceImpl], exports: [RustFSServiceImpl], }; } } ================================================ FILE: packages/rustfs/src/rustfs.service.ts ================================================ import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; import { S3Client, CreateBucketCommand, ListBucketsCommand, DeleteBucketCommand, HeadBucketCommand, GetObjectCommand, PutObjectCommand, CopyObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, GetBucketAclCommand, PutBucketAclCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, ListPartsCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { RustFSPackageOptions, Bucket, CreateBucketOptions, BucketAcl, StorageObject, PutObjectOptions, GetObjectOptions, CopyObjectOptions, ListObjectsOptions, ListObjectsResult, PresignedUrlOptions, PresignedPostOptions, CreateMultipartUploadOptions, UploadPartOptions, CompleteMultipartUploadOptions, ListPartsOptions, ListPartsResult, UploadPart, RustFSError, BucketNotFoundError, ObjectNotFoundError, BucketAlreadyExistsError, } from './rustfs.types'; @Injectable() export class RustFSServiceImpl implements OnModuleInit { private client: S3Client; private defaultBucket: string; private readonly logger = new Logger(RustFSServiceImpl.name); constructor(private readonly options: RustFSPackageOptions) { this.defaultBucket = options.bucket || ''; this.client = this.createClient(); } async onModuleInit() { if (this.defaultBucket) { const exists = await this.bucketExists(this.defaultBucket); if (!exists) { this.logger.warn(`Default bucket '${this.defaultBucket}' does not exist`); } } this.logger.log(`RustFS client initialized for endpoint: ${this.options.endpoint}`); } private createClient(): S3Client { const config: Record = { endpoint: this.options.endpoint, region: this.options.region || 'us-east-1', credentials: { accessKeyId: this.options.accessKeyId, secretAccessKey: this.options.secretAccessKey, }, forcePathStyle: this.options.forcePathStyle ?? true, }; if (this.options.sslEnabled !== false) { config.tls = true; } if (this.options.timeout) { config.requestTimeout = this.options.timeout; } if (this.options.maxAttempts) { config.maxAttempts = this.options.maxAttempts; } return new S3Client(config as Parameters[0]); } // ========================================================================= // Bucket Operations // ========================================================================= async createBucket(options: CreateBucketOptions): Promise { try { const command = new CreateBucketCommand({ Bucket: options.name, ACL: options.acl, }); await this.client.send(command); this.logger.log(`Bucket created: ${options.name}`); return { name: options.name, creationDate: new Date(), }; } catch (error) { const err = error as { name?: string; message?: string }; if (err.name === 'BucketAlreadyOwnedByYou' || err.name === 'BucketAlreadyExists') { throw new BucketAlreadyExistsError(options.name); } throw new RustFSError( `Failed to create bucket: ${err.message}`, 'CREATE_BUCKET_ERROR', 500, ); } } async listBuckets(): Promise { const command = new ListBucketsCommand({}); const response = await this.client.send(command); return (response.Buckets || []).map((b) => ({ name: b.Name || '', creationDate: b.CreationDate || new Date(), })); } async getBucketAcl(bucketName: string): Promise { const command = new GetBucketAclCommand({ Bucket: bucketName }); const response = await this.client.send(command); return { owner: response.Owner?.ID || '', grants: (response.Grants || []).map((g) => ({ grantee: { type: g.Grantee?.Type as 'canonical' | 'group' | 'email', id: g.Grantee?.ID, uri: g.Grantee?.URI, emailAddress: g.Grantee?.EmailAddress, }, permission: g.Permission as BucketAcl['grants'][0]['permission'], })), }; } async setBucketAcl(bucketName: string, acl: BucketAcl): Promise { const command = new PutBucketAclCommand({ Bucket: bucketName, ACL: acl as unknown as string, }); await this.client.send(command); } async deleteBucket(bucketName: string): Promise { const command = new DeleteBucketCommand({ Bucket: bucketName }); await this.client.send(command); this.logger.log(`Bucket deleted: ${bucketName}`); } async bucketExists(bucketName: string): Promise { try { const command = new HeadBucketCommand({ Bucket: bucketName }); await this.client.send(command); return true; } catch { return false; } } // ========================================================================= // Object Operations // ========================================================================= async putObject(bucketName: string, options: PutObjectOptions): Promise { const command = new PutObjectCommand({ Bucket: bucketName, Key: options.key, Body: options.body, ContentType: options.contentType, ContentEncoding: options.contentEncoding, ContentDisposition: options.contentDisposition, ContentLanguage: options.contentLanguage, Metadata: options.metadata, ACL: options.acl, StorageClass: options.storageClass, Expires: options.expires, CacheControl: options.cacheControl, }); const response = await this.client.send(command); return { key: options.key, bucket: bucketName, etag: response.ETag || '', size: response.$metadata?.httpStatusCode || 0, lastModified: new Date(), contentType: options.contentType, metadata: options.metadata, storageClass: options.storageClass as StorageObject['storageClass'], versionId: response.VersionId, }; } async getObject(bucketName: string, options: GetObjectOptions): Promise { const command = new GetObjectCommand({ Bucket: bucketName, Key: options.key, Range: options.range ? `bytes=${options.range.start}-${options.range.end}` : undefined, IfMatch: options.ifMatch, IfNoneMatch: options.ifNoneMatch, IfModifiedSince: options.ifModifiedSince, IfUnmodifiedSince: options.ifUnmodifiedSince, }); try { const response = await this.client.send(command); const chunks: Uint8Array[] = []; if (response.Body) { for await (const chunk of response.Body as AsyncIterable) { chunks.push(chunk); } } return Buffer.concat(chunks); } catch (error) { const err = error as { name?: string }; if (err.name === 'NoSuchKey' || err.name === '404') { throw new ObjectNotFoundError(options.key, bucketName); } throw error; } } async getObjectMetadata(bucketName: string, key: string): Promise { const command = new HeadObjectCommand({ Bucket: bucketName, Key: key, }); try { const response = await this.client.send(command); return { key, bucket: bucketName, etag: response.ETag || '', size: response.ContentLength || 0, lastModified: response.LastModified || new Date(), contentType: response.ContentType, metadata: response.Metadata || {}, storageClass: response.StorageClass as StorageObject['storageClass'], versionId: response.VersionId, }; } catch (error) { const err = error as { name?: string }; if (err.name === 'NoSuchKey' || err.name === '404') { throw new ObjectNotFoundError(key, bucketName); } throw error; } } async copyObject(bucketName: string, options: CopyObjectOptions): Promise { const sourceBucket = options.sourceBucket || bucketName; const destinationBucket = options.destinationBucket || bucketName; const command = new CopyObjectCommand({ Bucket: destinationBucket, Key: options.destinationKey, CopySource: `/${sourceBucket}/${options.sourceKey}`, ACL: options.acl, Metadata: options.metadata, StorageClass: options.storageClass, }); const response = await this.client.send(command); return { key: options.destinationKey, bucket: destinationBucket, etag: response.ETag || '', size: 0, lastModified: new Date(), storageClass: options.storageClass as StorageObject['storageClass'], versionId: response.VersionId, }; } async deleteObject(bucketName: string, key: string): Promise { const command = new DeleteObjectCommand({ Bucket: bucketName, Key: key, }); await this.client.send(command); this.logger.debug(`Object deleted: ${key} from ${bucketName}`); } async deleteObjects(bucketName: string, keys: string[]): Promise { const command = new DeleteObjectsCommand({ Bucket: bucketName, Delete: { Objects: keys.map((key) => ({ Key: key })), }, }); const response = await this.client.send(command); if (response.Errors && response.Errors.length > 0) { this.logger.warn(`Failed to delete some objects: ${response.Errors.length}`); } } async listObjects(bucketName: string, options?: ListObjectsOptions): Promise { const command = new ListObjectsV2Command({ Bucket: bucketName, Prefix: options?.prefix, Delimiter: options?.delimiter, MaxKeys: options?.maxKeys || 1000, ContinuationToken: options?.continuationToken, StartAfter: options?.startAfter, }); const response = await this.client.send(command); return { objects: (response.Contents || []).map((obj) => ({ key: obj.Key || '', bucket: bucketName, etag: obj.ETag || '', size: obj.Size || 0, lastModified: obj.LastModified || new Date(), storageClass: obj.StorageClass as StorageObject['storageClass'], versionId: obj.VersionId, })), prefixes: (response.CommonPrefixes || []).map((p) => p.Prefix || ''), isTruncated: response.IsTruncated || false, nextContinuationToken: response.NextContinuationToken, keyCount: response.KeyCount || 0, maxKeys: response.MaxKeys || 1000, }; } // ========================================================================= // Presigned URLs // ========================================================================= async getPresignedUrl( bucketName: string, options: PresignedUrlOptions, ): Promise { const command = new GetObjectCommand({ Bucket: bucketName, Key: options.key, ...(options.contentType && { ContentType: options.contentType }), }); const url = await getSignedUrl(this.client, command, { expiresIn: options.expiresIn || 3600, }); return url; } async getPresignedPostUrl( bucketName: string, options: PresignedPostOptions, ): Promise<{ url: string; fields: Record }> { // Note: Presigned POST requires additional implementation // For now, return a simple presigned PUT URL as alternative const url = await this.getPresignedUrl(bucketName, { key: options.key, expiresIn: options.expiresIn || 3600, method: 'PUT', contentType: options.conditions?.contentType, }); return { url, fields: { 'Content-Type': options.conditions?.contentType || 'application/octet-stream', }, }; } // ========================================================================= // Multipart Upload // ========================================================================= async createMultipartUpload( bucketName: string, options: CreateMultipartUploadOptions, ): Promise { const command = new CreateMultipartUploadCommand({ Bucket: bucketName, Key: options.key, ContentType: options.contentType, Metadata: options.metadata, ACL: options.acl, StorageClass: options.storageClass, }); const response = await this.client.send(command); if (!response.UploadId) { throw new RustFSError('Failed to create multipart upload', 'MULTIPART_UPLOAD_ERROR'); } return response.UploadId; } async uploadPart(bucketName: string, options: UploadPartOptions): Promise { const command = new UploadPartCommand({ Bucket: bucketName, Key: options.key, UploadId: options.uploadId, PartNumber: options.partNumber, Body: options.body, ContentLength: options.contentLength, }); const response = await this.client.send(command); return response.ETag || ''; } async completeMultipartUpload( bucketName: string, options: CompleteMultipartUploadOptions, ): Promise { const command = new CompleteMultipartUploadCommand({ Bucket: bucketName, Key: options.key, UploadId: options.uploadId, MultipartUpload: { Parts: options.parts.map((p) => ({ PartNumber: p.partNumber, ETag: p.etag, })), }, }); const response = await this.client.send(command); return { key: options.key, bucket: bucketName, etag: response.ETag || '', size: 0, lastModified: new Date(), }; } async abortMultipartUpload(bucketName: string, key: string, uploadId: string): Promise { const command = new AbortMultipartUploadCommand({ Bucket: bucketName, Key: key, UploadId: uploadId, }); await this.client.send(command); } async listParts(bucketName: string, options: ListPartsOptions): Promise { const command = new ListPartsCommand({ Bucket: bucketName, Key: options.key, UploadId: options.uploadId, MaxParts: options.maxParts, PartNumberMarker: options.partNumberMarker, }); const response = await this.client.send(command); return { key: options.key, uploadId: options.uploadId, parts: (response.Parts || []).map((p) => ({ partNumber: p.PartNumber || 0, etag: p.ETag || '', checksumSHA256: p.ChecksumSHA256, })), isTruncated: response.IsTruncated || false, nextPartNumberMarker: response.NextPartNumberMarker, maxParts: response.MaxParts || 1000, }; } // ========================================================================= // Health Check // ========================================================================= async isHealthy(): Promise { try { const command = new ListBucketsCommand({}); await this.client.send(command); return true; } catch { return false; } } } // Re-export types for convenience export type { RustFSModuleOptions } from './rustfs.types'; export { RustFSError } from './rustfs.types'; ================================================ FILE: packages/rustfs/src/rustfs.types.ts ================================================ // ============================================================================ // RustFS Types - S3-compatible storage // ============================================================================ export interface RustFSPackageOptions { endpoint: string; region?: string; accessKeyId: string; secretAccessKey: string; bucket?: string; forcePathStyle?: boolean; sslEnabled?: boolean; timeout?: number; maxAttempts?: number; } /** @deprecated Use RustFSPackageOptions instead */ export type RustFSModuleOptions = RustFSPackageOptions; // ============================================================================ // Bucket Types // ============================================================================ export interface Bucket { name: string; creationDate: Date; } export interface CreateBucketOptions { name: string; acl?: BucketCannedAcl; region?: string; } export type BucketCannedAcl = | 'private' | 'public-read' | 'public-read-write' | 'authenticated-read' | 'log-delivery-write'; export interface BucketAcl { owner: string; grants: BucketGrant[]; } export interface BucketGrant { grantee: Grantee; permission: BucketPermission; } export interface Grantee { type: 'canonical' | 'group' | 'email'; id?: string; uri?: string; emailAddress?: string; } export type BucketPermission = 'READ' | 'WRITE' | 'READ_ACP' | 'WRITE_ACP' | 'FULL_CONTROL'; // ============================================================================ // Object Types // ============================================================================ export interface StorageObject { key: string; bucket: string; etag: string; size: number; lastModified: Date; contentType?: string; metadata?: Record; storageClass?: StorageClass; versionId?: string; } export type StorageClass = | 'STANDARD' | 'REDUCED_REDUNDANCY' | 'GLACIER' | 'DEEP_ARCHIVE' | 'INTELLIGENT_TIERING' | 'ONEZONE_INFREQUENT_ACCESS'; export interface PutObjectOptions { key: string; body?: Buffer | Uint8Array | string; contentType?: string; contentEncoding?: string; contentDisposition?: string; contentLanguage?: string; metadata?: Record; acl?: ObjectCannedAcl; storageClass?: StorageClass; expires?: Date; cacheControl?: string; } export type ObjectCannedAcl = | 'private' | 'public-read' | 'public-read-write' | 'authenticated-read' | 'aws-exec-read' | 'bucket-owner-read' | 'bucket-owner-full-control'; export interface GetObjectOptions { key: string; range?: { start: number; end: number; }; ifMatch?: string; ifNoneMatch?: string; ifModifiedSince?: Date; ifUnmodifiedSince?: Date; } export interface CopyObjectOptions { sourceKey: string; destinationKey: string; sourceBucket?: string; destinationBucket?: string; acl?: ObjectCannedAcl; metadata?: Record; storageClass?: StorageClass; } export interface ListObjectsOptions { prefix?: string; delimiter?: string; maxKeys?: number; continuationToken?: string; startAfter?: string; includeOwn?: boolean; } export interface ListObjectsResult { objects: StorageObject[]; prefixes: string[]; isTruncated: boolean; nextContinuationToken?: string; keyCount: number; maxKeys: number; } // ============================================================================ // Presigned URL Types // ============================================================================ export interface PresignedUrlOptions { key: string; expiresIn?: number; method?: 'GET' | 'PUT' | 'DELETE' | 'POST'; contentType?: string; queryParams?: Record; } export interface PresignedPostOptions { key: string; expiresIn?: number; conditions?: { contentLengthRange?: { min: number; max: number; }; contentType?: string; acl?: ObjectCannedAcl; }; } // ============================================================================ // Multipart Upload Types // ============================================================================ export interface CreateMultipartUploadOptions { key: string; contentType?: string; metadata?: Record; acl?: ObjectCannedAcl; storageClass?: StorageClass; } export interface UploadPartOptions { key: string; uploadId: string; partNumber: number; body?: Buffer | Uint8Array | string; contentLength?: number; checksumSHA256?: string; } export interface CompleteMultipartUploadOptions { key: string; uploadId: string; parts: UploadPart[]; } export interface UploadPart { partNumber: number; etag: string; checksumSHA256?: string; } export interface ListPartsOptions { key: string; uploadId: string; maxParts?: number; partNumberMarker?: number; } export interface ListPartsResult { key: string; uploadId: string; parts: UploadPart[]; isTruncated: boolean; nextPartNumberMarker?: number; maxParts: number; } // ============================================================================ // Errors // ============================================================================ export class RustFSError extends Error { constructor( message: string, public code: string, public statusCode: number = 500, ) { super(message); this.name = 'RustFSError'; } } export class BucketNotFoundError extends RustFSError { constructor(bucketName: string) { super( `Bucket not found: ${bucketName}`, 'BUCKET_NOT_FOUND', 404, ); } } export class ObjectNotFoundError extends RustFSError { constructor(key: string, bucketName: string) { super( `Object not found: ${key} in ${bucketName}`, 'OBJECT_NOT_FOUND', 404, ); } } export class BucketAlreadyExistsError extends RustFSError { constructor(bucketName: string) { super( `Bucket already exists: ${bucketName}`, 'BUCKET_ALREADY_EXISTS', 409, ); } } export class InvalidAccessKeyIdError extends RustFSError { constructor() { super( 'Invalid access key ID', 'INVALID_ACCESS_KEY_ID', 403, ); } } export class SignatureDoesNotMatchError extends RustFSError { constructor() { super( 'Signature does not match', 'SIGNATURE_DOES_NOT_MATCH', 403, ); } } export class RegionMismatchError extends RustFSError { constructor(expected: string, actual: string) { super( `Region mismatch: expected ${expected}, got ${actual}`, 'REGION_MISMATCH', 400, ); } } ================================================ FILE: packages/rustfs/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "composite": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/__tests__"] } ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - apps/* - packages/* ================================================ FILE: tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "module": "commonjs", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2024", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, "noImplicitAny": true, "strictBindCallApply": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["src/*"] } } }