diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0e230f3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules +npm-debug.log + +# Build output +dist + +# Test files +coverage +*.spec.ts +__tests__ + +# Development files +.env +.env.local +.env.*.local + +# IDE +.idea +.vscode +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Documentation +README.md +docs + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4688a1d --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# Template SaaS Backend Configuration +# Copy this file to .env and adjust values + +# Server +NODE_ENV=development +PORT=3001 + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=template_saas_dev +DB_USER=gamilit_user +DB_PASSWORD=GO0jAOgw8Yzankwt + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d + +# CORS +CORS_ORIGIN=http://localhost:3000 + +# Stripe Integration (optional - leave empty to disable) +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key +STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret + +# OAuth 2.0 Integration (optional - leave empty to disable) +# Generate encryption key with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +OAUTH_TOKEN_ENCRYPTION_KEY= +OAUTH_FRONTEND_CALLBACK_URL=http://localhost:5173/auth/oauth/callback + +# Google OAuth (https://console.cloud.google.com/apis/credentials) +OAUTH_GOOGLE_CLIENT_ID= +OAUTH_GOOGLE_CLIENT_SECRET= +OAUTH_GOOGLE_CALLBACK_URL=http://localhost:3001/api/auth/oauth/google/callback + +# Microsoft OAuth (https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps) +OAUTH_MICROSOFT_CLIENT_ID= +OAUTH_MICROSOFT_CLIENT_SECRET= +OAUTH_MICROSOFT_CALLBACK_URL=http://localhost:3001/api/auth/oauth/microsoft/callback + +# GitHub OAuth (https://github.com/settings/developers) +OAUTH_GITHUB_CLIENT_ID= +OAUTH_GITHUB_CLIENT_SECRET= +OAUTH_GITHUB_CALLBACK_URL=http://localhost:3001/api/auth/oauth/github/callback + +# Apple OAuth (https://developer.apple.com/account/resources/identifiers) +# 1. Create an App ID with Sign in with Apple capability +# 2. Create a Services ID for your web application +# 3. Create a Sign in with Apple key and download the .p8 file +OAUTH_APPLE_CLIENT_ID= # Services ID (e.g., com.example.app.web) +OAUTH_APPLE_TEAM_ID= # Your Apple Developer Team ID +OAUTH_APPLE_KEY_ID= # Key ID from the .p8 key +OAUTH_APPLE_PRIVATE_KEY= # Contents of .p8 file (replace newlines with \n) +OAUTH_APPLE_CALLBACK_URL=http://localhost:3001/api/auth/oauth/apple/form-callback diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5e0b2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +coverage/ +.env +.env.* +!.env.example +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..56c4464 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS production + +WORKDIR /app + +# Create non-root user for security +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nestjs -u 1001 + +# Copy package files +COPY package*.json ./ + +# Install only production dependencies +RUN npm ci --only=production && npm cache clean --force + +# Copy built application from builder stage +COPY --from=builder /app/dist ./dist + +# Set ownership to non-root user +RUN chown -R nestjs:nodejs /app + +# Switch to non-root user +USER nestjs + +# Expose port +EXPOSE 3001 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3001/health || exit 1 + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=3001 + +# Start the application +CMD ["node", "-r", "tsconfig-paths/register", "dist/main.js"] diff --git a/__mocks__/@nestjs/websockets.d.ts b/__mocks__/@nestjs/websockets.d.ts new file mode 100644 index 0000000..d929673 --- /dev/null +++ b/__mocks__/@nestjs/websockets.d.ts @@ -0,0 +1,16 @@ +// Type declarations for @nestjs/websockets mock +declare module '@nestjs/websockets' { + export function WebSocketGateway(options?: any): ClassDecorator; + export function WebSocketServer(): PropertyDecorator; + export function SubscribeMessage(message: string): MethodDecorator; + export function MessageBody(): ParameterDecorator; + export function ConnectedSocket(): ParameterDecorator; + + export interface OnGatewayConnection { + handleConnection(client: T, ...args: any[]): any; + } + + export interface OnGatewayDisconnect { + handleDisconnect(client: T): any; + } +} diff --git a/__mocks__/@nestjs/websockets.ts b/__mocks__/@nestjs/websockets.ts new file mode 100644 index 0000000..3fbbd76 --- /dev/null +++ b/__mocks__/@nestjs/websockets.ts @@ -0,0 +1,31 @@ +// Mock for @nestjs/websockets + +export const WebSocketGateway = (options?: any) => { + return (target: any) => target; +}; + +export const WebSocketServer = () => { + return (target: any, propertyKey: string) => {}; +}; + +export const SubscribeMessage = (message: string) => { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + return descriptor; + }; +}; + +export const MessageBody = () => { + return (target: any, propertyKey: string, parameterIndex: number) => {}; +}; + +export const ConnectedSocket = () => { + return (target: any, propertyKey: string, parameterIndex: number) => {}; +}; + +export interface OnGatewayConnection { + handleConnection(client: any, ...args: any[]): any; +} + +export interface OnGatewayDisconnect { + handleDisconnect(client: any): any; +} diff --git a/__mocks__/socket.io.d.ts b/__mocks__/socket.io.d.ts new file mode 100644 index 0000000..b850054 --- /dev/null +++ b/__mocks__/socket.io.d.ts @@ -0,0 +1,34 @@ +// Type declarations for socket.io mock +declare module 'socket.io' { + export class Server { + to(room: string): this; + in(room: string): this; + emit(event: string, ...args: any[]): boolean; + } + + export interface Socket { + id: string; + handshake: { + auth: Record; + query: Record; + headers: Record; + time: string; + address: string; + xdomain: boolean; + secure: boolean; + issued: number; + url: string; + }; + rooms: Set; + data: any; + connected: boolean; + join(room: string | string[]): void; + leave(room: string): void; + emit(event: string, ...args: any[]): boolean; + on(event: string, listener: Function): this; + once(event: string, listener: Function): this; + disconnect(close?: boolean): this; + to(room: string): this; + broadcast: any; + } +} diff --git a/__mocks__/socket.io.ts b/__mocks__/socket.io.ts new file mode 100644 index 0000000..26d93cd --- /dev/null +++ b/__mocks__/socket.io.ts @@ -0,0 +1,33 @@ +// Mock for socket.io + +export class Server { + to = jest.fn().mockReturnThis(); + emit = jest.fn(); + in = jest.fn().mockReturnThis(); +} + +export interface Socket { + id: string; + handshake: { + auth: Record; + query: Record; + headers: Record; + time: string; + address: string; + xdomain: boolean; + secure: boolean; + issued: number; + url: string; + }; + rooms: Set; + data: any; + connected: boolean; + join: (room: string | string[]) => void; + leave: (room: string) => void; + emit: (event: string, ...args: any[]) => boolean; + on: (event: string, listener: Function) => this; + once: (event: string, listener: Function) => this; + disconnect: (close?: boolean) => this; + to: (room: string) => this; + broadcast: any; +} diff --git a/__mocks__/web-push.d.ts b/__mocks__/web-push.d.ts new file mode 100644 index 0000000..1d85c22 --- /dev/null +++ b/__mocks__/web-push.d.ts @@ -0,0 +1,41 @@ +// Type declarations for web-push mock +declare module 'web-push' { + export function setVapidDetails( + subject: string, + publicKey: string, + privateKey: string + ): void; + + export function sendNotification( + subscription: PushSubscription, + payload?: string | Buffer | null, + options?: RequestOptions + ): Promise; + + export interface PushSubscription { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; + } + + export interface RequestOptions { + headers?: Record; + TTL?: number; + vapidDetails?: { + subject: string; + publicKey: string; + privateKey: string; + }; + timeout?: number; + proxy?: string; + agent?: any; + } + + export interface SendResult { + statusCode: number; + body: string; + headers: Record; + } +} diff --git a/__mocks__/web-push.ts b/__mocks__/web-push.ts new file mode 100644 index 0000000..0d2496e --- /dev/null +++ b/__mocks__/web-push.ts @@ -0,0 +1,18 @@ +// Mock for web-push + +export const setVapidDetails = jest.fn(); +export const sendNotification = jest.fn(); + +export interface PushSubscription { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export interface SendResult { + statusCode: number; + body: string; + headers: Record; +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0566128 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,71 @@ +import globals from 'globals'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; + +export default [ + // Source files - stricter rules + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: import.meta.dirname, + sourceType: 'module', + }, + globals: { + ...globals.node, + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': ['warn', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + ignoreRestSiblings: true, + }], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-empty-function': 'warn', + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/no-require-imports': 'off', + 'no-console': 'off', + 'prefer-const': 'error', + }, + }, + // Test files - relaxed rules + { + files: ['__tests__/**/*.ts', 'src/**/*.spec.ts', 'src/**/*.test.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: import.meta.dirname, + sourceType: 'module', + }, + globals: { + ...globals.node, + ...globals.jest, + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-function': 'off', + 'no-console': 'off', + }, + }, + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.js', '*.mjs'], + }, +]; diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 0000000..3b03737 --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,35 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': ['ts-jest', { isolatedModules: true }], + }, + collectCoverageFrom: ['**/*.(t|j)s'], + coverageDirectory: '../coverage', + testEnvironment: 'node', + moduleNameMapper: { + '^@config/(.*)$': '/config/$1', + '^@modules/(.*)$': '/modules/$1', + '^@shared/(.*)$': '/shared/$1', + '^uuid$': 'uuid', + '^@nestjs/websockets$': '/../__mocks__/@nestjs/websockets.ts', + '^socket\\.io$': '/../__mocks__/socket.io.ts', + '^web-push$': '/../__mocks__/web-push.ts', + }, + transformIgnorePatterns: [ + 'node_modules/(?!(uuid)/)', + ], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/dist/', + '/__tests__/', + '.module.ts', + '.entity.ts', + '.dto.ts', + 'main.ts', + 'index.ts', + ], + setupFilesAfterEnv: [], + testTimeout: 10000, +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..49f4003 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,11790 @@ +{ + "name": "@template-saas/backend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@template-saas/backend", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.964.0", + "@aws-sdk/s3-request-presigner": "^3.964.0", + "@nestjs/bullmq": "^11.0.4", + "@nestjs/common": "^11.1.8", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.1.8", + "@nestjs/jwt": "^11.0.1", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.1.8", + "@nestjs/swagger": "^11.2.1", + "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.0.0", + "@nestjs/typeorm": "^11.0.0", + "bcrypt": "^5.1.1", + "bullmq": "^5.66.4", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "helmet": "^8.1.0", + "ioredis": "^5.9.0", + "joi": "^18.0.1", + "jsonwebtoken": "^9.0.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", + "pg": "^8.11.3", + "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", + "reflect-metadata": "^0.1.14", + "rxjs": "^7.8.1", + "speakeasy": "^2.0.0", + "stripe": "^17.5.0", + "typeorm": "^0.3.22", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@nestjs/testing": "^11.1.8", + "@types/bcrypt": "^6.0.0", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.7.2", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.53.0", + "@typescript-eslint/parser": "^8.53.0", + "eslint": "^9.17.0", + "globals": "^17.0.0", + "jest": "^29.7.0", + "prettier": "^3.2.4", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "tsconfig-paths": "^3.15.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.964.0.tgz", + "integrity": "sha512-mDK+3qpfHnEPXeF6D8nQkJOkOvchllQosgfxv0FK9PNBuU9WVkP8yj7y3YwH6JYTgy1ejz1Ju/YfoUbbE6m7zw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/credential-provider-node": "3.964.0", + "@aws-sdk/middleware-bucket-endpoint": "3.957.0", + "@aws-sdk/middleware-expect-continue": "3.957.0", + "@aws-sdk/middleware-flexible-checksums": "3.964.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-location-constraint": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-sdk-s3": "3.964.0", + "@aws-sdk/middleware-ssec": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.964.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/signature-v4-multi-region": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.964.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/eventstream-serde-browser": "^4.2.7", + "@smithy/eventstream-serde-config-resolver": "^4.3.7", + "@smithy/eventstream-serde-node": "^4.2.7", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-blob-browser": "^4.2.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/hash-stream-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/md5-js": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.964.0.tgz", + "integrity": "sha512-IenVyY8Io2CwBgmS22xk/H5LibmSbvLnPA9oFqLORO6Ji1Ks8z/ow+ud/ZurVjFekz3LD/uxVFX3ZKGo6N7Byw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.964.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.964.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.964.0.tgz", + "integrity": "sha512-1gIfbt0KRxI8am1UYFcIxQ5QKb22JyN3k52sxyrKXJYC8Knn/rTUAZbYti45CfETe5PLadInGvWqClwGRlZKNg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@aws-sdk/xml-builder": "3.957.0", + "@smithy/core": "^3.20.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/signature-v4": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.957.0.tgz", + "integrity": "sha512-qSwSfI+qBU9HDsd6/4fM9faCxYJx2yDuHtj+NVOQ6XYDWQzFab/hUdwuKZ77Pi6goLF1pBZhJ2azaC2w7LbnTA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.964.0.tgz", + "integrity": "sha512-jWNSXOOBMYuxzI2rXi8x91YL07dhomyGzzh0CdaLej0LRmknmDrZcZNkVpa7Fredy1PFcmOlokwCS5PmZMN8ZQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.964.0.tgz", + "integrity": "sha512-up7dl6vcaoXuYSwGXDvx8RnF8Lwj3jGChhyUR7krZOXLarIfUUN3ILOZnVNK5s/HnVNkEILlkdPvjhr9LVC1/Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-stream": "^4.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.964.0.tgz", + "integrity": "sha512-t4FN9qTWU4nXDU6EQ6jopvyhXw0dbQ3n+3g6x5hmc1ECFAqA+xmFd1i5LljdZCi79cUXHduQWwvW8RJHMf0qJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/credential-provider-env": "3.964.0", + "@aws-sdk/credential-provider-http": "3.964.0", + "@aws-sdk/credential-provider-login": "3.964.0", + "@aws-sdk/credential-provider-process": "3.964.0", + "@aws-sdk/credential-provider-sso": "3.964.0", + "@aws-sdk/credential-provider-web-identity": "3.964.0", + "@aws-sdk/nested-clients": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.964.0.tgz", + "integrity": "sha512-c64dmTizMkJXDRzN3NYPTmUpKxegr5lmLOYPeQ60Zcbft6HFwPme8Gwy8pNxO4gG1fw6Ja2Vu6fZuSTn8aDFOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/nested-clients": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.964.0.tgz", + "integrity": "sha512-FHxDXPOj888/qc/X8s0x4aUBdp4Y3k9VePRehUJBWRhhTsAyuIJis5V0iQeY1qvtqHXYa2qd1EZHGJ3bTjHxSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.964.0", + "@aws-sdk/credential-provider-http": "3.964.0", + "@aws-sdk/credential-provider-ini": "3.964.0", + "@aws-sdk/credential-provider-process": "3.964.0", + "@aws-sdk/credential-provider-sso": "3.964.0", + "@aws-sdk/credential-provider-web-identity": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.964.0.tgz", + "integrity": "sha512-HaTLKqj3jeZY88E/iBjsNJsXgmRTTT7TghqeRiF8FKb/7UY1xEvasBO0c1xqfOye8dsyt35nTfTTyIsd/CBfww==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.964.0.tgz", + "integrity": "sha512-oR78TjSpjVf1IpPWQnGHEGqlnQs+K4f5nCxLK2P6JDPprXay6oknsoSiU4x2urav6VCyMPMC9KTCGjBoFKUIxQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.964.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/token-providers": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.964.0.tgz", + "integrity": "sha512-07JQDmbjZjOt3nL/j1wTcvQqjmPkynQYftUV/ooZ+qTbmJXFbCBdal1VCElyeiu0AgBq9dfhw0rBBcbND1ZMlA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/nested-clients": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.957.0.tgz", + "integrity": "sha512-iczcn/QRIBSpvsdAS/rbzmoBpleX1JBjXvCynMbDceVLBIcVrwT1hXECrhtIC2cjh4HaLo9ClAbiOiWuqt+6MA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-arn-parser": "3.957.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-config-provider": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.957.0.tgz", + "integrity": "sha512-AlbK3OeVNwZZil0wlClgeI/ISlOt/SPUxBsIns876IFaVu/Pj3DgImnYhpcJuFRek4r4XM51xzIaGQXM6GDHGg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.964.0.tgz", + "integrity": "sha512-IA2kSKkwC/HHFF75nTR7s/nWt5CboB6vMgpLpvx40Cc01cMp+06Jr7U2/+DPPc8fkCagTytchY4gX9Hzn5ej8g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/crc64-nvme": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.957.0.tgz", + "integrity": "sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.957.0.tgz", + "integrity": "sha512-y8/W7TOQpmDJg/fPYlqAhwA4+I15LrS7TwgUEoxogtkD8gfur9wFMRLT8LCyc9o4NMEcAnK50hSb4+wB0qv6tQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.957.0.tgz", + "integrity": "sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.957.0.tgz", + "integrity": "sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.964.0.tgz", + "integrity": "sha512-SeFcLo3tUdI3amzoIiArd9O0i7vAB0n5fgbQHBu137s3SbSLO5tPspE25rrUITwlc5HTbHMK6UzBq+3hITmImA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-arn-parser": "3.957.0", + "@smithy/core": "^3.20.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/signature-v4": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.957.0.tgz", + "integrity": "sha512-qwkmrK0lizdjNt5qxl4tHYfASh8DFpHXM1iDVo+qHe+zuslfMqQEGRkzxS8tJq/I+8F0c6v3IKOveKJAfIvfqQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.964.0.tgz", + "integrity": "sha512-/QyBl8WLNtqw3ucyAggumQXVCi8GRxaDGE1ElyYMmacfiwHl37S9y8JVW/QLL1lIEXGcsrhMUKV3pyFJFALA7w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@smithy/core": "^3.20.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.964.0.tgz", + "integrity": "sha512-ql+ftRwjyZkZeG3qbrRJFVmNR0id83WEUqhFVjvrQMWspNApBhz0Ar4YVSn7Uv0QaKkaR7ALPtmdMzFr3/E4bQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.964.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.964.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.957.0.tgz", + "integrity": "sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.964.0.tgz", + "integrity": "sha512-gKKdIZGYV8Ohm3X8j3y6Xr2ua1oD/Wsa3N7hYro3HqcnuGvl1h+mdw0IqUU+5yEzcoM5ItLJnH+6Q8Xz+Wv9gw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-format-url": "3.957.0", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.964.0.tgz", + "integrity": "sha512-ASQmO9EB2ukSTGpO7B2ZceSbNVivCLqWh89o/JJtcIdGpOu8p9XHpeK3hiUz2OQo2Igw03/n8s+DNvP+N9krpw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/signature-v4": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.964.0.tgz", + "integrity": "sha512-UqouLQbYepZnMFJGB/DVpA5GhF9uT98vNWSMz9PVbhgEPUKa73FECRT6YFZvZOh8kA+0JiENrnmS6d93I70ykQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.964.0", + "@aws-sdk/nested-clients": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.957.0.tgz", + "integrity": "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.957.0.tgz", + "integrity": "sha512-Aj6m+AyrhWyg8YQ4LDPg2/gIfGHCEcoQdBt5DeSFogN5k9mmJPOJ+IAmNSWmWRjpOxEy6eY813RNDI6qS97M0g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.957.0.tgz", + "integrity": "sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-endpoints": "^3.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.957.0.tgz", + "integrity": "sha512-Yyo/tlc0iGFGTPPkuxub1uRAv6XrnVnvSNjslZh5jIYA8GZoeEFPgJa3Qdu0GUS/YwoK8GOLnnaL9h/eH5LDJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/querystring-builder": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.957.0.tgz", + "integrity": "sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.957.0.tgz", + "integrity": "sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.964.0.tgz", + "integrity": "sha512-jgob8Z/bZIh1dwEgLqE12q+aCf0ieLy7anT8bWpqMijMJqsnrPBToa7smSykfom9YHrdOgrQhXswMpE75dzLRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.964.0", + "@aws-sdk/types": "3.957.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.957.0.tgz", + "integrity": "sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", + "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.4.tgz", + "integrity": "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@nestjs/bull-shared": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", + "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/bullmq": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/bullmq/-/bullmq-11.0.4.tgz", + "integrity": "sha512-wBzK9raAVG0/6NTMdvLGM4/FQ1lsB35/pYS8L6a0SDgkTiLpd7mAjQ8R692oMx5s7IjvgntaZOuTUrKYLNfIkA==", + "license": "MIT", + "dependencies": { + "@nestjs/bull-shared": "^11.0.4", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "bullmq": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz", + "integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==", + "license": "MIT", + "peer": true, + "dependencies": { + "file-type": "21.2.0", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz", + "integrity": "sha512-McMW6EXtpc8+CwTUwFdg6h7dYcBUpH5iUILCclAsa+MbCEvC9ZKu4dCHRlJqALuhjLw97pbQu62l4+wRwGeZqA==", + "license": "MIT", + "dependencies": { + "dotenv": "16.4.7", + "dotenv-expand": "12.0.1", + "lodash": "4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz", + "integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nuxt/opencollective": "0.4.1", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.3.0", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", + "integrity": "sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz", + "integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==", + "license": "MIT", + "peer": true, + "dependencies": { + "cors": "2.8.5", + "express": "5.2.1", + "multer": "2.0.2", + "path-to-regexp": "8.3.0", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/platform-express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-express/node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@nestjs/platform-express/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@nestjs/platform-express/node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@nestjs/platform-express/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@nestjs/platform-express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@nestjs/platform-express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/platform-express/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@nestjs/platform-express/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/platform-express/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@nestjs/swagger": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.4.tgz", + "integrity": "sha512-7MLqtHfD2qfhZUyg13FyX6liwigtXUL8gHXq7PaBcGo9cu8QWDDT//Fn3qzJx59+Wh+Ly/Zn+prCMpskPI5nrQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.0", + "js-yaml": "4.1.1", + "lodash": "4.17.21", + "path-to-regexp": "8.3.0", + "swagger-ui-dist": "5.31.0" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/terminus": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-11.0.0.tgz", + "integrity": "sha512-c55LOo9YGovmQHtFUMa/vDaxGZ2cglMTZejqgHREaApt/GArTfgYYGwhRXPLq8ZwiQQlLuYB+79e9iA8mlDSLA==", + "license": "MIT", + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^2.0.0 || ^3.0.0 || ^4.0.0", + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "@nestjs/microservices": "^10.0.0 || ^11.0.0", + "@nestjs/mongoose": "^11.0.0", + "@nestjs/sequelize": "^10.0.0 || ^11.0.0", + "@nestjs/typeorm": "^10.0.0 || ^11.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x || 0.2.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/@nestjs/testing": { + "version": "11.1.11", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.11.tgz", + "integrity": "sha512-Po2aZKXlxuySDEh3Gi05LJ7/BtfTAPRZ3KPTrbpNrTmgGr3rFgEGYpQwN50wXYw0pywoICiFLZSZ/qXsplf6NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, + "node_modules/@nestjs/typeorm": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", + "integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0" + } + }, + "node_modules/@nuxt/opencollective": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", + "integrity": "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", + "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", + "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.5.tgz", + "integrity": "sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.20.0.tgz", + "integrity": "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.8", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.7.tgz", + "integrity": "sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.7.tgz", + "integrity": "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.11.0", + "@smithy/util-hex-encoding": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.7.tgz", + "integrity": "sha512-ujzPk8seYoDBmABDE5YqlhQZAXLOrtxtJLrbhHMKjBoG5b4dK4i6/mEU+6/7yXIAkqOO8sJ6YxZl+h0QQ1IJ7g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.7.tgz", + "integrity": "sha512-x7BtAiIPSaNaWuzm24Q/mtSkv+BrISO/fmheiJ39PKRNH3RmH2Hph/bUKSOBOBC9unqfIYDhKTHwpyZycLGPVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.7.tgz", + "integrity": "sha512-roySCtHC5+pQq5lK4be1fZ/WR6s/AxnPaLfCODIPArtN2du8s5Ot4mKVK3pPtijL/L654ws592JHJ1PbZFF6+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.7.tgz", + "integrity": "sha512-QVD+g3+icFkThoy4r8wVFZMsIP08taHVKjE6Jpmz8h5CgX/kk6pTODq5cht0OMtcapUx+xrPzUTQdA+TmO0m1g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.8.tgz", + "integrity": "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/querystring-builder": "^4.2.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.8.tgz", + "integrity": "sha512-07InZontqsM1ggTCPSRgI7d8DirqRrnpL7nIACT4PW0AWrgDiHhjGZzbAE5UtRSiU0NISGUYe7/rri9ZeWyDpw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.2.0", + "@smithy/chunked-blob-reader-native": "^4.2.1", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.7.tgz", + "integrity": "sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.7.tgz", + "integrity": "sha512-ZQVoAwNYnFMIbd4DUc517HuwNelJUY6YOzwqrbcAgCnVn+79/OK7UjwA93SPpdTOpKDVkLIzavWm/Ck7SmnDPQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.7.tgz", + "integrity": "sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.7.tgz", + "integrity": "sha512-Wv6JcUxtOLTnxvNjDnAiATUsk8gvA6EeS8zzHig07dotpByYsLot+m0AaQEniUBjx97AC41MQR4hW0baraD1Xw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.7.tgz", + "integrity": "sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.1.tgz", + "integrity": "sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.0", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-middleware": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.17.tgz", + "integrity": "sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/service-error-classification": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.8.tgz", + "integrity": "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.7.tgz", + "integrity": "sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz", + "integrity": "sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.7.tgz", + "integrity": "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/querystring-builder": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.7.tgz", + "integrity": "sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.7.tgz", + "integrity": "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.7.tgz", + "integrity": "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.7.tgz", + "integrity": "sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.7.tgz", + "integrity": "sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz", + "integrity": "sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.7.tgz", + "integrity": "sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.2.tgz", + "integrity": "sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.0", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-stream": "^4.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.11.0.tgz", + "integrity": "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.7.tgz", + "integrity": "sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.16", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.16.tgz", + "integrity": "sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.19.tgz", + "integrity": "sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.5", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz", + "integrity": "sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.7.tgz", + "integrity": "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.7.tgz", + "integrity": "sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.8.tgz", + "integrity": "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.7.tgz", + "integrity": "sha512-vHJFXi9b7kUEpHWUCY3Twl+9NPOZvQ0SAi+Ewtn48mbiJk4JY9MZmKQjGB4SCvVb9WPiSphZJYY6RIbs+grrzw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/inflate/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@tokenizer/inflate/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz", + "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", + "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/type-utils": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.53.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", + "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", + "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/utils": "8.53.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", + "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.66.4", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz", + "integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.8.2", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.3", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/bullmq/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/check-disk-space": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", + "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT", + "peer": true + }, + "node_modules/class-validator": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", + "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.20" + } + }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dedent": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz", + "integrity": "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.1.tgz", + "integrity": "sha512-LaKRbou8gt0RNID/9RoI+J2rvXsBRPMV7p+ElHlPhcSARbCPDYcYG2s1TIzAfWv4YSgyY5taidWzzs31lNV3yQ==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-type": { + "version": "21.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.2.0.tgz", + "integrity": "sha512-vCYBgFOrJQLoTzDyAXAL/RFfKnXXpUYt4+tipVy26nJJhT7ftgGETf2tAQF59EEL61i3MrorV/PG6tf7LJK7eg==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.0.0.tgz", + "integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.0.tgz", + "integrity": "sha512-T3VieIilNumOJCXI9SDgo4NnF6sZkd6XcmPi6qWtw4xqbt8nNz/ZVNiIH1L9puMTSHZh1mUWA4xKa2nWPF4NwQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", + "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.33", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz", + "integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stripe": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz", + "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==", + "license": "MIT", + "dependencies": { + "@types/node": ">=8.1.0", + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/ts-node-dev/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-node-dev/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tsconfig/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tsconfig/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typeorm": { + "version": "0.3.28", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", + "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.2.0", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "dayjs": "^1.11.19", + "debug": "^4.4.3", + "dedent": "^1.7.0", + "dotenv": "^16.6.1", + "glob": "^10.5.0", + "reflect-metadata": "^0.2.2", + "sha.js": "^2.4.12", + "sql-highlight": "^6.1.0", + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typeorm/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/typeorm/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/typeorm/node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/typeorm/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..208bd7c --- /dev/null +++ b/package.json @@ -0,0 +1,87 @@ +{ + "name": "@template-saas/backend", + "version": "1.0.0", + "type": "module", + "description": "Template SaaS Backend - Multi-tenant Platform", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node -r tsconfig-paths/register dist/main.js", + "start:dev": "ts-node-dev --respawn --transpile-only -r tsconfig-paths/register src/main.ts", + "start:prod": "NODE_ENV=production node -r tsconfig-paths/register dist/main.js", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "lint": "eslint \"{src,__tests__}/**/*.ts\"", + "format": "prettier --write \"src/**/*.ts\"" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.964.0", + "@aws-sdk/s3-request-presigner": "^3.964.0", + "@nestjs/bullmq": "^11.0.4", + "@nestjs/common": "^11.1.8", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.1.8", + "@nestjs/jwt": "^11.0.1", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.1.8", + "@nestjs/swagger": "^11.2.1", + "@nestjs/terminus": "^11.0.0", + "@nestjs/throttler": "^6.0.0", + "@nestjs/typeorm": "^11.0.0", + "bcrypt": "^5.1.1", + "bullmq": "^5.66.4", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "helmet": "^8.1.0", + "ioredis": "^5.9.0", + "joi": "^18.0.1", + "jsonwebtoken": "^9.0.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", + "pg": "^8.11.3", + "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", + "reflect-metadata": "^0.1.14", + "rxjs": "^7.8.1", + "speakeasy": "^2.0.0", + "stripe": "^17.5.0", + "typeorm": "^0.3.22", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@nestjs/testing": "^11.1.8", + "@types/bcrypt": "^6.0.0", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.7.2", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", + "@types/qrcode": "^1.5.6", + "@types/speakeasy": "^2.0.10", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "^8.53.0", + "@typescript-eslint/parser": "^8.53.0", + "eslint": "^9.17.0", + "globals": "^17.0.0", + "jest": "^29.7.0", + "prettier": "^3.2.4", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "tsconfig-paths": "^3.15.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=9.0.0" + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..8807522 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,93 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { TerminusModule } from '@nestjs/terminus'; +import { BullModule } from '@nestjs/bullmq'; + +// Config +import { envConfig, validationSchema } from '@config/env.config'; +import { databaseConfig } from '@config/database.config'; + +// Modules +import { AuthModule } from '@modules/auth/auth.module'; +import { TenantsModule } from '@modules/tenants/tenants.module'; +import { UsersModule } from '@modules/users/users.module'; +import { RbacModule } from '@modules/rbac/rbac.module'; +import { NotificationsModule } from '@modules/notifications/notifications.module'; +import { BillingModule } from '@modules/billing/billing.module'; +import { AuditModule } from '@modules/audit/audit.module'; +import { FeatureFlagsModule } from '@modules/feature-flags/feature-flags.module'; +import { HealthModule } from '@modules/health/health.module'; +import { SuperadminModule } from '@modules/superadmin/superadmin.module'; +import { AIModule } from '@modules/ai/ai.module'; +import { StorageModule } from '@modules/storage/storage.module'; +import { WebhooksModule } from '@modules/webhooks/webhooks.module'; +import { EmailModule } from '@modules/email/email.module'; +import { OnboardingModule } from '@modules/onboarding/onboarding.module'; +import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module'; +import { AnalyticsModule } from '@modules/analytics/analytics.module'; +import { ReportsModule } from '@modules/reports/reports.module'; + +@Module({ + imports: [ + // Configuration + ConfigModule.forRoot({ + isGlobal: true, + load: [envConfig], + validationSchema, + }), + + // Database + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => databaseConfig(configService), + inject: [ConfigService], + }), + + // Rate limiting + ThrottlerModule.forRoot([ + { + ttl: 60000, // 1 minute + limit: 100, // 100 requests per minute + }, + ]), + + // Health checks + TerminusModule, + + // BullMQ for background jobs (webhooks, etc.) + BullModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST', 'localhost'), + port: configService.get('REDIS_PORT', 6379), + password: configService.get('REDIS_PASSWORD', undefined), + }, + }), + inject: [ConfigService], + }), + + // Feature modules + AuthModule, + TenantsModule, + UsersModule, + RbacModule, + NotificationsModule, + BillingModule, + AuditModule, + FeatureFlagsModule, + HealthModule, + SuperadminModule, + AIModule, + StorageModule, + WebhooksModule, + EmailModule, + OnboardingModule, + WhatsAppModule, + AnalyticsModule, + ReportsModule, + ], +}) +export class AppModule {} diff --git a/src/config/database.config.ts b/src/config/database.config.ts new file mode 100644 index 0000000..be91066 --- /dev/null +++ b/src/config/database.config.ts @@ -0,0 +1,17 @@ +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; + +export const databaseConfig = (configService: ConfigService): TypeOrmModuleOptions => ({ + type: 'postgres', + host: configService.get('database.host'), + port: configService.get('database.port'), + database: configService.get('database.name'), + username: configService.get('database.user'), + password: configService.get('database.password'), + entities: [__dirname + '/../**/*.entity{.ts,.js}'], + synchronize: false, // NEVER true in production - use migrations + logging: configService.get('nodeEnv') === 'development', + ssl: configService.get('nodeEnv') === 'production' + ? { rejectUnauthorized: false } + : false, +}); diff --git a/src/config/env.config.ts b/src/config/env.config.ts new file mode 100644 index 0000000..ceae564 --- /dev/null +++ b/src/config/env.config.ts @@ -0,0 +1,164 @@ +import * as Joi from 'joi'; + +export const envConfig = () => ({ + nodeEnv: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT || '3001', 10), + + database: { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + name: process.env.DB_NAME || 'template_saas_dev', + user: process.env.DB_USER || 'template_saas_user', + password: process.env.DB_PASSWORD || 'template_saas_dev_2026', + }, + + jwt: { + secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', + expiresIn: process.env.JWT_EXPIRES_IN || '15m', + refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', + }, + + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:3000', + }, + + stripe: { + secretKey: process.env.STRIPE_SECRET_KEY || '', + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '', + }, + + ai: { + openrouterApiKey: process.env.OPENROUTER_API_KEY || '', + defaultModel: process.env.AI_DEFAULT_MODEL || 'anthropic/claude-3-haiku', + fallbackModel: process.env.AI_FALLBACK_MODEL || 'openai/gpt-3.5-turbo', + timeoutMs: parseInt(process.env.AI_TIMEOUT_MS || '30000', 10), + }, + + email: { + provider: process.env.EMAIL_PROVIDER || 'sendgrid', // 'sendgrid' | 'ses' | 'smtp' + from: process.env.EMAIL_FROM || 'noreply@example.com', + fromName: process.env.EMAIL_FROM_NAME || 'Template SaaS', + replyTo: process.env.EMAIL_REPLY_TO || '', + // SendGrid + sendgridApiKey: process.env.SENDGRID_API_KEY || '', + // AWS SES + sesRegion: process.env.AWS_SES_REGION || 'us-east-1', + sesAccessKeyId: process.env.AWS_SES_ACCESS_KEY_ID || '', + sesSecretAccessKey: process.env.AWS_SES_SECRET_ACCESS_KEY || '', + // SMTP (fallback) + smtpHost: process.env.SMTP_HOST || '', + smtpPort: parseInt(process.env.SMTP_PORT || '587', 10), + smtpUser: process.env.SMTP_USER || '', + smtpPassword: process.env.SMTP_PASSWORD || '', + smtpSecure: process.env.SMTP_SECURE === 'true', + }, + + whatsapp: { + apiVersion: process.env.WHATSAPP_API_VERSION || 'v17.0', + verifyToken: process.env.WHATSAPP_VERIFY_TOKEN || '', + appSecret: process.env.WHATSAPP_APP_SECRET || '', + }, + + oauth: { + tokenEncryptionKey: process.env.OAUTH_TOKEN_ENCRYPTION_KEY || '', + frontendCallbackUrl: process.env.OAUTH_FRONTEND_CALLBACK_URL || 'http://localhost:5173/auth/oauth/callback', + google: { + clientId: process.env.OAUTH_GOOGLE_CLIENT_ID || '', + clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET || '', + callbackUrl: process.env.OAUTH_GOOGLE_CALLBACK_URL || 'http://localhost:3001/api/auth/oauth/google/callback', + }, + microsoft: { + clientId: process.env.OAUTH_MICROSOFT_CLIENT_ID || '', + clientSecret: process.env.OAUTH_MICROSOFT_CLIENT_SECRET || '', + callbackUrl: process.env.OAUTH_MICROSOFT_CALLBACK_URL || 'http://localhost:3001/api/auth/oauth/microsoft/callback', + }, + github: { + clientId: process.env.OAUTH_GITHUB_CLIENT_ID || '', + clientSecret: process.env.OAUTH_GITHUB_CLIENT_SECRET || '', + callbackUrl: process.env.OAUTH_GITHUB_CALLBACK_URL || 'http://localhost:3001/api/auth/oauth/github/callback', + }, + apple: { + clientId: process.env.OAUTH_APPLE_CLIENT_ID || '', // Services ID from Apple Developer + teamId: process.env.OAUTH_APPLE_TEAM_ID || '', // Apple Developer Team ID + keyId: process.env.OAUTH_APPLE_KEY_ID || '', // Key ID from Apple Developer + privateKey: process.env.OAUTH_APPLE_PRIVATE_KEY || '', // Contents of .p8 key file + callbackUrl: process.env.OAUTH_APPLE_CALLBACK_URL || 'http://localhost:3001/api/auth/oauth/apple/callback', + }, + }, +}); + +export const validationSchema = Joi.object({ + NODE_ENV: Joi.string() + .valid('development', 'production', 'test') + .default('development'), + PORT: Joi.number().default(3001), + + DB_HOST: Joi.string().default('localhost'), + DB_PORT: Joi.number().default(5432), + DB_NAME: Joi.string().default('template_saas_dev'), + DB_USER: Joi.string().default('template_saas_user'), + DB_PASSWORD: Joi.string().default('template_saas_dev_2026'), + + JWT_SECRET: Joi.string().default('dev-jwt-secret-change-in-production'), + JWT_EXPIRES_IN: Joi.string().default('15m'), + JWT_REFRESH_EXPIRES_IN: Joi.string().default('7d'), + + CORS_ORIGIN: Joi.string().default('http://localhost:3000'), + + // Stripe (optional - integration disabled if not set) + STRIPE_SECRET_KEY: Joi.string().allow('').default(''), + STRIPE_WEBHOOK_SECRET: Joi.string().allow('').default(''), + STRIPE_PUBLISHABLE_KEY: Joi.string().allow('').default(''), + + // AI (optional - integration disabled if not set) + OPENROUTER_API_KEY: Joi.string().allow('').default(''), + AI_DEFAULT_MODEL: Joi.string().default('anthropic/claude-3-haiku'), + AI_FALLBACK_MODEL: Joi.string().default('openai/gpt-3.5-turbo'), + AI_TIMEOUT_MS: Joi.number().default(30000), + + // Email (optional - integration disabled if not set) + EMAIL_PROVIDER: Joi.string().valid('sendgrid', 'ses', 'smtp').default('sendgrid'), + EMAIL_FROM: Joi.string().email().default('noreply@example.com'), + EMAIL_FROM_NAME: Joi.string().default('Template SaaS'), + EMAIL_REPLY_TO: Joi.string().allow('').default(''), + // SendGrid + SENDGRID_API_KEY: Joi.string().allow('').default(''), + // AWS SES + AWS_SES_REGION: Joi.string().default('us-east-1'), + AWS_SES_ACCESS_KEY_ID: Joi.string().allow('').default(''), + AWS_SES_SECRET_ACCESS_KEY: Joi.string().allow('').default(''), + // SMTP + SMTP_HOST: Joi.string().allow('').default(''), + SMTP_PORT: Joi.number().default(587), + SMTP_USER: Joi.string().allow('').default(''), + SMTP_PASSWORD: Joi.string().allow('').default(''), + SMTP_SECURE: Joi.boolean().default(false), + + // WhatsApp (optional) + WHATSAPP_API_VERSION: Joi.string().default('v17.0'), + WHATSAPP_VERIFY_TOKEN: Joi.string().allow('').default(''), + WHATSAPP_APP_SECRET: Joi.string().allow('').default(''), + + // OAuth 2.0 (optional - integration disabled if not set) + OAUTH_TOKEN_ENCRYPTION_KEY: Joi.string().allow('').default(''), + OAUTH_FRONTEND_CALLBACK_URL: Joi.string().default('http://localhost:5173/auth/oauth/callback'), + // Google OAuth + OAUTH_GOOGLE_CLIENT_ID: Joi.string().allow('').default(''), + OAUTH_GOOGLE_CLIENT_SECRET: Joi.string().allow('').default(''), + OAUTH_GOOGLE_CALLBACK_URL: Joi.string().default('http://localhost:3001/api/auth/oauth/google/callback'), + // Microsoft OAuth + OAUTH_MICROSOFT_CLIENT_ID: Joi.string().allow('').default(''), + OAUTH_MICROSOFT_CLIENT_SECRET: Joi.string().allow('').default(''), + OAUTH_MICROSOFT_CALLBACK_URL: Joi.string().default('http://localhost:3001/api/auth/oauth/microsoft/callback'), + // GitHub OAuth + OAUTH_GITHUB_CLIENT_ID: Joi.string().allow('').default(''), + OAUTH_GITHUB_CLIENT_SECRET: Joi.string().allow('').default(''), + OAUTH_GITHUB_CALLBACK_URL: Joi.string().default('http://localhost:3001/api/auth/oauth/github/callback'), + // Apple OAuth + OAUTH_APPLE_CLIENT_ID: Joi.string().allow('').default(''), + OAUTH_APPLE_TEAM_ID: Joi.string().allow('').default(''), + OAUTH_APPLE_KEY_ID: Joi.string().allow('').default(''), + OAUTH_APPLE_PRIVATE_KEY: Joi.string().allow('').default(''), + OAUTH_APPLE_CALLBACK_URL: Joi.string().default('http://localhost:3001/api/auth/oauth/apple/callback'), +}); diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..4c6af9a --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './env.config'; +export * from './database.config'; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..d354c11 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,74 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import helmet from 'helmet'; +import compression from 'compression'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + rawBody: true, // Required for Stripe webhook signature verification + }); + const configService = app.get(ConfigService); + + // Security + app.use(helmet()); + app.use(compression()); + + // CORS + app.enableCors({ + origin: configService.get('CORS_ORIGIN') || 'http://localhost:3000', + credentials: true, + }); + + // Global prefix + app.setGlobalPrefix('api/v1'); + + // Validation + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + // Swagger + if (configService.get('NODE_ENV') !== 'production') { + const config = new DocumentBuilder() + .setTitle('Template SaaS API') + .setDescription('Multi-tenant SaaS Platform API') + .setVersion('1.0') + .addBearerAuth() + .addTag('auth', 'Authentication endpoints') + .addTag('tenants', 'Tenant management') + .addTag('users', 'User management') + .addTag('billing', 'Billing and subscriptions') + .addTag('stripe', 'Stripe integration endpoints') + .addTag('stripe-webhooks', 'Stripe webhook handlers') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + } + + // Start server + const port = configService.get('PORT') || 3001; + await app.listen(port); + + console.log(` +╔════════════════════════════════════════════════╗ +║ Template SaaS Backend ║ +╠════════════════════════════════════════════════╣ +║ Server running on: http://localhost:${port} ║ +║ API Docs: http://localhost:${port}/api/docs ║ +║ Environment: ${configService.get('NODE_ENV') || 'development'} ║ +╚════════════════════════════════════════════════╝ + `); +} + +bootstrap(); diff --git a/src/modules/ai/__tests__/ai.controller.spec.ts b/src/modules/ai/__tests__/ai.controller.spec.ts new file mode 100644 index 0000000..c5c943b --- /dev/null +++ b/src/modules/ai/__tests__/ai.controller.spec.ts @@ -0,0 +1,756 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, HttpStatus } from '@nestjs/common'; +import { AIController } from '../ai.controller'; +import { AIService } from '../services/ai.service'; +import { AIProvider } from '../entities/ai-config.entity'; +import { UsageStatus } from '../entities/ai-usage.entity'; +import { + ChatRequestDto, + ChatResponseDto, + UpdateAIConfigDto, + AIConfigResponseDto, + UsageStatsDto, + AIModelDto, +} from '../dto'; + +describe('AIController', () => { + let controller: AIController; + let aiService: jest.Mocked; + + // ==================== Mock Data ==================== + + const mockUser = { + id: '550e8400-e29b-41d4-a716-446655440002', + tenant_id: '550e8400-e29b-41d4-a716-446655440001', + email: 'test@example.com', + role: 'admin', + }; + + const mockConfig: AIConfigResponseDto = { + id: 'config-001', + tenant_id: mockUser.tenant_id, + provider: AIProvider.OPENROUTER, + default_model: 'anthropic/claude-3-haiku', + fallback_model: 'anthropic/claude-3-sonnet', + temperature: 0.7, + max_tokens: 2048, + system_prompt: 'You are a helpful assistant.', + is_enabled: true, + allow_custom_prompts: true, + log_conversations: false, + created_at: new Date('2024-01-01T00:00:00Z'), + updated_at: new Date('2024-01-01T00:00:00Z'), + }; + + const mockChatResponse: ChatResponseDto = { + id: 'gen-001', + model: 'anthropic/claude-3-haiku', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello! How can I help you today?' }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }, + created: Date.now(), + }; + + const mockModels: AIModelDto[] = [ + { + id: 'anthropic/claude-3-haiku', + name: 'Claude 3 Haiku', + provider: 'anthropic', + context_length: 200000, + pricing: { prompt: 0.25, completion: 1.25 }, + }, + { + id: 'anthropic/claude-3-sonnet', + name: 'Claude 3 Sonnet', + provider: 'anthropic', + context_length: 200000, + pricing: { prompt: 3.0, completion: 15.0 }, + }, + { + id: 'openai/gpt-4-turbo', + name: 'GPT-4 Turbo', + provider: 'openai', + context_length: 128000, + pricing: { prompt: 10.0, completion: 30.0 }, + }, + ]; + + const mockUsageStats: UsageStatsDto = { + request_count: 25, + total_input_tokens: 5000, + total_output_tokens: 2500, + total_tokens: 7500, + total_cost: 0.125, + avg_latency_ms: 450, + }; + + const mockUsageHistory = { + data: [ + { + id: 'usage-001', + tenant_id: mockUser.tenant_id, + user_id: mockUser.id, + provider: AIProvider.OPENROUTER, + model: 'anthropic/claude-3-haiku', + status: UsageStatus.COMPLETED, + input_tokens: 100, + output_tokens: 50, + cost_input: 0.000025, + cost_output: 0.0000625, + latency_ms: 500, + created_at: new Date(), + }, + { + id: 'usage-002', + tenant_id: mockUser.tenant_id, + user_id: mockUser.id, + provider: AIProvider.OPENROUTER, + model: 'anthropic/claude-3-haiku', + status: UsageStatus.COMPLETED, + input_tokens: 200, + output_tokens: 100, + cost_input: 0.00005, + cost_output: 0.000125, + latency_ms: 600, + created_at: new Date(), + }, + ], + total: 2, + }; + + // ==================== Setup ==================== + + beforeEach(async () => { + const mockAIService = { + chat: jest.fn(), + getModels: jest.fn(), + getConfig: jest.fn(), + updateConfig: jest.fn(), + getUsageHistory: jest.fn(), + getCurrentMonthUsage: jest.fn(), + isServiceReady: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AIController], + providers: [{ provide: AIService, useValue: mockAIService }], + }).compile(); + + controller = module.get(AIController); + aiService = module.get(AIService); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + // ==================== Controller Instantiation ==================== + + describe('constructor', () => { + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + }); + + // ==================== Chat Endpoint Tests ==================== + + describe('chat', () => { + const chatDto: ChatRequestDto = { + messages: [{ role: 'user', content: 'Hello, how are you?' }], + }; + + it('should successfully return chat response', async () => { + aiService.chat.mockResolvedValue(mockChatResponse); + + const result = await controller.chat(mockUser, chatDto); + + expect(result).toEqual(mockChatResponse); + expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, chatDto); + expect(aiService.chat).toHaveBeenCalledTimes(1); + }); + + it('should pass model override from dto', async () => { + const dtoWithModel: ChatRequestDto = { + ...chatDto, + model: 'openai/gpt-4-turbo', + }; + aiService.chat.mockResolvedValue({ + ...mockChatResponse, + model: 'openai/gpt-4-turbo', + }); + + const result = await controller.chat(mockUser, dtoWithModel); + + expect(result.model).toBe('openai/gpt-4-turbo'); + expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, dtoWithModel); + }); + + it('should pass temperature from dto', async () => { + const dtoWithTemp: ChatRequestDto = { + ...chatDto, + temperature: 0.9, + }; + aiService.chat.mockResolvedValue(mockChatResponse); + + await controller.chat(mockUser, dtoWithTemp); + + expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, dtoWithTemp); + }); + + it('should pass max_tokens from dto', async () => { + const dtoWithMaxTokens: ChatRequestDto = { + ...chatDto, + max_tokens: 4096, + }; + aiService.chat.mockResolvedValue(mockChatResponse); + + await controller.chat(mockUser, dtoWithMaxTokens); + + expect(aiService.chat).toHaveBeenCalledWith( + mockUser.tenant_id, + mockUser.id, + dtoWithMaxTokens, + ); + }); + + it('should handle multiple messages in conversation', async () => { + const conversationDto: ChatRequestDto = { + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + { role: 'user', content: 'How are you?' }, + ], + }; + aiService.chat.mockResolvedValue(mockChatResponse); + + await controller.chat(mockUser, conversationDto); + + expect(aiService.chat).toHaveBeenCalledWith( + mockUser.tenant_id, + mockUser.id, + conversationDto, + ); + }); + + it('should throw BadRequestException when AI is disabled', async () => { + aiService.chat.mockRejectedValue( + new BadRequestException('AI features are disabled for this tenant'), + ); + + await expect(controller.chat(mockUser, chatDto)).rejects.toThrow(BadRequestException); + await expect(controller.chat(mockUser, chatDto)).rejects.toThrow( + 'AI features are disabled for this tenant', + ); + }); + + it('should throw BadRequestException when service not configured', async () => { + aiService.chat.mockRejectedValue(new BadRequestException('AI service is not configured')); + + await expect(controller.chat(mockUser, chatDto)).rejects.toThrow(BadRequestException); + await expect(controller.chat(mockUser, chatDto)).rejects.toThrow( + 'AI service is not configured', + ); + }); + + it('should propagate generic errors from service', async () => { + aiService.chat.mockRejectedValue(new Error('OpenRouter API error')); + + await expect(controller.chat(mockUser, chatDto)).rejects.toThrow('OpenRouter API error'); + }); + + it('should handle rate limit errors', async () => { + aiService.chat.mockRejectedValue(new BadRequestException('Rate limit exceeded')); + + await expect(controller.chat(mockUser, chatDto)).rejects.toThrow('Rate limit exceeded'); + }); + }); + + // ==================== Models Endpoint Tests ==================== + + describe('getModels', () => { + it('should return list of available models', async () => { + aiService.getModels.mockResolvedValue(mockModels); + + const result = await controller.getModels(); + + expect(result).toEqual(mockModels); + expect(result).toHaveLength(3); + expect(aiService.getModels).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when no models available', async () => { + aiService.getModels.mockResolvedValue([]); + + const result = await controller.getModels(); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('should include model pricing information', async () => { + aiService.getModels.mockResolvedValue(mockModels); + + const result = await controller.getModels(); + + expect(result[0].pricing).toBeDefined(); + expect(result[0].pricing.prompt).toBe(0.25); + expect(result[0].pricing.completion).toBe(1.25); + }); + + it('should include context length for each model', async () => { + aiService.getModels.mockResolvedValue(mockModels); + + const result = await controller.getModels(); + + expect(result[0].context_length).toBe(200000); + expect(result[2].context_length).toBe(128000); + }); + + it('should propagate errors from service', async () => { + aiService.getModels.mockRejectedValue(new Error('Failed to fetch models')); + + await expect(controller.getModels()).rejects.toThrow('Failed to fetch models'); + }); + }); + + // ==================== Configuration Endpoint Tests ==================== + + describe('getConfig', () => { + it('should return tenant configuration', async () => { + aiService.getConfig.mockResolvedValue(mockConfig as any); + + const result = await controller.getConfig(mockUser); + + expect(result).toEqual(mockConfig); + expect(aiService.getConfig).toHaveBeenCalledWith(mockUser.tenant_id); + expect(aiService.getConfig).toHaveBeenCalledTimes(1); + }); + + it('should return default configuration for new tenant', async () => { + const defaultConfig = { + ...mockConfig, + system_prompt: null, + }; + aiService.getConfig.mockResolvedValue(defaultConfig as any); + + const result = await controller.getConfig(mockUser); + + expect(result.provider).toBe(AIProvider.OPENROUTER); + expect(result.default_model).toBe('anthropic/claude-3-haiku'); + expect(result.is_enabled).toBe(true); + }); + + it('should include all config fields', async () => { + aiService.getConfig.mockResolvedValue(mockConfig as any); + + const result = await controller.getConfig(mockUser); + + expect(result.id).toBeDefined(); + expect(result.tenant_id).toBe(mockUser.tenant_id); + expect(result.provider).toBeDefined(); + expect(result.default_model).toBeDefined(); + expect(result.temperature).toBeDefined(); + expect(result.max_tokens).toBeDefined(); + expect(result.is_enabled).toBeDefined(); + expect(result.allow_custom_prompts).toBeDefined(); + expect(result.log_conversations).toBeDefined(); + }); + + it('should propagate errors from service', async () => { + aiService.getConfig.mockRejectedValue(new Error('Database connection error')); + + await expect(controller.getConfig(mockUser)).rejects.toThrow('Database connection error'); + }); + }); + + describe('updateConfig', () => { + it('should update temperature', async () => { + const updateDto: UpdateAIConfigDto = { temperature: 0.9 }; + const updatedConfig = { ...mockConfig, temperature: 0.9 }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.temperature).toBe(0.9); + expect(aiService.updateConfig).toHaveBeenCalledWith(mockUser.tenant_id, updateDto); + }); + + it('should update max_tokens', async () => { + const updateDto: UpdateAIConfigDto = { max_tokens: 4096 }; + const updatedConfig = { ...mockConfig, max_tokens: 4096 }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.max_tokens).toBe(4096); + expect(aiService.updateConfig).toHaveBeenCalledWith(mockUser.tenant_id, updateDto); + }); + + it('should update default_model', async () => { + const updateDto: UpdateAIConfigDto = { default_model: 'openai/gpt-4-turbo' }; + const updatedConfig = { ...mockConfig, default_model: 'openai/gpt-4-turbo' }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.default_model).toBe('openai/gpt-4-turbo'); + }); + + it('should update system_prompt', async () => { + const updateDto: UpdateAIConfigDto = { system_prompt: 'You are a coding assistant.' }; + const updatedConfig = { ...mockConfig, system_prompt: 'You are a coding assistant.' }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.system_prompt).toBe('You are a coding assistant.'); + }); + + it('should update is_enabled to false', async () => { + const updateDto: UpdateAIConfigDto = { is_enabled: false }; + const updatedConfig = { ...mockConfig, is_enabled: false }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.is_enabled).toBe(false); + }); + + it('should update is_enabled to true', async () => { + const updateDto: UpdateAIConfigDto = { is_enabled: true }; + const updatedConfig = { ...mockConfig, is_enabled: true }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.is_enabled).toBe(true); + }); + + it('should update allow_custom_prompts', async () => { + const updateDto: UpdateAIConfigDto = { allow_custom_prompts: false }; + const updatedConfig = { ...mockConfig, allow_custom_prompts: false }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.allow_custom_prompts).toBe(false); + }); + + it('should update log_conversations', async () => { + const updateDto: UpdateAIConfigDto = { log_conversations: true }; + const updatedConfig = { ...mockConfig, log_conversations: true }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.log_conversations).toBe(true); + }); + + it('should update multiple fields at once', async () => { + const updateDto: UpdateAIConfigDto = { + temperature: 0.5, + max_tokens: 1024, + default_model: 'openai/gpt-3.5-turbo', + }; + const updatedConfig = { + ...mockConfig, + temperature: 0.5, + max_tokens: 1024, + default_model: 'openai/gpt-3.5-turbo', + }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.temperature).toBe(0.5); + expect(result.max_tokens).toBe(1024); + expect(result.default_model).toBe('openai/gpt-3.5-turbo'); + }); + + it('should update provider', async () => { + const updateDto: UpdateAIConfigDto = { provider: AIProvider.OPENAI }; + const updatedConfig = { ...mockConfig, provider: AIProvider.OPENAI }; + aiService.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockUser, updateDto); + + expect(result.provider).toBe(AIProvider.OPENAI); + }); + + it('should propagate errors from service', async () => { + const updateDto: UpdateAIConfigDto = { temperature: 0.9 }; + aiService.updateConfig.mockRejectedValue(new Error('Failed to update config')); + + await expect(controller.updateConfig(mockUser, updateDto)).rejects.toThrow( + 'Failed to update config', + ); + }); + }); + + // ==================== Usage Endpoint Tests ==================== + + describe('getUsage', () => { + it('should return usage history with default pagination', async () => { + aiService.getUsageHistory.mockResolvedValue(mockUsageHistory as any); + + const result = await controller.getUsage(mockUser); + + expect(result).toEqual(mockUsageHistory); + expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 1, 20); + }); + + it('should return usage history with custom page', async () => { + aiService.getUsageHistory.mockResolvedValue(mockUsageHistory as any); + + await controller.getUsage(mockUser, 2); + + expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 2, 20); + }); + + it('should return usage history with custom limit', async () => { + aiService.getUsageHistory.mockResolvedValue(mockUsageHistory as any); + + await controller.getUsage(mockUser, 1, 50); + + expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 1, 50); + }); + + it('should return usage history with custom page and limit', async () => { + aiService.getUsageHistory.mockResolvedValue(mockUsageHistory as any); + + await controller.getUsage(mockUser, 3, 10); + + expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 3, 10); + }); + + it('should return empty data for new tenant', async () => { + aiService.getUsageHistory.mockResolvedValue({ data: [], total: 0 }); + + const result = await controller.getUsage(mockUser); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('should propagate errors from service', async () => { + aiService.getUsageHistory.mockRejectedValue(new Error('Database error')); + + await expect(controller.getUsage(mockUser)).rejects.toThrow('Database error'); + }); + }); + + describe('getCurrentUsage', () => { + it('should return current month usage statistics', async () => { + aiService.getCurrentMonthUsage.mockResolvedValue(mockUsageStats); + + const result = await controller.getCurrentUsage(mockUser); + + expect(result).toEqual(mockUsageStats); + expect(aiService.getCurrentMonthUsage).toHaveBeenCalledWith(mockUser.tenant_id); + expect(aiService.getCurrentMonthUsage).toHaveBeenCalledTimes(1); + }); + + it('should return zero values for new tenant', async () => { + const zeroStats: UsageStatsDto = { + request_count: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_tokens: 0, + total_cost: 0, + avg_latency_ms: 0, + }; + aiService.getCurrentMonthUsage.mockResolvedValue(zeroStats); + + const result = await controller.getCurrentUsage(mockUser); + + expect(result.request_count).toBe(0); + expect(result.total_tokens).toBe(0); + expect(result.total_cost).toBe(0); + }); + + it('should include all statistics fields', async () => { + aiService.getCurrentMonthUsage.mockResolvedValue(mockUsageStats); + + const result = await controller.getCurrentUsage(mockUser); + + expect(result.request_count).toBeDefined(); + expect(result.total_input_tokens).toBeDefined(); + expect(result.total_output_tokens).toBeDefined(); + expect(result.total_tokens).toBeDefined(); + expect(result.total_cost).toBeDefined(); + expect(result.avg_latency_ms).toBeDefined(); + }); + + it('should propagate errors from service', async () => { + aiService.getCurrentMonthUsage.mockRejectedValue(new Error('Failed to calculate usage')); + + await expect(controller.getCurrentUsage(mockUser)).rejects.toThrow( + 'Failed to calculate usage', + ); + }); + }); + + // ==================== Health Endpoint Tests ==================== + + describe('health', () => { + it('should return ready status when service is configured', async () => { + aiService.isServiceReady.mockReturnValue(true); + + const result = await controller.health(); + + expect(result.status).toBe('ready'); + expect(result.timestamp).toBeDefined(); + expect(aiService.isServiceReady).toHaveBeenCalledTimes(1); + }); + + it('should return not_configured status when service is not ready', async () => { + aiService.isServiceReady.mockReturnValue(false); + + const result = await controller.health(); + + expect(result.status).toBe('not_configured'); + expect(result.timestamp).toBeDefined(); + }); + + it('should include ISO timestamp', async () => { + aiService.isServiceReady.mockReturnValue(true); + + const result = await controller.health(); + + expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + it('should return valid timestamp format', async () => { + aiService.isServiceReady.mockReturnValue(true); + + const result = await controller.health(); + const timestamp = new Date(result.timestamp); + + expect(timestamp).toBeInstanceOf(Date); + expect(isNaN(timestamp.getTime())).toBe(false); + }); + }); + + // ==================== Edge Cases and Error Handling ==================== + + describe('Edge Cases', () => { + it('should handle empty messages array in chat', async () => { + const emptyDto: ChatRequestDto = { messages: [] }; + aiService.chat.mockRejectedValue(new BadRequestException('Messages cannot be empty')); + + await expect(controller.chat(mockUser, emptyDto)).rejects.toThrow(BadRequestException); + }); + + it('should handle very long message content', async () => { + const longContent = 'a'.repeat(100000); + const longDto: ChatRequestDto = { + messages: [{ role: 'user', content: longContent }], + }; + aiService.chat.mockResolvedValue(mockChatResponse); + + await controller.chat(mockUser, longDto); + + expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, longDto); + }); + + it('should handle special characters in message content', async () => { + const specialDto: ChatRequestDto = { + messages: [{ role: 'user', content: '!@#$%^&*()_+{}|:"<>?\n\t\r' }], + }; + aiService.chat.mockResolvedValue(mockChatResponse); + + await controller.chat(mockUser, specialDto); + + expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, specialDto); + }); + + it('should handle unicode characters in message content', async () => { + const unicodeDto: ChatRequestDto = { + messages: [{ role: 'user', content: 'Hello! Hola! Bonjour! Guten Tag!' }], + }; + aiService.chat.mockResolvedValue(mockChatResponse); + + await controller.chat(mockUser, unicodeDto); + + expect(aiService.chat).toHaveBeenCalledWith(mockUser.tenant_id, mockUser.id, unicodeDto); + }); + + it('should handle large page numbers in getUsage', async () => { + aiService.getUsageHistory.mockResolvedValue({ data: [], total: 100 }); + + const result = await controller.getUsage(mockUser, 1000, 20); + + expect(result.data).toHaveLength(0); + expect(aiService.getUsageHistory).toHaveBeenCalledWith(mockUser.tenant_id, 1000, 20); + }); + + it('should handle config update with empty object', async () => { + const emptyDto: UpdateAIConfigDto = {}; + aiService.updateConfig.mockResolvedValue(mockConfig as any); + + const result = await controller.updateConfig(mockUser, emptyDto); + + expect(result).toEqual(mockConfig); + expect(aiService.updateConfig).toHaveBeenCalledWith(mockUser.tenant_id, emptyDto); + }); + }); + + // ==================== Different User Contexts ==================== + + describe('User Context Handling', () => { + it('should use correct tenant_id from user context', async () => { + const differentUser = { + ...mockUser, + tenant_id: '550e8400-e29b-41d4-a716-446655440099', + }; + aiService.getConfig.mockResolvedValue(mockConfig as any); + + await controller.getConfig(differentUser); + + expect(aiService.getConfig).toHaveBeenCalledWith(differentUser.tenant_id); + }); + + it('should use correct user_id in chat', async () => { + const differentUser = { + ...mockUser, + id: '550e8400-e29b-41d4-a716-446655440088', + }; + const chatDto: ChatRequestDto = { + messages: [{ role: 'user', content: 'Hello' }], + }; + aiService.chat.mockResolvedValue(mockChatResponse); + + await controller.chat(differentUser, chatDto); + + expect(aiService.chat).toHaveBeenCalledWith( + differentUser.tenant_id, + differentUser.id, + chatDto, + ); + }); + + it('should isolate usage between tenants', async () => { + const tenant1 = { ...mockUser, tenant_id: 'tenant-1' }; + const tenant2 = { ...mockUser, tenant_id: 'tenant-2' }; + + aiService.getCurrentMonthUsage.mockResolvedValue(mockUsageStats); + + await controller.getCurrentUsage(tenant1); + await controller.getCurrentUsage(tenant2); + + expect(aiService.getCurrentMonthUsage).toHaveBeenCalledWith('tenant-1'); + expect(aiService.getCurrentMonthUsage).toHaveBeenCalledWith('tenant-2'); + }); + }); +}); diff --git a/src/modules/ai/__tests__/ai.service.spec.ts b/src/modules/ai/__tests__/ai.service.spec.ts new file mode 100644 index 0000000..f90bc6b --- /dev/null +++ b/src/modules/ai/__tests__/ai.service.spec.ts @@ -0,0 +1,336 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { AIService } from '../services/ai.service'; +import { AIConfig, AIProvider } from '../entities/ai-config.entity'; +import { AIUsage, UsageStatus } from '../entities/ai-usage.entity'; +import { OpenRouterClient } from '../clients/openrouter.client'; + +describe('AIService', () => { + let service: AIService; + let configRepo: jest.Mocked>; + let usageRepo: jest.Mocked>; + let openRouterClient: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockConfig = { + id: 'config-001', + tenant_id: mockTenantId, + provider: AIProvider.OPENROUTER, + default_model: 'anthropic/claude-3-haiku', + temperature: 0.7, + max_tokens: 2048, + is_enabled: true, + system_prompt: 'You are a helpful assistant.', + allow_custom_prompts: true, + log_conversations: false, + settings: {}, + created_at: new Date(), + updated_at: new Date(), + } as AIConfig; + + const mockUsage: Partial = { + id: 'usage-001', + tenant_id: mockTenantId, + user_id: mockUserId, + provider: AIProvider.OPENROUTER, + model: 'anthropic/claude-3-haiku', + status: UsageStatus.COMPLETED, + input_tokens: 100, + output_tokens: 50, + cost_input: 0.000025, + cost_output: 0.0000625, + latency_ms: 500, + }; + + const mockChatResponse = { + id: 'gen-001', + model: 'anthropic/claude-3-haiku', + choices: [ + { + index: 0, + message: { role: 'assistant' as const, content: 'Hello! How can I help you?' }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }, + created: Date.now(), + }; + + beforeEach(async () => { + const mockConfigRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockUsageRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + findAndCount: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockOpenRouterClient = { + isReady: jest.fn(), + chatCompletion: jest.fn(), + getModels: jest.fn(), + calculateCost: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AIService, + { provide: getRepositoryToken(AIConfig), useValue: mockConfigRepo }, + { provide: getRepositoryToken(AIUsage), useValue: mockUsageRepo }, + { provide: OpenRouterClient, useValue: mockOpenRouterClient }, + ], + }).compile(); + + service = module.get(AIService); + configRepo = module.get(getRepositoryToken(AIConfig)); + usageRepo = module.get(getRepositoryToken(AIUsage)); + openRouterClient = module.get(OpenRouterClient); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + // ==================== Configuration Tests ==================== + + describe('getConfig', () => { + it('should return existing config', async () => { + configRepo.findOne.mockResolvedValue(mockConfig); + + const result = await service.getConfig(mockTenantId); + + expect(result).toEqual(mockConfig); + expect(configRepo.findOne).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + }); + }); + + it('should create default config if not exists', async () => { + configRepo.findOne.mockResolvedValue(null); + configRepo.create.mockReturnValue(mockConfig); + configRepo.save.mockResolvedValue(mockConfig); + + const result = await service.getConfig(mockTenantId); + + expect(result).toEqual(mockConfig); + expect(configRepo.create).toHaveBeenCalled(); + expect(configRepo.save).toHaveBeenCalled(); + }); + }); + + describe('updateConfig', () => { + it('should update config successfully', async () => { + configRepo.findOne.mockResolvedValue(mockConfig); + configRepo.save.mockResolvedValue({ + ...mockConfig, + temperature: 0.9, + max_tokens: 4096, + } as AIConfig); + + const result = await service.updateConfig(mockTenantId, { + temperature: 0.9, + max_tokens: 4096, + }); + + expect(result.temperature).toBe(0.9); + expect(result.max_tokens).toBe(4096); + expect(configRepo.save).toHaveBeenCalled(); + }); + + it('should update system prompt', async () => { + configRepo.findOne.mockResolvedValue(mockConfig); + configRepo.save.mockResolvedValue({ + ...mockConfig, + system_prompt: 'New prompt', + } as AIConfig); + + const result = await service.updateConfig(mockTenantId, { + system_prompt: 'New prompt', + }); + + expect(result.system_prompt).toBe('New prompt'); + }); + + it('should disable AI features', async () => { + configRepo.findOne.mockResolvedValue(mockConfig); + configRepo.save.mockResolvedValue({ + ...mockConfig, + is_enabled: false, + } as AIConfig); + + const result = await service.updateConfig(mockTenantId, { + is_enabled: false, + }); + + expect(result.is_enabled).toBe(false); + }); + }); + + // ==================== Chat Tests ==================== + + describe('chat', () => { + const chatDto = { + messages: [{ role: 'user' as const, content: 'Hello' }], + }; + + it('should throw when AI is disabled for tenant', async () => { + configRepo.findOne.mockResolvedValue({ + ...mockConfig, + is_enabled: false, + }); + + await expect(service.chat(mockTenantId, mockUserId, chatDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw when service not configured', async () => { + configRepo.findOne.mockResolvedValue(mockConfig); + openRouterClient.isReady.mockReturnValue(false); + + await expect(service.chat(mockTenantId, mockUserId, chatDto)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // ==================== Models Tests ==================== + + describe('getModels', () => { + it('should return available models', async () => { + const models = [ + { + id: 'anthropic/claude-3-haiku', + name: 'Claude 3 Haiku', + provider: 'anthropic', + context_length: 200000, + pricing: { prompt: 0.25, completion: 1.25 }, + }, + ]; + openRouterClient.getModels.mockResolvedValue(models); + + const result = await service.getModels(); + + expect(result).toEqual(models); + expect(openRouterClient.getModels).toHaveBeenCalled(); + }); + }); + + // ==================== Usage Stats Tests ==================== + + describe('getCurrentMonthUsage', () => { + it('should return usage statistics', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + request_count: '10', + total_input_tokens: '1000', + total_output_tokens: '500', + total_tokens: '1500', + total_cost: '0.05', + avg_latency_ms: '450', + }), + }; + + usageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getCurrentMonthUsage(mockTenantId); + + expect(result.request_count).toBe(10); + expect(result.total_input_tokens).toBe(1000); + expect(result.total_output_tokens).toBe(500); + expect(result.total_tokens).toBe(1500); + expect(result.total_cost).toBe(0.05); + expect(result.avg_latency_ms).toBe(450); + }); + + it('should return zero values for new tenant', async () => { + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + request_count: '0', + total_input_tokens: '0', + total_output_tokens: '0', + total_tokens: '0', + total_cost: '0', + avg_latency_ms: '0', + }), + }; + + usageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getCurrentMonthUsage(mockTenantId); + + expect(result.request_count).toBe(0); + expect(result.total_cost).toBe(0); + }); + }); + + describe('getUsageHistory', () => { + it('should return paginated usage history', async () => { + usageRepo.findAndCount.mockResolvedValue([[mockUsage as AIUsage], 1]); + + const result = await service.getUsageHistory(mockTenantId, 1, 20); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(usageRepo.findAndCount).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + order: { created_at: 'DESC' }, + skip: 0, + take: 20, + }); + }); + + it('should handle pagination correctly', async () => { + usageRepo.findAndCount.mockResolvedValue([[], 100]); + + const result = await service.getUsageHistory(mockTenantId, 5, 10); + + expect(usageRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 40, + take: 10, + }), + ); + expect(result.total).toBe(100); + }); + }); + + // ==================== Health Check Tests ==================== + + describe('isServiceReady', () => { + it('should return true when client is ready', () => { + openRouterClient.isReady.mockReturnValue(true); + + expect(service.isServiceReady()).toBe(true); + }); + + it('should return false when client is not ready', () => { + openRouterClient.isReady.mockReturnValue(false); + + expect(service.isServiceReady()).toBe(false); + }); + }); +}); diff --git a/src/modules/ai/ai.controller.ts b/src/modules/ai/ai.controller.ts new file mode 100644 index 0000000..afcf357 --- /dev/null +++ b/src/modules/ai/ai.controller.ts @@ -0,0 +1,120 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { AIService } from './services'; +import { + ChatRequestDto, + ChatResponseDto, + UpdateAIConfigDto, + AIConfigResponseDto, + UsageStatsDto, + AIModelDto, +} from './dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@ApiTags('ai') +@Controller('ai') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class AIController { + constructor(private readonly aiService: AIService) {} + + // ==================== Chat ==================== + + @Post('chat') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send chat completion request' }) + @ApiResponse({ status: 200, description: 'Chat response', type: ChatResponseDto }) + @ApiResponse({ status: 400, description: 'Bad request or AI disabled' }) + async chat( + @CurrentUser() user: RequestUser, + @Body() dto: ChatRequestDto, + ): Promise { + return this.aiService.chat(user.tenant_id, user.id, dto); + } + + // ==================== Models ==================== + + @Get('models') + @ApiOperation({ summary: 'List available AI models' }) + @ApiResponse({ status: 200, description: 'List of models', type: [AIModelDto] }) + async getModels(): Promise { + return this.aiService.getModels(); + } + + // ==================== Configuration ==================== + + @Get('config') + @ApiOperation({ summary: 'Get AI configuration for tenant' }) + @ApiResponse({ status: 200, description: 'AI configuration', type: AIConfigResponseDto }) + async getConfig(@CurrentUser() user: RequestUser): Promise { + const config = await this.aiService.getConfig(user.tenant_id); + return config as AIConfigResponseDto; + } + + @Patch('config') + @ApiOperation({ summary: 'Update AI configuration' }) + @ApiResponse({ status: 200, description: 'Updated configuration', type: AIConfigResponseDto }) + async updateConfig( + @CurrentUser() user: RequestUser, + @Body() dto: UpdateAIConfigDto, + ): Promise { + const config = await this.aiService.updateConfig(user.tenant_id, dto); + return config as AIConfigResponseDto; + } + + // ==================== Usage ==================== + + @Get('usage') + @ApiOperation({ summary: 'Get usage history' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getUsage( + @CurrentUser() user: RequestUser, + @Query('page') page = 1, + @Query('limit') limit = 20, + ) { + return this.aiService.getUsageHistory(user.tenant_id, page, limit); + } + + @Get('usage/current') + @ApiOperation({ summary: 'Get current month usage stats' }) + @ApiResponse({ status: 200, description: 'Usage statistics', type: UsageStatsDto }) + async getCurrentUsage(@CurrentUser() user: RequestUser): Promise { + return this.aiService.getCurrentMonthUsage(user.tenant_id); + } + + // ==================== Health ==================== + + @Get('health') + @ApiOperation({ summary: 'Check AI service health' }) + async health() { + return { + status: this.aiService.isServiceReady() ? 'ready' : 'not_configured', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/src/modules/ai/ai.module.ts b/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..f1e2890 --- /dev/null +++ b/src/modules/ai/ai.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { AIController } from './ai.controller'; +import { AIService } from './services'; +import { OpenRouterClient } from './clients'; +import { AIConfig, AIUsage } from './entities'; + +@Module({ + imports: [ + ConfigModule, + TypeOrmModule.forFeature([AIConfig, AIUsage]), + ], + controllers: [AIController], + providers: [AIService, OpenRouterClient], + exports: [AIService], +}) +export class AIModule {} diff --git a/src/modules/ai/clients/index.ts b/src/modules/ai/clients/index.ts new file mode 100644 index 0000000..422375d --- /dev/null +++ b/src/modules/ai/clients/index.ts @@ -0,0 +1 @@ +export { OpenRouterClient } from './openrouter.client'; diff --git a/src/modules/ai/clients/openrouter.client.ts b/src/modules/ai/clients/openrouter.client.ts new file mode 100644 index 0000000..f16cff0 --- /dev/null +++ b/src/modules/ai/clients/openrouter.client.ts @@ -0,0 +1,234 @@ +import { Injectable, Logger, OnModuleInit, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ChatRequestDto, ChatResponseDto, AIModelDto } from '../dto'; + +interface OpenRouterRequest { + model: string; + messages: { role: string; content: string }[]; + temperature?: number; + max_tokens?: number; + top_p?: number; + stream?: boolean; +} + +interface OpenRouterResponse { + id: string; + model: string; + choices: { + index: number; + message: { role: string; content: string }; + finish_reason: string; + }[]; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; + created: number; +} + +interface OpenRouterModel { + id: string; + name: string; + description?: string; + context_length: number; + pricing: { + prompt: string; + completion: string; + }; +} + +@Injectable() +export class OpenRouterClient implements OnModuleInit { + private readonly logger = new Logger(OpenRouterClient.name); + private apiKey: string; + private readonly baseUrl = 'https://openrouter.ai/api/v1'; + private readonly timeout: number; + private isConfigured = false; + + constructor(private readonly configService: ConfigService) { + this.timeout = this.configService.get('AI_TIMEOUT_MS', 30000); + } + + onModuleInit() { + this.apiKey = this.configService.get('OPENROUTER_API_KEY', ''); + if (!this.apiKey) { + this.logger.warn('OpenRouter API key not configured. AI features will be disabled.'); + return; + } + this.isConfigured = true; + this.logger.log('OpenRouter client initialized'); + } + + isReady(): boolean { + return this.isConfigured; + } + + private ensureConfigured(): void { + if (!this.isConfigured) { + throw new BadRequestException('AI service is not configured. Please set OPENROUTER_API_KEY.'); + } + } + + async chatCompletion( + dto: ChatRequestDto, + defaultModel: string, + defaultTemperature: number, + defaultMaxTokens: number, + ): Promise { + this.ensureConfigured(); + + const requestBody: OpenRouterRequest = { + model: dto.model || defaultModel, + messages: dto.messages, + temperature: dto.temperature ?? defaultTemperature, + max_tokens: dto.max_tokens ?? defaultMaxTokens, + top_p: dto.top_p ?? 1.0, + stream: false, // For now, no streaming + }; + + const startTime = Date.now(); + + try { + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + 'HTTP-Referer': this.configService.get('APP_URL', 'http://localhost:3001'), + 'X-Title': 'Template SaaS', + }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(this.timeout), + }); + + if (!response.ok) { + const errorBody = await response.text(); + this.logger.error(`OpenRouter API error: ${response.status} - ${errorBody}`); + throw new BadRequestException(`AI request failed: ${response.statusText}`); + } + + const data: OpenRouterResponse = await response.json(); + const latencyMs = Date.now() - startTime; + + this.logger.debug(`Chat completion completed in ${latencyMs}ms, tokens: ${data.usage?.total_tokens}`); + + return { + id: data.id, + model: data.model, + choices: data.choices.map((c) => ({ + index: c.index, + message: { + role: c.message.role as 'system' | 'user' | 'assistant', + content: c.message.content, + }, + finish_reason: c.finish_reason, + })), + usage: { + prompt_tokens: data.usage?.prompt_tokens || 0, + completion_tokens: data.usage?.completion_tokens || 0, + total_tokens: data.usage?.total_tokens || 0, + }, + created: data.created, + }; + } catch (error) { + if (error.name === 'AbortError') { + throw new BadRequestException('AI request timed out'); + } + throw error; + } + } + + async getModels(): Promise { + this.ensureConfigured(); + + try { + const response = await fetch(`${this.baseUrl}/models`, { + headers: { + Authorization: `Bearer ${this.apiKey}`, + }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new BadRequestException('Failed to fetch models'); + } + + const data = await response.json(); + const models: OpenRouterModel[] = data.data || []; + + // Filter to popular models + const popularModels = [ + 'anthropic/claude-3-haiku', + 'anthropic/claude-3-sonnet', + 'anthropic/claude-3-opus', + 'openai/gpt-4-turbo', + 'openai/gpt-4', + 'openai/gpt-3.5-turbo', + 'google/gemini-pro', + 'meta-llama/llama-3-70b-instruct', + ]; + + return models + .filter((m) => popularModels.some((p) => m.id.includes(p.split('/')[1]))) + .slice(0, 20) + .map((m) => ({ + id: m.id, + name: m.name, + provider: m.id.split('/')[0], + context_length: m.context_length, + pricing: { + prompt: parseFloat(m.pricing.prompt) * 1000000, // Per million tokens + completion: parseFloat(m.pricing.completion) * 1000000, + }, + })); + } catch (error) { + this.logger.error('Failed to fetch models:', error); + // Return default models if API fails + return [ + { + id: 'anthropic/claude-3-haiku', + name: 'Claude 3 Haiku', + provider: 'anthropic', + context_length: 200000, + pricing: { prompt: 0.25, completion: 1.25 }, + }, + { + id: 'openai/gpt-3.5-turbo', + name: 'GPT-3.5 Turbo', + provider: 'openai', + context_length: 16385, + pricing: { prompt: 0.5, completion: 1.5 }, + }, + ]; + } + } + + // Calculate cost for a request + calculateCost( + model: string, + inputTokens: number, + outputTokens: number, + ): { input: number; output: number; total: number } { + // Approximate pricing per million tokens (in USD) + const pricing: Record = { + 'anthropic/claude-3-haiku': { input: 0.25, output: 1.25 }, + 'anthropic/claude-3-sonnet': { input: 3.0, output: 15.0 }, + 'anthropic/claude-3-opus': { input: 15.0, output: 75.0 }, + 'openai/gpt-4-turbo': { input: 10.0, output: 30.0 }, + 'openai/gpt-4': { input: 30.0, output: 60.0 }, + 'openai/gpt-3.5-turbo': { input: 0.5, output: 1.5 }, + default: { input: 1.0, output: 2.0 }, + }; + + const modelPricing = pricing[model] || pricing.default; + const inputCost = (inputTokens / 1_000_000) * modelPricing.input; + const outputCost = (outputTokens / 1_000_000) * modelPricing.output; + + return { + input: inputCost, + output: outputCost, + total: inputCost + outputCost, + }; + } +} diff --git a/src/modules/ai/dto/chat.dto.ts b/src/modules/ai/dto/chat.dto.ts new file mode 100644 index 0000000..bcb400f --- /dev/null +++ b/src/modules/ai/dto/chat.dto.ts @@ -0,0 +1,100 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsArray, + IsOptional, + IsNumber, + Min, + Max, + ValidateNested, + IsIn, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ChatMessageDto { + @ApiProperty({ description: 'Message role', enum: ['system', 'user', 'assistant'] }) + @IsString() + @IsIn(['system', 'user', 'assistant']) + role: 'system' | 'user' | 'assistant'; + + @ApiProperty({ description: 'Message content' }) + @IsString() + content: string; +} + +export class ChatRequestDto { + @ApiProperty({ description: 'Array of chat messages', type: [ChatMessageDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ChatMessageDto) + messages: ChatMessageDto[]; + + @ApiPropertyOptional({ description: 'Model to use (e.g., anthropic/claude-3-haiku)' }) + @IsOptional() + @IsString() + model?: string; + + @ApiPropertyOptional({ description: 'Temperature (0-2)', default: 0.7 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @ApiPropertyOptional({ description: 'Maximum tokens to generate', default: 2048 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(32000) + max_tokens?: number; + + @ApiPropertyOptional({ description: 'Top P sampling (0-1)', default: 1.0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(1) + top_p?: number; + + @ApiPropertyOptional({ description: 'Stream response', default: false }) + @IsOptional() + stream?: boolean; +} + +export class ChatChoiceDto { + @ApiProperty() + index: number; + + @ApiProperty() + message: ChatMessageDto; + + @ApiProperty() + finish_reason: string; +} + +export class UsageDto { + @ApiProperty() + prompt_tokens: number; + + @ApiProperty() + completion_tokens: number; + + @ApiProperty() + total_tokens: number; +} + +export class ChatResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + model: string; + + @ApiProperty({ type: [ChatChoiceDto] }) + choices: ChatChoiceDto[]; + + @ApiProperty() + usage: UsageDto; + + @ApiProperty() + created: number; +} diff --git a/src/modules/ai/dto/config.dto.ts b/src/modules/ai/dto/config.dto.ts new file mode 100644 index 0000000..a4e7b48 --- /dev/null +++ b/src/modules/ai/dto/config.dto.ts @@ -0,0 +1,149 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsOptional, + IsNumber, + IsBoolean, + Min, + Max, + IsEnum, + IsObject, +} from 'class-validator'; +import { AIProvider } from '../entities'; + +export class UpdateAIConfigDto { + @ApiPropertyOptional({ description: 'AI provider', enum: AIProvider }) + @IsOptional() + @IsEnum(AIProvider) + provider?: AIProvider; + + @ApiPropertyOptional({ description: 'Default model to use' }) + @IsOptional() + @IsString() + default_model?: string; + + @ApiPropertyOptional({ description: 'Fallback model if default fails' }) + @IsOptional() + @IsString() + fallback_model?: string; + + @ApiPropertyOptional({ description: 'Temperature (0-2)', default: 0.7 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @ApiPropertyOptional({ description: 'Maximum tokens', default: 2048 }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(32000) + max_tokens?: number; + + @ApiPropertyOptional({ description: 'Default system prompt' }) + @IsOptional() + @IsString() + system_prompt?: string; + + @ApiPropertyOptional({ description: 'Enable AI features' }) + @IsOptional() + @IsBoolean() + is_enabled?: boolean; + + @ApiPropertyOptional({ description: 'Allow custom prompts' }) + @IsOptional() + @IsBoolean() + allow_custom_prompts?: boolean; + + @ApiPropertyOptional({ description: 'Log conversations' }) + @IsOptional() + @IsBoolean() + log_conversations?: boolean; + + @ApiPropertyOptional({ description: 'Additional settings' }) + @IsOptional() + @IsObject() + settings?: Record; +} + +export class AIConfigResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + tenant_id: string; + + @ApiProperty({ enum: AIProvider }) + provider: AIProvider; + + @ApiProperty() + default_model: string; + + @ApiPropertyOptional() + fallback_model?: string; + + @ApiProperty() + temperature: number; + + @ApiProperty() + max_tokens: number; + + @ApiPropertyOptional() + system_prompt?: string; + + @ApiProperty() + is_enabled: boolean; + + @ApiProperty() + allow_custom_prompts: boolean; + + @ApiProperty() + log_conversations: boolean; + + @ApiProperty() + created_at: Date; + + @ApiProperty() + updated_at: Date; +} + +export class UsageStatsDto { + @ApiProperty() + request_count: number; + + @ApiProperty() + total_input_tokens: number; + + @ApiProperty() + total_output_tokens: number; + + @ApiProperty() + total_tokens: number; + + @ApiProperty() + total_cost: number; + + @ApiProperty() + avg_latency_ms: number; +} + +export class AIModelDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiProperty() + provider: string; + + @ApiProperty() + context_length: number; + + @ApiProperty() + pricing: { + prompt: number; + completion: number; + }; +} diff --git a/src/modules/ai/dto/index.ts b/src/modules/ai/dto/index.ts new file mode 100644 index 0000000..fb4f4ba --- /dev/null +++ b/src/modules/ai/dto/index.ts @@ -0,0 +1,14 @@ +export { + ChatMessageDto, + ChatRequestDto, + ChatChoiceDto, + UsageDto, + ChatResponseDto, +} from './chat.dto'; + +export { + UpdateAIConfigDto, + AIConfigResponseDto, + UsageStatsDto, + AIModelDto, +} from './config.dto'; diff --git a/src/modules/ai/entities/ai-config.entity.ts b/src/modules/ai/entities/ai-config.entity.ts new file mode 100644 index 0000000..6ee2095 --- /dev/null +++ b/src/modules/ai/entities/ai-config.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum AIProvider { + OPENROUTER = 'openrouter', + OPENAI = 'openai', + ANTHROPIC = 'anthropic', + GOOGLE = 'google', +} + +@Entity({ name: 'configs', schema: 'ai' }) +export class AIConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'enum', enum: AIProvider, default: AIProvider.OPENROUTER }) + provider: AIProvider; + + @Column({ type: 'varchar', length: 100, default: 'anthropic/claude-3-haiku' }) + default_model: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + fallback_model: string; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 0.7 }) + temperature: number; + + @Column({ type: 'int', default: 2048 }) + max_tokens: number; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 1.0, nullable: true }) + top_p: number; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 0.0, nullable: true }) + frequency_penalty: number; + + @Column({ type: 'numeric', precision: 3, scale: 2, default: 0.0, nullable: true }) + presence_penalty: number; + + @Column({ type: 'text', nullable: true }) + system_prompt: string; + + @Column({ type: 'int', nullable: true }) + rate_limit_requests_per_minute: number; + + @Column({ type: 'int', nullable: true }) + rate_limit_tokens_per_minute: number; + + @Column({ type: 'int', nullable: true }) + rate_limit_tokens_per_month: number; + + @Column({ type: 'boolean', default: true }) + is_enabled: boolean; + + @Column({ type: 'boolean', default: true }) + allow_custom_prompts: boolean; + + @Column({ type: 'boolean', default: false }) + log_conversations: boolean; + + @Column({ type: 'jsonb', default: {} }) + settings: Record; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updated_at: Date; +} diff --git a/src/modules/ai/entities/ai-usage.entity.ts b/src/modules/ai/entities/ai-usage.entity.ts new file mode 100644 index 0000000..bf76741 --- /dev/null +++ b/src/modules/ai/entities/ai-usage.entity.ts @@ -0,0 +1,90 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; +import { AIProvider } from './ai-config.entity'; + +export enum AIModelType { + CHAT = 'chat', + COMPLETION = 'completion', + EMBEDDING = 'embedding', + IMAGE = 'image', +} + +export enum UsageStatus { + PENDING = 'pending', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +@Entity({ name: 'usage', schema: 'ai' }) +export class AIUsage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @Column({ type: 'enum', enum: AIProvider }) + provider: AIProvider; + + @Column({ type: 'varchar', length: 100 }) + model: string; + + @Column({ type: 'enum', enum: AIModelType, default: AIModelType.CHAT }) + model_type: AIModelType; + + @Column({ type: 'enum', enum: UsageStatus, default: UsageStatus.PENDING }) + status: UsageStatus; + + @Column({ type: 'int', default: 0 }) + input_tokens: number; + + @Column({ type: 'int', default: 0 }) + output_tokens: number; + + // total_tokens is computed in DB, but we can add it for convenience + get total_tokens(): number { + return this.input_tokens + this.output_tokens; + } + + @Column({ type: 'numeric', precision: 12, scale: 6, default: 0 }) + cost_input: number; + + @Column({ type: 'numeric', precision: 12, scale: 6, default: 0 }) + cost_output: number; + + get cost_total(): number { + return Number(this.cost_input) + Number(this.cost_output); + } + + @Column({ type: 'int', nullable: true }) + latency_ms: number; + + @Column({ type: 'timestamptz', default: () => 'NOW()' }) + started_at: Date; + + @Column({ type: 'timestamptz', nullable: true }) + completed_at: Date; + + @Column({ type: 'varchar', length: 100, nullable: true }) + request_id: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + endpoint: string; + + @Column({ type: 'text', nullable: true }) + error_message: string; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at: Date; +} diff --git a/src/modules/ai/entities/index.ts b/src/modules/ai/entities/index.ts new file mode 100644 index 0000000..6ed5c23 --- /dev/null +++ b/src/modules/ai/entities/index.ts @@ -0,0 +1,2 @@ +export { AIConfig, AIProvider } from './ai-config.entity'; +export { AIUsage, AIModelType, UsageStatus } from './ai-usage.entity'; diff --git a/src/modules/ai/index.ts b/src/modules/ai/index.ts new file mode 100644 index 0000000..3b3aa34 --- /dev/null +++ b/src/modules/ai/index.ts @@ -0,0 +1,6 @@ +export { AIModule } from './ai.module'; +export { AIController } from './ai.controller'; +export { AIService } from './services'; +export { OpenRouterClient } from './clients'; +export * from './entities'; +export * from './dto'; diff --git a/src/modules/ai/services/ai.service.ts b/src/modules/ai/services/ai.service.ts new file mode 100644 index 0000000..ac3bf63 --- /dev/null +++ b/src/modules/ai/services/ai.service.ts @@ -0,0 +1,193 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, MoreThanOrEqual } from 'typeorm'; +import { AIConfig, AIUsage, UsageStatus, AIProvider } from '../entities'; +import { OpenRouterClient } from '../clients'; +import { + ChatRequestDto, + ChatResponseDto, + UpdateAIConfigDto, + UsageStatsDto, + AIModelDto, +} from '../dto'; + +@Injectable() +export class AIService { + private readonly logger = new Logger(AIService.name); + + constructor( + @InjectRepository(AIConfig) + private readonly configRepository: Repository, + @InjectRepository(AIUsage) + private readonly usageRepository: Repository, + private readonly openRouterClient: OpenRouterClient, + ) {} + + // ==================== Configuration ==================== + + async getConfig(tenantId: string): Promise { + let config = await this.configRepository.findOne({ + where: { tenant_id: tenantId }, + }); + + // Create default config if not exists + if (!config) { + config = this.configRepository.create({ + tenant_id: tenantId, + provider: AIProvider.OPENROUTER, + default_model: 'anthropic/claude-3-haiku', + temperature: 0.7, + max_tokens: 2048, + is_enabled: true, + }); + await this.configRepository.save(config); + } + + return config; + } + + async updateConfig(tenantId: string, dto: UpdateAIConfigDto): Promise { + const config = await this.getConfig(tenantId); + + // Update fields + Object.assign(config, dto); + config.updated_at = new Date(); + + return this.configRepository.save(config); + } + + // ==================== Chat Completion ==================== + + async chat( + tenantId: string, + userId: string, + dto: ChatRequestDto, + ): Promise { + const config = await this.getConfig(tenantId); + + if (!config.is_enabled) { + throw new BadRequestException('AI features are disabled for this tenant'); + } + + if (!this.openRouterClient.isReady()) { + throw new BadRequestException('AI service is not configured'); + } + + // Create usage record + const usage = this.usageRepository.create({ + tenant_id: tenantId, + user_id: userId, + provider: config.provider, + model: dto.model || config.default_model, + status: UsageStatus.PENDING, + started_at: new Date(), + }); + await this.usageRepository.save(usage); + + try { + // Apply system prompt if configured and not provided + let messages = [...dto.messages]; + if (config.system_prompt && !messages.some((m) => m.role === 'system')) { + messages = [{ role: 'system', content: config.system_prompt }, ...messages]; + } + + const startTime = Date.now(); + + const response = await this.openRouterClient.chatCompletion( + { ...dto, messages }, + config.default_model, + config.temperature, + config.max_tokens, + ); + + const latencyMs = Date.now() - startTime; + + // Calculate costs + const costs = this.openRouterClient.calculateCost( + response.model, + response.usage.prompt_tokens, + response.usage.completion_tokens, + ); + + // Update usage record + usage.status = UsageStatus.COMPLETED; + usage.model = response.model; + usage.input_tokens = response.usage.prompt_tokens; + usage.output_tokens = response.usage.completion_tokens; + usage.cost_input = costs.input; + usage.cost_output = costs.output; + usage.latency_ms = latencyMs; + usage.completed_at = new Date(); + usage.request_id = response.id; + usage.endpoint = 'chat'; + await this.usageRepository.save(usage); + + return response; + } catch (error) { + // Record failure + usage.status = UsageStatus.FAILED; + usage.error_message = error.message; + usage.completed_at = new Date(); + await this.usageRepository.save(usage); + + throw error; + } + } + + // ==================== Models ==================== + + async getModels(): Promise { + return this.openRouterClient.getModels(); + } + + // ==================== Usage Stats ==================== + + async getCurrentMonthUsage(tenantId: string): Promise { + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const result = await this.usageRepository + .createQueryBuilder('usage') + .select('COUNT(*)', 'request_count') + .addSelect('COALESCE(SUM(usage.input_tokens), 0)', 'total_input_tokens') + .addSelect('COALESCE(SUM(usage.output_tokens), 0)', 'total_output_tokens') + .addSelect('COALESCE(SUM(usage.input_tokens + usage.output_tokens), 0)', 'total_tokens') + .addSelect('COALESCE(SUM(usage.cost_input + usage.cost_output), 0)', 'total_cost') + .addSelect('COALESCE(AVG(usage.latency_ms), 0)', 'avg_latency_ms') + .where('usage.tenant_id = :tenantId', { tenantId }) + .andWhere('usage.status = :status', { status: UsageStatus.COMPLETED }) + .andWhere('usage.created_at >= :startOfMonth', { startOfMonth }) + .getRawOne(); + + return { + request_count: parseInt(result.request_count, 10), + total_input_tokens: parseInt(result.total_input_tokens, 10), + total_output_tokens: parseInt(result.total_output_tokens, 10), + total_tokens: parseInt(result.total_tokens, 10), + total_cost: parseFloat(result.total_cost), + avg_latency_ms: parseFloat(result.avg_latency_ms), + }; + } + + async getUsageHistory( + tenantId: string, + page = 1, + limit = 20, + ): Promise<{ data: AIUsage[]; total: number }> { + const [data, total] = await this.usageRepository.findAndCount({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + // ==================== Health Check ==================== + + isServiceReady(): boolean { + return this.openRouterClient.isReady(); + } +} diff --git a/src/modules/ai/services/index.ts b/src/modules/ai/services/index.ts new file mode 100644 index 0000000..a4310d7 --- /dev/null +++ b/src/modules/ai/services/index.ts @@ -0,0 +1 @@ +export { AIService } from './ai.service'; diff --git a/src/modules/analytics/analytics.controller.ts b/src/modules/analytics/analytics.controller.ts new file mode 100644 index 0000000..3a3f9f1 --- /dev/null +++ b/src/modules/analytics/analytics.controller.ts @@ -0,0 +1,111 @@ +import { + Controller, + Get, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiOkResponse, +} from '@nestjs/swagger'; +import { AnalyticsService } from './analytics.service'; +import { + AnalyticsQueryDto, + UserMetricsDto, + BillingMetricsDto, + UsageMetricsDto, + AnalyticsSummaryDto, + TrendDataDto, +} from './dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; + +@ApiTags('analytics') +@Controller('analytics') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Get('users') + @ApiOperation({ + summary: 'Get user metrics', + description: 'Returns user-related metrics including total users, active users, growth rate, and retention rate for the specified period.', + }) + @ApiOkResponse({ + description: 'User metrics retrieved successfully', + type: UserMetricsDto, + }) + async getUserMetrics( + @CurrentUser() user: RequestUser, + @Query() query: AnalyticsQueryDto, + ): Promise { + return this.analyticsService.getUserMetrics(user.tenant_id, query.period || '30d'); + } + + @Get('billing') + @ApiOperation({ + summary: 'Get billing metrics', + description: 'Returns billing-related metrics including subscription status, revenue, invoices, and revenue trends for the specified period.', + }) + @ApiOkResponse({ + description: 'Billing metrics retrieved successfully', + type: BillingMetricsDto, + }) + async getBillingMetrics( + @CurrentUser() user: RequestUser, + @Query() query: AnalyticsQueryDto, + ): Promise { + return this.analyticsService.getBillingMetrics(user.tenant_id, query.period || '30d'); + } + + @Get('usage') + @ApiOperation({ + summary: 'Get usage metrics', + description: 'Returns usage-related metrics including total actions, active users count, actions by type, peak usage hours, and more for the specified period.', + }) + @ApiOkResponse({ + description: 'Usage metrics retrieved successfully', + type: UsageMetricsDto, + }) + async getUsageMetrics( + @CurrentUser() user: RequestUser, + @Query() query: AnalyticsQueryDto, + ): Promise { + return this.analyticsService.getUsageMetrics(user.tenant_id, query.period || '30d'); + } + + @Get('summary') + @ApiOperation({ + summary: 'Get analytics summary', + description: 'Returns a summary of key performance indicators (KPIs) including total users, MRR, actions this month, and growth metrics.', + }) + @ApiOkResponse({ + description: 'Analytics summary retrieved successfully', + type: AnalyticsSummaryDto, + }) + async getSummary( + @CurrentUser() user: RequestUser, + ): Promise { + return this.analyticsService.getSummary(user.tenant_id); + } + + @Get('trends') + @ApiOperation({ + summary: 'Get trend data', + description: 'Returns historical trend data for new users, actions, logins, and revenue over the specified period.', + }) + @ApiOkResponse({ + description: 'Trend data retrieved successfully', + type: [TrendDataDto], + }) + async getTrends( + @CurrentUser() user: RequestUser, + @Query() query: AnalyticsQueryDto, + ): Promise { + return this.analyticsService.getTrends(user.tenant_id, query.period || '30d'); + } +} diff --git a/src/modules/analytics/analytics.module.ts b/src/modules/analytics/analytics.module.ts new file mode 100644 index 0000000..bd266b1 --- /dev/null +++ b/src/modules/analytics/analytics.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './analytics.service'; +import { User } from '../auth/entities/user.entity'; +import { Subscription } from '../billing/entities/subscription.entity'; +import { Invoice } from '../billing/entities/invoice.entity'; +import { AuditLog } from '../audit/entities/audit-log.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Subscription, Invoice, AuditLog]), + ], + controllers: [AnalyticsController], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts new file mode 100644 index 0000000..f8d2086 --- /dev/null +++ b/src/modules/analytics/analytics.service.ts @@ -0,0 +1,513 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, MoreThanOrEqual } from 'typeorm'; +import { User } from '../auth/entities/user.entity'; +import { Subscription, SubscriptionStatus } from '../billing/entities/subscription.entity'; +import { Invoice, InvoiceStatus } from '../billing/entities/invoice.entity'; +import { AuditLog, AuditAction } from '../audit/entities/audit-log.entity'; +import { + AnalyticsPeriod, + UserMetricsDto, + BillingMetricsDto, + UsageMetricsDto, + AnalyticsSummaryDto, + TrendDataDto, +} from './dto'; + +@Injectable() +export class AnalyticsService { + constructor( + @InjectRepository(User) + private readonly userRepo: Repository, + @InjectRepository(Subscription) + private readonly subscriptionRepo: Repository, + @InjectRepository(Invoice) + private readonly invoiceRepo: Repository, + @InjectRepository(AuditLog) + private readonly auditLogRepo: Repository, + ) {} + + /** + * Get the date range for a given period + */ + private getDateRange(period: AnalyticsPeriod): { startDate: Date; endDate: Date } { + const endDate = new Date(); + const startDate = new Date(); + + switch (period) { + case '7d': + startDate.setDate(startDate.getDate() - 7); + break; + case '30d': + startDate.setDate(startDate.getDate() - 30); + break; + case '90d': + startDate.setDate(startDate.getDate() - 90); + break; + case '1y': + startDate.setFullYear(startDate.getFullYear() - 1); + break; + } + + return { startDate, endDate }; + } + + /** + * Get previous period date range for comparison + */ + private getPreviousPeriodRange(period: AnalyticsPeriod): { startDate: Date; endDate: Date } { + const { startDate: currentStart, endDate: currentEnd } = this.getDateRange(period); + const duration = currentEnd.getTime() - currentStart.getTime(); + + return { + startDate: new Date(currentStart.getTime() - duration), + endDate: new Date(currentStart.getTime()), + }; + } + + /** + * Get user metrics for a tenant + */ + async getUserMetrics(tenantId: string, period: AnalyticsPeriod): Promise { + const { startDate, endDate } = this.getDateRange(period); + const { startDate: prevStart, endDate: prevEnd } = this.getPreviousPeriodRange(period); + + // Total users + const totalUsers = await this.userRepo.count({ + where: { tenant_id: tenantId }, + }); + + // Users by status + const activeUsers = await this.userRepo.count({ + where: { tenant_id: tenantId, status: 'active' }, + }); + + const inactiveUsers = await this.userRepo.count({ + where: { tenant_id: tenantId, status: 'inactive' }, + }); + + const pendingVerification = await this.userRepo.count({ + where: { tenant_id: tenantId, status: 'pending_verification' }, + }); + + const suspendedUsers = await this.userRepo.count({ + where: { tenant_id: tenantId, status: 'suspended' }, + }); + + // New users in period + const newUsers = await this.userRepo.count({ + where: { + tenant_id: tenantId, + created_at: Between(startDate, endDate), + }, + }); + + // New users in previous period (for growth rate) + const prevNewUsers = await this.userRepo.count({ + where: { + tenant_id: tenantId, + created_at: Between(prevStart, prevEnd), + }, + }); + + // Users who logged in during the period + const usersLoggedIn = await this.userRepo.count({ + where: { + tenant_id: tenantId, + last_login_at: Between(startDate, endDate), + }, + }); + + // Calculate growth rate + const growthRate = prevNewUsers > 0 + ? ((newUsers - prevNewUsers) / prevNewUsers) * 100 + : newUsers > 0 ? 100 : 0; + + // Calculate retention rate (users who logged in / active users) + const retentionRate = activeUsers > 0 + ? (usersLoggedIn / activeUsers) * 100 + : 0; + + return { + totalUsers, + activeUsers, + inactiveUsers, + pendingVerification, + suspendedUsers, + newUsers, + usersLoggedIn, + growthRate: Math.round(growthRate * 100) / 100, + retentionRate: Math.round(retentionRate * 100) / 100, + }; + } + + /** + * Get billing metrics for a tenant + */ + async getBillingMetrics(tenantId: string, period: AnalyticsPeriod): Promise { + const { startDate, endDate } = this.getDateRange(period); + const { startDate: prevStart, endDate: prevEnd } = this.getPreviousPeriodRange(period); + + // Get current subscription + const subscription = await this.subscriptionRepo.findOne({ + where: { tenant_id: tenantId }, + relations: ['plan'], + order: { created_at: 'DESC' }, + }); + + // Get paid invoices in period + const paidInvoices = await this.invoiceRepo.find({ + where: { + tenant_id: tenantId, + status: InvoiceStatus.PAID, + paid_at: Between(startDate, endDate), + }, + }); + + // Get paid invoices in previous period + const prevPaidInvoices = await this.invoiceRepo.find({ + where: { + tenant_id: tenantId, + status: InvoiceStatus.PAID, + paid_at: Between(prevStart, prevEnd), + }, + }); + + // Get pending invoices + const pendingInvoicesList = await this.invoiceRepo.find({ + where: { + tenant_id: tenantId, + status: InvoiceStatus.OPEN, + }, + }); + + // Calculate totals + const totalRevenue = paidInvoices.reduce((sum, inv) => sum + Number(inv.total), 0); + const prevRevenue = prevPaidInvoices.reduce((sum, inv) => sum + Number(inv.total), 0); + const totalDue = pendingInvoicesList.reduce((sum, inv) => sum + Number(inv.total), 0); + const averageInvoiceAmount = paidInvoices.length > 0 + ? totalRevenue / paidInvoices.length + : 0; + + // Calculate days until expiration + const daysUntilExpiration = subscription + ? Math.max(0, Math.ceil( + (new Date(subscription.current_period_end).getTime() - Date.now()) / (1000 * 60 * 60 * 24) + )) + : 0; + + // Revenue trend + const revenueTrend = prevRevenue > 0 + ? ((totalRevenue - prevRevenue) / prevRevenue) * 100 + : totalRevenue > 0 ? 100 : 0; + + return { + subscriptionStatus: subscription?.status || 'none', + currentPlan: subscription?.plan?.name || null, + totalRevenue: Math.round(totalRevenue * 100) / 100, + paidInvoices: paidInvoices.length, + pendingInvoices: pendingInvoicesList.length, + totalDue: Math.round(totalDue * 100) / 100, + averageInvoiceAmount: Math.round(averageInvoiceAmount * 100) / 100, + daysUntilExpiration, + isTrialPeriod: subscription?.status === SubscriptionStatus.TRIAL, + revenueTrend: Math.round(revenueTrend * 100) / 100, + }; + } + + /** + * Get usage metrics for a tenant + */ + async getUsageMetrics(tenantId: string, period: AnalyticsPeriod): Promise { + const { startDate, endDate } = this.getDateRange(period); + + // Total actions in period + const totalActions = await this.auditLogRepo.count({ + where: { + tenant_id: tenantId, + created_at: Between(startDate, endDate), + }, + }); + + // Unique active users + const activeUsersQuery = await this.auditLogRepo + .createQueryBuilder('audit') + .select('COUNT(DISTINCT audit.user_id)', 'count') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .andWhere('audit.user_id IS NOT NULL') + .getRawOne(); + + const activeUsersCount = parseInt(activeUsersQuery?.count || '0', 10); + + // Actions by type + const actionsByTypeQuery = await this.auditLogRepo + .createQueryBuilder('audit') + .select('audit.action', 'action') + .addSelect('COUNT(*)', 'count') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('audit.action') + .getRawMany(); + + const actionsByType: Record = {}; + actionsByTypeQuery.forEach((row) => { + actionsByType[row.action] = parseInt(row.count, 10); + }); + + // Actions by entity type + const actionsByEntityQuery = await this.auditLogRepo + .createQueryBuilder('audit') + .select('audit.entity_type', 'entity_type') + .addSelect('COUNT(*)', 'count') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('audit.entity_type') + .getRawMany(); + + const actionsByEntity: Record = {}; + actionsByEntityQuery.forEach((row) => { + actionsByEntity[row.entity_type] = parseInt(row.count, 10); + }); + + // Peak usage hour + const peakHourQuery = await this.auditLogRepo + .createQueryBuilder('audit') + .select('EXTRACT(HOUR FROM audit.created_at)', 'hour') + .addSelect('COUNT(*)', 'count') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('EXTRACT(HOUR FROM audit.created_at)') + .orderBy('count', 'DESC') + .limit(1) + .getRawOne(); + + const peakUsageHour = peakHourQuery ? parseInt(peakHourQuery.hour, 10) : 0; + + // Login count + const loginCount = await this.auditLogRepo.count({ + where: { + tenant_id: tenantId, + action: AuditAction.LOGIN, + created_at: Between(startDate, endDate), + }, + }); + + // Average actions per user + const averageActionsPerUser = activeUsersCount > 0 + ? totalActions / activeUsersCount + : 0; + + // Estimate average session duration (based on time between first and last action per user per day) + // This is a simplified estimation + const avgSessionDuration = loginCount > 0 && totalActions > 0 + ? Math.round((totalActions / loginCount) * 2) // Rough estimate: 2 minutes per action on average + : 0; + + // Top entities + const topEntitiesQuery = await this.auditLogRepo + .createQueryBuilder('audit') + .select('audit.entity_type', 'entityType') + .addSelect('COUNT(*)', 'count') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('audit.entity_type') + .orderBy('count', 'DESC') + .limit(5) + .getRawMany(); + + const topEntities = topEntitiesQuery.map((row) => ({ + entityType: row.entityType, + count: parseInt(row.count, 10), + })); + + return { + totalActions, + activeUsersCount, + actionsByType, + actionsByEntity, + averageActionsPerUser: Math.round(averageActionsPerUser * 100) / 100, + peakUsageHour, + loginCount, + avgSessionDuration, + topEntities, + }; + } + + /** + * Get summary KPIs for a tenant + */ + async getSummary(tenantId: string): Promise { + const now = new Date(); + const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0); + + // Users + const totalUsers = await this.userRepo.count({ + where: { tenant_id: tenantId }, + }); + + const activeUsers = await this.userRepo.count({ + where: { tenant_id: tenantId, status: 'active' }, + }); + + // Users last month + const usersLastMonth = await this.userRepo.count({ + where: { + tenant_id: tenantId, + created_at: Between(lastMonthStart, lastMonthEnd), + }, + }); + + const usersThisMonth = await this.userRepo.count({ + where: { + tenant_id: tenantId, + created_at: MoreThanOrEqual(thisMonthStart), + }, + }); + + const userGrowth = usersLastMonth > 0 + ? ((usersThisMonth - usersLastMonth) / usersLastMonth) * 100 + : usersThisMonth > 0 ? 100 : 0; + + // Subscription and MRR + const subscription = await this.subscriptionRepo.findOne({ + where: { tenant_id: tenantId }, + relations: ['plan'], + order: { created_at: 'DESC' }, + }); + + const mrr = subscription?.plan?.price_monthly || 0; + + // Actions + const actionsThisMonth = await this.auditLogRepo.count({ + where: { + tenant_id: tenantId, + created_at: MoreThanOrEqual(thisMonthStart), + }, + }); + + const actionsLastMonth = await this.auditLogRepo.count({ + where: { + tenant_id: tenantId, + created_at: Between(lastMonthStart, lastMonthEnd), + }, + }); + + const actionsGrowth = actionsLastMonth > 0 + ? ((actionsThisMonth - actionsLastMonth) / actionsLastMonth) * 100 + : actionsThisMonth > 0 ? 100 : 0; + + // Pending amount + const pendingInvoices = await this.invoiceRepo.find({ + where: { + tenant_id: tenantId, + status: InvoiceStatus.OPEN, + }, + }); + + const pendingAmount = pendingInvoices.reduce((sum, inv) => sum + Number(inv.total), 0); + + return { + totalUsers, + activeUsers, + mrr: Number(mrr), + subscriptionStatus: subscription?.status || 'none', + actionsThisMonth, + actionsGrowth: Math.round(actionsGrowth * 100) / 100, + userGrowth: Math.round(userGrowth * 100) / 100, + pendingAmount: Math.round(pendingAmount * 100) / 100, + }; + } + + /** + * Get trend data for various metrics + */ + async getTrends(tenantId: string, period: AnalyticsPeriod): Promise { + const { startDate, endDate } = this.getDateRange(period); + + // Determine date truncation based on period + const truncate = period === '7d' ? 'day' : period === '30d' ? 'day' : period === '90d' ? 'week' : 'month'; + + // User registration trend + const userTrendQuery = await this.userRepo + .createQueryBuilder('user') + .select(`DATE_TRUNC('${truncate}', user.created_at)`, 'date') + .addSelect('COUNT(*)', 'value') + .where('user.tenant_id = :tenantId', { tenantId }) + .andWhere('user.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy(`DATE_TRUNC('${truncate}', user.created_at)`) + .orderBy('date', 'ASC') + .getRawMany(); + + const userTrend: TrendDataDto = { + metric: 'new_users', + data: userTrendQuery.map((row) => ({ + date: new Date(row.date).toISOString().split('T')[0], + value: parseInt(row.value, 10), + })), + }; + + // Actions trend + const actionsTrendQuery = await this.auditLogRepo + .createQueryBuilder('audit') + .select(`DATE_TRUNC('${truncate}', audit.created_at)`, 'date') + .addSelect('COUNT(*)', 'value') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy(`DATE_TRUNC('${truncate}', audit.created_at)`) + .orderBy('date', 'ASC') + .getRawMany(); + + const actionsTrend: TrendDataDto = { + metric: 'actions', + data: actionsTrendQuery.map((row) => ({ + date: new Date(row.date).toISOString().split('T')[0], + value: parseInt(row.value, 10), + })), + }; + + // Login trend + const loginTrendQuery = await this.auditLogRepo + .createQueryBuilder('audit') + .select(`DATE_TRUNC('${truncate}', audit.created_at)`, 'date') + .addSelect('COUNT(*)', 'value') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.action = :action', { action: AuditAction.LOGIN }) + .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy(`DATE_TRUNC('${truncate}', audit.created_at)`) + .orderBy('date', 'ASC') + .getRawMany(); + + const loginTrend: TrendDataDto = { + metric: 'logins', + data: loginTrendQuery.map((row) => ({ + date: new Date(row.date).toISOString().split('T')[0], + value: parseInt(row.value, 10), + })), + }; + + // Revenue trend (based on paid invoices) + const revenueTrendQuery = await this.invoiceRepo + .createQueryBuilder('invoice') + .select(`DATE_TRUNC('${truncate}', invoice.paid_at)`, 'date') + .addSelect('SUM(invoice.total)', 'value') + .where('invoice.tenant_id = :tenantId', { tenantId }) + .andWhere('invoice.status = :status', { status: InvoiceStatus.PAID }) + .andWhere('invoice.paid_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy(`DATE_TRUNC('${truncate}', invoice.paid_at)`) + .orderBy('date', 'ASC') + .getRawMany(); + + const revenueTrend: TrendDataDto = { + metric: 'revenue', + data: revenueTrendQuery.map((row) => ({ + date: new Date(row.date).toISOString().split('T')[0], + value: Math.round(parseFloat(row.value || '0') * 100) / 100, + })), + }; + + return [userTrend, actionsTrend, loginTrend, revenueTrend]; + } +} diff --git a/src/modules/analytics/dto/analytics-query.dto.ts b/src/modules/analytics/dto/analytics-query.dto.ts new file mode 100644 index 0000000..ef1e7e2 --- /dev/null +++ b/src/modules/analytics/dto/analytics-query.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsIn } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export type AnalyticsPeriod = '7d' | '30d' | '90d' | '1y'; + +export class AnalyticsQueryDto { + @ApiPropertyOptional({ + description: 'Period for analytics data', + enum: ['7d', '30d', '90d', '1y'], + default: '30d', + }) + @IsOptional() + @IsIn(['7d', '30d', '90d', '1y']) + period?: AnalyticsPeriod = '30d'; +} diff --git a/src/modules/analytics/dto/analytics-summary.dto.ts b/src/modules/analytics/dto/analytics-summary.dto.ts new file mode 100644 index 0000000..a7d8870 --- /dev/null +++ b/src/modules/analytics/dto/analytics-summary.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AnalyticsSummaryDto { + @ApiProperty({ description: 'Total users' }) + totalUsers: number; + + @ApiProperty({ description: 'Active users' }) + activeUsers: number; + + @ApiProperty({ description: 'Current MRR (Monthly Recurring Revenue)' }) + mrr: number; + + @ApiProperty({ description: 'Subscription status' }) + subscriptionStatus: string; + + @ApiProperty({ description: 'Total actions this month' }) + actionsThisMonth: number; + + @ApiProperty({ description: 'Actions growth percentage' }) + actionsGrowth: number; + + @ApiProperty({ description: 'User growth percentage' }) + userGrowth: number; + + @ApiProperty({ description: 'Pending invoices amount' }) + pendingAmount: number; +} diff --git a/src/modules/analytics/dto/billing-metrics.dto.ts b/src/modules/analytics/dto/billing-metrics.dto.ts new file mode 100644 index 0000000..5c065e2 --- /dev/null +++ b/src/modules/analytics/dto/billing-metrics.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BillingMetricsDto { + @ApiProperty({ description: 'Current subscription status' }) + subscriptionStatus: string; + + @ApiProperty({ description: 'Current plan name' }) + currentPlan: string | null; + + @ApiProperty({ description: 'Total revenue in the period' }) + totalRevenue: number; + + @ApiProperty({ description: 'Total paid invoices count' }) + paidInvoices: number; + + @ApiProperty({ description: 'Total pending invoices count' }) + pendingInvoices: number; + + @ApiProperty({ description: 'Total amount due' }) + totalDue: number; + + @ApiProperty({ description: 'Average invoice amount' }) + averageInvoiceAmount: number; + + @ApiProperty({ description: 'Days until subscription expires' }) + daysUntilExpiration: number; + + @ApiProperty({ description: 'Whether subscription is in trial' }) + isTrialPeriod: boolean; + + @ApiProperty({ description: 'Revenue trend (positive/negative percentage)' }) + revenueTrend: number; +} diff --git a/src/modules/analytics/dto/index.ts b/src/modules/analytics/dto/index.ts new file mode 100644 index 0000000..cf7c440 --- /dev/null +++ b/src/modules/analytics/dto/index.ts @@ -0,0 +1,6 @@ +export * from './analytics-query.dto'; +export * from './user-metrics.dto'; +export * from './billing-metrics.dto'; +export * from './usage-metrics.dto'; +export * from './analytics-summary.dto'; +export * from './trend-data.dto'; diff --git a/src/modules/analytics/dto/trend-data.dto.ts b/src/modules/analytics/dto/trend-data.dto.ts new file mode 100644 index 0000000..0915cbd --- /dev/null +++ b/src/modules/analytics/dto/trend-data.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TrendDataPointDto { + @ApiProperty({ description: 'Date label' }) + date: string; + + @ApiProperty({ description: 'Value for this date' }) + value: number; +} + +export class TrendDataDto { + @ApiProperty({ description: 'Metric name' }) + metric: string; + + @ApiProperty({ description: 'Data points for the trend', type: [TrendDataPointDto] }) + data: TrendDataPointDto[]; +} diff --git a/src/modules/analytics/dto/usage-metrics.dto.ts b/src/modules/analytics/dto/usage-metrics.dto.ts new file mode 100644 index 0000000..3f65a1b --- /dev/null +++ b/src/modules/analytics/dto/usage-metrics.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UsageMetricsDto { + @ApiProperty({ description: 'Total audit log entries in the period' }) + totalActions: number; + + @ApiProperty({ description: 'Unique users who performed actions' }) + activeUsersCount: number; + + @ApiProperty({ description: 'Actions breakdown by type' }) + actionsByType: Record; + + @ApiProperty({ description: 'Actions breakdown by entity type' }) + actionsByEntity: Record; + + @ApiProperty({ description: 'Average actions per user' }) + averageActionsPerUser: number; + + @ApiProperty({ description: 'Peak usage hour (0-23)' }) + peakUsageHour: number; + + @ApiProperty({ description: 'Total login count in the period' }) + loginCount: number; + + @ApiProperty({ description: 'Average session duration in minutes (estimated)' }) + avgSessionDuration: number; + + @ApiProperty({ description: 'Most accessed entities' }) + topEntities: Array<{ entityType: string; count: number }>; +} diff --git a/src/modules/analytics/dto/user-metrics.dto.ts b/src/modules/analytics/dto/user-metrics.dto.ts new file mode 100644 index 0000000..7c9ca2d --- /dev/null +++ b/src/modules/analytics/dto/user-metrics.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserMetricsDto { + @ApiProperty({ description: 'Total users in the tenant' }) + totalUsers: number; + + @ApiProperty({ description: 'Active users (status = active)' }) + activeUsers: number; + + @ApiProperty({ description: 'Inactive users (status = inactive)' }) + inactiveUsers: number; + + @ApiProperty({ description: 'Pending verification users' }) + pendingVerification: number; + + @ApiProperty({ description: 'Suspended users' }) + suspendedUsers: number; + + @ApiProperty({ description: 'New users in the period' }) + newUsers: number; + + @ApiProperty({ description: 'Users who logged in during the period' }) + usersLoggedIn: number; + + @ApiProperty({ description: 'User growth rate as percentage' }) + growthRate: number; + + @ApiProperty({ description: 'User retention rate as percentage' }) + retentionRate: number; +} diff --git a/src/modules/analytics/index.ts b/src/modules/analytics/index.ts new file mode 100644 index 0000000..7d3e738 --- /dev/null +++ b/src/modules/analytics/index.ts @@ -0,0 +1,4 @@ +export * from './analytics.module'; +export * from './analytics.controller'; +export * from './analytics.service'; +export * from './dto'; diff --git a/src/modules/audit/__tests__/audit.controller.spec.ts b/src/modules/audit/__tests__/audit.controller.spec.ts new file mode 100644 index 0000000..2a47195 --- /dev/null +++ b/src/modules/audit/__tests__/audit.controller.spec.ts @@ -0,0 +1,919 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuditController } from '../audit.controller'; +import { AuditService } from '../services/audit.service'; +import { QueryAuditLogsDto } from '../dto/query-audit.dto'; +import { QueryActivityLogsDto } from '../dto/query-activity.dto'; +import { CreateActivityLogDto } from '../dto/create-activity.dto'; +import { AuditAction, AuditLog } from '../entities/audit-log.entity'; +import { ActivityLog, ActivityType } from '../entities/activity-log.entity'; +import { RequestUser } from '../../auth/strategies/jwt.strategy'; + +describe('AuditController', () => { + let controller: AuditController; + let auditService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockRequestUser: RequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + }; + + const mockRequest = { + ip: '192.168.1.1', + headers: { + 'user-agent': 'Mozilla/5.0 Test Agent', + 'x-session-id': 'session-12345', + }, + }; + + const mockAuditLog: Partial = { + id: 'audit-001', + tenant_id: mockTenantId, + user_id: mockUserId, + action: AuditAction.CREATE, + entity_type: 'user', + entity_id: 'user-001', + new_values: { email: 'test@example.com' }, + changed_fields: ['email'], + ip_address: '192.168.1.1', + created_at: new Date('2026-01-13T10:00:00Z'), + }; + + const mockActivityLog: Partial = { + id: 'activity-001', + tenant_id: mockTenantId, + user_id: mockUserId, + activity_type: ActivityType.PAGE_VIEW, + resource_type: 'dashboard', + description: 'Viewed dashboard', + ip_address: '192.168.1.1', + user_agent: 'Mozilla/5.0 Test Agent', + session_id: 'session-12345', + created_at: new Date('2026-01-13T10:00:00Z'), + }; + + const mockPaginatedAuditLogs = { + data: [mockAuditLog as AuditLog], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + const mockPaginatedActivityLogs = { + data: [mockActivityLog as ActivityLog], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + + const mockAuditStats = { + total_actions: 100, + actions_by_type: [ + { action: AuditAction.CREATE, count: 30 }, + { action: AuditAction.UPDATE, count: 50 }, + { action: AuditAction.DELETE, count: 20 }, + ], + top_users: [ + { user_id: mockUserId, count: 45 }, + { user_id: 'user-002', count: 30 }, + ], + }; + + const mockActivitySummary = [ + { activity_type: ActivityType.PAGE_VIEW, count: 50 }, + { activity_type: ActivityType.FEATURE_USE, count: 25 }, + ]; + + beforeEach(async () => { + const mockAuditService = { + queryAuditLogs: jest.fn(), + getAuditLogById: jest.fn(), + getEntityAuditHistory: jest.fn(), + getAuditStats: jest.fn(), + queryActivityLogs: jest.fn(), + createActivityLog: jest.fn(), + getUserActivitySummary: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuditController], + providers: [{ provide: AuditService, useValue: mockAuditService }], + }).compile(); + + controller = module.get(AuditController); + auditService = module.get(AuditService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== AUDIT LOGS TESTS ==================== + + describe('queryAuditLogs', () => { + it('should return paginated audit logs with default pagination', async () => { + const query: QueryAuditLogsDto = {}; + auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); + + const result = await controller.queryAuditLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedAuditLogs); + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter audit logs by user_id', async () => { + const query: QueryAuditLogsDto = { user_id: mockUserId }; + auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); + + const result = await controller.queryAuditLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedAuditLogs); + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter audit logs by action', async () => { + const query: QueryAuditLogsDto = { action: AuditAction.CREATE }; + auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); + + const result = await controller.queryAuditLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedAuditLogs); + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter audit logs by entity_type', async () => { + const query: QueryAuditLogsDto = { entity_type: 'user' }; + auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); + + const result = await controller.queryAuditLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedAuditLogs); + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter audit logs by entity_id', async () => { + const query: QueryAuditLogsDto = { + entity_id: '550e8400-e29b-41d4-a716-446655440003', + }; + auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); + + const result = await controller.queryAuditLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedAuditLogs); + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter audit logs by date range', async () => { + const query: QueryAuditLogsDto = { + from_date: '2026-01-01', + to_date: '2026-01-31', + }; + auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); + + const result = await controller.queryAuditLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedAuditLogs); + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should handle custom pagination', async () => { + const query: QueryAuditLogsDto = { page: 2, limit: 10 }; + const paginatedResult = { + ...mockPaginatedAuditLogs, + page: 2, + limit: 10, + totalPages: 5, + total: 50, + }; + auditService.queryAuditLogs.mockResolvedValue(paginatedResult); + + const result = await controller.queryAuditLogs(mockRequestUser, query); + + expect(result.page).toBe(2); + expect(result.limit).toBe(10); + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter with multiple criteria', async () => { + const query: QueryAuditLogsDto = { + user_id: mockUserId, + action: AuditAction.UPDATE, + entity_type: 'document', + from_date: '2026-01-01', + page: 1, + limit: 50, + }; + auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); + + const result = await controller.queryAuditLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedAuditLogs); + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should return empty data when no results', async () => { + const emptyResult = { + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }; + auditService.queryAuditLogs.mockResolvedValue(emptyResult); + + const result = await controller.queryAuditLogs(mockRequestUser, {}); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + describe('getAuditLogById', () => { + it('should return audit log by ID', async () => { + auditService.getAuditLogById.mockResolvedValue(mockAuditLog as AuditLog); + + const result = await controller.getAuditLogById( + mockRequestUser, + 'audit-001', + ); + + expect(result).toEqual(mockAuditLog); + expect(auditService.getAuditLogById).toHaveBeenCalledWith( + mockTenantId, + 'audit-001', + ); + }); + + it('should return null when audit log not found', async () => { + auditService.getAuditLogById.mockResolvedValue(null); + + const result = await controller.getAuditLogById( + mockRequestUser, + 'non-existent-id', + ); + + expect(result).toBeNull(); + expect(auditService.getAuditLogById).toHaveBeenCalledWith( + mockTenantId, + 'non-existent-id', + ); + }); + + it('should use tenant_id from current user for isolation', async () => { + auditService.getAuditLogById.mockResolvedValue(mockAuditLog as AuditLog); + + await controller.getAuditLogById(mockRequestUser, 'audit-001'); + + expect(auditService.getAuditLogById).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + 'audit-001', + ); + }); + }); + + describe('getEntityAuditHistory', () => { + it('should return audit history for specific entity', async () => { + const history = [mockAuditLog as AuditLog]; + auditService.getEntityAuditHistory.mockResolvedValue(history); + + const result = await controller.getEntityAuditHistory( + mockRequestUser, + 'user', + 'user-001', + ); + + expect(result).toEqual(history); + expect(auditService.getEntityAuditHistory).toHaveBeenCalledWith( + mockTenantId, + 'user', + 'user-001', + ); + }); + + it('should return empty array when no history found', async () => { + auditService.getEntityAuditHistory.mockResolvedValue([]); + + const result = await controller.getEntityAuditHistory( + mockRequestUser, + 'document', + 'doc-999', + ); + + expect(result).toHaveLength(0); + expect(auditService.getEntityAuditHistory).toHaveBeenCalledWith( + mockTenantId, + 'document', + 'doc-999', + ); + }); + + it('should handle different entity types', async () => { + auditService.getEntityAuditHistory.mockResolvedValue([]); + + await controller.getEntityAuditHistory( + mockRequestUser, + 'product', + 'prod-123', + ); + + expect(auditService.getEntityAuditHistory).toHaveBeenCalledWith( + mockTenantId, + 'product', + 'prod-123', + ); + }); + }); + + describe('getAuditStats', () => { + it('should return audit statistics with default days', async () => { + auditService.getAuditStats.mockResolvedValue(mockAuditStats); + + const result = await controller.getAuditStats(mockRequestUser); + + expect(result).toEqual(mockAuditStats); + expect(auditService.getAuditStats).toHaveBeenCalledWith(mockTenantId, 7); + }); + + it('should return audit statistics with custom days', async () => { + auditService.getAuditStats.mockResolvedValue(mockAuditStats); + + const result = await controller.getAuditStats(mockRequestUser, 30); + + expect(result).toEqual(mockAuditStats); + expect(auditService.getAuditStats).toHaveBeenCalledWith(mockTenantId, 30); + }); + + it('should return correct statistics structure', async () => { + auditService.getAuditStats.mockResolvedValue(mockAuditStats); + + const result = await controller.getAuditStats(mockRequestUser, 7); + + expect(result.total_actions).toBe(100); + expect(result.actions_by_type).toHaveLength(3); + expect(result.top_users).toHaveLength(2); + }); + + it('should handle zero statistics', async () => { + const emptyStats = { + total_actions: 0, + actions_by_type: [], + top_users: [], + }; + auditService.getAuditStats.mockResolvedValue(emptyStats); + + const result = await controller.getAuditStats(mockRequestUser, 1); + + expect(result.total_actions).toBe(0); + expect(result.actions_by_type).toHaveLength(0); + expect(result.top_users).toHaveLength(0); + }); + }); + + // ==================== ACTIVITY LOGS TESTS ==================== + + describe('queryActivityLogs', () => { + it('should return paginated activity logs with default pagination', async () => { + const query: QueryActivityLogsDto = {}; + auditService.queryActivityLogs.mockResolvedValue( + mockPaginatedActivityLogs, + ); + + const result = await controller.queryActivityLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedActivityLogs); + expect(auditService.queryActivityLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter activity logs by user_id', async () => { + const query: QueryActivityLogsDto = { user_id: mockUserId }; + auditService.queryActivityLogs.mockResolvedValue( + mockPaginatedActivityLogs, + ); + + const result = await controller.queryActivityLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedActivityLogs); + expect(auditService.queryActivityLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter activity logs by activity_type', async () => { + const query: QueryActivityLogsDto = { + activity_type: ActivityType.PAGE_VIEW, + }; + auditService.queryActivityLogs.mockResolvedValue( + mockPaginatedActivityLogs, + ); + + const result = await controller.queryActivityLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedActivityLogs); + expect(auditService.queryActivityLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter activity logs by resource_type', async () => { + const query: QueryActivityLogsDto = { resource_type: 'dashboard' }; + auditService.queryActivityLogs.mockResolvedValue( + mockPaginatedActivityLogs, + ); + + const result = await controller.queryActivityLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedActivityLogs); + expect(auditService.queryActivityLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should filter activity logs by date range', async () => { + const query: QueryActivityLogsDto = { + from_date: '2026-01-01', + to_date: '2026-01-31', + }; + auditService.queryActivityLogs.mockResolvedValue( + mockPaginatedActivityLogs, + ); + + const result = await controller.queryActivityLogs(mockRequestUser, query); + + expect(result).toEqual(mockPaginatedActivityLogs); + expect(auditService.queryActivityLogs).toHaveBeenCalledWith( + mockTenantId, + query, + ); + }); + + it('should handle custom pagination', async () => { + const query: QueryActivityLogsDto = { page: 3, limit: 15 }; + const paginatedResult = { + ...mockPaginatedActivityLogs, + page: 3, + limit: 15, + totalPages: 7, + total: 100, + }; + auditService.queryActivityLogs.mockResolvedValue(paginatedResult); + + const result = await controller.queryActivityLogs(mockRequestUser, query); + + expect(result.page).toBe(3); + expect(result.limit).toBe(15); + }); + + it('should return empty data when no results', async () => { + const emptyResult = { + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }; + auditService.queryActivityLogs.mockResolvedValue(emptyResult); + + const result = await controller.queryActivityLogs(mockRequestUser, {}); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + }); + + describe('createActivityLog', () => { + it('should create activity log with all context', async () => { + const dto: CreateActivityLogDto = { + activity_type: ActivityType.PAGE_VIEW, + resource_type: 'dashboard', + description: 'Viewed main dashboard', + }; + auditService.createActivityLog.mockResolvedValue( + mockActivityLog as ActivityLog, + ); + + const result = await controller.createActivityLog( + mockRequestUser, + dto, + mockRequest as any, + ); + + expect(result).toEqual(mockActivityLog); + expect(auditService.createActivityLog).toHaveBeenCalledWith( + mockTenantId, + mockUserId, + dto, + { + ip_address: mockRequest.ip, + user_agent: mockRequest.headers['user-agent'], + session_id: mockRequest.headers['x-session-id'], + }, + ); + }); + + it('should create activity log with minimal data', async () => { + const dto: CreateActivityLogDto = { + activity_type: ActivityType.FEATURE_USE, + }; + auditService.createActivityLog.mockResolvedValue( + mockActivityLog as ActivityLog, + ); + + const result = await controller.createActivityLog( + mockRequestUser, + dto, + mockRequest as any, + ); + + expect(result).toEqual(mockActivityLog); + expect(auditService.createActivityLog).toHaveBeenCalledWith( + mockTenantId, + mockUserId, + dto, + expect.any(Object), + ); + }); + + it('should create activity log with resource_id', async () => { + const dto: CreateActivityLogDto = { + activity_type: ActivityType.DOWNLOAD, + resource_type: 'document', + resource_id: '550e8400-e29b-41d4-a716-446655440005', + description: 'Downloaded document', + }; + auditService.createActivityLog.mockResolvedValue( + mockActivityLog as ActivityLog, + ); + + await controller.createActivityLog( + mockRequestUser, + dto, + mockRequest as any, + ); + + expect(auditService.createActivityLog).toHaveBeenCalledWith( + mockTenantId, + mockUserId, + dto, + expect.objectContaining({ + ip_address: mockRequest.ip, + }), + ); + }); + + it('should create activity log with metadata', async () => { + const dto: CreateActivityLogDto = { + activity_type: ActivityType.SEARCH, + description: 'Searched for products', + metadata: { query: 'laptop', results: 25 }, + }; + auditService.createActivityLog.mockResolvedValue( + mockActivityLog as ActivityLog, + ); + + await controller.createActivityLog( + mockRequestUser, + dto, + mockRequest as any, + ); + + expect(auditService.createActivityLog).toHaveBeenCalledWith( + mockTenantId, + mockUserId, + dto, + expect.any(Object), + ); + }); + + it('should handle request without session_id header', async () => { + const dto: CreateActivityLogDto = { + activity_type: ActivityType.PAGE_VIEW, + }; + const requestWithoutSession = { + ip: '192.168.1.1', + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }; + auditService.createActivityLog.mockResolvedValue( + mockActivityLog as ActivityLog, + ); + + await controller.createActivityLog( + mockRequestUser, + dto, + requestWithoutSession as any, + ); + + expect(auditService.createActivityLog).toHaveBeenCalledWith( + mockTenantId, + mockUserId, + dto, + { + ip_address: '192.168.1.1', + user_agent: 'Mozilla/5.0', + session_id: undefined, + }, + ); + }); + + it('should create activity for different activity types', async () => { + const activityTypes = [ + ActivityType.PAGE_VIEW, + ActivityType.FEATURE_USE, + ActivityType.SEARCH, + ActivityType.DOWNLOAD, + ActivityType.UPLOAD, + ActivityType.SHARE, + ActivityType.INVITE, + ActivityType.SETTINGS_CHANGE, + ActivityType.SUBSCRIPTION_CHANGE, + ActivityType.PAYMENT, + ]; + + for (const activityType of activityTypes) { + const dto: CreateActivityLogDto = { activity_type: activityType }; + auditService.createActivityLog.mockResolvedValue( + mockActivityLog as ActivityLog, + ); + + await controller.createActivityLog( + mockRequestUser, + dto, + mockRequest as any, + ); + + expect(auditService.createActivityLog).toHaveBeenCalledWith( + mockTenantId, + mockUserId, + dto, + expect.any(Object), + ); + } + }); + }); + + describe('getUserActivitySummary', () => { + it('should return user activity summary with default days', async () => { + auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary); + + const result = await controller.getUserActivitySummary(mockRequestUser); + + expect(result).toEqual(mockActivitySummary); + expect(auditService.getUserActivitySummary).toHaveBeenCalledWith( + mockTenantId, + mockUserId, + 30, + ); + }); + + it('should return user activity summary with custom days', async () => { + auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary); + + const result = await controller.getUserActivitySummary( + mockRequestUser, + 60, + ); + + expect(result).toEqual(mockActivitySummary); + expect(auditService.getUserActivitySummary).toHaveBeenCalledWith( + mockTenantId, + mockUserId, + 60, + ); + }); + + it('should return empty summary when no activity', async () => { + auditService.getUserActivitySummary.mockResolvedValue([]); + + const result = await controller.getUserActivitySummary(mockRequestUser, 7); + + expect(result).toHaveLength(0); + }); + + it('should use current user id for summary', async () => { + auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary); + + await controller.getUserActivitySummary(mockRequestUser); + + expect(auditService.getUserActivitySummary).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockRequestUser.id, + 30, + ); + }); + }); + + describe('getSpecificUserActivitySummary', () => { + const targetUserId = '550e8400-e29b-41d4-a716-446655440010'; + + it('should return specific user activity summary with default days', async () => { + auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary); + + const result = await controller.getSpecificUserActivitySummary( + mockRequestUser, + targetUserId, + ); + + expect(result).toEqual(mockActivitySummary); + expect(auditService.getUserActivitySummary).toHaveBeenCalledWith( + mockTenantId, + targetUserId, + 30, + ); + }); + + it('should return specific user activity summary with custom days', async () => { + auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary); + + const result = await controller.getSpecificUserActivitySummary( + mockRequestUser, + targetUserId, + 90, + ); + + expect(result).toEqual(mockActivitySummary); + expect(auditService.getUserActivitySummary).toHaveBeenCalledWith( + mockTenantId, + targetUserId, + 90, + ); + }); + + it('should return empty summary for user with no activity', async () => { + auditService.getUserActivitySummary.mockResolvedValue([]); + + const result = await controller.getSpecificUserActivitySummary( + mockRequestUser, + 'inactive-user-id', + ); + + expect(result).toHaveLength(0); + }); + + it('should use tenant_id from current user for tenant isolation', async () => { + auditService.getUserActivitySummary.mockResolvedValue(mockActivitySummary); + + await controller.getSpecificUserActivitySummary( + mockRequestUser, + targetUserId, + 30, + ); + + expect(auditService.getUserActivitySummary).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + targetUserId, + 30, + ); + }); + }); + + // ==================== GUARDS VERIFICATION TESTS ==================== + + describe('Guards and Decorators', () => { + it('should have JwtAuthGuard applied at controller level', () => { + const guards = Reflect.getMetadata('__guards__', AuditController); + expect(guards).toBeDefined(); + expect(guards.length).toBeGreaterThan(0); + }); + + it('should have ApiTags decorator', () => { + const tags = Reflect.getMetadata('swagger/apiUseTags', AuditController); + expect(tags).toContain('Audit'); + }); + + it('should have ApiBearerAuth decorator', () => { + const security = Reflect.getMetadata( + 'swagger/apiSecurity', + AuditController, + ); + expect(security).toBeDefined(); + }); + }); + + // ==================== TENANT ISOLATION TESTS ==================== + + describe('Tenant Isolation', () => { + it('should always pass tenant_id from current user to service methods', async () => { + auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); + auditService.getAuditLogById.mockResolvedValue(mockAuditLog as AuditLog); + auditService.getEntityAuditHistory.mockResolvedValue([]); + auditService.getAuditStats.mockResolvedValue(mockAuditStats); + auditService.queryActivityLogs.mockResolvedValue( + mockPaginatedActivityLogs, + ); + auditService.createActivityLog.mockResolvedValue( + mockActivityLog as ActivityLog, + ); + auditService.getUserActivitySummary.mockResolvedValue([]); + + // Call all endpoints + await controller.queryAuditLogs(mockRequestUser, {}); + await controller.getAuditLogById(mockRequestUser, 'id'); + await controller.getEntityAuditHistory(mockRequestUser, 'type', 'id'); + await controller.getAuditStats(mockRequestUser); + await controller.queryActivityLogs(mockRequestUser, {}); + await controller.createActivityLog( + mockRequestUser, + { activity_type: ActivityType.PAGE_VIEW }, + mockRequest as any, + ); + await controller.getUserActivitySummary(mockRequestUser); + await controller.getSpecificUserActivitySummary( + mockRequestUser, + 'user-id', + ); + + // Verify tenant_id is always passed + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + mockTenantId, + expect.any(Object), + ); + expect(auditService.getAuditLogById).toHaveBeenCalledWith( + mockTenantId, + expect.any(String), + ); + expect(auditService.getEntityAuditHistory).toHaveBeenCalledWith( + mockTenantId, + expect.any(String), + expect.any(String), + ); + expect(auditService.getAuditStats).toHaveBeenCalledWith( + mockTenantId, + expect.any(Number), + ); + expect(auditService.queryActivityLogs).toHaveBeenCalledWith( + mockTenantId, + expect.any(Object), + ); + expect(auditService.createActivityLog).toHaveBeenCalledWith( + mockTenantId, + expect.any(String), + expect.any(Object), + expect.any(Object), + ); + expect(auditService.getUserActivitySummary).toHaveBeenCalledWith( + mockTenantId, + expect.any(String), + expect.any(Number), + ); + }); + + it('should not allow cross-tenant data access', async () => { + const differentTenantUser: RequestUser = { + id: 'different-user', + email: 'other@example.com', + tenant_id: 'different-tenant-id', + }; + + auditService.queryAuditLogs.mockResolvedValue({ + data: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }); + + await controller.queryAuditLogs(differentTenantUser, {}); + + expect(auditService.queryAuditLogs).toHaveBeenCalledWith( + 'different-tenant-id', + expect.any(Object), + ); + }); + }); +}); diff --git a/src/modules/audit/__tests__/audit.service.spec.ts b/src/modules/audit/__tests__/audit.service.spec.ts new file mode 100644 index 0000000..6bb86f8 --- /dev/null +++ b/src/modules/audit/__tests__/audit.service.spec.ts @@ -0,0 +1,567 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditService } from '../services/audit.service'; +import { AuditLog, AuditAction } from '../entities/audit-log.entity'; +import { ActivityLog, ActivityType } from '../entities/activity-log.entity'; + +describe('AuditService', () => { + let service: AuditService; + let auditLogRepo: jest.Mocked>; + let activityLogRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockAuditLog: Partial = { + id: 'audit-001', + tenant_id: mockTenantId, + user_id: mockUserId, + action: AuditAction.CREATE, + entity_type: 'user', + entity_id: 'user-001', + new_values: { email: 'test@example.com' }, + changed_fields: ['email'], + ip_address: '192.168.1.1', + created_at: new Date(), + }; + + const mockActivityLog: Partial = { + id: 'activity-001', + tenant_id: mockTenantId, + user_id: mockUserId, + activity_type: ActivityType.PAGE_VIEW, + resource_type: 'dashboard', + description: 'Viewed dashboard', + ip_address: '192.168.1.1', + created_at: new Date(), + }; + + beforeEach(async () => { + const mockAuditLogRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockActivityLogRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuditService, + { provide: getRepositoryToken(AuditLog), useValue: mockAuditLogRepo }, + { provide: getRepositoryToken(ActivityLog), useValue: mockActivityLogRepo }, + ], + }).compile(); + + service = module.get(AuditService); + auditLogRepo = module.get(getRepositoryToken(AuditLog)); + activityLogRepo = module.get(getRepositoryToken(ActivityLog)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== Create Audit Log Tests ==================== + + describe('createAuditLog', () => { + it('should create audit log successfully', async () => { + auditLogRepo.create.mockReturnValue(mockAuditLog as AuditLog); + auditLogRepo.save.mockResolvedValue(mockAuditLog as AuditLog); + + const result = await service.createAuditLog({ + tenant_id: mockTenantId, + user_id: mockUserId, + action: AuditAction.CREATE, + entity_type: 'user', + entity_id: 'user-001', + new_values: { email: 'test@example.com' }, + }); + + expect(result).toEqual(mockAuditLog); + expect(auditLogRepo.create).toHaveBeenCalled(); + expect(auditLogRepo.save).toHaveBeenCalled(); + }); + + it('should detect changed fields', async () => { + auditLogRepo.create.mockReturnValue(mockAuditLog as AuditLog); + auditLogRepo.save.mockResolvedValue(mockAuditLog as AuditLog); + + await service.createAuditLog({ + tenant_id: mockTenantId, + user_id: mockUserId, + action: AuditAction.UPDATE, + entity_type: 'user', + entity_id: 'user-001', + old_values: { email: 'old@example.com', name: 'Old Name' }, + new_values: { email: 'new@example.com', name: 'Old Name' }, + }); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + changed_fields: ['email'], + }), + ); + }); + + it('should handle null old/new values', async () => { + auditLogRepo.create.mockReturnValue(mockAuditLog as AuditLog); + auditLogRepo.save.mockResolvedValue(mockAuditLog as AuditLog); + + await service.createAuditLog({ + tenant_id: mockTenantId, + action: AuditAction.DELETE, + entity_type: 'user', + entity_id: 'user-001', + }); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + changed_fields: [], + }), + ); + }); + + it('should include request metadata', async () => { + auditLogRepo.create.mockReturnValue(mockAuditLog as AuditLog); + auditLogRepo.save.mockResolvedValue(mockAuditLog as AuditLog); + + await service.createAuditLog({ + tenant_id: mockTenantId, + user_id: mockUserId, + action: AuditAction.READ, + entity_type: 'document', + ip_address: '192.168.1.1', + user_agent: 'Mozilla/5.0', + endpoint: '/api/documents/1', + http_method: 'GET', + response_status: 200, + duration_ms: 150, + }); + + expect(auditLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + ip_address: '192.168.1.1', + endpoint: '/api/documents/1', + duration_ms: 150, + }), + ); + }); + }); + + // ==================== Query Audit Logs Tests ==================== + + describe('queryAuditLogs', () => { + it('should return paginated audit logs', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[mockAuditLog as AuditLog], 1]), + }; + auditLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + const result = await service.queryAuditLogs(mockTenantId, {}); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + + it('should filter by user_id', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + auditLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + await service.queryAuditLogs(mockTenantId, { user_id: mockUserId }); + + expect(qb.andWhere).toHaveBeenCalledWith('audit.user_id = :user_id', { + user_id: mockUserId, + }); + }); + + it('should filter by action', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + auditLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + await service.queryAuditLogs(mockTenantId, { action: AuditAction.CREATE }); + + expect(qb.andWhere).toHaveBeenCalledWith('audit.action = :action', { + action: AuditAction.CREATE, + }); + }); + + it('should filter by entity_type', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + auditLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + await service.queryAuditLogs(mockTenantId, { entity_type: 'user' }); + + expect(qb.andWhere).toHaveBeenCalledWith('audit.entity_type = :entity_type', { + entity_type: 'user', + }); + }); + + it('should filter by date range', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + auditLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + const from_date = '2026-01-01'; + const to_date = '2026-01-31'; + + await service.queryAuditLogs(mockTenantId, { from_date, to_date }); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'audit.created_at BETWEEN :from_date AND :to_date', + { from_date, to_date }, + ); + }); + + it('should handle pagination correctly', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 100]), + }; + auditLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + const result = await service.queryAuditLogs(mockTenantId, { + page: 3, + limit: 10, + }); + + expect(qb.skip).toHaveBeenCalledWith(20); + expect(qb.take).toHaveBeenCalledWith(10); + expect(result.totalPages).toBe(10); + }); + }); + + // ==================== Get Audit Log By ID Tests ==================== + + describe('getAuditLogById', () => { + it('should return audit log by id', async () => { + auditLogRepo.findOne.mockResolvedValue(mockAuditLog as AuditLog); + + const result = await service.getAuditLogById(mockTenantId, 'audit-001'); + + expect(result).toEqual(mockAuditLog); + expect(auditLogRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'audit-001', tenant_id: mockTenantId }, + }); + }); + + it('should return null when not found', async () => { + auditLogRepo.findOne.mockResolvedValue(null); + + const result = await service.getAuditLogById(mockTenantId, 'invalid'); + + expect(result).toBeNull(); + }); + }); + + // ==================== Get Entity Audit History Tests ==================== + + describe('getEntityAuditHistory', () => { + it('should return audit history for entity', async () => { + auditLogRepo.find.mockResolvedValue([mockAuditLog as AuditLog]); + + const result = await service.getEntityAuditHistory( + mockTenantId, + 'user', + 'user-001', + ); + + expect(result).toHaveLength(1); + expect(auditLogRepo.find).toHaveBeenCalledWith({ + where: { + tenant_id: mockTenantId, + entity_type: 'user', + entity_id: 'user-001', + }, + order: { created_at: 'DESC' }, + }); + }); + + it('should return empty array for no history', async () => { + auditLogRepo.find.mockResolvedValue([]); + + const result = await service.getEntityAuditHistory( + mockTenantId, + 'document', + 'doc-999', + ); + + expect(result).toHaveLength(0); + }); + }); + + // ==================== Create Activity Log Tests ==================== + + describe('createActivityLog', () => { + it('should create activity log successfully', async () => { + activityLogRepo.create.mockReturnValue(mockActivityLog as ActivityLog); + activityLogRepo.save.mockResolvedValue(mockActivityLog as ActivityLog); + + const result = await service.createActivityLog( + mockTenantId, + mockUserId, + { + activity_type: ActivityType.PAGE_VIEW, + resource_type: 'dashboard', + description: 'Viewed dashboard', + }, + { ip_address: '192.168.1.1' }, + ); + + expect(result).toEqual(mockActivityLog); + expect(activityLogRepo.create).toHaveBeenCalled(); + }); + + it('should include session context', async () => { + activityLogRepo.create.mockReturnValue(mockActivityLog as ActivityLog); + activityLogRepo.save.mockResolvedValue(mockActivityLog as ActivityLog); + + await service.createActivityLog( + mockTenantId, + mockUserId, + { + activity_type: ActivityType.FEATURE_USE, + description: 'Used export feature', + }, + { + ip_address: '192.168.1.1', + user_agent: 'Mozilla/5.0', + session_id: 'session-001', + }, + ); + + expect(activityLogRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + session_id: 'session-001', + }), + ); + }); + }); + + // ==================== Query Activity Logs Tests ==================== + + describe('queryActivityLogs', () => { + it('should return paginated activity logs', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest + .fn() + .mockResolvedValue([[mockActivityLog as ActivityLog], 1]), + }; + activityLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + const result = await service.queryActivityLogs(mockTenantId, {}); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by activity_type', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + activityLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + await service.queryActivityLogs(mockTenantId, { + activity_type: ActivityType.PAGE_VIEW, + }); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'activity.activity_type = :activity_type', + { activity_type: ActivityType.PAGE_VIEW }, + ); + }); + + it('should filter by resource_type', async () => { + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + activityLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + await service.queryActivityLogs(mockTenantId, { resource_type: 'document' }); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'activity.resource_type = :resource_type', + { resource_type: 'document' }, + ); + }); + }); + + // ==================== Get User Activity Summary Tests ==================== + + describe('getUserActivitySummary', () => { + it('should return activity summary by type', async () => { + const qb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { activity_type: ActivityType.PAGE_VIEW, count: '50' }, + { activity_type: ActivityType.FEATURE_USE, count: '25' }, + ]), + }; + activityLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + const result = await service.getUserActivitySummary( + mockTenantId, + mockUserId, + 30, + ); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + activity_type: ActivityType.PAGE_VIEW, + count: 50, + }); + }); + + it('should use default 30 days', async () => { + const qb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + activityLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + await service.getUserActivitySummary(mockTenantId, mockUserId); + + expect(qb.andWhere).toHaveBeenCalledWith( + 'activity.created_at >= :fromDate', + expect.any(Object), + ); + }); + }); + + // ==================== Get Audit Stats Tests ==================== + + describe('getAuditStats', () => { + it('should return audit statistics', async () => { + auditLogRepo.count.mockResolvedValue(100); + + const actionsByTypeQb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { action: AuditAction.CREATE, count: '30' }, + { action: AuditAction.UPDATE, count: '50' }, + { action: AuditAction.DELETE, count: '20' }, + ]), + }; + + const topUsersQb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { user_id: mockUserId, count: '45' }, + { user_id: 'user-002', count: '30' }, + ]), + }; + + auditLogRepo.createQueryBuilder + .mockReturnValueOnce(actionsByTypeQb as any) + .mockReturnValueOnce(topUsersQb as any); + + const result = await service.getAuditStats(mockTenantId, 7); + + expect(result.total_actions).toBe(100); + expect(result.actions_by_type).toHaveLength(3); + expect(result.top_users).toHaveLength(2); + }); + + it('should use default 7 days', async () => { + auditLogRepo.count.mockResolvedValue(0); + + const qb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + + auditLogRepo.createQueryBuilder.mockReturnValue(qb as any); + + await service.getAuditStats(mockTenantId); + + expect(auditLogRepo.count).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..dd3cfbc --- /dev/null +++ b/src/modules/audit/audit.controller.ts @@ -0,0 +1,145 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + UseGuards, + Req, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, +} from '@nestjs/swagger'; +import { AuditService } from './services/audit.service'; +import { QueryAuditLogsDto } from './dto/query-audit.dto'; +import { QueryActivityLogsDto } from './dto/query-activity.dto'; +import { CreateActivityLogDto } from './dto/create-activity.dto'; +import { JwtAuthGuard } from '../auth/guards'; +import { CurrentUser } from '../auth/decorators'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; + +@ApiTags('Audit') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('audit') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + // ==================== AUDIT LOGS ==================== + + @Get('logs') + @ApiOperation({ summary: 'Query audit logs with filters' }) + @ApiResponse({ status: 200, description: 'Paginated audit logs' }) + async queryAuditLogs( + @CurrentUser() user: RequestUser, + @Query() query: QueryAuditLogsDto, + ) { + return this.auditService.queryAuditLogs(user.tenant_id, query); + } + + @Get('logs/:id') + @ApiOperation({ summary: 'Get audit log by ID' }) + @ApiParam({ name: 'id', description: 'Audit log ID' }) + @ApiResponse({ status: 200, description: 'Audit log details' }) + @ApiResponse({ status: 404, description: 'Audit log not found' }) + async getAuditLogById( + @CurrentUser() user: RequestUser, + @Param('id') id: string, + ) { + return this.auditService.getAuditLogById(user.tenant_id, id); + } + + @Get('entity/:entityType/:entityId') + @ApiOperation({ summary: 'Get audit history for a specific entity' }) + @ApiParam({ name: 'entityType', description: 'Entity type (e.g., user, product)' }) + @ApiParam({ name: 'entityId', description: 'Entity ID' }) + @ApiResponse({ status: 200, description: 'Entity audit history' }) + async getEntityAuditHistory( + @CurrentUser() user: RequestUser, + @Param('entityType') entityType: string, + @Param('entityId') entityId: string, + ) { + return this.auditService.getEntityAuditHistory( + user.tenant_id, + entityType, + entityId, + ); + } + + @Get('stats') + @ApiOperation({ summary: 'Get audit statistics for dashboard' }) + @ApiResponse({ status: 200, description: 'Audit statistics' }) + async getAuditStats( + @CurrentUser() user: RequestUser, + @Query('days') days?: number, + ) { + return this.auditService.getAuditStats(user.tenant_id, days || 7); + } + + // ==================== ACTIVITY LOGS ==================== + + @Get('activities') + @ApiOperation({ summary: 'Query activity logs with filters' }) + @ApiResponse({ status: 200, description: 'Paginated activity logs' }) + async queryActivityLogs( + @CurrentUser() user: RequestUser, + @Query() query: QueryActivityLogsDto, + ) { + return this.auditService.queryActivityLogs(user.tenant_id, query); + } + + @Post('activities') + @ApiOperation({ summary: 'Create an activity log entry' }) + @ApiResponse({ status: 201, description: 'Activity log created' }) + async createActivityLog( + @CurrentUser() user: RequestUser, + @Body() dto: CreateActivityLogDto, + @Req() request: any, + ) { + return this.auditService.createActivityLog( + user.tenant_id, + user.id, + dto, + { + ip_address: request.ip, + user_agent: request.headers['user-agent'], + session_id: request.headers['x-session-id'], + }, + ); + } + + @Get('activities/summary') + @ApiOperation({ summary: 'Get user activity summary' }) + @ApiResponse({ status: 200, description: 'Activity summary by type' }) + async getUserActivitySummary( + @CurrentUser() user: RequestUser, + @Query('days') days?: number, + ) { + return this.auditService.getUserActivitySummary( + user.tenant_id, + user.id, + days || 30, + ); + } + + @Get('activities/user/:userId') + @ApiOperation({ summary: 'Get activity summary for a specific user' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiResponse({ status: 200, description: 'User activity summary' }) + async getSpecificUserActivitySummary( + @CurrentUser() user: RequestUser, + @Param('userId') userId: string, + @Query('days') days?: number, + ) { + return this.auditService.getUserActivitySummary( + user.tenant_id, + userId, + days || 30, + ); + } +} diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..6dad1c8 --- /dev/null +++ b/src/modules/audit/audit.module.ts @@ -0,0 +1,23 @@ +import { Module, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { AuditController } from './audit.controller'; +import { AuditService } from './services/audit.service'; +import { AuditLog, ActivityLog } from './entities'; +import { AuditInterceptor } from './interceptors/audit.interceptor'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog, ActivityLog])], + controllers: [AuditController], + providers: [ + AuditService, + // Register interceptor globally + { + provide: APP_INTERCEPTOR, + useClass: AuditInterceptor, + }, + ], + exports: [AuditService], +}) +export class AuditModule {} diff --git a/src/modules/audit/dto/create-activity.dto.ts b/src/modules/audit/dto/create-activity.dto.ts new file mode 100644 index 0000000..49553d5 --- /dev/null +++ b/src/modules/audit/dto/create-activity.dto.ts @@ -0,0 +1,29 @@ +import { IsEnum, IsOptional, IsUUID, IsString, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ActivityType } from '../entities/activity-log.entity'; + +export class CreateActivityLogDto { + @ApiProperty({ description: 'Activity type', enum: ActivityType }) + @IsEnum(ActivityType) + activity_type: ActivityType; + + @ApiPropertyOptional({ description: 'Resource type' }) + @IsOptional() + @IsString() + resource_type?: string; + + @ApiPropertyOptional({ description: 'Resource ID' }) + @IsOptional() + @IsUUID() + resource_id?: string; + + @ApiPropertyOptional({ description: 'Activity description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/src/modules/audit/dto/index.ts b/src/modules/audit/dto/index.ts new file mode 100644 index 0000000..eaa3d5a --- /dev/null +++ b/src/modules/audit/dto/index.ts @@ -0,0 +1,3 @@ +export * from './query-audit.dto'; +export * from './query-activity.dto'; +export * from './create-activity.dto'; diff --git a/src/modules/audit/dto/query-activity.dto.ts b/src/modules/audit/dto/query-activity.dto.ts new file mode 100644 index 0000000..42237a5 --- /dev/null +++ b/src/modules/audit/dto/query-activity.dto.ts @@ -0,0 +1,60 @@ +import { + IsOptional, + IsUUID, + IsEnum, + IsDateString, + IsNumber, + Min, + Max, + IsString, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ActivityType } from '../entities/activity-log.entity'; + +export class QueryActivityLogsDto { + @ApiPropertyOptional({ description: 'Filter by user ID' }) + @IsOptional() + @IsUUID() + user_id?: string; + + @ApiPropertyOptional({ description: 'Filter by activity type', enum: ActivityType }) + @IsOptional() + @IsEnum(ActivityType) + activity_type?: ActivityType; + + @ApiPropertyOptional({ description: 'Filter by resource type' }) + @IsOptional() + @IsString() + resource_type?: string; + + @ApiPropertyOptional({ description: 'Filter by resource ID' }) + @IsOptional() + @IsUUID() + resource_id?: string; + + @ApiPropertyOptional({ description: 'Start date filter (ISO 8601)' }) + @IsOptional() + @IsDateString() + from_date?: string; + + @ApiPropertyOptional({ description: 'End date filter (ISO 8601)' }) + @IsOptional() + @IsDateString() + to_date?: string; + + @ApiPropertyOptional({ description: 'Page number', default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', default: 20 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/src/modules/audit/dto/query-audit.dto.ts b/src/modules/audit/dto/query-audit.dto.ts new file mode 100644 index 0000000..1329ec0 --- /dev/null +++ b/src/modules/audit/dto/query-audit.dto.ts @@ -0,0 +1,60 @@ +import { + IsOptional, + IsUUID, + IsEnum, + IsDateString, + IsNumber, + Min, + Max, + IsString, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { AuditAction } from '../entities/audit-log.entity'; + +export class QueryAuditLogsDto { + @ApiPropertyOptional({ description: 'Filter by user ID' }) + @IsOptional() + @IsUUID() + user_id?: string; + + @ApiPropertyOptional({ description: 'Filter by action', enum: AuditAction }) + @IsOptional() + @IsEnum(AuditAction) + action?: AuditAction; + + @ApiPropertyOptional({ description: 'Filter by entity type' }) + @IsOptional() + @IsString() + entity_type?: string; + + @ApiPropertyOptional({ description: 'Filter by entity ID' }) + @IsOptional() + @IsUUID() + entity_id?: string; + + @ApiPropertyOptional({ description: 'Start date filter (ISO 8601)' }) + @IsOptional() + @IsDateString() + from_date?: string; + + @ApiPropertyOptional({ description: 'End date filter (ISO 8601)' }) + @IsOptional() + @IsDateString() + to_date?: string; + + @ApiPropertyOptional({ description: 'Page number', default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', default: 20 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/src/modules/audit/entities/activity-log.entity.ts b/src/modules/audit/entities/activity-log.entity.ts new file mode 100644 index 0000000..0bc6331 --- /dev/null +++ b/src/modules/audit/entities/activity-log.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum ActivityType { + PAGE_VIEW = 'page_view', + FEATURE_USE = 'feature_use', + SEARCH = 'search', + DOWNLOAD = 'download', + UPLOAD = 'upload', + SHARE = 'share', + INVITE = 'invite', + SETTINGS_CHANGE = 'settings_change', + SUBSCRIPTION_CHANGE = 'subscription_change', + PAYMENT = 'payment', +} + +@Entity({ name: 'activity_logs', schema: 'audit' }) +@Index(['tenant_id', 'created_at']) +@Index(['user_id', 'activity_type']) +export class ActivityLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @Column({ + type: 'enum', + enum: ActivityType, + }) + activity_type: ActivityType; + + @Column({ type: 'varchar', length: 255, nullable: true }) + resource_type: string; + + @Column({ type: 'uuid', nullable: true }) + resource_id: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ip_address: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + user_agent: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + session_id: string; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/audit/entities/audit-log.entity.ts b/src/modules/audit/entities/audit-log.entity.ts new file mode 100644 index 0000000..906ade5 --- /dev/null +++ b/src/modules/audit/entities/audit-log.entity.ts @@ -0,0 +1,81 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum AuditAction { + CREATE = 'create', + UPDATE = 'update', + DELETE = 'delete', + READ = 'read', + LOGIN = 'login', + LOGOUT = 'logout', + EXPORT = 'export', + IMPORT = 'import', +} + +@Entity({ name: 'audit_logs', schema: 'audit' }) +@Index(['tenant_id', 'created_at']) +@Index(['entity_type', 'entity_id']) +@Index(['user_id', 'created_at']) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid', nullable: true }) + user_id: string; + + @Column({ + type: 'enum', + enum: AuditAction, + }) + action: AuditAction; + + @Column({ type: 'varchar', length: 100 }) + entity_type: string; + + @Column({ type: 'uuid', nullable: true }) + entity_id: string; + + @Column({ type: 'jsonb', nullable: true }) + old_values: Record; + + @Column({ type: 'jsonb', nullable: true }) + new_values: Record; + + @Column({ type: 'jsonb', nullable: true }) + changed_fields: string[]; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ip_address: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + user_agent: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + endpoint: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + http_method: string; + + @Column({ type: 'smallint', nullable: true }) + response_status: number; + + @Column({ type: 'integer', nullable: true }) + duration_ms: number; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/audit/entities/index.ts b/src/modules/audit/entities/index.ts new file mode 100644 index 0000000..9e57810 --- /dev/null +++ b/src/modules/audit/entities/index.ts @@ -0,0 +1,2 @@ +export * from './audit-log.entity'; +export * from './activity-log.entity'; diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts new file mode 100644 index 0000000..d70595c --- /dev/null +++ b/src/modules/audit/index.ts @@ -0,0 +1,6 @@ +export * from './audit.module'; +export * from './audit.controller'; +export * from './services'; +export * from './entities'; +export * from './dto'; +export { AuditInterceptor, AuditActionDecorator, AuditEntity, SkipAudit, AUDIT_ACTION_KEY, AUDIT_ENTITY_KEY, SKIP_AUDIT_KEY } from './interceptors'; diff --git a/src/modules/audit/interceptors/audit.interceptor.ts b/src/modules/audit/interceptors/audit.interceptor.ts new file mode 100644 index 0000000..a9262b3 --- /dev/null +++ b/src/modules/audit/interceptors/audit.interceptor.ts @@ -0,0 +1,179 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable, tap } from 'rxjs'; +import { Reflector } from '@nestjs/core'; +import { AuditService, CreateAuditLogParams } from '../services/audit.service'; +import { AuditAction as AuditActionEnum } from '../entities/audit-log.entity'; + +export const AUDIT_ACTION_KEY = 'audit_action'; +export const AUDIT_ENTITY_KEY = 'audit_entity'; +export const SKIP_AUDIT_KEY = 'skip_audit'; + +/** + * Decorator to specify audit action for a route + */ +export function AuditActionDecorator(action: AuditActionEnum) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + Reflect.defineMetadata(AUDIT_ACTION_KEY, action, descriptor.value); + return descriptor; + }; +} + +/** + * Decorator to specify entity type for audit logging + */ +export function AuditEntity(entityType: string) { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + Reflect.defineMetadata(AUDIT_ENTITY_KEY, entityType, descriptor.value); + return descriptor; + }; +} + +/** + * Decorator to skip audit logging for a route + */ +export function SkipAudit() { + return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + Reflect.defineMetadata(SKIP_AUDIT_KEY, true, descriptor.value); + return descriptor; + }; +} + +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor( + private readonly auditService: AuditService, + private readonly reflector: Reflector, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const handler = context.getHandler(); + const startTime = Date.now(); + + // Check if audit should be skipped + const skipAudit = this.reflector.get(SKIP_AUDIT_KEY, handler); + if (skipAudit) { + return next.handle(); + } + + // Get audit metadata + const auditAction = this.reflector.get(AUDIT_ACTION_KEY, handler); + const entityType = this.reflector.get(AUDIT_ENTITY_KEY, handler); + + // If no explicit audit action, try to infer from HTTP method + const action = auditAction || this.inferActionFromMethod(request.method); + if (!action) { + return next.handle(); + } + + return next.handle().pipe( + tap({ + next: async (response) => { + const duration = Date.now() - startTime; + await this.logAudit(request, action, entityType, response, 200, duration); + }, + error: async (error) => { + const duration = Date.now() - startTime; + const statusCode = error.status || 500; + await this.logAudit(request, action, entityType, null, statusCode, duration); + }, + }), + ); + } + + private inferActionFromMethod(method: string): AuditActionEnum | null { + switch (method.toUpperCase()) { + case 'POST': + return AuditActionEnum.CREATE; + case 'PUT': + case 'PATCH': + return AuditActionEnum.UPDATE; + case 'DELETE': + return AuditActionEnum.DELETE; + case 'GET': + return null; // Don't log reads by default + default: + return null; + } + } + + private async logAudit( + request: any, + action: AuditActionEnum, + entityType: string | undefined, + response: any, + statusCode: number, + duration: number, + ): Promise { + try { + const tenantId = request.user?.tenantId || request.headers['x-tenant-id']; + if (!tenantId) { + return; // Cannot log without tenant context + } + + const params: CreateAuditLogParams = { + tenant_id: tenantId, + user_id: request.user?.sub || request.user?.id, + action, + entity_type: entityType || this.inferEntityFromPath(request.path), + entity_id: request.params?.id, + old_values: request.body?._oldValues, // If provided by controller + new_values: this.sanitizeBody(request.body), + ip_address: this.getClientIp(request), + user_agent: request.headers['user-agent'], + endpoint: request.path, + http_method: request.method, + response_status: statusCode, + duration_ms: duration, + metadata: { + query: request.query, + response_id: response?.id, + }, + }; + + await this.auditService.createAuditLog(params); + } catch (error) { + // Don't let audit logging failures affect the request + console.error('Failed to create audit log:', error); + } + } + + private inferEntityFromPath(path: string): string { + // Extract entity type from path like /api/v1/users/:id -> users + const segments = path.split('/').filter(Boolean); + const apiIndex = segments.findIndex((s) => s === 'api'); + if (apiIndex !== -1 && segments.length > apiIndex + 2) { + return segments[apiIndex + 2]; // Skip 'api' and version + } + return segments[segments.length - 1] || 'unknown'; + } + + private sanitizeBody(body: any): Record | undefined { + if (!body) return undefined; + + const sanitized = { ...body }; + // Remove sensitive fields + const sensitiveFields = ['password', 'token', 'secret', 'creditCard', 'cvv', '_oldValues']; + for (const field of sensitiveFields) { + if (field in sanitized) { + sanitized[field] = '[REDACTED]'; + } + } + return sanitized; + } + + private getClientIp(request: any): string { + return ( + request.headers['x-forwarded-for']?.split(',')[0]?.trim() || + request.headers['x-real-ip'] || + request.connection?.remoteAddress || + request.ip || + 'unknown' + ); + } +} diff --git a/src/modules/audit/interceptors/index.ts b/src/modules/audit/interceptors/index.ts new file mode 100644 index 0000000..de21279 --- /dev/null +++ b/src/modules/audit/interceptors/index.ts @@ -0,0 +1,9 @@ +export { + AuditInterceptor, + AuditActionDecorator, + AuditEntity, + SkipAudit, + AUDIT_ACTION_KEY, + AUDIT_ENTITY_KEY, + SKIP_AUDIT_KEY +} from './audit.interceptor'; diff --git a/src/modules/audit/services/audit.service.ts b/src/modules/audit/services/audit.service.ts new file mode 100644 index 0000000..5669be4 --- /dev/null +++ b/src/modules/audit/services/audit.service.ts @@ -0,0 +1,320 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { AuditLog, AuditAction } from '../entities/audit-log.entity'; +import { ActivityLog, ActivityType } from '../entities/activity-log.entity'; +import { QueryAuditLogsDto } from '../dto/query-audit.dto'; +import { QueryActivityLogsDto } from '../dto/query-activity.dto'; +import { CreateActivityLogDto } from '../dto/create-activity.dto'; + +export interface CreateAuditLogParams { + tenant_id: string; + user_id?: string; + action: AuditAction; + entity_type: string; + entity_id?: string; + old_values?: Record; + new_values?: Record; + ip_address?: string; + user_agent?: string; + endpoint?: string; + http_method?: string; + response_status?: number; + duration_ms?: number; + description?: string; + metadata?: Record; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@Injectable() +export class AuditService { + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepository: Repository, + @InjectRepository(ActivityLog) + private readonly activityLogRepository: Repository, + ) {} + + /** + * Create an audit log entry + */ + async createAuditLog(params: CreateAuditLogParams): Promise { + const changedFields = this.detectChangedFields( + params.old_values, + params.new_values, + ); + + const auditLog = this.auditLogRepository.create({ + ...params, + changed_fields: changedFields, + }); + + return this.auditLogRepository.save(auditLog); + } + + /** + * Query audit logs with filters and pagination + */ + async queryAuditLogs( + tenantId: string, + query: QueryAuditLogsDto, + ): Promise> { + const { user_id, action, entity_type, entity_id, from_date, to_date, page = 1, limit = 20 } = query; + + const queryBuilder = this.auditLogRepository + .createQueryBuilder('audit') + .where('audit.tenant_id = :tenantId', { tenantId }); + + if (user_id) { + queryBuilder.andWhere('audit.user_id = :user_id', { user_id }); + } + + if (action) { + queryBuilder.andWhere('audit.action = :action', { action }); + } + + if (entity_type) { + queryBuilder.andWhere('audit.entity_type = :entity_type', { entity_type }); + } + + if (entity_id) { + queryBuilder.andWhere('audit.entity_id = :entity_id', { entity_id }); + } + + if (from_date && to_date) { + queryBuilder.andWhere('audit.created_at BETWEEN :from_date AND :to_date', { + from_date, + to_date, + }); + } else if (from_date) { + queryBuilder.andWhere('audit.created_at >= :from_date', { from_date }); + } else if (to_date) { + queryBuilder.andWhere('audit.created_at <= :to_date', { to_date }); + } + + queryBuilder + .orderBy('audit.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Get audit log by ID + */ + async getAuditLogById(tenantId: string, id: string): Promise { + return this.auditLogRepository.findOne({ + where: { id, tenant_id: tenantId }, + }); + } + + /** + * Get audit logs for a specific entity + */ + async getEntityAuditHistory( + tenantId: string, + entityType: string, + entityId: string, + ): Promise { + return this.auditLogRepository.find({ + where: { + tenant_id: tenantId, + entity_type: entityType, + entity_id: entityId, + }, + order: { created_at: 'DESC' }, + }); + } + + /** + * Create an activity log entry + */ + async createActivityLog( + tenantId: string, + userId: string, + dto: CreateActivityLogDto, + context?: { ip_address?: string; user_agent?: string; session_id?: string }, + ): Promise { + const activityLog = this.activityLogRepository.create({ + tenant_id: tenantId, + user_id: userId, + ...dto, + ip_address: context?.ip_address, + user_agent: context?.user_agent, + session_id: context?.session_id, + }); + + return this.activityLogRepository.save(activityLog); + } + + /** + * Query activity logs with filters and pagination + */ + async queryActivityLogs( + tenantId: string, + query: QueryActivityLogsDto, + ): Promise> { + const { user_id, activity_type, resource_type, from_date, to_date, page = 1, limit = 20 } = query; + + const queryBuilder = this.activityLogRepository + .createQueryBuilder('activity') + .where('activity.tenant_id = :tenantId', { tenantId }); + + if (user_id) { + queryBuilder.andWhere('activity.user_id = :user_id', { user_id }); + } + + if (activity_type) { + queryBuilder.andWhere('activity.activity_type = :activity_type', { activity_type }); + } + + if (resource_type) { + queryBuilder.andWhere('activity.resource_type = :resource_type', { resource_type }); + } + + if (from_date && to_date) { + queryBuilder.andWhere('activity.created_at BETWEEN :from_date AND :to_date', { + from_date, + to_date, + }); + } else if (from_date) { + queryBuilder.andWhere('activity.created_at >= :from_date', { from_date }); + } else if (to_date) { + queryBuilder.andWhere('activity.created_at <= :to_date', { to_date }); + } + + queryBuilder + .orderBy('activity.created_at', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Get user activity summary + */ + async getUserActivitySummary( + tenantId: string, + userId: string, + days: number = 30, + ): Promise<{ activity_type: ActivityType; count: number }[]> { + const fromDate = new Date(); + fromDate.setDate(fromDate.getDate() - days); + + const result = await this.activityLogRepository + .createQueryBuilder('activity') + .select('activity.activity_type', 'activity_type') + .addSelect('COUNT(*)', 'count') + .where('activity.tenant_id = :tenantId', { tenantId }) + .andWhere('activity.user_id = :userId', { userId }) + .andWhere('activity.created_at >= :fromDate', { fromDate }) + .groupBy('activity.activity_type') + .getRawMany(); + + return result.map((r) => ({ + activity_type: r.activity_type, + count: parseInt(r.count, 10), + })); + } + + /** + * Get audit statistics for dashboard + */ + async getAuditStats( + tenantId: string, + days: number = 7, + ): Promise<{ + total_actions: number; + actions_by_type: { action: AuditAction; count: number }[]; + top_users: { user_id: string; count: number }[]; + }> { + const fromDate = new Date(); + fromDate.setDate(fromDate.getDate() - days); + + const [totalActions, actionsByType, topUsers] = await Promise.all([ + this.auditLogRepository.count({ + where: { + tenant_id: tenantId, + created_at: MoreThanOrEqual(fromDate), + }, + }), + this.auditLogRepository + .createQueryBuilder('audit') + .select('audit.action', 'action') + .addSelect('COUNT(*)', 'count') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.created_at >= :fromDate', { fromDate }) + .groupBy('audit.action') + .getRawMany(), + this.auditLogRepository + .createQueryBuilder('audit') + .select('audit.user_id', 'user_id') + .addSelect('COUNT(*)', 'count') + .where('audit.tenant_id = :tenantId', { tenantId }) + .andWhere('audit.created_at >= :fromDate', { fromDate }) + .andWhere('audit.user_id IS NOT NULL') + .groupBy('audit.user_id') + .orderBy('count', 'DESC') + .limit(10) + .getRawMany(), + ]); + + return { + total_actions: totalActions, + actions_by_type: actionsByType.map((r) => ({ + action: r.action, + count: parseInt(r.count, 10), + })), + top_users: topUsers.map((r) => ({ + user_id: r.user_id, + count: parseInt(r.count, 10), + })), + }; + } + + /** + * Detect changed fields between old and new values + */ + private detectChangedFields( + oldValues?: Record, + newValues?: Record, + ): string[] { + if (!oldValues || !newValues) return []; + + const changedFields: string[] = []; + const allKeys = new Set([...Object.keys(oldValues), ...Object.keys(newValues)]); + + for (const key of allKeys) { + if (JSON.stringify(oldValues[key]) !== JSON.stringify(newValues[key])) { + changedFields.push(key); + } + } + + return changedFields; + } +} diff --git a/src/modules/audit/services/index.ts b/src/modules/audit/services/index.ts new file mode 100644 index 0000000..fc3a14f --- /dev/null +++ b/src/modules/audit/services/index.ts @@ -0,0 +1 @@ +export * from './audit.service'; diff --git a/src/modules/auth/__tests__/auth.controller.spec.ts b/src/modules/auth/__tests__/auth.controller.spec.ts new file mode 100644 index 0000000..7227de8 --- /dev/null +++ b/src/modules/auth/__tests__/auth.controller.spec.ts @@ -0,0 +1,240 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from '../auth.controller'; +import { AuthService } from '../services/auth.service'; +import { MfaService } from '../services/mfa.service'; +import { RegisterDto, LoginDto, ChangePasswordDto } from '../dto'; + +describe('AuthController', () => { + let controller: AuthController; + let authService: jest.Mocked; + + const mockUser = { + id: '550e8400-e29b-41d4-a716-446655440000', + tenant_id: '550e8400-e29b-41d4-a716-446655440001', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + }; + + const mockAuthResponse = { + user: mockUser, + accessToken: 'access_token', + refreshToken: 'refresh_token', + }; + + const mockRequestUser = { + id: mockUser.id, + email: mockUser.email, + tenant_id: mockUser.tenant_id, + }; + + const mockRequest = { + ip: '127.0.0.1', + headers: { + 'user-agent': 'test-agent', + 'x-tenant-id': mockUser.tenant_id, + }, + }; + + beforeEach(async () => { + const mockAuthService = { + register: jest.fn(), + login: jest.fn(), + logout: jest.fn(), + logoutAll: jest.fn(), + refreshToken: jest.fn(), + changePassword: jest.fn(), + requestPasswordReset: jest.fn(), + resetPassword: jest.fn(), + verifyEmail: jest.fn(), + getProfile: jest.fn(), + }; + + const mockMfaService = { + generateSecret: jest.fn(), + verifyToken: jest.fn(), + enableMfa: jest.fn(), + disableMfa: jest.fn(), + generateBackupCodes: jest.fn(), + verifyBackupCode: jest.fn(), + isMfaEnabled: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { provide: AuthService, useValue: mockAuthService }, + { provide: MfaService, useValue: mockMfaService }, + ], + }).compile(); + + controller = module.get(AuthController); + authService = module.get(AuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('register', () => { + it('should register a new user', async () => { + const registerDto: RegisterDto = { + email: 'new@example.com', + password: 'Password123!', + first_name: 'New', + last_name: 'User', + }; + + authService.register.mockResolvedValue(mockAuthResponse); + + const result = await controller.register(registerDto, mockUser.tenant_id, mockRequest as any); + + expect(result).toEqual(mockAuthResponse); + expect(authService.register).toHaveBeenCalledWith( + registerDto, + mockUser.tenant_id, + mockRequest.ip, + mockRequest.headers['user-agent'], + ); + }); + }); + + describe('login', () => { + it('should login user', async () => { + const loginDto: LoginDto = { + email: 'test@example.com', + password: 'password123', + }; + + authService.login.mockResolvedValue(mockAuthResponse); + + const result = await controller.login(loginDto, mockUser.tenant_id, mockRequest as any); + + expect(result).toEqual(mockAuthResponse); + expect(authService.login).toHaveBeenCalledWith( + loginDto, + mockUser.tenant_id, + mockRequest.ip, + mockRequest.headers['user-agent'], + ); + }); + }); + + describe('logout', () => { + it('should logout user', async () => { + authService.logout.mockResolvedValue(undefined); + + const result = await controller.logout(mockRequestUser, 'session_token'); + + expect(result).toEqual({ message: 'Sesión cerrada correctamente' }); + expect(authService.logout).toHaveBeenCalledWith( + mockRequestUser.id, + 'session_token', + ); + }); + }); + + describe('logoutAll', () => { + it('should logout all sessions', async () => { + authService.logoutAll.mockResolvedValue(undefined); + + const result = await controller.logoutAll(mockRequestUser); + + expect(result).toEqual({ message: 'Todas las sesiones cerradas' }); + expect(authService.logoutAll).toHaveBeenCalledWith(mockRequestUser.id); + }); + }); + + describe('refresh', () => { + it('should refresh tokens', async () => { + const newTokens = { + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + }; + authService.refreshToken.mockResolvedValue(newTokens); + + const result = await controller.refresh( + 'old_refresh_token', + mockRequest as any, + ); + + expect(result).toEqual(newTokens); + }); + }); + + describe('changePassword', () => { + it('should change password', async () => { + const changePasswordDto: ChangePasswordDto = { + currentPassword: 'oldPassword', + newPassword: 'newPassword', + }; + authService.changePassword.mockResolvedValue({ + message: 'Password actualizado correctamente', + }); + + const result = await controller.changePassword( + mockRequestUser, + changePasswordDto, + ); + + expect(result.message).toBe('Password actualizado correctamente'); + expect(authService.changePassword).toHaveBeenCalledWith( + mockRequestUser.id, + changePasswordDto, + ); + }); + }); + + describe('requestPasswordReset', () => { + it('should request password reset', async () => { + authService.requestPasswordReset.mockResolvedValue({ + message: 'Si el email existe, recibirás instrucciones', + }); + + const result = await controller.requestPasswordReset( + { email: 'test@example.com' }, + mockRequest as any, + ); + + expect(result).toHaveProperty('message'); + }); + }); + + describe('resetPassword', () => { + it('should reset password', async () => { + authService.resetPassword.mockResolvedValue({ + message: 'Password restablecido correctamente', + }); + + const result = await controller.resetPassword( + { token: 'reset_token', password: 'newPassword123' }, + mockRequest as any, + ); + + expect(result.message).toBe('Password restablecido correctamente'); + }); + }); + + describe('verifyEmail', () => { + it('should verify email', async () => { + authService.verifyEmail.mockResolvedValue({ + message: 'Email verificado correctamente', + }); + + const result = await controller.verifyEmail('verification_token', mockRequest as any); + + expect(result.message).toBe('Email verificado correctamente'); + }); + }); + + describe('getProfile', () => { + it('should get user profile', async () => { + authService.getProfile.mockResolvedValue(mockUser); + + const result = await controller.getProfile(mockRequestUser); + + expect(result).toEqual(mockUser); + expect(authService.getProfile).toHaveBeenCalledWith(mockRequestUser.id); + }); + }); +}); diff --git a/src/modules/auth/__tests__/auth.service.spec.ts b/src/modules/auth/__tests__/auth.service.spec.ts new file mode 100644 index 0000000..1b0a008 --- /dev/null +++ b/src/modules/auth/__tests__/auth.service.spec.ts @@ -0,0 +1,845 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, Repository } from 'typeorm'; +import { + UnauthorizedException, + ConflictException, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { AuthService } from '../services/auth.service'; +import { User, Session, Token } from '../entities'; + +// Mock bcrypt +jest.mock('bcrypt'); +const mockedBcrypt = bcrypt as jest.Mocked; + +describe('AuthService', () => { + let service: AuthService; + let userRepository: jest.Mocked>; + let sessionRepository: jest.Mocked>; + let tokenRepository: jest.Mocked>; + let jwtService: jest.Mocked; + let configService: jest.Mocked; + + const mockUser: Partial = { + id: '550e8400-e29b-41d4-a716-446655440000', + tenant_id: '550e8400-e29b-41d4-a716-446655440001', + email: 'test@example.com', + password_hash: 'hashed_password', + first_name: 'Test', + last_name: 'User', + status: 'active', + email_verified: true, + }; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + + beforeEach(async () => { + const mockUserRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + const mockSessionRepo = { + findOne: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + const mockTokenRepo = { + findOne: jest.fn(), + save: jest.fn(), + update: jest.fn(), + }; + + const mockJwtService = { + sign: jest.fn(), + verify: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + const mockDataSource = { + createQueryRunner: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: getRepositoryToken(Session), useValue: mockSessionRepo }, + { provide: getRepositoryToken(Token), useValue: mockTokenRepo }, + { provide: JwtService, useValue: mockJwtService }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: DataSource, useValue: mockDataSource }, + ], + }).compile(); + + service = module.get(AuthService); + userRepository = module.get(getRepositoryToken(User)); + sessionRepository = module.get(getRepositoryToken(Session)); + tokenRepository = module.get(getRepositoryToken(Token)); + jwtService = module.get(JwtService); + configService = module.get(ConfigService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('register', () => { + const registerDto = { + email: 'newuser@example.com', + password: 'SecurePass123!', + first_name: 'New', + last_name: 'User', + }; + + it('should register a new user successfully', async () => { + userRepository.findOne.mockResolvedValue(null); + (mockedBcrypt.hash as jest.Mock).mockResolvedValue('hashed_password'); + userRepository.create.mockReturnValue({ + ...mockUser, + email: registerDto.email, + status: 'pending_verification', + } as User); + userRepository.save.mockResolvedValue({ + ...mockUser, + email: registerDto.email, + status: 'pending_verification', + } as User); + sessionRepository.save.mockResolvedValue({} as Session); + tokenRepository.save.mockResolvedValue({} as Token); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + const result = await service.register(registerDto, mockTenantId); + + expect(result).toHaveProperty('user'); + expect(result).toHaveProperty('accessToken'); + expect(result).toHaveProperty('refreshToken'); + expect(userRepository.findOne).toHaveBeenCalled(); + expect(userRepository.save).toHaveBeenCalled(); + }); + + it('should throw ConflictException if email already exists', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + + await expect( + service.register(registerDto, mockTenantId), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('login', () => { + const loginDto = { + email: 'test@example.com', + password: 'password123', + }; + + it('should login user successfully', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + userRepository.save.mockResolvedValue(mockUser as User); + sessionRepository.save.mockResolvedValue({} as Session); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + const result = await service.login(loginDto, mockTenantId); + + expect(result).toHaveProperty('user'); + expect(result).toHaveProperty('accessToken'); + expect(result).toHaveProperty('refreshToken'); + expect(result.user).not.toHaveProperty('password_hash'); + }); + + it('should throw UnauthorizedException for invalid email', async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect( + service.login(loginDto, mockTenantId), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException for invalid password', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(false); + + await expect( + service.login(loginDto, mockTenantId), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException for suspended user', async () => { + userRepository.findOne.mockResolvedValue({ + ...mockUser, + status: 'suspended', + } as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + + await expect( + service.login(loginDto, mockTenantId), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException for inactive user', async () => { + userRepository.findOne.mockResolvedValue({ + ...mockUser, + status: 'inactive', + } as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + + await expect( + service.login(loginDto, mockTenantId), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('logout', () => { + it('should invalidate session successfully', async () => { + sessionRepository.update.mockResolvedValue({ affected: 1 } as any); + + await service.logout(mockUser.id!, 'session_token'); + + expect(sessionRepository.update).toHaveBeenCalledWith( + { user_id: mockUser.id, session_token: 'session_token' }, + { is_active: false }, + ); + }); + }); + + describe('logoutAll', () => { + it('should invalidate all sessions for user', async () => { + sessionRepository.update.mockResolvedValue({ affected: 3 } as any); + + await service.logoutAll(mockUser.id!); + + expect(sessionRepository.update).toHaveBeenCalledWith( + { user_id: mockUser.id }, + { is_active: false }, + ); + }); + }); + + describe('changePassword', () => { + const changePasswordDto = { + currentPassword: 'oldPassword123', + newPassword: 'newPassword456', + }; + + it('should change password successfully', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + (mockedBcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password'); + userRepository.update.mockResolvedValue({ affected: 1 } as any); + + const result = await service.changePassword(mockUser.id!, changePasswordDto); + + expect(result).toHaveProperty('message'); + expect(userRepository.update).toHaveBeenCalled(); + }); + + it('should throw NotFoundException if user not found', async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect( + service.changePassword('invalid-id', changePasswordDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for incorrect current password', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(false); + + await expect( + service.changePassword(mockUser.id!, changePasswordDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException if new password same as current', async () => { + const samePasswordDto = { + currentPassword: 'samePassword', + newPassword: 'samePassword', + }; + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + + await expect( + service.changePassword(mockUser.id!, samePasswordDto), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('requestPasswordReset', () => { + it('should create reset token for existing user', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + tokenRepository.save.mockResolvedValue({} as Token); + + const result = await service.requestPasswordReset(mockUser.email!, mockTenantId); + + expect(result).toHaveProperty('message'); + expect(tokenRepository.save).toHaveBeenCalled(); + }); + + it('should return success message even for non-existing email (security)', async () => { + userRepository.findOne.mockResolvedValue(null); + + const result = await service.requestPasswordReset('nonexistent@example.com', mockTenantId); + + expect(result).toHaveProperty('message'); + expect(tokenRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('resetPassword', () => { + const mockToken = { + id: 'token-id', + user_id: mockUser.id, + tenant_id: mockTenantId, + token_type: 'password_reset', + is_used: false, + expires_at: new Date(Date.now() + 3600000), // 1 hour from now + }; + + it('should reset password successfully', async () => { + tokenRepository.findOne.mockResolvedValue(mockToken as Token); + (mockedBcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password'); + userRepository.update.mockResolvedValue({ affected: 1 } as any); + tokenRepository.update.mockResolvedValue({ affected: 1 } as any); + sessionRepository.update.mockResolvedValue({ affected: 1 } as any); + + const result = await service.resetPassword('valid_token', 'newPassword123', mockTenantId); + + expect(result).toHaveProperty('message'); + expect(userRepository.update).toHaveBeenCalled(); + expect(tokenRepository.update).toHaveBeenCalled(); + }); + + it('should throw BadRequestException for invalid token', async () => { + tokenRepository.findOne.mockResolvedValue(null); + + await expect( + service.resetPassword('invalid_token', 'newPassword123', mockTenantId), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for expired token', async () => { + tokenRepository.findOne.mockResolvedValue({ + ...mockToken, + expires_at: new Date(Date.now() - 3600000), // 1 hour ago + } as Token); + + await expect( + service.resetPassword('expired_token', 'newPassword123', mockTenantId), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('verifyEmail', () => { + const mockToken = { + id: 'token-id', + user_id: mockUser.id, + tenant_id: mockTenantId, + token_type: 'email_verification', + is_used: false, + expires_at: new Date(Date.now() + 3600000), + }; + + it('should verify email successfully', async () => { + tokenRepository.findOne.mockResolvedValue(mockToken as Token); + userRepository.update.mockResolvedValue({ affected: 1 } as any); + tokenRepository.update.mockResolvedValue({ affected: 1 } as any); + + const result = await service.verifyEmail('valid_token', mockTenantId); + + expect(result).toHaveProperty('message'); + expect(userRepository.update).toHaveBeenCalledWith( + { id: mockToken.user_id }, + expect.objectContaining({ + email_verified: true, + status: 'active', + }), + ); + }); + + it('should throw BadRequestException for invalid token', async () => { + tokenRepository.findOne.mockResolvedValue(null); + + await expect( + service.verifyEmail('invalid_token', mockTenantId), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('validateUser', () => { + it('should return user if active', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + + const result = await service.validateUser(mockUser.id!); + + expect(result).toEqual(mockUser); + }); + + it('should return null if user not found or not active', async () => { + userRepository.findOne.mockResolvedValue(null); + + const result = await service.validateUser('invalid-id'); + + expect(result).toBeNull(); + }); + }); + + describe('getProfile', () => { + it('should return sanitized user profile', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + + const result = await service.getProfile(mockUser.id!); + + expect(result).not.toHaveProperty('password_hash'); + expect(result).toHaveProperty('email'); + }); + + it('should throw NotFoundException if user not found', async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect( + service.getProfile('invalid-id'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('refreshToken', () => { + const mockSession = { + id: 'session-id', + user_id: mockUser.id, + tenant_id: mockTenantId, + refresh_token_hash: 'hashed_refresh_token', + is_active: true, + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now + }; + + it('should refresh tokens successfully with valid refresh token', async () => { + jwtService.verify.mockReturnValue({ + sub: mockUser.id, + email: mockUser.email, + tenant_id: mockTenantId, + }); + configService.get.mockReturnValue('test-secret'); + userRepository.findOne.mockResolvedValue(mockUser as User); + sessionRepository.findOne.mockResolvedValue(mockSession as Session); + sessionRepository.update.mockResolvedValue({ affected: 1 } as any); + jwtService.sign.mockReturnValueOnce('new_access_token').mockReturnValueOnce('new_refresh_token'); + + const result = await service.refreshToken('valid_refresh_token', '127.0.0.1', 'Mozilla/5.0'); + + expect(result).toHaveProperty('accessToken', 'new_access_token'); + expect(result).toHaveProperty('refreshToken', 'new_refresh_token'); + expect(jwtService.verify).toHaveBeenCalled(); + expect(sessionRepository.update).toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException when user not found', async () => { + jwtService.verify.mockReturnValue({ + sub: 'non-existent-user-id', + email: 'test@example.com', + tenant_id: mockTenantId, + }); + configService.get.mockReturnValue('test-secret'); + userRepository.findOne.mockResolvedValue(null); + + await expect( + service.refreshToken('valid_token_but_user_deleted'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException when session not found', async () => { + jwtService.verify.mockReturnValue({ + sub: mockUser.id, + email: mockUser.email, + tenant_id: mockTenantId, + }); + configService.get.mockReturnValue('test-secret'); + userRepository.findOne.mockResolvedValue(mockUser as User); + sessionRepository.findOne.mockResolvedValue(null); + + await expect( + service.refreshToken('token_with_no_session'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException when session is expired', async () => { + const expiredSession = { + ...mockSession, + expires_at: new Date(Date.now() - 3600000), // 1 hour ago + }; + jwtService.verify.mockReturnValue({ + sub: mockUser.id, + email: mockUser.email, + tenant_id: mockTenantId, + }); + configService.get.mockReturnValue('test-secret'); + userRepository.findOne.mockResolvedValue(mockUser as User); + sessionRepository.findOne.mockResolvedValue(expiredSession as Session); + sessionRepository.update.mockResolvedValue({ affected: 1 } as any); + + await expect( + service.refreshToken('token_with_expired_session'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException for invalid JWT token', async () => { + jwtService.verify.mockImplementation(() => { + throw new Error('Invalid token'); + }); + configService.get.mockReturnValue('test-secret'); + + await expect( + service.refreshToken('invalid_jwt_token'), + ).rejects.toThrow(UnauthorizedException); + }); + + it('should deactivate expired session when detected', async () => { + const expiredSession = { + ...mockSession, + expires_at: new Date(Date.now() - 3600000), + }; + jwtService.verify.mockReturnValue({ + sub: mockUser.id, + email: mockUser.email, + tenant_id: mockTenantId, + }); + configService.get.mockReturnValue('test-secret'); + userRepository.findOne.mockResolvedValue(mockUser as User); + sessionRepository.findOne.mockResolvedValue(expiredSession as Session); + sessionRepository.update.mockResolvedValue({ affected: 1 } as any); + + await expect( + service.refreshToken('token'), + ).rejects.toThrow(UnauthorizedException); + + expect(sessionRepository.update).toHaveBeenCalledWith( + { id: expiredSession.id }, + { is_active: false }, + ); + }); + }); + + describe('verifyEmail - additional cases', () => { + const mockToken = { + id: 'token-id', + user_id: mockUser.id, + tenant_id: mockTenantId, + token_type: 'email_verification', + is_used: false, + expires_at: new Date(Date.now() + 3600000), + }; + + it('should throw BadRequestException for expired verification token', async () => { + tokenRepository.findOne.mockResolvedValue({ + ...mockToken, + expires_at: new Date(Date.now() - 3600000), // 1 hour ago + } as Token); + + await expect( + service.verifyEmail('expired_verification_token', mockTenantId), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('register - additional cases', () => { + const registerDto = { + email: 'newuser@example.com', + password: 'SecurePass123!', + }; + + it('should register user with IP and userAgent metadata', async () => { + userRepository.findOne.mockResolvedValue(null); + (mockedBcrypt.hash as jest.Mock).mockResolvedValue('hashed_password'); + userRepository.create.mockReturnValue({ + ...mockUser, + email: registerDto.email, + status: 'pending_verification', + } as User); + userRepository.save.mockResolvedValue({ + ...mockUser, + email: registerDto.email, + status: 'pending_verification', + } as User); + sessionRepository.save.mockResolvedValue({} as Session); + tokenRepository.save.mockResolvedValue({} as Token); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + const ip = '192.168.1.100'; + const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'; + + const result = await service.register(registerDto, mockTenantId, ip, userAgent); + + expect(result).toHaveProperty('user'); + expect(result).toHaveProperty('accessToken'); + expect(sessionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + ip_address: ip, + user_agent: userAgent, + }), + ); + }); + + it('should register user without optional fields', async () => { + userRepository.findOne.mockResolvedValue(null); + (mockedBcrypt.hash as jest.Mock).mockResolvedValue('hashed_password'); + userRepository.create.mockReturnValue({ + ...mockUser, + email: registerDto.email, + first_name: null, + last_name: null, + phone: null, + status: 'pending_verification', + } as User); + userRepository.save.mockResolvedValue({ + ...mockUser, + email: registerDto.email, + status: 'pending_verification', + } as User); + sessionRepository.save.mockResolvedValue({} as Session); + tokenRepository.save.mockResolvedValue({} as Token); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + const result = await service.register(registerDto, mockTenantId); + + expect(result).toHaveProperty('user'); + expect(userRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + first_name: null, + last_name: null, + phone: null, + }), + ); + }); + + it('should create email verification token on registration', async () => { + userRepository.findOne.mockResolvedValue(null); + (mockedBcrypt.hash as jest.Mock).mockResolvedValue('hashed_password'); + userRepository.create.mockReturnValue({ + ...mockUser, + email: registerDto.email, + status: 'pending_verification', + } as User); + userRepository.save.mockResolvedValue({ + ...mockUser, + email: registerDto.email, + status: 'pending_verification', + } as User); + sessionRepository.save.mockResolvedValue({} as Session); + tokenRepository.save.mockResolvedValue({} as Token); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + await service.register(registerDto, mockTenantId); + + expect(tokenRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + token_type: 'email_verification', + }), + ); + }); + }); + + describe('login - additional cases', () => { + const loginDto = { + email: 'test@example.com', + password: 'password123', + }; + + it('should update last_login_at and last_login_ip on successful login', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + userRepository.save.mockResolvedValue(mockUser as User); + sessionRepository.save.mockResolvedValue({} as Session); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + const ip = '10.0.0.1'; + await service.login(loginDto, mockTenantId, ip); + + expect(userRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + last_login_ip: ip, + }), + ); + }); + + it('should detect device type from userAgent - mobile', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + userRepository.save.mockResolvedValue(mockUser as User); + sessionRepository.save.mockResolvedValue({} as Session); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + await service.login(loginDto, mockTenantId, '127.0.0.1', 'Mozilla/5.0 (iPhone; CPU iPhone OS)'); + + expect(sessionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + device_type: 'mobile', + }), + ); + }); + + it('should detect device type from userAgent - tablet', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + userRepository.save.mockResolvedValue(mockUser as User); + sessionRepository.save.mockResolvedValue({} as Session); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + await service.login(loginDto, mockTenantId, '127.0.0.1', 'Mozilla/5.0 (iPad; CPU OS)'); + + expect(sessionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + device_type: 'tablet', + }), + ); + }); + + it('should detect device type from userAgent - desktop', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + userRepository.save.mockResolvedValue(mockUser as User); + sessionRepository.save.mockResolvedValue({} as Session); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + await service.login(loginDto, mockTenantId, '127.0.0.1', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + + expect(sessionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + device_type: 'desktop', + }), + ); + }); + + it('should detect device type as unknown when no userAgent', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + userRepository.save.mockResolvedValue(mockUser as User); + sessionRepository.save.mockResolvedValue({} as Session); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + await service.login(loginDto, mockTenantId, '127.0.0.1'); + + expect(sessionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + device_type: 'unknown', + }), + ); + }); + + it('should login with Android device', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + (mockedBcrypt.compare as jest.Mock).mockResolvedValue(true); + userRepository.save.mockResolvedValue(mockUser as User); + sessionRepository.save.mockResolvedValue({} as Session); + jwtService.sign.mockReturnValueOnce('access_token').mockReturnValueOnce('refresh_token'); + configService.get.mockReturnValue('15m'); + + await service.login(loginDto, mockTenantId, '127.0.0.1', 'Mozilla/5.0 (Linux; Android 10)'); + + expect(sessionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + device_type: 'mobile', + }), + ); + }); + }); + + describe('requestPasswordReset - additional cases', () => { + it('should create token with correct expiry time (1 hour)', async () => { + const beforeCall = Date.now(); + userRepository.findOne.mockResolvedValue(mockUser as User); + tokenRepository.save.mockResolvedValue({} as Token); + + await service.requestPasswordReset(mockUser.email!, mockTenantId); + + const savedToken = tokenRepository.save.mock.calls[0][0]; + expect(savedToken.expires_at).toBeDefined(); + const expiryTime = (savedToken.expires_at as Date).getTime(); + const expectedMinExpiry = beforeCall + 60 * 60 * 1000 - 1000; // 1 hour minus 1 second tolerance + const expectedMaxExpiry = beforeCall + 60 * 60 * 1000 + 1000; // 1 hour plus 1 second tolerance + + expect(expiryTime).toBeGreaterThanOrEqual(expectedMinExpiry); + expect(expiryTime).toBeLessThanOrEqual(expectedMaxExpiry); + expect(savedToken.token_type).toBe('password_reset'); + }); + }); + + describe('resetPassword - additional cases', () => { + const mockToken = { + id: 'token-id', + user_id: mockUser.id, + tenant_id: mockTenantId, + token_type: 'password_reset', + is_used: false, + expires_at: new Date(Date.now() + 3600000), + }; + + it('should invalidate all sessions after password reset', async () => { + tokenRepository.findOne.mockResolvedValue(mockToken as Token); + (mockedBcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password'); + userRepository.update.mockResolvedValue({ affected: 1 } as any); + tokenRepository.update.mockResolvedValue({ affected: 1 } as any); + sessionRepository.update.mockResolvedValue({ affected: 3 } as any); + + await service.resetPassword('valid_token', 'newPassword123', mockTenantId); + + expect(sessionRepository.update).toHaveBeenCalledWith( + { user_id: mockToken.user_id }, + { is_active: false }, + ); + }); + + it('should mark token as used with timestamp', async () => { + const beforeCall = Date.now(); + tokenRepository.findOne.mockResolvedValue(mockToken as Token); + (mockedBcrypt.hash as jest.Mock).mockResolvedValue('new_hashed_password'); + userRepository.update.mockResolvedValue({ affected: 1 } as any); + tokenRepository.update.mockResolvedValue({ affected: 1 } as any); + sessionRepository.update.mockResolvedValue({ affected: 1 } as any); + + await service.resetPassword('valid_token', 'newPassword123', mockTenantId); + + expect(tokenRepository.update).toHaveBeenCalledWith( + { id: mockToken.id }, + expect.objectContaining({ + is_used: true, + }), + ); + const updateCall = tokenRepository.update.mock.calls[0][1]; + expect(updateCall.used_at).toBeDefined(); + expect((updateCall.used_at as Date).getTime()).toBeGreaterThanOrEqual(beforeCall); + }); + }); + + describe('validateUser - additional cases', () => { + it('should query for active users only', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + + await service.validateUser(mockUser.id!); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: mockUser.id, status: 'active' }, + }); + }); + }); +}); diff --git a/src/modules/auth/__tests__/jwt.strategy.spec.ts b/src/modules/auth/__tests__/jwt.strategy.spec.ts new file mode 100644 index 0000000..103d763 --- /dev/null +++ b/src/modules/auth/__tests__/jwt.strategy.spec.ts @@ -0,0 +1,81 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { UnauthorizedException } from '@nestjs/common'; +import { JwtStrategy, RequestUser } from '../strategies/jwt.strategy'; +import { AuthService } from '../services/auth.service'; + +describe('JwtStrategy', () => { + let strategy: JwtStrategy; + let authService: jest.Mocked; + + const mockUser = { + id: '550e8400-e29b-41d4-a716-446655440000', + tenant_id: '550e8400-e29b-41d4-a716-446655440001', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + status: 'active', + }; + + const mockPayload = { + sub: mockUser.id, + email: mockUser.email, + tenant_id: mockUser.tenant_id, + }; + + beforeEach(async () => { + const mockAuthService = { + validateUser: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn().mockReturnValue('test-secret'), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtStrategy, + { provide: AuthService, useValue: mockAuthService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + strategy = module.get(JwtStrategy); + authService = module.get(AuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('validate', () => { + it('should return RequestUser for valid payload', async () => { + authService.validateUser.mockResolvedValue(mockUser as any); + + const result: RequestUser = await strategy.validate(mockPayload); + + expect(result).toEqual({ + id: mockUser.id, + email: mockUser.email, + tenant_id: mockUser.tenant_id, + }); + expect(authService.validateUser).toHaveBeenCalledWith(mockPayload.sub); + }); + + it('should throw UnauthorizedException for invalid user', async () => { + authService.validateUser.mockResolvedValue(null); + + await expect(strategy.validate(mockPayload)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should include tenant_id from payload in result', async () => { + authService.validateUser.mockResolvedValue(mockUser as any); + + const result = await strategy.validate(mockPayload); + + expect(result.tenant_id).toBe(mockPayload.tenant_id); + }); + }); +}); diff --git a/src/modules/auth/__tests__/oauth.controller.spec.ts b/src/modules/auth/__tests__/oauth.controller.spec.ts new file mode 100644 index 0000000..648aeee --- /dev/null +++ b/src/modules/auth/__tests__/oauth.controller.spec.ts @@ -0,0 +1,1394 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { OAuthController } from '../controllers/oauth.controller'; +import { + OAuthService, + OAuthProfile, + OAuthTokens, + OAuthUrlResponse, + OAuthConnectionResponse, +} from '../services/oauth.service'; +import { OAuthProvider } from '../entities/oauth-provider.enum'; +import { RequestUser } from '../strategies/jwt.strategy'; + +describe('OAuthController', () => { + let controller: OAuthController; + let oauthService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440000'; + + const mockRequestUser: RequestUser = { + id: mockUserId, + email: 'test@example.com', + tenant_id: mockTenantId, + }; + + const mockOAuthProfile: OAuthProfile = { + id: 'oauth-provider-user-id-123', + email: 'oauth-user@example.com', + name: 'OAuth User', + avatar_url: 'https://example.com/avatar.jpg', + raw_data: { provider_specific: 'data' }, + }; + + const mockOAuthTokens: OAuthTokens = { + access_token: 'oauth_access_token_abc123', + refresh_token: 'oauth_refresh_token_xyz789', + expires_at: new Date(Date.now() + 3600000), // 1 hour from now + scopes: ['email', 'profile'], + }; + + const mockUser = { + id: mockUserId, + tenant_id: mockTenantId, + email: 'oauth-user@example.com', + first_name: 'OAuth', + last_name: 'User', + status: 'active', + email_verified: true, + }; + + const mockAuthResponse = { + user: mockUser, + accessToken: 'jwt_access_token', + refreshToken: 'jwt_refresh_token', + }; + + const mockRequest = { + ip: '127.0.0.1', + headers: { + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + 'x-tenant-id': mockTenantId, + }, + }; + + const mockOAuthUrlResponse: OAuthUrlResponse = { + url: 'https://accounts.google.com/o/oauth2/v2/auth?...', + state: 'encoded_state_string', + }; + + const mockOAuthConnection: OAuthConnectionResponse = { + id: '550e8400-e29b-41d4-a716-446655440002', + provider: OAuthProvider.GOOGLE, + provider_email: 'oauth-user@example.com', + provider_name: 'OAuth User', + provider_avatar_url: 'https://example.com/avatar.jpg', + created_at: new Date(), + last_used_at: new Date(), + }; + + beforeEach(async () => { + const mockOAuthService = { + getAuthorizationUrl: jest.fn(), + handleOAuthLogin: jest.fn(), + getConnections: jest.fn(), + disconnectProvider: jest.fn(), + linkProvider: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn((key: string) => { + const config: Record = { + 'app.frontendUrl': 'http://localhost:3000', + }; + return config[key]; + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [OAuthController], + providers: [ + { provide: OAuthService, useValue: mockOAuthService }, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + controller = module.get(OAuthController); + oauthService = module.get(OAuthService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== getAuthorizationUrl Tests ==================== + + describe('getAuthorizationUrl', () => { + describe('success cases', () => { + it('should return authorization URL for Google provider', () => { + const googleUrl: OAuthUrlResponse = { + url: 'https://accounts.google.com/o/oauth2/v2/auth?client_id=...', + state: 'google_state_123', + }; + oauthService.getAuthorizationUrl.mockReturnValue(googleUrl); + + const result = controller.getAuthorizationUrl('google', mockTenantId); + + expect(result).toEqual(googleUrl); + expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( + OAuthProvider.GOOGLE, + mockTenantId, + ); + }); + + it('should return authorization URL for Microsoft provider', () => { + const microsoftUrl: OAuthUrlResponse = { + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?...', + state: 'microsoft_state_456', + }; + oauthService.getAuthorizationUrl.mockReturnValue(microsoftUrl); + + const result = controller.getAuthorizationUrl('microsoft', mockTenantId); + + expect(result).toEqual(microsoftUrl); + expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( + OAuthProvider.MICROSOFT, + mockTenantId, + ); + }); + + it('should return authorization URL for GitHub provider', () => { + const githubUrl: OAuthUrlResponse = { + url: 'https://github.com/login/oauth/authorize?...', + state: 'github_state_789', + }; + oauthService.getAuthorizationUrl.mockReturnValue(githubUrl); + + const result = controller.getAuthorizationUrl('github', mockTenantId); + + expect(result).toEqual(githubUrl); + expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( + OAuthProvider.GITHUB, + mockTenantId, + ); + }); + + it('should return authorization URL for Apple provider', () => { + const appleUrl: OAuthUrlResponse = { + url: 'https://appleid.apple.com/auth/authorize?...', + state: 'apple_state_abc', + }; + oauthService.getAuthorizationUrl.mockReturnValue(appleUrl); + + const result = controller.getAuthorizationUrl('apple', mockTenantId); + + expect(result).toEqual(appleUrl); + expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( + OAuthProvider.APPLE, + mockTenantId, + ); + }); + + it('should handle uppercase provider names', () => { + oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); + + const result = controller.getAuthorizationUrl('GOOGLE', mockTenantId); + + expect(result).toEqual(mockOAuthUrlResponse); + expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( + OAuthProvider.GOOGLE, + mockTenantId, + ); + }); + + it('should handle mixed case provider names', () => { + oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); + + const result = controller.getAuthorizationUrl('GoOgLe', mockTenantId); + + expect(result).toEqual(mockOAuthUrlResponse); + expect(oauthService.getAuthorizationUrl).toHaveBeenCalledWith( + OAuthProvider.GOOGLE, + mockTenantId, + ); + }); + }); + + describe('error cases', () => { + it('should throw BadRequestException when tenant ID is missing', () => { + expect(() => + controller.getAuthorizationUrl('google', undefined as any), + ).toThrow(BadRequestException); + expect(() => + controller.getAuthorizationUrl('google', undefined as any), + ).toThrow('Tenant ID es requerido'); + }); + + it('should throw BadRequestException when tenant ID is empty string', () => { + expect(() => controller.getAuthorizationUrl('google', '')).toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException for invalid provider', () => { + expect(() => + controller.getAuthorizationUrl('facebook', mockTenantId), + ).toThrow(BadRequestException); + expect(() => + controller.getAuthorizationUrl('facebook', mockTenantId), + ).toThrow(/Proveedor OAuth no válido: facebook/); + }); + + it('should throw BadRequestException for unknown provider', () => { + expect(() => + controller.getAuthorizationUrl('linkedin', mockTenantId), + ).toThrow(BadRequestException); + }); + + it('should include supported providers in error message', () => { + try { + controller.getAuthorizationUrl('invalid-provider', mockTenantId); + fail('Expected BadRequestException'); + } catch (error) { + expect(error.message).toContain('google'); + expect(error.message).toContain('microsoft'); + expect(error.message).toContain('github'); + expect(error.message).toContain('apple'); + } + }); + }); + }); + + // ==================== handleCallback Tests ==================== + + describe('handleCallback', () => { + describe('success cases', () => { + it('should handle Google OAuth callback successfully', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const body = { + code: 'authorization_code_123', + state: 'state_token', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + const result = await controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ); + + expect(result).toEqual(mockAuthResponse); + expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( + OAuthProvider.GOOGLE, + mockOAuthProfile, + mockOAuthTokens, + mockTenantId, + mockRequest, + ); + }); + + it('should handle Microsoft OAuth callback successfully', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const body = { + code: 'ms_auth_code', + profile: { ...mockOAuthProfile, email: 'user@outlook.com' }, + tokens: mockOAuthTokens, + }; + + const result = await controller.handleCallback( + 'microsoft', + body, + mockTenantId, + mockRequest as any, + ); + + expect(result).toEqual(mockAuthResponse); + expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( + OAuthProvider.MICROSOFT, + expect.objectContaining({ email: 'user@outlook.com' }), + mockOAuthTokens, + mockTenantId, + mockRequest, + ); + }); + + it('should handle GitHub OAuth callback successfully', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const body = { + code: 'github_code', + profile: { ...mockOAuthProfile, email: 'dev@github.com' }, + tokens: { ...mockOAuthTokens, refresh_token: undefined }, // GitHub doesn't return refresh token + }; + + const result = await controller.handleCallback( + 'github', + body, + mockTenantId, + mockRequest as any, + ); + + expect(result).toEqual(mockAuthResponse); + expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( + OAuthProvider.GITHUB, + expect.objectContaining({ email: 'dev@github.com' }), + expect.objectContaining({ refresh_token: undefined }), + mockTenantId, + mockRequest, + ); + }); + + it('should create new user when OAuth email does not exist', async () => { + const newUserResponse = { + user: { + ...mockUser, + id: 'new-user-id', + email: 'newuser@oauth.com', + }, + accessToken: 'new_access_token', + refreshToken: 'new_refresh_token', + }; + oauthService.handleOAuthLogin.mockResolvedValue(newUserResponse); + + const body = { + code: 'new_user_code', + profile: { ...mockOAuthProfile, email: 'newuser@oauth.com' }, + tokens: mockOAuthTokens, + }; + + const result = await controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ); + + expect(result.user.email).toBe('newuser@oauth.com'); + expect(result).toHaveProperty('accessToken'); + expect(result).toHaveProperty('refreshToken'); + }); + + it('should link OAuth to existing user when email matches', async () => { + const existingUserResponse = { + user: mockUser, + accessToken: 'linked_access_token', + refreshToken: 'linked_refresh_token', + }; + oauthService.handleOAuthLogin.mockResolvedValue(existingUserResponse); + + const body = { + code: 'existing_user_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + const result = await controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ); + + expect(result.user.id).toBe(mockUserId); + }); + + it('should handle callback with minimal profile data', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const minimalProfile: OAuthProfile = { + id: 'min-oauth-id', + email: 'minimal@example.com', + }; + + const body = { + code: 'minimal_code', + profile: minimalProfile, + tokens: mockOAuthTokens, + }; + + const result = await controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ); + + expect(result).toEqual(mockAuthResponse); + expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( + OAuthProvider.GOOGLE, + minimalProfile, + mockOAuthTokens, + mockTenantId, + mockRequest, + ); + }); + + it('should handle callback with minimal token data', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const minimalTokens: OAuthTokens = { + access_token: 'only_access_token', + }; + + const body = { + code: 'minimal_token_code', + profile: mockOAuthProfile, + tokens: minimalTokens, + }; + + const result = await controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ); + + expect(result).toEqual(mockAuthResponse); + }); + }); + + describe('error cases', () => { + it('should throw BadRequestException when tenant ID is missing', async () => { + const body = { + code: 'some_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + await expect( + controller.handleCallback( + 'google', + body, + undefined as any, + mockRequest as any, + ), + ).rejects.toThrow(BadRequestException); + await expect( + controller.handleCallback( + 'google', + body, + undefined as any, + mockRequest as any, + ), + ).rejects.toThrow('Tenant ID es requerido'); + }); + + it('should throw BadRequestException when profile is missing', async () => { + const body = { + code: 'some_code', + tokens: mockOAuthTokens, + } as any; + + await expect( + controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ), + ).rejects.toThrow(BadRequestException); + await expect( + controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ), + ).rejects.toThrow('Se requiere profile y tokens del proveedor OAuth'); + }); + + it('should throw BadRequestException when tokens are missing', async () => { + const body = { + code: 'some_code', + profile: mockOAuthProfile, + } as any; + + await expect( + controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when both profile and tokens are missing', async () => { + const body = { + code: 'some_code', + } as any; + + await expect( + controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for invalid provider', async () => { + const body = { + code: 'some_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + await expect( + controller.handleCallback( + 'facebook', + body, + mockTenantId, + mockRequest as any, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should propagate service errors', async () => { + const serviceError = new Error('OAuth service error'); + oauthService.handleOAuthLogin.mockRejectedValue(serviceError); + + const body = { + code: 'error_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + await expect( + controller.handleCallback( + 'google', + body, + mockTenantId, + mockRequest as any, + ), + ).rejects.toThrow('OAuth service error'); + }); + }); + }); + + // ==================== getConnections Tests ==================== + + describe('getConnections', () => { + describe('success cases', () => { + it('should return list of OAuth connections for user', async () => { + const connections: OAuthConnectionResponse[] = [ + mockOAuthConnection, + { + ...mockOAuthConnection, + id: '550e8400-e29b-41d4-a716-446655440003', + provider: OAuthProvider.GITHUB, + provider_email: 'dev@github.com', + }, + ]; + oauthService.getConnections.mockResolvedValue(connections); + + const result = await controller.getConnections(mockRequestUser); + + expect(result).toEqual(connections); + expect(result).toHaveLength(2); + expect(oauthService.getConnections).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + ); + }); + + it('should return empty array when user has no OAuth connections', async () => { + oauthService.getConnections.mockResolvedValue([]); + + const result = await controller.getConnections(mockRequestUser); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('should return single connection when user has one OAuth provider', async () => { + oauthService.getConnections.mockResolvedValue([mockOAuthConnection]); + + const result = await controller.getConnections(mockRequestUser); + + expect(result).toHaveLength(1); + expect(result[0].provider).toBe(OAuthProvider.GOOGLE); + }); + + it('should return connections with all providers', async () => { + const allProviderConnections: OAuthConnectionResponse[] = [ + { ...mockOAuthConnection, provider: OAuthProvider.GOOGLE }, + { ...mockOAuthConnection, id: 'id-2', provider: OAuthProvider.MICROSOFT }, + { ...mockOAuthConnection, id: 'id-3', provider: OAuthProvider.GITHUB }, + { ...mockOAuthConnection, id: 'id-4', provider: OAuthProvider.APPLE }, + ]; + oauthService.getConnections.mockResolvedValue(allProviderConnections); + + const result = await controller.getConnections(mockRequestUser); + + expect(result).toHaveLength(4); + expect(result.map((c) => c.provider)).toContain(OAuthProvider.GOOGLE); + expect(result.map((c) => c.provider)).toContain(OAuthProvider.MICROSOFT); + expect(result.map((c) => c.provider)).toContain(OAuthProvider.GITHUB); + expect(result.map((c) => c.provider)).toContain(OAuthProvider.APPLE); + }); + }); + + describe('error cases', () => { + it('should propagate service errors', async () => { + oauthService.getConnections.mockRejectedValue( + new Error('Database error'), + ); + + await expect(controller.getConnections(mockRequestUser)).rejects.toThrow( + 'Database error', + ); + }); + }); + }); + + // ==================== disconnectProvider Tests ==================== + + describe('disconnectProvider', () => { + describe('success cases', () => { + it('should disconnect Google provider successfully', async () => { + const successMessage = { + message: 'Proveedor google desconectado correctamente', + }; + oauthService.disconnectProvider.mockResolvedValue(successMessage); + + const result = await controller.disconnectProvider( + 'google', + mockRequestUser, + ); + + expect(result).toEqual(successMessage); + expect(oauthService.disconnectProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.GOOGLE, + ); + }); + + it('should disconnect Microsoft provider successfully', async () => { + const successMessage = { + message: 'Proveedor microsoft desconectado correctamente', + }; + oauthService.disconnectProvider.mockResolvedValue(successMessage); + + const result = await controller.disconnectProvider( + 'microsoft', + mockRequestUser, + ); + + expect(result).toEqual(successMessage); + expect(oauthService.disconnectProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.MICROSOFT, + ); + }); + + it('should disconnect GitHub provider successfully', async () => { + const successMessage = { + message: 'Proveedor github desconectado correctamente', + }; + oauthService.disconnectProvider.mockResolvedValue(successMessage); + + const result = await controller.disconnectProvider( + 'github', + mockRequestUser, + ); + + expect(result).toEqual(successMessage); + expect(oauthService.disconnectProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.GITHUB, + ); + }); + + it('should disconnect Apple provider successfully', async () => { + const successMessage = { + message: 'Proveedor apple desconectado correctamente', + }; + oauthService.disconnectProvider.mockResolvedValue(successMessage); + + const result = await controller.disconnectProvider( + 'apple', + mockRequestUser, + ); + + expect(result).toEqual(successMessage); + expect(oauthService.disconnectProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.APPLE, + ); + }); + + it('should handle uppercase provider names when disconnecting', async () => { + const successMessage = { message: 'Provider disconnected' }; + oauthService.disconnectProvider.mockResolvedValue(successMessage); + + await controller.disconnectProvider('GOOGLE', mockRequestUser); + + expect(oauthService.disconnectProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.GOOGLE, + ); + }); + }); + + describe('error cases', () => { + it('should throw BadRequestException for invalid provider', async () => { + await expect( + controller.disconnectProvider('facebook', mockRequestUser), + ).rejects.toThrow(BadRequestException); + }); + + it('should propagate NotFoundException when connection not found', async () => { + const notFoundError = new Error('No existe conexion con google'); + oauthService.disconnectProvider.mockRejectedValue(notFoundError); + + await expect( + controller.disconnectProvider('google', mockRequestUser), + ).rejects.toThrow('No existe conexion con google'); + }); + + it('should propagate ConflictException when trying to disconnect only auth method', async () => { + const conflictError = new Error( + 'No puedes desconectar el unico metodo de autenticacion', + ); + oauthService.disconnectProvider.mockRejectedValue(conflictError); + + await expect( + controller.disconnectProvider('google', mockRequestUser), + ).rejects.toThrow('No puedes desconectar el unico metodo de autenticacion'); + }); + }); + }); + + // ==================== linkProvider Tests ==================== + + describe('linkProvider', () => { + describe('success cases', () => { + it('should link Google provider to existing account', async () => { + oauthService.linkProvider.mockResolvedValue(mockOAuthConnection); + + const body = { + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + const result = await controller.linkProvider( + 'google', + body, + mockRequestUser, + ); + + expect(result).toEqual(mockOAuthConnection); + expect(oauthService.linkProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.GOOGLE, + mockOAuthProfile, + mockOAuthTokens, + ); + }); + + it('should link Microsoft provider to existing account', async () => { + const microsoftConnection: OAuthConnectionResponse = { + ...mockOAuthConnection, + provider: OAuthProvider.MICROSOFT, + provider_email: 'user@outlook.com', + }; + oauthService.linkProvider.mockResolvedValue(microsoftConnection); + + const body = { + profile: { ...mockOAuthProfile, email: 'user@outlook.com' }, + tokens: mockOAuthTokens, + }; + + const result = await controller.linkProvider( + 'microsoft', + body, + mockRequestUser, + ); + + expect(result.provider).toBe(OAuthProvider.MICROSOFT); + expect(oauthService.linkProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.MICROSOFT, + expect.objectContaining({ email: 'user@outlook.com' }), + mockOAuthTokens, + ); + }); + + it('should link GitHub provider to existing account', async () => { + const githubConnection: OAuthConnectionResponse = { + ...mockOAuthConnection, + provider: OAuthProvider.GITHUB, + provider_email: 'dev@github.com', + }; + oauthService.linkProvider.mockResolvedValue(githubConnection); + + const body = { + profile: { ...mockOAuthProfile, email: 'dev@github.com' }, + tokens: mockOAuthTokens, + }; + + const result = await controller.linkProvider( + 'github', + body, + mockRequestUser, + ); + + expect(result.provider).toBe(OAuthProvider.GITHUB); + }); + + it('should link Apple provider to existing account', async () => { + const appleConnection: OAuthConnectionResponse = { + ...mockOAuthConnection, + provider: OAuthProvider.APPLE, + provider_email: 'user@privaterelay.appleid.com', + }; + oauthService.linkProvider.mockResolvedValue(appleConnection); + + const body = { + profile: { + ...mockOAuthProfile, + email: 'user@privaterelay.appleid.com', + }, + tokens: mockOAuthTokens, + }; + + const result = await controller.linkProvider( + 'apple', + body, + mockRequestUser, + ); + + expect(result.provider).toBe(OAuthProvider.APPLE); + }); + + it('should handle link with minimal profile data', async () => { + oauthService.linkProvider.mockResolvedValue(mockOAuthConnection); + + const minimalProfile: OAuthProfile = { + id: 'min-id', + email: 'minimal@test.com', + }; + + const body = { + profile: minimalProfile, + tokens: mockOAuthTokens, + }; + + const result = await controller.linkProvider( + 'google', + body, + mockRequestUser, + ); + + expect(result).toEqual(mockOAuthConnection); + expect(oauthService.linkProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.GOOGLE, + minimalProfile, + mockOAuthTokens, + ); + }); + + it('should handle uppercase provider names when linking', async () => { + oauthService.linkProvider.mockResolvedValue(mockOAuthConnection); + + const body = { + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + await controller.linkProvider('GOOGLE', body, mockRequestUser); + + expect(oauthService.linkProvider).toHaveBeenCalledWith( + mockRequestUser.id, + mockRequestUser.tenant_id, + OAuthProvider.GOOGLE, + mockOAuthProfile, + mockOAuthTokens, + ); + }); + }); + + describe('error cases', () => { + it('should throw BadRequestException when profile is missing', async () => { + const body = { + tokens: mockOAuthTokens, + } as any; + + await expect( + controller.linkProvider('google', body, mockRequestUser), + ).rejects.toThrow(BadRequestException); + await expect( + controller.linkProvider('google', body, mockRequestUser), + ).rejects.toThrow('Se requiere profile y tokens del proveedor OAuth'); + }); + + it('should throw BadRequestException when tokens are missing', async () => { + const body = { + profile: mockOAuthProfile, + } as any; + + await expect( + controller.linkProvider('google', body, mockRequestUser), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when both profile and tokens are missing', async () => { + const body = {} as any; + + await expect( + controller.linkProvider('google', body, mockRequestUser), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for invalid provider', async () => { + const body = { + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + await expect( + controller.linkProvider('facebook', body, mockRequestUser), + ).rejects.toThrow(BadRequestException); + }); + + it('should propagate ConflictException when provider already linked to same user', async () => { + const conflictError = new Error('Ya tienes vinculado google'); + oauthService.linkProvider.mockRejectedValue(conflictError); + + const body = { + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + await expect( + controller.linkProvider('google', body, mockRequestUser), + ).rejects.toThrow('Ya tienes vinculado google'); + }); + + it('should propagate ConflictException when provider account linked to another user', async () => { + const conflictError = new Error( + 'Esta cuenta de google ya esta vinculada a otro usuario', + ); + oauthService.linkProvider.mockRejectedValue(conflictError); + + const body = { + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }; + + await expect( + controller.linkProvider('google', body, mockRequestUser), + ).rejects.toThrow('Esta cuenta de google ya esta vinculada a otro usuario'); + }); + }); + }); + + // ==================== validateProvider Private Method Tests ==================== + + describe('validateProvider (through public methods)', () => { + it('should accept all valid providers', () => { + oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); + + // Test all valid providers through getAuthorizationUrl + const providers = ['google', 'microsoft', 'github', 'apple']; + providers.forEach((provider) => { + expect(() => + controller.getAuthorizationUrl(provider, mockTenantId), + ).not.toThrow(); + }); + }); + + it('should reject invalid providers with descriptive error', () => { + const invalidProviders = [ + 'facebook', + 'twitter', + 'linkedin', + 'unknown', + '', + ' ', + 'null', + 'undefined', + ]; + + invalidProviders.forEach((provider) => { + expect(() => + controller.getAuthorizationUrl(provider, mockTenantId), + ).toThrow(BadRequestException); + }); + }); + + it('should be case-insensitive for provider validation', () => { + oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); + + const casedProviders = [ + 'GOOGLE', + 'Google', + 'gOoGlE', + 'MICROSOFT', + 'Microsoft', + 'GITHUB', + 'GitHub', + 'gitHub', + 'APPLE', + 'Apple', + ]; + + casedProviders.forEach((provider) => { + expect(() => + controller.getAuthorizationUrl(provider, mockTenantId), + ).not.toThrow(); + }); + }); + }); + + // ==================== Integration/Flow Tests ==================== + + describe('OAuth Flow Integration', () => { + it('should complete full OAuth login flow for new user', async () => { + // Step 1: Get authorization URL + oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); + const urlResult = controller.getAuthorizationUrl('google', mockTenantId); + expect(urlResult.url).toContain('accounts.google.com'); + + // Step 2: Handle callback (simulating new user) + const newUserAuthResponse = { + user: { ...mockUser, id: 'new-user-uuid' }, + accessToken: 'new_user_access_token', + refreshToken: 'new_user_refresh_token', + }; + oauthService.handleOAuthLogin.mockResolvedValue(newUserAuthResponse); + + const callbackResult = await controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ); + + expect(callbackResult).toHaveProperty('user'); + expect(callbackResult).toHaveProperty('accessToken'); + expect(callbackResult).toHaveProperty('refreshToken'); + }); + + it('should complete full OAuth login flow for existing user', async () => { + // Step 1: Get authorization URL + oauthService.getAuthorizationUrl.mockReturnValue(mockOAuthUrlResponse); + const urlResult = controller.getAuthorizationUrl('google', mockTenantId); + expect(urlResult).toBeDefined(); + + // Step 2: Handle callback (simulating existing user) + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const callbackResult = await controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ); + + expect(callbackResult.user.id).toBe(mockUserId); + }); + + it('should allow linking multiple providers to same account', async () => { + const googleConnection = { ...mockOAuthConnection, provider: OAuthProvider.GOOGLE }; + const githubConnection = { ...mockOAuthConnection, id: 'github-conn-id', provider: OAuthProvider.GITHUB }; + + oauthService.linkProvider + .mockResolvedValueOnce(googleConnection) + .mockResolvedValueOnce(githubConnection); + + // Link Google + const googleResult = await controller.linkProvider( + 'google', + { profile: mockOAuthProfile, tokens: mockOAuthTokens }, + mockRequestUser, + ); + expect(googleResult.provider).toBe(OAuthProvider.GOOGLE); + + // Link GitHub + const githubResult = await controller.linkProvider( + 'github', + { profile: { ...mockOAuthProfile, id: 'github-id' }, tokens: mockOAuthTokens }, + mockRequestUser, + ); + expect(githubResult.provider).toBe(OAuthProvider.GITHUB); + }); + + it('should show all connections after linking multiple providers', async () => { + const multipleConnections: OAuthConnectionResponse[] = [ + { ...mockOAuthConnection, provider: OAuthProvider.GOOGLE }, + { ...mockOAuthConnection, id: 'ms-conn-id', provider: OAuthProvider.MICROSOFT }, + { ...mockOAuthConnection, id: 'gh-conn-id', provider: OAuthProvider.GITHUB }, + ]; + + oauthService.getConnections.mockResolvedValue(multipleConnections); + + const connections = await controller.getConnections(mockRequestUser); + + expect(connections).toHaveLength(3); + expect(connections.map((c) => c.provider)).toEqual([ + OAuthProvider.GOOGLE, + OAuthProvider.MICROSOFT, + OAuthProvider.GITHUB, + ]); + }); + }); + + // ==================== Session Management Tests ==================== + + describe('Session Management after OAuth', () => { + it('should return valid JWT tokens after successful OAuth login', async () => { + oauthService.handleOAuthLogin.mockResolvedValue({ + user: mockUser, + accessToken: 'valid_jwt_access_token', + refreshToken: 'valid_jwt_refresh_token', + }); + + const result = await controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ); + + expect(result.accessToken).toBe('valid_jwt_access_token'); + expect(result.refreshToken).toBe('valid_jwt_refresh_token'); + expect(result.accessToken).not.toBe(mockOAuthTokens.access_token); + }); + + it('should include user agent in OAuth login request', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + await controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ); + + expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.any(Object), + mockTenantId, + expect.objectContaining({ + headers: expect.objectContaining({ + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + }), + }), + ); + }); + + it('should include IP address in OAuth login request', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + await controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ); + + expect(oauthService.handleOAuthLogin).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.any(Object), + mockTenantId, + expect.objectContaining({ + ip: '127.0.0.1', + }), + ); + }); + }); + + // ==================== Edge Cases ==================== + + describe('Edge Cases', () => { + it('should handle OAuth profile with special characters in name', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const profileWithSpecialChars: OAuthProfile = { + id: 'special-id', + email: 'test@example.com', + name: "O'Connor, Jose Maria", + avatar_url: 'https://example.com/avatar.jpg', + }; + + await expect( + controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: profileWithSpecialChars, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ), + ).resolves.not.toThrow(); + }); + + it('should handle OAuth profile with unicode characters', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const profileWithUnicode: OAuthProfile = { + id: 'unicode-id', + email: 'test@example.com', + name: 'Juan Carlos', + }; + + await expect( + controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: profileWithUnicode, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ), + ).resolves.not.toThrow(); + }); + + it('should handle very long OAuth token strings', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const longTokens: OAuthTokens = { + access_token: 'a'.repeat(10000), + refresh_token: 'r'.repeat(5000), + }; + + await expect( + controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: mockOAuthProfile, + tokens: longTokens, + }, + mockTenantId, + mockRequest as any, + ), + ).resolves.not.toThrow(); + }); + + it('should handle empty state in callback body', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const result = await controller.handleCallback( + 'google', + { + code: 'auth_code', + state: '', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ); + + expect(result).toEqual(mockAuthResponse); + }); + + it('should handle null values in OAuth profile optional fields', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const profileWithNulls: OAuthProfile = { + id: 'null-fields-id', + email: 'test@example.com', + name: undefined, + avatar_url: undefined, + raw_data: undefined, + }; + + await expect( + controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: profileWithNulls, + tokens: mockOAuthTokens, + }, + mockTenantId, + mockRequest as any, + ), + ).resolves.not.toThrow(); + }); + + it('should handle request without user-agent header', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const requestWithoutUserAgent = { + ip: '127.0.0.1', + headers: { + 'x-tenant-id': mockTenantId, + }, + }; + + await expect( + controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }, + mockTenantId, + requestWithoutUserAgent as any, + ), + ).resolves.not.toThrow(); + }); + + it('should handle request without IP address', async () => { + oauthService.handleOAuthLogin.mockResolvedValue(mockAuthResponse); + + const requestWithoutIp = { + headers: { + 'user-agent': 'Mozilla/5.0', + 'x-tenant-id': mockTenantId, + }, + }; + + await expect( + controller.handleCallback( + 'google', + { + code: 'auth_code', + profile: mockOAuthProfile, + tokens: mockOAuthTokens, + }, + mockTenantId, + requestWithoutIp as any, + ), + ).resolves.not.toThrow(); + }); + }); +}); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..73ed79b --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,281 @@ +import { + Controller, + Post, + Body, + Get, + UseGuards, + Req, + HttpCode, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiHeader, +} from '@nestjs/swagger'; +import { AuthService, AuthResponse } from './services/auth.service'; +import { MfaService } from './services/mfa.service'; +import { + LoginDto, + RegisterDto, + RequestPasswordResetDto, + ResetPasswordDto, + ChangePasswordDto, +} from './dto'; +import { + SetupMfaResponseDto, + VerifyMfaSetupDto, + DisableMfaDto, + MfaStatusDto, + RegenerateBackupCodesDto, + BackupCodesResponseDto, +} from './dto/mfa.dto'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { Public } from './decorators/public.decorator'; +import { CurrentUser } from './decorators/current-user.decorator'; +import { CurrentTenant } from './decorators/tenant.decorator'; +import { RequestUser } from './strategies/jwt.strategy'; + +@ApiTags('auth') +@Controller('auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly mfaService: MfaService, + ) {} + + @Post('register') + @Public() + @ApiOperation({ summary: 'Register new user' }) + @ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' }) + @ApiResponse({ status: 201, description: 'User registered successfully' }) + @ApiResponse({ status: 400, description: 'Bad request' }) + @ApiResponse({ status: 409, description: 'Email already exists' }) + async register( + @Body() dto: RegisterDto, + @CurrentTenant() tenantId: string, + @Req() req: Request, + ): Promise { + if (!tenantId) { + throw new BadRequestException('Tenant ID es requerido'); + } + + return this.authService.register( + dto, + tenantId, + req.ip, + req.headers['user-agent'], + ); + } + + @Post('login') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Login user' }) + @ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' }) + @ApiResponse({ status: 200, description: 'Login successful' }) + @ApiResponse({ status: 401, description: 'Invalid credentials' }) + async login( + @Body() dto: LoginDto, + @CurrentTenant() tenantId: string, + @Req() req: Request, + ): Promise { + if (!tenantId) { + throw new BadRequestException('Tenant ID es requerido'); + } + + return this.authService.login( + dto, + tenantId, + req.ip, + req.headers['user-agent'], + ); + } + + @Post('logout') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Logout user' }) + @ApiResponse({ status: 200, description: 'Logout successful' }) + async logout( + @CurrentUser() user: RequestUser, + @Body('sessionToken') sessionToken: string, + ): Promise<{ message: string }> { + await this.authService.logout(user.id, sessionToken); + return { message: 'Sesión cerrada correctamente' }; + } + + @Post('logout-all') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Logout all sessions' }) + @ApiResponse({ status: 200, description: 'All sessions closed' }) + async logoutAll(@CurrentUser() user: RequestUser): Promise<{ message: string }> { + await this.authService.logoutAll(user.id); + return { message: 'Todas las sesiones cerradas' }; + } + + @Post('refresh') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Refresh access token' }) + @ApiResponse({ status: 200, description: 'Token refreshed' }) + @ApiResponse({ status: 401, description: 'Invalid refresh token' }) + async refresh( + @Body('refreshToken') refreshToken: string, + @Req() req: Request, + ): Promise<{ accessToken: string; refreshToken: string }> { + return this.authService.refreshToken( + refreshToken, + req.ip, + req.headers['user-agent'], + ); + } + + @Post('password/request-reset') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Request password reset' }) + @ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' }) + @ApiResponse({ status: 200, description: 'Reset email sent if user exists' }) + async requestPasswordReset( + @Body() dto: RequestPasswordResetDto, + @CurrentTenant() tenantId: string, + ): Promise<{ message: string }> { + if (!tenantId) { + throw new BadRequestException('Tenant ID es requerido'); + } + + return this.authService.requestPasswordReset(dto.email, tenantId); + } + + @Post('password/reset') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Reset password with token' }) + @ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' }) + @ApiResponse({ status: 200, description: 'Password reset successful' }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async resetPassword( + @Body() dto: ResetPasswordDto, + @CurrentTenant() tenantId: string, + ): Promise<{ message: string }> { + if (!tenantId) { + throw new BadRequestException('Tenant ID es requerido'); + } + + return this.authService.resetPassword(dto.token, dto.password, tenantId); + } + + @Post('password/change') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Change password' }) + @ApiResponse({ status: 200, description: 'Password changed' }) + @ApiResponse({ status: 400, description: 'Invalid current password' }) + async changePassword( + @CurrentUser() user: RequestUser, + @Body() dto: ChangePasswordDto, + ): Promise<{ message: string }> { + return this.authService.changePassword(user.id, dto); + } + + @Post('verify-email') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify email with token' }) + @ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' }) + @ApiResponse({ status: 200, description: 'Email verified' }) + @ApiResponse({ status: 400, description: 'Invalid or expired token' }) + async verifyEmail( + @Body('token') token: string, + @CurrentTenant() tenantId: string, + ): Promise<{ message: string }> { + if (!tenantId) { + throw new BadRequestException('Tenant ID es requerido'); + } + + return this.authService.verifyEmail(token, tenantId); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user profile' }) + @ApiResponse({ status: 200, description: 'Current user profile' }) + async getProfile(@CurrentUser() user: RequestUser) { + return this.authService.getProfile(user.id); + } + + // ==================== MFA Endpoints ==================== + + @Get('mfa/status') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get MFA status for current user' }) + @ApiResponse({ status: 200, description: 'MFA status', type: MfaStatusDto }) + async getMfaStatus(@CurrentUser() user: RequestUser): Promise { + return this.mfaService.getMfaStatus(user.id); + } + + @Post('mfa/setup') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Initialize MFA setup - get QR code and secret' }) + @ApiResponse({ status: 200, description: 'MFA setup data', type: SetupMfaResponseDto }) + @ApiResponse({ status: 400, description: 'MFA already enabled' }) + async setupMfa(@CurrentUser() user: RequestUser): Promise { + return this.mfaService.setupMfa(user.id); + } + + @Post('mfa/verify-setup') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Verify TOTP code and enable MFA' }) + @ApiResponse({ status: 200, description: 'MFA enabled successfully' }) + @ApiResponse({ status: 400, description: 'Invalid code or MFA already enabled' }) + async verifyMfaSetup( + @CurrentUser() user: RequestUser, + @Body() dto: VerifyMfaSetupDto, + ): Promise<{ success: boolean; message: string }> { + return this.mfaService.verifyMfaSetup(user.id, dto); + } + + @Post('mfa/disable') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Disable MFA (requires password and code)' }) + @ApiResponse({ status: 200, description: 'MFA disabled successfully' }) + @ApiResponse({ status: 400, description: 'Invalid code or MFA not enabled' }) + @ApiResponse({ status: 401, description: 'Invalid password' }) + async disableMfa( + @CurrentUser() user: RequestUser, + @Body() dto: DisableMfaDto, + ): Promise<{ success: boolean; message: string }> { + return this.mfaService.disableMfa(user.id, dto); + } + + @Post('mfa/backup-codes/regenerate') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Regenerate backup codes (requires password and code)' }) + @ApiResponse({ status: 200, description: 'New backup codes generated', type: BackupCodesResponseDto }) + @ApiResponse({ status: 400, description: 'Invalid code or MFA not enabled' }) + @ApiResponse({ status: 401, description: 'Invalid password' }) + async regenerateBackupCodes( + @CurrentUser() user: RequestUser, + @Body() dto: RegenerateBackupCodesDto, + ): Promise { + return this.mfaService.regenerateBackupCodes(user.id, dto.password, dto.code); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..688cd79 --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +// Entities +import { User, Session, Token, OAuthConnection } from './entities'; + +// Services +import { AuthService } from './services/auth.service'; +import { OAuthService } from './services/oauth.service'; +import { MfaService } from './services/mfa.service'; + +// Controllers +import { AuthController } from './auth.controller'; +import { OAuthController } from './controllers/oauth.controller'; + +// Strategies +import { JwtStrategy } from './strategies/jwt.strategy'; + +@Module({ + imports: [ + // Passport + PassportModule.register({ defaultStrategy: 'jwt' }), + + // JWT + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('jwt.secret'), + signOptions: { + expiresIn: (configService.get('jwt.expiresIn') || '15m') as any, + }, + }), + inject: [ConfigService], + }), + + // TypeORM entities + TypeOrmModule.forFeature([User, Session, Token, OAuthConnection]), + ], + controllers: [AuthController, OAuthController], + providers: [AuthService, OAuthService, MfaService, JwtStrategy], + exports: [AuthService, OAuthService, MfaService, JwtModule, PassportModule], +}) +export class AuthModule {} diff --git a/src/modules/auth/controllers/index.ts b/src/modules/auth/controllers/index.ts new file mode 100644 index 0000000..3c30b71 --- /dev/null +++ b/src/modules/auth/controllers/index.ts @@ -0,0 +1 @@ +export * from './oauth.controller'; diff --git a/src/modules/auth/controllers/oauth.controller.ts b/src/modules/auth/controllers/oauth.controller.ts new file mode 100644 index 0000000..274366d --- /dev/null +++ b/src/modules/auth/controllers/oauth.controller.ts @@ -0,0 +1,278 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + UseGuards, + Req, + Res, + HttpCode, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiHeader, + ApiParam, +} from '@nestjs/swagger'; +import { + OAuthService, + OAuthProfile, + OAuthTokens, + OAuthUrlResponse, + OAuthConnectionResponse, +} from '../services/oauth.service'; +import { AuthResponse } from '../services/auth.service'; +import { OAuthProvider } from '../entities/oauth-provider.enum'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { Public } from '../decorators/public.decorator'; +import { CurrentUser } from '../decorators/current-user.decorator'; +import { CurrentTenant } from '../decorators/tenant.decorator'; +import { RequestUser } from '../strategies/jwt.strategy'; + +@ApiTags('auth/oauth') +@Controller('auth/oauth') +export class OAuthController { + constructor( + private readonly oauthService: OAuthService, + private readonly configService: ConfigService, + ) {} + + @Get(':provider/url') + @Public() + @ApiOperation({ summary: 'Get OAuth authorization URL' }) + @ApiParam({ + name: 'provider', + enum: OAuthProvider, + description: 'OAuth provider', + }) + @ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' }) + @ApiResponse({ + status: 200, + description: 'Authorization URL returned', + schema: { + type: 'object', + properties: { + url: { type: 'string' }, + state: { type: 'string' }, + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid provider or missing tenant' }) + getAuthorizationUrl( + @Param('provider') provider: string, + @CurrentTenant() tenantId: string, + ): OAuthUrlResponse { + if (!tenantId) { + throw new BadRequestException('Tenant ID es requerido'); + } + + const validProvider = this.validateProvider(provider); + return this.oauthService.getAuthorizationUrl(validProvider, tenantId); + } + + /** + * Apple OAuth form_post callback handler + * Apple uses response_mode=form_post, which sends data via POST body + * This endpoint receives the data and redirects to the frontend callback page + */ + @Post('apple/form-callback') + @Public() + @ApiOperation({ summary: 'Handle Apple OAuth form_post callback' }) + @ApiResponse({ status: 302, description: 'Redirect to frontend callback' }) + async handleAppleFormCallback( + @Body() body: { code?: string; state?: string; id_token?: string; user?: string; error?: string }, + @Res() res: Response, + ): Promise { + const frontendCallbackUrl = this.configService.get('oauth.frontendCallbackUrl'); + + // Handle OAuth error from Apple + if (body.error) { + const errorUrl = `${frontendCallbackUrl}/apple?error=${encodeURIComponent(body.error)}`; + res.redirect(errorUrl); + return; + } + + // Build redirect URL with OAuth data + const params = new URLSearchParams(); + if (body.code) params.append('code', body.code); + if (body.state) params.append('state', body.state); + if (body.id_token) params.append('id_token', body.id_token); + if (body.user) params.append('user', body.user); // Only sent on first authorization + + res.redirect(`${frontendCallbackUrl}/apple?${params.toString()}`); + } + + @Post(':provider/callback') + @Public() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Handle OAuth callback' }) + @ApiParam({ + name: 'provider', + enum: OAuthProvider, + description: 'OAuth provider', + }) + @ApiHeader({ name: 'x-tenant-id', required: true, description: 'Tenant ID' }) + @ApiResponse({ status: 200, description: 'OAuth login successful' }) + @ApiResponse({ status: 400, description: 'Invalid callback data' }) + @ApiResponse({ status: 401, description: 'OAuth authentication failed' }) + async handleCallback( + @Param('provider') provider: string, + @Body() body: { + code: string; + state?: string; + profile?: OAuthProfile; + tokens?: OAuthTokens; + // Apple-specific params + id_token?: string; + user?: string; // JSON string with name/email (only on first auth) + }, + @CurrentTenant() tenantId: string, + @Req() req: Request, + ): Promise { + if (!tenantId) { + throw new BadRequestException('Tenant ID es requerido'); + } + + const validProvider = this.validateProvider(provider); + + // Handle Apple OAuth - process id_token and user data + if (validProvider === OAuthProvider.APPLE) { + return this.oauthService.handleAppleOAuth( + body.code, + body.id_token, + body.user, + tenantId, + req, + ); + } + + // For other providers, expect profile and tokens to be provided + if (!body.profile || !body.tokens) { + throw new BadRequestException( + 'Se requiere profile y tokens del proveedor OAuth', + ); + } + + return this.oauthService.handleOAuthLogin( + validProvider, + body.profile, + body.tokens, + tenantId, + req, + ); + } + + @Get('connections') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get user OAuth connections' }) + @ApiResponse({ + status: 200, + description: 'List of OAuth connections', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', format: 'uuid' }, + provider: { type: 'string', enum: Object.values(OAuthProvider) }, + provider_email: { type: 'string', nullable: true }, + provider_name: { type: 'string', nullable: true }, + provider_avatar_url: { type: 'string', nullable: true }, + created_at: { type: 'string', format: 'date-time' }, + last_used_at: { type: 'string', format: 'date-time', nullable: true }, + }, + }, + }, + }) + async getConnections( + @CurrentUser() user: RequestUser, + ): Promise { + return this.oauthService.getConnections(user.id, user.tenant_id); + } + + @Delete('connections/:provider') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Disconnect OAuth provider' }) + @ApiParam({ + name: 'provider', + enum: OAuthProvider, + description: 'OAuth provider to disconnect', + }) + @ApiResponse({ status: 200, description: 'Provider disconnected' }) + @ApiResponse({ status: 404, description: 'Connection not found' }) + @ApiResponse({ + status: 409, + description: 'Cannot disconnect only authentication method', + }) + async disconnectProvider( + @Param('provider') provider: string, + @CurrentUser() user: RequestUser, + ): Promise<{ message: string }> { + const validProvider = this.validateProvider(provider); + return this.oauthService.disconnectProvider( + user.id, + user.tenant_id, + validProvider, + ); + } + + @Post('connections/:provider/link') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiOperation({ summary: 'Link OAuth provider to existing account' }) + @ApiParam({ + name: 'provider', + enum: OAuthProvider, + description: 'OAuth provider to link', + }) + @ApiResponse({ status: 201, description: 'Provider linked successfully' }) + @ApiResponse({ status: 409, description: 'Provider already linked' }) + async linkProvider( + @Param('provider') provider: string, + @Body() body: { profile: OAuthProfile; tokens: OAuthTokens }, + @CurrentUser() user: RequestUser, + ): Promise { + const validProvider = this.validateProvider(provider); + + if (!body.profile || !body.tokens) { + throw new BadRequestException( + 'Se requiere profile y tokens del proveedor OAuth', + ); + } + + return this.oauthService.linkProvider( + user.id, + user.tenant_id, + validProvider, + body.profile, + body.tokens, + ); + } + + // ==================== Private Methods ==================== + + private validateProvider(provider: string): OAuthProvider { + const validProviders = Object.values(OAuthProvider); + const lowercaseProvider = provider.toLowerCase(); + + if (!validProviders.includes(lowercaseProvider as OAuthProvider)) { + throw new BadRequestException( + `Proveedor OAuth no válido: ${provider}. Proveedores soportados: ${validProviders.join(', ')}`, + ); + } + + return lowercaseProvider as OAuthProvider; + } +} diff --git a/src/modules/auth/decorators/current-user.decorator.ts b/src/modules/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..ea75ca4 --- /dev/null +++ b/src/modules/auth/decorators/current-user.decorator.ts @@ -0,0 +1,15 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { RequestUser } from '../strategies/jwt.strategy'; + +export const CurrentUser = createParamDecorator( + (data: keyof RequestUser | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user as RequestUser; + + if (!user) { + return null; + } + + return data ? user[data] : user; + }, +); diff --git a/src/modules/auth/decorators/index.ts b/src/modules/auth/decorators/index.ts new file mode 100644 index 0000000..2ad5e2a --- /dev/null +++ b/src/modules/auth/decorators/index.ts @@ -0,0 +1,4 @@ +export * from './public.decorator'; +export * from './current-user.decorator'; +export * from './tenant.decorator'; +export * from './roles.decorator'; diff --git a/src/modules/auth/decorators/public.decorator.ts b/src/modules/auth/decorators/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/modules/auth/decorators/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/modules/auth/decorators/roles.decorator.ts b/src/modules/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..f6c377d --- /dev/null +++ b/src/modules/auth/decorators/roles.decorator.ts @@ -0,0 +1,9 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; + +/** + * Decorator to specify required roles for a route + * Usage: @Roles('admin', 'superadmin') + */ +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/modules/auth/decorators/tenant.decorator.ts b/src/modules/auth/decorators/tenant.decorator.ts new file mode 100644 index 0000000..e1a8c8f --- /dev/null +++ b/src/modules/auth/decorators/tenant.decorator.ts @@ -0,0 +1,27 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentTenant = createParamDecorator( + (data: unknown, ctx: ExecutionContext): string => { + const request = ctx.switchToHttp().getRequest(); + + // Get tenant from user (if authenticated) + if (request.user?.tenant_id) { + return request.user.tenant_id; + } + + // Get tenant from header (for public routes) + const tenantId = request.headers['x-tenant-id']; + if (tenantId) { + return tenantId as string; + } + + // Get tenant from subdomain (optional) + const host = request.headers.host || ''; + const subdomain = host.split('.')[0]; + if (subdomain && subdomain !== 'www' && subdomain !== 'api') { + return subdomain; + } + + return ''; + }, +); diff --git a/src/modules/auth/dto/index.ts b/src/modules/auth/dto/index.ts new file mode 100644 index 0000000..095a8ad --- /dev/null +++ b/src/modules/auth/dto/index.ts @@ -0,0 +1,3 @@ +export * from './login.dto'; +export * from './register.dto'; +export * from './reset-password.dto'; diff --git a/src/modules/auth/dto/login.dto.ts b/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..34ca0d1 --- /dev/null +++ b/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,15 @@ +import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail({}, { message: 'Email inválido' }) + @IsNotEmpty({ message: 'Email es requerido' }) + email: string; + + @ApiProperty({ example: 'password123' }) + @IsString() + @IsNotEmpty({ message: 'Password es requerido' }) + @MinLength(8, { message: 'Password debe tener al menos 8 caracteres' }) + password: string; +} diff --git a/src/modules/auth/dto/mfa.dto.ts b/src/modules/auth/dto/mfa.dto.ts new file mode 100644 index 0000000..a79b7f4 --- /dev/null +++ b/src/modules/auth/dto/mfa.dto.ts @@ -0,0 +1,94 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, Length, IsOptional, Matches } from 'class-validator'; + +export class SetupMfaResponseDto { + @ApiProperty({ description: 'Base32 encoded TOTP secret' }) + secret: string; + + @ApiProperty({ description: 'QR code data URI for authenticator apps' }) + qrCodeDataUrl: string; + + @ApiProperty({ description: 'Backup codes for account recovery', type: [String] }) + backupCodes: string[]; +} + +export class VerifyMfaSetupDto { + @ApiProperty({ description: 'TOTP code from authenticator app', example: '123456' }) + @IsString() + @IsNotEmpty() + @Length(6, 6, { message: 'TOTP code must be exactly 6 digits' }) + @Matches(/^\d{6}$/, { message: 'TOTP code must contain only digits' }) + code: string; + + @ApiProperty({ description: 'TOTP secret from setup step' }) + @IsString() + @IsNotEmpty() + secret: string; +} + +export class VerifyMfaLoginDto { + @ApiProperty({ description: 'TOTP code from authenticator app', example: '123456' }) + @IsString() + @IsNotEmpty() + @Length(6, 8, { message: 'Code must be 6-8 characters' }) + code: string; + + @ApiPropertyOptional({ description: 'If true, code is a backup code' }) + @IsOptional() + isBackupCode?: boolean; +} + +export class DisableMfaDto { + @ApiProperty({ description: 'Current password for confirmation' }) + @IsString() + @IsNotEmpty() + password: string; + + @ApiProperty({ description: 'TOTP code or backup code' }) + @IsString() + @IsNotEmpty() + code: string; +} + +export class MfaStatusDto { + @ApiProperty({ description: 'Whether MFA is enabled' }) + enabled: boolean; + + @ApiProperty({ description: 'When MFA was enabled', required: false }) + enabledAt?: Date; + + @ApiProperty({ description: 'Number of remaining backup codes' }) + backupCodesRemaining: number; +} + +export class MfaChallengeResponseDto { + @ApiProperty({ description: 'Temporary challenge token' }) + challengeToken: string; + + @ApiProperty({ description: 'Type of challenge', example: 'mfa_required' }) + type: string; + + @ApiProperty({ description: 'Challenge message' }) + message: string; +} + +export class RegenerateBackupCodesDto { + @ApiProperty({ description: 'Current password for confirmation' }) + @IsString() + @IsNotEmpty() + password: string; + + @ApiProperty({ description: 'TOTP code for verification' }) + @IsString() + @IsNotEmpty() + @Length(6, 6) + code: string; +} + +export class BackupCodesResponseDto { + @ApiProperty({ description: 'New backup codes', type: [String] }) + backupCodes: string[]; + + @ApiProperty({ description: 'Message to user' }) + message: string; +} diff --git a/src/modules/auth/dto/register.dto.ts b/src/modules/auth/dto/register.dto.ts new file mode 100644 index 0000000..ed0e4d1 --- /dev/null +++ b/src/modules/auth/dto/register.dto.ts @@ -0,0 +1,41 @@ +import { + IsEmail, + IsNotEmpty, + IsString, + MinLength, + IsOptional, + Matches, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RegisterDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail({}, { message: 'Email inválido' }) + @IsNotEmpty({ message: 'Email es requerido' }) + email: string; + + @ApiProperty({ example: 'SecurePass123!' }) + @IsString() + @IsNotEmpty({ message: 'Password es requerido' }) + @MinLength(8, { message: 'Password debe tener al menos 8 caracteres' }) + @Matches( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, + { message: 'Password debe contener mayúsculas, minúsculas y números' }, + ) + password: string; + + @ApiPropertyOptional({ example: 'John' }) + @IsOptional() + @IsString() + first_name?: string; + + @ApiPropertyOptional({ example: 'Doe' }) + @IsOptional() + @IsString() + last_name?: string; + + @ApiPropertyOptional({ example: '+1234567890' }) + @IsOptional() + @IsString() + phone?: string; +} diff --git a/src/modules/auth/dto/reset-password.dto.ts b/src/modules/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..342b213 --- /dev/null +++ b/src/modules/auth/dto/reset-password.dto.ts @@ -0,0 +1,43 @@ +import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RequestPasswordResetDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail({}, { message: 'Email inválido' }) + @IsNotEmpty({ message: 'Email es requerido' }) + email: string; +} + +export class ResetPasswordDto { + @ApiProperty({ example: 'abc123token' }) + @IsString() + @IsNotEmpty({ message: 'Token es requerido' }) + token: string; + + @ApiProperty({ example: 'NewSecurePass123!' }) + @IsString() + @IsNotEmpty({ message: 'Password es requerido' }) + @MinLength(8, { message: 'Password debe tener al menos 8 caracteres' }) + @Matches( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, + { message: 'Password debe contener mayúsculas, minúsculas y números' }, + ) + password: string; +} + +export class ChangePasswordDto { + @ApiProperty({ example: 'OldPassword123!' }) + @IsString() + @IsNotEmpty({ message: 'Password actual es requerido' }) + currentPassword: string; + + @ApiProperty({ example: 'NewPassword456!' }) + @IsString() + @IsNotEmpty({ message: 'Nuevo password es requerido' }) + @MinLength(8, { message: 'Password debe tener al menos 8 caracteres' }) + @Matches( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, + { message: 'Password debe contener mayúsculas, minúsculas y números' }, + ) + newPassword: string; +} diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts new file mode 100644 index 0000000..31d77a2 --- /dev/null +++ b/src/modules/auth/entities/index.ts @@ -0,0 +1,5 @@ +export * from './user.entity'; +export * from './session.entity'; +export * from './token.entity'; +export * from './oauth-provider.enum'; +export * from './oauth-connection.entity'; diff --git a/src/modules/auth/entities/oauth-connection.entity.ts b/src/modules/auth/entities/oauth-connection.entity.ts new file mode 100644 index 0000000..1f49339 --- /dev/null +++ b/src/modules/auth/entities/oauth-connection.entity.ts @@ -0,0 +1,76 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity'; +import { OAuthProvider } from './oauth-provider.enum'; + +@Entity({ schema: 'auth', name: 'oauth_connections' }) +@Index(['tenant_id', 'user_id']) +@Index(['tenant_id', 'provider', 'provider_user_id'], { unique: true }) +export class OAuthConnection { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'uuid' }) + @Index() + user_id: string; + + @Column({ + type: 'enum', + enum: OAuthProvider, + enumName: 'auth.oauth_provider', + }) + provider: OAuthProvider; + + @Column({ type: 'varchar', length: 255 }) + provider_user_id: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + provider_email: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + provider_name: string | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + provider_avatar_url: string | null; + + @Column({ type: 'text', nullable: true }) + access_token: string | null; + + @Column({ type: 'text', nullable: true }) + refresh_token: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + token_expires_at: Date | null; + + @Column({ type: 'jsonb', nullable: true }) + scopes: string[] | null; + + @Column({ type: 'jsonb', nullable: true }) + raw_data: Record | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + last_used_at: Date | null; + + // Relations + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; +} diff --git a/src/modules/auth/entities/oauth-provider.enum.ts b/src/modules/auth/entities/oauth-provider.enum.ts new file mode 100644 index 0000000..6c6fdbd --- /dev/null +++ b/src/modules/auth/entities/oauth-provider.enum.ts @@ -0,0 +1,9 @@ +/** + * Enum for supported OAuth 2.0 providers + */ +export enum OAuthProvider { + GOOGLE = 'google', + MICROSOFT = 'microsoft', + GITHUB = 'github', + APPLE = 'apple', +} diff --git a/src/modules/auth/entities/session.entity.ts b/src/modules/auth/entities/session.entity.ts new file mode 100644 index 0000000..3151415 --- /dev/null +++ b/src/modules/auth/entities/session.entity.ts @@ -0,0 +1,49 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'auth', name: 'sessions' }) +export class Session { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + user_id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'varchar', length: 64 }) + @Index({ unique: true }) + session_token: string; + + @Column({ type: 'varchar', length: 64, nullable: true }) + refresh_token_hash: string | null; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ip_address: string | null; + + @Column({ type: 'text', nullable: true }) + user_agent: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + device_type: string | null; + + @Column({ type: 'timestamp with time zone' }) + expires_at: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + last_activity_at: Date | null; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/auth/entities/token.entity.ts b/src/modules/auth/entities/token.entity.ts new file mode 100644 index 0000000..3756f15 --- /dev/null +++ b/src/modules/auth/entities/token.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'auth', name: 'tokens' }) +export class Token { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + user_id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ + type: 'enum', + enum: ['email_verification', 'password_reset', 'invitation', 'api_key'], + enumName: 'auth.token_type', + }) + token_type: string; + + @Column({ type: 'varchar', length: 64 }) + @Index({ unique: true }) + token_hash: string; + + @Column({ type: 'timestamp with time zone' }) + expires_at: Date; + + @Column({ type: 'boolean', default: false }) + is_used: boolean; + + @Column({ type: 'timestamp with time zone', nullable: true }) + used_at: Date | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts new file mode 100644 index 0000000..b12342a --- /dev/null +++ b/src/modules/auth/entities/user.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +@Entity({ schema: 'users', name: 'users' }) +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'varchar', length: 255 }) + @Index() + email: string; + + @Column({ type: 'varchar', length: 255 }) + password_hash: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + first_name: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + last_name: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + avatar_url: string | null; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string | null; + + @Column({ + type: 'enum', + enum: ['active', 'inactive', 'suspended', 'pending_verification'], + enumName: 'users.user_status', + default: 'pending_verification', + }) + status: string; + + @Column({ type: 'boolean', default: false }) + email_verified: boolean; + + @Column({ type: 'timestamp with time zone', nullable: true }) + email_verified_at: Date | null; + + @Column({ type: 'boolean', default: false }) + mfa_enabled: boolean; + + @Column({ type: 'varchar', length: 255, nullable: true }) + mfa_secret: string | null; + + @Column({ type: 'text', array: true, nullable: true }) + mfa_backup_codes: string[] | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + mfa_enabled_at: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + last_login_at: Date | null; + + @Column({ type: 'varchar', length: 45, nullable: true }) + last_login_ip: string | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + // Computed property + get fullName(): string { + return [this.first_name, this.last_name].filter(Boolean).join(' '); + } +} diff --git a/src/modules/auth/guards/index.ts b/src/modules/auth/guards/index.ts new file mode 100644 index 0000000..e174be2 --- /dev/null +++ b/src/modules/auth/guards/index.ts @@ -0,0 +1,2 @@ +export * from './jwt-auth.guard'; +export * from './roles.guard'; diff --git a/src/modules/auth/guards/jwt-auth.guard.ts b/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..a01aef2 --- /dev/null +++ b/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,28 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { Observable } from 'rxjs'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate( + context: ExecutionContext, + ): boolean | Promise | Observable { + // Check if route is marked as public + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } +} diff --git a/src/modules/auth/guards/roles.guard.ts b/src/modules/auth/guards/roles.guard.ts new file mode 100644 index 0000000..f3846aa --- /dev/null +++ b/src/modules/auth/guards/roles.guard.ts @@ -0,0 +1,27 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) { + return true; + } + + const { user } = context.switchToHttp().getRequest(); + + if (!user || !user.role) { + return false; + } + + return requiredRoles.some((role) => user.role === role); + } +} diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts new file mode 100644 index 0000000..a56e192 --- /dev/null +++ b/src/modules/auth/index.ts @@ -0,0 +1,8 @@ +export * from './auth.module'; +export * from './auth.controller'; +export * from './services'; +export * from './entities'; +export * from './dto'; +export * from './guards'; +export * from './decorators'; +export * from './strategies'; diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts new file mode 100644 index 0000000..845aa89 --- /dev/null +++ b/src/modules/auth/services/auth.service.ts @@ -0,0 +1,482 @@ +import { + Injectable, + UnauthorizedException, + ConflictException, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; +import { User, Session, Token } from '../entities'; +import { RegisterDto, LoginDto, ChangePasswordDto } from '../dto'; + +export interface AuthResponse { + user: Partial; + accessToken: string; + refreshToken: string; +} + +export interface JwtPayload { + sub: string; + email: string; + tenant_id: string; +} + +@Injectable() +export class AuthService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Session) + private readonly sessionRepository: Repository, + @InjectRepository(Token) + private readonly tokenRepository: Repository, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + private readonly dataSource: DataSource, + ) {} + + /** + * Register new user with tenant context + */ + async register( + dto: RegisterDto, + tenantId: string, + ip?: string, + userAgent?: string, + ): Promise { + // Check if email already exists for this tenant + const existing = await this.userRepository.findOne({ + where: { email: dto.email, tenant_id: tenantId }, + }); + + if (existing) { + throw new ConflictException('Email ya registrado en esta organización'); + } + + // Hash password + const passwordHash = await bcrypt.hash(dto.password, 12); + + // Create user + const user = this.userRepository.create({ + tenant_id: tenantId, + email: dto.email, + password_hash: passwordHash, + first_name: dto.first_name || null, + last_name: dto.last_name || null, + phone: dto.phone || null, + status: 'pending_verification', + email_verified: false, + }); + + await this.userRepository.save(user); + + // Generate tokens + const tokens = await this.generateTokens(user, ip, userAgent); + + // Create email verification token + await this.createVerificationToken(user); + + return { + user: this.sanitizeUser(user), + ...tokens, + }; + } + + /** + * Login user + */ + async login( + dto: LoginDto, + tenantId: string, + ip?: string, + userAgent?: string, + ): Promise { + // Find user + const user = await this.userRepository.findOne({ + where: { email: dto.email, tenant_id: tenantId }, + }); + + if (!user) { + throw new UnauthorizedException('Credenciales inválidas'); + } + + // Validate password + const isValid = await bcrypt.compare(dto.password, user.password_hash); + if (!isValid) { + throw new UnauthorizedException('Credenciales inválidas'); + } + + // Check user status + if (user.status === 'suspended') { + throw new UnauthorizedException('Cuenta suspendida'); + } + + if (user.status === 'inactive') { + throw new UnauthorizedException('Cuenta inactiva'); + } + + // Update last login + user.last_login_at = new Date(); + user.last_login_ip = ip || null; + await this.userRepository.save(user); + + // Generate tokens + const tokens = await this.generateTokens(user, ip, userAgent); + + return { + user: this.sanitizeUser(user), + ...tokens, + }; + } + + /** + * Logout user - invalidate session + */ + async logout(userId: string, sessionToken: string): Promise { + await this.sessionRepository.update( + { user_id: userId, session_token: sessionToken }, + { is_active: false }, + ); + } + + /** + * Logout all sessions for user + */ + async logoutAll(userId: string): Promise { + await this.sessionRepository.update( + { user_id: userId }, + { is_active: false }, + ); + } + + /** + * Refresh access token + */ + async refreshToken( + refreshToken: string, + ip?: string, + userAgent?: string, + ): Promise<{ accessToken: string; refreshToken: string }> { + try { + // Verify refresh token + const payload = this.jwtService.verify(refreshToken, { + secret: this.configService.get('jwt.secret'), + }); + + // Find user + const user = await this.userRepository.findOne({ + where: { id: payload.sub }, + }); + + if (!user) { + throw new UnauthorizedException('Usuario no encontrado'); + } + + // Find session with this refresh token + const refreshTokenHash = this.hashToken(refreshToken); + const session = await this.sessionRepository.findOne({ + where: { + user_id: user.id, + refresh_token_hash: refreshTokenHash, + is_active: true, + }, + }); + + if (!session) { + throw new UnauthorizedException('Sesión inválida'); + } + + // Check if session expired + if (new Date() > session.expires_at) { + await this.sessionRepository.update({ id: session.id }, { is_active: false }); + throw new UnauthorizedException('Sesión expirada'); + } + + // Generate new tokens + const tokens = await this.generateTokens(user, ip, userAgent, session.id); + + return tokens; + } catch (error) { + if (error instanceof UnauthorizedException) { + throw error; + } + throw new UnauthorizedException('Token inválido'); + } + } + + /** + * Change password for authenticated user + */ + async changePassword( + userId: string, + dto: ChangePasswordDto, + ): Promise<{ message: string }> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('Usuario no encontrado'); + } + + // Validate current password + const isValid = await bcrypt.compare(dto.currentPassword, user.password_hash); + if (!isValid) { + throw new BadRequestException('Password actual incorrecto'); + } + + // Validate new password is different + if (dto.currentPassword === dto.newPassword) { + throw new BadRequestException('El nuevo password debe ser diferente al actual'); + } + + // Hash new password + const newHash = await bcrypt.hash(dto.newPassword, 12); + + // Update password + await this.userRepository.update({ id: userId }, { password_hash: newHash }); + + // Optionally invalidate all sessions except current + // await this.logoutAll(userId); + + return { message: 'Password actualizado correctamente' }; + } + + /** + * Request password reset + */ + async requestPasswordReset(email: string, tenantId: string): Promise<{ message: string }> { + const user = await this.userRepository.findOne({ + where: { email, tenant_id: tenantId }, + }); + + // Always return success to prevent email enumeration + if (!user) { + return { message: 'Si el email existe, recibirás instrucciones para restablecer tu password' }; + } + + // Create reset token + const token = crypto.randomBytes(32).toString('hex'); + const tokenHash = this.hashToken(token); + + await this.tokenRepository.save({ + user_id: user.id, + tenant_id: tenantId, + token_type: 'password_reset', + token_hash: tokenHash, + expires_at: new Date(Date.now() + 60 * 60 * 1000), // 1 hour + }); + + // TODO: Send email with reset link containing token + + return { message: 'Si el email existe, recibirás instrucciones para restablecer tu password' }; + } + + /** + * Reset password with token + */ + async resetPassword( + token: string, + newPassword: string, + tenantId: string, + ): Promise<{ message: string }> { + const tokenHash = this.hashToken(token); + + const tokenRecord = await this.tokenRepository.findOne({ + where: { + token_hash: tokenHash, + tenant_id: tenantId, + token_type: 'password_reset', + is_used: false, + }, + }); + + if (!tokenRecord) { + throw new BadRequestException('Token inválido o expirado'); + } + + if (new Date() > tokenRecord.expires_at) { + throw new BadRequestException('Token expirado'); + } + + // Hash new password + const passwordHash = await bcrypt.hash(newPassword, 12); + + // Update password + await this.userRepository.update( + { id: tokenRecord.user_id }, + { password_hash: passwordHash }, + ); + + // Mark token as used + await this.tokenRepository.update( + { id: tokenRecord.id }, + { is_used: true, used_at: new Date() }, + ); + + // Invalidate all sessions + await this.logoutAll(tokenRecord.user_id); + + return { message: 'Password restablecido correctamente' }; + } + + /** + * Verify email with token + */ + async verifyEmail(token: string, tenantId: string): Promise<{ message: string }> { + const tokenHash = this.hashToken(token); + + const tokenRecord = await this.tokenRepository.findOne({ + where: { + token_hash: tokenHash, + tenant_id: tenantId, + token_type: 'email_verification', + is_used: false, + }, + }); + + if (!tokenRecord) { + throw new BadRequestException('Token inválido o expirado'); + } + + if (new Date() > tokenRecord.expires_at) { + throw new BadRequestException('Token expirado'); + } + + // Update user + await this.userRepository.update( + { id: tokenRecord.user_id }, + { + email_verified: true, + email_verified_at: new Date(), + status: 'active', + }, + ); + + // Mark token as used + await this.tokenRepository.update( + { id: tokenRecord.id }, + { is_used: true, used_at: new Date() }, + ); + + return { message: 'Email verificado correctamente' }; + } + + /** + * Validate user by ID (for JWT strategy) + */ + async validateUser(userId: string): Promise { + return this.userRepository.findOne({ + where: { id: userId, status: 'active' }, + }); + } + + /** + * Get current user profile + */ + async getProfile(userId: string): Promise> { + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundException('Usuario no encontrado'); + } + + return this.sanitizeUser(user); + } + + // ==================== Private Methods ==================== + + private async generateTokens( + user: User, + ip?: string, + userAgent?: string, + existingSessionId?: string, + ): Promise<{ accessToken: string; refreshToken: string }> { + const payload: JwtPayload = { + sub: user.id, + email: user.email, + tenant_id: user.tenant_id, + }; + + const accessTokenExpiry = this.configService.get('jwt.expiresIn') || '15m'; + const refreshTokenExpiry = this.configService.get('jwt.refreshExpiresIn') || '7d'; + + const accessToken = this.jwtService.sign(payload, { + expiresIn: accessTokenExpiry as any, + }); + + const refreshToken = this.jwtService.sign(payload, { + expiresIn: refreshTokenExpiry as any, + }); + + const sessionToken = crypto.randomBytes(32).toString('hex'); + const refreshTokenHash = this.hashToken(refreshToken); + + if (existingSessionId) { + // Update existing session + await this.sessionRepository.update( + { id: existingSessionId }, + { + refresh_token_hash: refreshTokenHash, + last_activity_at: new Date(), + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + ); + } else { + // Create new session + await this.sessionRepository.save({ + user_id: user.id, + tenant_id: user.tenant_id, + session_token: sessionToken, + refresh_token_hash: refreshTokenHash, + ip_address: ip || null, + user_agent: userAgent || null, + device_type: this.detectDeviceType(userAgent), + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + last_activity_at: new Date(), + is_active: true, + }); + } + + return { accessToken, refreshToken }; + } + + private async createVerificationToken(user: User): Promise { + const token = crypto.randomBytes(32).toString('hex'); + const tokenHash = this.hashToken(token); + + await this.tokenRepository.save({ + user_id: user.id, + tenant_id: user.tenant_id, + token_type: 'email_verification', + token_hash: tokenHash, + expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + }); + + return token; + } + + private hashToken(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + private sanitizeUser(user: User): Partial { + const { password_hash, ...sanitized } = user; + return sanitized; + } + + private detectDeviceType(userAgent?: string): string { + if (!userAgent) return 'unknown'; + const ua = userAgent.toLowerCase(); + if (/mobile|android|iphone|ipod/.test(ua)) return 'mobile'; + if (/tablet|ipad/.test(ua)) return 'tablet'; + return 'desktop'; + } +} diff --git a/src/modules/auth/services/index.ts b/src/modules/auth/services/index.ts new file mode 100644 index 0000000..45b1174 --- /dev/null +++ b/src/modules/auth/services/index.ts @@ -0,0 +1,2 @@ +export * from './auth.service'; +export * from './oauth.service'; diff --git a/src/modules/auth/services/mfa.service.ts b/src/modules/auth/services/mfa.service.ts new file mode 100644 index 0000000..f74c22f --- /dev/null +++ b/src/modules/auth/services/mfa.service.ts @@ -0,0 +1,351 @@ +import { + Injectable, + BadRequestException, + UnauthorizedException, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import * as speakeasy from 'speakeasy'; +import * as QRCode from 'qrcode'; +import * as bcrypt from 'bcrypt'; +import * as crypto from 'crypto'; +import { User } from '../entities'; +import { + SetupMfaResponseDto, + VerifyMfaSetupDto, + DisableMfaDto, + MfaStatusDto, + BackupCodesResponseDto, +} from '../dto/mfa.dto'; + +@Injectable() +export class MfaService { + private readonly appName: string; + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly configService: ConfigService, + ) { + this.appName = this.configService.get('app.name') || 'Template SaaS'; + } + + /** + * Initialize MFA setup - generate secret and QR code + */ + async setupMfa(userId: string): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.mfa_enabled) { + throw new BadRequestException('MFA is already enabled'); + } + + // Generate TOTP secret + const secret = speakeasy.generateSecret({ + name: `${this.appName} (${user.email})`, + length: 20, + }); + + // Generate QR code + const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url!); + + // Generate backup codes + const backupCodes = this.generateBackupCodes(); + + return { + secret: secret.base32, + qrCodeDataUrl, + backupCodes, + }; + } + + /** + * Verify MFA setup and enable + */ + async verifyMfaSetup( + userId: string, + dto: VerifyMfaSetupDto, + ): Promise<{ success: boolean; message: string }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.mfa_enabled) { + throw new BadRequestException('MFA is already enabled'); + } + + // Verify the TOTP code + const isValid = speakeasy.totp.verify({ + secret: dto.secret, + encoding: 'base32', + token: dto.code, + window: 1, // Allow 1 step (30 seconds) tolerance + }); + + if (!isValid) { + throw new BadRequestException('Invalid verification code'); + } + + // Generate and hash backup codes + const backupCodes = this.generateBackupCodes(); + const hashedBackupCodes = await Promise.all( + backupCodes.map((code) => bcrypt.hash(code, 10)), + ); + + // Enable MFA and store secret + await this.userRepository.update( + { id: userId }, + { + mfa_enabled: true, + mfa_secret: this.encryptSecret(dto.secret), + mfa_backup_codes: hashedBackupCodes, + mfa_enabled_at: new Date(), + }, + ); + + return { + success: true, + message: 'MFA enabled successfully. Please save your backup codes.', + }; + } + + /** + * Verify TOTP code during login + */ + async verifyMfaCode( + userId: string, + code: string, + isBackupCode: boolean = false, + ): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.mfa_enabled || !user.mfa_secret) { + throw new BadRequestException('MFA is not enabled for this user'); + } + + if (isBackupCode) { + return this.verifyBackupCode(user, code); + } + + // Decrypt secret and verify TOTP + const secret = this.decryptSecret(user.mfa_secret); + + const isValid = speakeasy.totp.verify({ + secret, + encoding: 'base32', + token: code, + window: 1, + }); + + return isValid; + } + + /** + * Disable MFA for user + */ + async disableMfa( + userId: string, + dto: DisableMfaDto, + ): Promise<{ success: boolean; message: string }> { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.mfa_enabled) { + throw new BadRequestException('MFA is not enabled'); + } + + // Verify password + const isPasswordValid = await bcrypt.compare(dto.password, user.password_hash); + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid password'); + } + + // Verify MFA code + const isMfaValid = await this.verifyMfaCode(userId, dto.code, dto.code.length > 6); + if (!isMfaValid) { + throw new BadRequestException('Invalid verification code'); + } + + // Disable MFA + await this.userRepository.update( + { id: userId }, + { + mfa_enabled: false, + mfa_secret: null, + mfa_backup_codes: null, + mfa_enabled_at: null, + }, + ); + + return { + success: true, + message: 'MFA disabled successfully', + }; + } + + /** + * Get MFA status for user + */ + async getMfaStatus(userId: string): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + const backupCodesRemaining = user.mfa_backup_codes?.length || 0; + + return { + enabled: user.mfa_enabled || false, + enabledAt: user.mfa_enabled_at || undefined, + backupCodesRemaining, + }; + } + + /** + * Regenerate backup codes + */ + async regenerateBackupCodes( + userId: string, + password: string, + code: string, + ): Promise { + const user = await this.userRepository.findOne({ where: { id: userId } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (!user.mfa_enabled) { + throw new BadRequestException('MFA is not enabled'); + } + + // Verify password + const isPasswordValid = await bcrypt.compare(password, user.password_hash); + if (!isPasswordValid) { + throw new UnauthorizedException('Invalid password'); + } + + // Verify MFA code + const isMfaValid = await this.verifyMfaCode(userId, code); + if (!isMfaValid) { + throw new BadRequestException('Invalid verification code'); + } + + // Generate new backup codes + const backupCodes = this.generateBackupCodes(); + const hashedBackupCodes = await Promise.all( + backupCodes.map((code) => bcrypt.hash(code, 10)), + ); + + // Update user + await this.userRepository.update( + { id: userId }, + { mfa_backup_codes: hashedBackupCodes }, + ); + + return { + backupCodes, + message: 'New backup codes generated. Please save them securely.', + }; + } + + // ==================== Private Methods ==================== + + /** + * Generate 10 random backup codes + */ + private generateBackupCodes(): string[] { + const codes: string[] = []; + for (let i = 0; i < 10; i++) { + const code = crypto.randomBytes(4).toString('hex').toUpperCase(); + // Format as XXXX-XXXX for readability + codes.push(`${code.slice(0, 4)}-${code.slice(4)}`); + } + return codes; + } + + /** + * Verify a backup code + */ + private async verifyBackupCode(user: User, code: string): Promise { + if (!user.mfa_backup_codes || user.mfa_backup_codes.length === 0) { + return false; + } + + // Normalize code (remove dashes, uppercase) + const normalizedCode = code.replace(/-/g, '').toUpperCase(); + const formattedCode = `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4)}`; + + // Check each hashed backup code + for (let i = 0; i < user.mfa_backup_codes.length; i++) { + const isMatch = await bcrypt.compare(formattedCode, user.mfa_backup_codes[i]); + if (isMatch) { + // Remove the used backup code + const updatedCodes = [...user.mfa_backup_codes]; + updatedCodes.splice(i, 1); + await this.userRepository.update( + { id: user.id }, + { mfa_backup_codes: updatedCodes }, + ); + return true; + } + } + + return false; + } + + /** + * Encrypt MFA secret for storage + */ + private encryptSecret(secret: string): string { + const encryptionKey = this.configService.get('mfa.encryptionKey') || + this.configService.get('jwt.secret') || + 'default-encryption-key-change-me'; + + // Use first 32 bytes of key for AES-256 + const key = crypto.createHash('sha256').update(encryptionKey).digest(); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + + let encrypted = cipher.update(secret, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + // Return IV + encrypted data + return iv.toString('hex') + ':' + encrypted; + } + + /** + * Decrypt MFA secret from storage + */ + private decryptSecret(encryptedSecret: string): string { + const encryptionKey = this.configService.get('mfa.encryptionKey') || + this.configService.get('jwt.secret') || + 'default-encryption-key-change-me'; + + const key = crypto.createHash('sha256').update(encryptionKey).digest(); + const [ivHex, encrypted] = encryptedSecret.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; + } +} diff --git a/src/modules/auth/services/oauth.service.ts b/src/modules/auth/services/oauth.service.ts new file mode 100644 index 0000000..a7d3cae --- /dev/null +++ b/src/modules/auth/services/oauth.service.ts @@ -0,0 +1,521 @@ +import { + Injectable, + ConflictException, + NotFoundException, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; +import * as jwt from 'jsonwebtoken'; +import { OAuthConnection } from '../entities/oauth-connection.entity'; +import { User } from '../entities/user.entity'; +import { OAuthProvider } from '../entities/oauth-provider.enum'; +import { AuthService, AuthResponse } from './auth.service'; + +/** + * OAuth profile data from provider + */ +export interface OAuthProfile { + id: string; + email: string; + name?: string; + avatar_url?: string; + raw_data?: Record; +} + +/** + * OAuth tokens from provider + */ +export interface OAuthTokens { + access_token: string; + refresh_token?: string; + expires_at?: Date; + scopes?: string[]; +} + +/** + * OAuth authorization URL response + */ +export interface OAuthUrlResponse { + url: string; + state: string; +} + +/** + * OAuth connection response + */ +export interface OAuthConnectionResponse { + id: string; + provider: OAuthProvider; + provider_email: string | null; + provider_name: string | null; + provider_avatar_url: string | null; + created_at: Date; + last_used_at: Date | null; +} + +@Injectable() +export class OAuthService { + constructor( + @InjectRepository(OAuthConnection) + private readonly oauthConnectionRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly authService: AuthService, + private readonly configService: ConfigService, + private readonly dataSource: DataSource, + ) {} + + /** + * Get OAuth authorization URL for a provider + */ + getAuthorizationUrl( + provider: OAuthProvider, + tenantId: string, + redirectUri?: string, + ): OAuthUrlResponse { + const state = this.generateState(tenantId); + const baseRedirectUri = redirectUri || this.getDefaultRedirectUri(provider); + + let url: string; + + switch (provider) { + case OAuthProvider.GOOGLE: + url = this.buildGoogleAuthUrl(state, baseRedirectUri); + break; + case OAuthProvider.MICROSOFT: + url = this.buildMicrosoftAuthUrl(state, baseRedirectUri); + break; + case OAuthProvider.GITHUB: + url = this.buildGithubAuthUrl(state, baseRedirectUri); + break; + case OAuthProvider.APPLE: + url = this.buildAppleAuthUrl(state, baseRedirectUri); + break; + default: + throw new BadRequestException(`Proveedor OAuth no soportado: ${provider}`); + } + + return { url, state }; + } + + /** + * Handle OAuth login/registration callback + */ + async handleOAuthLogin( + provider: OAuthProvider, + profile: OAuthProfile, + tokens: OAuthTokens, + tenantId: string, + req: Request, + ): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Check if OAuth connection already exists + let connection = await this.oauthConnectionRepository.findOne({ + where: { + tenant_id: tenantId, + provider, + provider_user_id: profile.id, + }, + relations: ['user'], + }); + + let user: User | null = null; + + if (connection) { + // Existing connection - update tokens and last used + user = connection.user; + + connection.access_token = tokens.access_token; + connection.refresh_token = tokens.refresh_token || connection.refresh_token; + connection.token_expires_at = tokens.expires_at || null; + connection.scopes = tokens.scopes || null; + connection.provider_email = profile.email; + connection.provider_name = profile.name || null; + connection.provider_avatar_url = profile.avatar_url || null; + connection.raw_data = profile.raw_data || null; + connection.last_used_at = new Date(); + + await queryRunner.manager.save(connection); + } else { + // Check if user with this email already exists + user = await this.userRepository.findOne({ + where: { + email: profile.email, + tenant_id: tenantId, + }, + }); + + if (!user) { + // Create new user + user = this.userRepository.create({ + tenant_id: tenantId, + email: profile.email, + password_hash: '', // OAuth users don't have password + first_name: profile.name?.split(' ')[0] || null, + last_name: profile.name?.split(' ').slice(1).join(' ') || null, + avatar_url: profile.avatar_url || null, + status: 'active', + email_verified: true, // OAuth emails are pre-verified + email_verified_at: new Date(), + }); + + await queryRunner.manager.save(user); + } + + // Create new OAuth connection + connection = this.oauthConnectionRepository.create({ + tenant_id: tenantId, + user_id: user.id, + provider, + provider_user_id: profile.id, + provider_email: profile.email, + provider_name: profile.name || null, + provider_avatar_url: profile.avatar_url || null, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || null, + token_expires_at: tokens.expires_at || null, + scopes: tokens.scopes || null, + raw_data: profile.raw_data || null, + last_used_at: new Date(), + }); + + await queryRunner.manager.save(connection); + } + + // Update last login + if (!user) { + throw new BadRequestException('Failed to find or create user'); + } + user.last_login_at = new Date(); + user.last_login_ip = req.ip || null; + await queryRunner.manager.save(user); + + await queryRunner.commitTransaction(); + + // Generate auth tokens using AuthService + const authTokens = await this.generateAuthTokens( + user, + req.ip, + req.headers['user-agent'] as string, + ); + + return { + user: this.sanitizeUser(user), + ...authTokens, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + /** + * Get all OAuth connections for a user + */ + async getConnections( + userId: string, + tenantId: string, + ): Promise { + const connections = await this.oauthConnectionRepository.find({ + where: { + user_id: userId, + tenant_id: tenantId, + }, + order: { created_at: 'DESC' }, + }); + + return connections.map((conn) => ({ + id: conn.id, + provider: conn.provider, + provider_email: conn.provider_email, + provider_name: conn.provider_name, + provider_avatar_url: conn.provider_avatar_url, + created_at: conn.created_at, + last_used_at: conn.last_used_at, + })); + } + + /** + * Disconnect an OAuth provider from user account + */ + async disconnectProvider( + userId: string, + tenantId: string, + provider: OAuthProvider, + ): Promise<{ message: string }> { + // Find the connection + const connection = await this.oauthConnectionRepository.findOne({ + where: { + user_id: userId, + tenant_id: tenantId, + provider, + }, + }); + + if (!connection) { + throw new NotFoundException(`No existe conexión con ${provider}`); + } + + // Check if user has password or other OAuth connections + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + const otherConnections = await this.oauthConnectionRepository.count({ + where: { + user_id: userId, + tenant_id: tenantId, + }, + }); + + // Prevent disconnection if it's the only auth method + if (!user?.password_hash && otherConnections <= 1) { + throw new ConflictException( + 'No puedes desconectar el único método de autenticación. Agrega un password primero.', + ); + } + + // Delete the connection + await this.oauthConnectionRepository.delete({ id: connection.id }); + + return { message: `Proveedor ${provider} desconectado correctamente` }; + } + + /** + * Link OAuth provider to existing user account + */ + async linkProvider( + userId: string, + tenantId: string, + provider: OAuthProvider, + profile: OAuthProfile, + tokens: OAuthTokens, + ): Promise { + // Check if connection already exists for this provider + const existingConnection = await this.oauthConnectionRepository.findOne({ + where: { + tenant_id: tenantId, + provider, + provider_user_id: profile.id, + }, + }); + + if (existingConnection) { + if (existingConnection.user_id === userId) { + throw new ConflictException(`Ya tienes vinculado ${provider}`); + } else { + throw new ConflictException( + `Esta cuenta de ${provider} ya está vinculada a otro usuario`, + ); + } + } + + // Create new connection + const connection = this.oauthConnectionRepository.create({ + tenant_id: tenantId, + user_id: userId, + provider, + provider_user_id: profile.id, + provider_email: profile.email, + provider_name: profile.name || null, + provider_avatar_url: profile.avatar_url || null, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || null, + token_expires_at: tokens.expires_at || null, + scopes: tokens.scopes || null, + raw_data: profile.raw_data || null, + last_used_at: new Date(), + }); + + await this.oauthConnectionRepository.save(connection); + + return { + id: connection.id, + provider: connection.provider, + provider_email: connection.provider_email, + provider_name: connection.provider_name, + provider_avatar_url: connection.provider_avatar_url, + created_at: connection.created_at, + last_used_at: connection.last_used_at, + }; + } + + /** + * Handle Apple OAuth callback + * Apple OAuth is different from other providers: + * - Uses id_token for user info (JWT) + * - User data (name) is only sent on first authorization + * - Requires generating a client_secret JWT for token exchange + */ + async handleAppleOAuth( + code: string, + idToken?: string, + userData?: string, + tenantId?: string, + req?: Request, + ): Promise { + if (!code) { + throw new BadRequestException('Authorization code is required'); + } + + if (!tenantId) { + throw new BadRequestException('Tenant ID is required'); + } + + // Parse user info from id_token + let email: string; + let appleUserId: string; + let name: string | undefined; + + if (idToken) { + // Decode the id_token (don't verify signature in this simplified implementation) + // In production, you should verify with Apple's public keys + const decoded = jwt.decode(idToken) as { + sub?: string; + email?: string; + email_verified?: string; + }; + + if (!decoded || !decoded.sub || !decoded.email) { + throw new UnauthorizedException('Invalid id_token from Apple'); + } + + appleUserId = decoded.sub; + email = decoded.email; + } else { + throw new BadRequestException('id_token is required for Apple OAuth'); + } + + // Parse user data (only sent on first authorization) + if (userData) { + try { + const parsedUser = JSON.parse(userData); + if (parsedUser.name) { + const firstName = parsedUser.name.firstName || ''; + const lastName = parsedUser.name.lastName || ''; + name = [firstName, lastName].filter(Boolean).join(' '); + } + } catch { + // Ignore parsing errors for user data + } + } + + // Create profile and tokens for handleOAuthLogin + const profile: OAuthProfile = { + id: appleUserId, + email, + name, + raw_data: { idToken, userData }, + }; + + // For simplified implementation, we use the code as access_token placeholder + // In production, you should exchange the code for tokens using Apple's token endpoint + const tokens: OAuthTokens = { + access_token: code, // Placeholder - should exchange code for actual tokens + }; + + return this.handleOAuthLogin( + OAuthProvider.APPLE, + profile, + tokens, + tenantId, + req!, + ); + } + + // ==================== Private Methods ==================== + + private generateState(tenantId: string): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 15); + return Buffer.from(JSON.stringify({ tenantId, timestamp, random })).toString( + 'base64', + ); + } + + private getDefaultRedirectUri(provider: OAuthProvider): string { + const baseUrl = this.configService.get('app.baseUrl') || 'http://localhost:3000'; + // Apple uses form_post response mode, so we need a special endpoint + if (provider === OAuthProvider.APPLE) { + return `${baseUrl}/api/auth/oauth/apple/form-callback`; + } + return `${baseUrl}/api/auth/oauth/${provider}/callback`; + } + + private buildGoogleAuthUrl(state: string, redirectUri: string): string { + const clientId = this.configService.get('oauth.google.clientId'); + const params = new URLSearchParams({ + client_id: clientId || '', + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile', + state, + access_type: 'offline', + prompt: 'consent', + }); + return `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + } + + private buildMicrosoftAuthUrl(state: string, redirectUri: string): string { + const clientId = this.configService.get('oauth.microsoft.clientId'); + const tenantId = this.configService.get('oauth.microsoft.tenantId') || 'common'; + const params = new URLSearchParams({ + client_id: clientId || '', + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid email profile offline_access', + state, + response_mode: 'query', + }); + return `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`; + } + + private buildGithubAuthUrl(state: string, redirectUri: string): string { + const clientId = this.configService.get('oauth.github.clientId'); + const params = new URLSearchParams({ + client_id: clientId || '', + redirect_uri: redirectUri, + scope: 'read:user user:email', + state, + }); + return `https://github.com/login/oauth/authorize?${params.toString()}`; + } + + private buildAppleAuthUrl(state: string, redirectUri: string): string { + const clientId = this.configService.get('oauth.apple.clientId'); + const params = new URLSearchParams({ + client_id: clientId || '', + redirect_uri: redirectUri, + response_type: 'code id_token', + scope: 'name email', + state, + response_mode: 'form_post', + }); + return `https://appleid.apple.com/auth/authorize?${params.toString()}`; + } + + private async generateAuthTokens( + user: User, + ip?: string, + userAgent?: string, + ): Promise<{ accessToken: string; refreshToken: string }> { + // Use reflection to access private method via AuthService + // Note: In production, consider exposing this method properly + return (this.authService as any).generateTokens(user, ip, userAgent); + } + + private sanitizeUser(user: User): Partial { + const { password_hash, ...sanitized } = user; + return sanitized; + } +} diff --git a/src/modules/auth/strategies/index.ts b/src/modules/auth/strategies/index.ts new file mode 100644 index 0000000..e7586f7 --- /dev/null +++ b/src/modules/auth/strategies/index.ts @@ -0,0 +1 @@ +export * from './jwt.strategy'; diff --git a/src/modules/auth/strategies/jwt.strategy.ts b/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..32a2810 --- /dev/null +++ b/src/modules/auth/strategies/jwt.strategy.ts @@ -0,0 +1,39 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '@nestjs/config'; +import { AuthService, JwtPayload } from '../services/auth.service'; + +export interface RequestUser { + id: string; + email: string; + tenant_id: string; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('jwt.secret') || 'default-secret-change-in-production', + }); + } + + async validate(payload: JwtPayload): Promise { + const user = await this.authService.validateUser(payload.sub); + + if (!user) { + throw new UnauthorizedException('Usuario no encontrado o inactivo'); + } + + return { + id: user.id, + email: user.email, + tenant_id: user.tenant_id, + }; + } +} diff --git a/src/modules/billing/__tests__/billing-edge-cases.spec.ts b/src/modules/billing/__tests__/billing-edge-cases.spec.ts new file mode 100644 index 0000000..e30cc2c --- /dev/null +++ b/src/modules/billing/__tests__/billing-edge-cases.spec.ts @@ -0,0 +1,903 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { BillingService } from '../services/billing.service'; +import { Subscription, SubscriptionStatus } from '../entities/subscription.entity'; +import { Invoice, InvoiceStatus } from '../entities/invoice.entity'; +import { PaymentMethod, PaymentMethodType } from '../entities/payment-method.entity'; + +/** + * Edge case tests for BillingService + * These tests cover scenarios not covered in the main billing.service.spec.ts + */ +describe('BillingService - Edge Cases', () => { + let service: BillingService; + let subscriptionRepo: jest.Mocked>; + let invoiceRepo: jest.Mocked>; + let paymentMethodRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockPlanId = '550e8400-e29b-41d4-a716-446655440002'; + + // Helper to create partial mock objects with proper typing + const createMockSubscription = (overrides: Partial = {}): Subscription => ({ + id: 'sub-001', + tenant_id: mockTenantId, + plan_id: mockPlanId, + plan: null, + status: SubscriptionStatus.ACTIVE, + current_period_start: new Date('2026-01-01'), + current_period_end: new Date('2026-02-01'), + trial_end: null, + cancelled_at: null, + external_subscription_id: '', + payment_provider: 'stripe', + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }); + + const createMockInvoice = (overrides: Partial = {}): Invoice => ({ + id: 'inv-001', + tenant_id: mockTenantId, + subscription_id: 'sub-001', + invoice_number: 'INV-202601-000001', + status: InvoiceStatus.OPEN, + currency: 'MXN', + subtotal: 100, + tax: 16, + total: 116, + due_date: new Date('2026-01-15'), + paid_at: null as unknown as Date, + external_invoice_id: '', + pdf_url: null, + line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }], + billing_details: null as unknown as { name?: string; email?: string; address?: string; tax_id?: string }, + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }); + + const createMockPaymentMethod = (overrides: Partial = {}): PaymentMethod => ({ + id: 'pm-001', + tenant_id: mockTenantId, + type: PaymentMethodType.CARD, + card_last_four: '4242', + card_brand: 'visa', + card_exp_month: 12, + card_exp_year: 2026, + external_payment_method_id: '', + payment_provider: 'stripe', + is_default: true, + is_active: true, + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + ...overrides, + }); + + beforeEach(async () => { + const mockSubscriptionRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + }; + + const mockInvoiceRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + findAndCount: jest.fn(), + count: jest.fn(), + }; + + const mockPaymentMethodRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BillingService, + { provide: getRepositoryToken(Subscription), useValue: mockSubscriptionRepo }, + { provide: getRepositoryToken(Invoice), useValue: mockInvoiceRepo }, + { provide: getRepositoryToken(PaymentMethod), useValue: mockPaymentMethodRepo }, + ], + }).compile(); + + service = module.get(BillingService); + subscriptionRepo = module.get(getRepositoryToken(Subscription)); + invoiceRepo = module.get(getRepositoryToken(Invoice)); + paymentMethodRepo = module.get(getRepositoryToken(PaymentMethod)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== Subscription Edge Cases ==================== + + describe('createSubscription - Edge Cases', () => { + it('should create subscription with far future trial end date', async () => { + const farFutureTrial = new Date(); + farFutureTrial.setFullYear(farFutureTrial.getFullYear() + 1); + + const trialSub = createMockSubscription({ + status: SubscriptionStatus.TRIAL, + trial_end: farFutureTrial, + }); + + subscriptionRepo.create.mockReturnValue(trialSub); + subscriptionRepo.save.mockResolvedValue(trialSub); + + const dto = { + tenant_id: mockTenantId, + plan_id: mockPlanId, + payment_provider: 'stripe', + trial_end: farFutureTrial.toISOString(), + }; + + const result = await service.createSubscription(dto); + + expect(result.status).toBe(SubscriptionStatus.TRIAL); + expect(result.trial_end).toEqual(farFutureTrial); + }); + + it('should create subscription without optional payment_provider', async () => { + const subscription = createMockSubscription({ + payment_provider: undefined, + }); + + subscriptionRepo.create.mockReturnValue(subscription); + subscriptionRepo.save.mockResolvedValue(subscription); + + const dto = { + tenant_id: mockTenantId, + plan_id: mockPlanId, + }; + + await service.createSubscription(dto as any); + + expect(subscriptionRepo.create).toHaveBeenCalled(); + }); + + it('should set period end one month from creation', async () => { + const now = new Date('2026-01-15T10:00:00Z'); + jest.useFakeTimers(); + jest.setSystemTime(now); + + const subscription = createMockSubscription({ + current_period_start: now, + current_period_end: new Date('2026-02-15T10:00:00Z'), + }); + + subscriptionRepo.create.mockReturnValue(subscription); + subscriptionRepo.save.mockResolvedValue(subscription); + + await service.createSubscription({ + tenant_id: mockTenantId, + plan_id: mockPlanId, + payment_provider: 'stripe', + }); + + expect(subscriptionRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + current_period_start: now, + }), + ); + + jest.useRealTimers(); + }); + }); + + describe('cancelSubscription - Edge Cases', () => { + it('should cancel with custom reason in metadata', async () => { + const existingSub = createMockSubscription({ + metadata: { existing_key: 'existing_value' }, + }); + + const cancelledSub = createMockSubscription({ + cancelled_at: new Date(), + metadata: { + existing_key: 'existing_value', + cancellation_reason: 'Too expensive', + }, + }); + + subscriptionRepo.findOne.mockResolvedValue(existingSub); + subscriptionRepo.save.mockResolvedValue(cancelledSub); + + const result = await service.cancelSubscription(mockTenantId, { + immediately: false, + reason: 'Too expensive', + }); + + expect(result.metadata.cancellation_reason).toBe('Too expensive'); + expect(result.metadata.existing_key).toBe('existing_value'); + }); + + it('should cancel trial subscription immediately', async () => { + const trialSub = createMockSubscription({ + status: SubscriptionStatus.TRIAL, + trial_end: new Date('2026-01-20'), + }); + + const cancelledSub = createMockSubscription({ + status: SubscriptionStatus.CANCELLED, + cancelled_at: new Date(), + }); + + subscriptionRepo.findOne.mockResolvedValue(trialSub); + subscriptionRepo.save.mockResolvedValue(cancelledSub); + + const result = await service.cancelSubscription(mockTenantId, { + immediately: true, + }); + + expect(result.status).toBe(SubscriptionStatus.CANCELLED); + }); + + it('should preserve active status when scheduling end-of-period cancellation', async () => { + const activeSub = createMockSubscription({ + current_period_end: new Date('2026-02-01'), + }); + + subscriptionRepo.findOne.mockResolvedValue(activeSub); + subscriptionRepo.save.mockImplementation((sub) => + Promise.resolve({ ...sub, cancelled_at: new Date() } as Subscription), + ); + + const result = await service.cancelSubscription(mockTenantId, { + immediately: false, + }); + + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + expect(result.cancelled_at).toBeDefined(); + }); + }); + + describe('changePlan - Edge Cases (Upgrade/Downgrade)', () => { + it('should upgrade from basic to pro plan', async () => { + const basicSub = createMockSubscription({ + plan_id: 'plan-basic', + }); + + const upgradedSub = createMockSubscription({ + plan_id: 'plan-pro', + metadata: { plan_changed_at: expect.any(String) }, + }); + + subscriptionRepo.findOne.mockResolvedValue(basicSub); + subscriptionRepo.save.mockResolvedValue(upgradedSub); + + const result = await service.changePlan(mockTenantId, 'plan-pro'); + + expect(result.plan_id).toBe('plan-pro'); + expect(result.metadata.plan_changed_at).toBeDefined(); + }); + + it('should downgrade from pro to basic plan', async () => { + const proSub = createMockSubscription({ + plan_id: 'plan-pro', + }); + + const downgradedSub = createMockSubscription({ + plan_id: 'plan-basic', + metadata: { plan_changed_at: expect.any(String) }, + }); + + subscriptionRepo.findOne.mockResolvedValue(proSub); + subscriptionRepo.save.mockResolvedValue(downgradedSub); + + const result = await service.changePlan(mockTenantId, 'plan-basic'); + + expect(result.plan_id).toBe('plan-basic'); + }); + + it('should preserve existing metadata when changing plan', async () => { + const existingSub = createMockSubscription({ + plan_id: 'plan-basic', + metadata: { + original_signup: '2025-01-01', + referral_code: 'ABC123', + }, + }); + + subscriptionRepo.findOne.mockResolvedValue(existingSub); + subscriptionRepo.save.mockImplementation((sub) => Promise.resolve(sub as Subscription)); + + const result = await service.changePlan(mockTenantId, 'plan-pro'); + + expect(result.metadata.original_signup).toBe('2025-01-01'); + expect(result.metadata.referral_code).toBe('ABC123'); + expect(result.metadata.plan_changed_at).toBeDefined(); + }); + }); + + describe('renewSubscription - Edge Cases', () => { + it('should renew expired subscription', async () => { + const expiredSub = createMockSubscription({ + status: SubscriptionStatus.EXPIRED, + current_period_start: new Date('2025-12-01'), + current_period_end: new Date('2026-01-01'), + }); + + const renewedSub = createMockSubscription({ + status: SubscriptionStatus.ACTIVE, + current_period_start: new Date('2026-01-01'), + current_period_end: new Date('2026-02-01'), + }); + + subscriptionRepo.findOne.mockResolvedValue(expiredSub); + subscriptionRepo.save.mockResolvedValue(renewedSub); + + const result = await service.renewSubscription(mockTenantId); + + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + }); + + it('should renew past_due subscription after payment', async () => { + const pastDueSub = createMockSubscription({ + status: SubscriptionStatus.PAST_DUE, + current_period_start: new Date('2025-12-01'), + current_period_end: new Date('2026-01-01'), + }); + + const renewedSub = createMockSubscription({ + status: SubscriptionStatus.ACTIVE, + current_period_start: new Date('2026-01-01'), + current_period_end: new Date('2026-02-01'), + }); + + subscriptionRepo.findOne.mockResolvedValue(pastDueSub); + subscriptionRepo.save.mockResolvedValue(renewedSub); + + const result = await service.renewSubscription(mockTenantId); + + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + }); + + it('should throw NotFoundException when renewing non-existent subscription', async () => { + subscriptionRepo.findOne.mockResolvedValue(null); + + await expect(service.renewSubscription(mockTenantId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should correctly calculate new period end across year boundary', async () => { + const decemberSub = createMockSubscription({ + current_period_start: new Date('2025-12-15'), + current_period_end: new Date('2026-01-15'), + }); + + subscriptionRepo.findOne.mockResolvedValue(decemberSub); + subscriptionRepo.save.mockImplementation((sub) => Promise.resolve(sub as Subscription)); + + const result = await service.renewSubscription(mockTenantId); + + // New period should start from old end and add one month + expect(result.current_period_start).toEqual(new Date('2026-01-15')); + const expectedEnd = new Date('2026-01-15'); + expectedEnd.setMonth(expectedEnd.getMonth() + 1); + expect(result.current_period_end).toEqual(expectedEnd); + }); + }); + + // ==================== Invoice Edge Cases ==================== + + describe('createInvoice - Edge Cases', () => { + it('should calculate tax correctly (16% IVA)', async () => { + invoiceRepo.count.mockResolvedValue(0); + invoiceRepo.create.mockImplementation((data) => data as Invoice); + invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice)); + + await service.createInvoice(mockTenantId, 'sub-001', [ + { description: 'Pro Plan', quantity: 1, unit_price: 1000 }, + ]); + + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + subtotal: 1000, + tax: 160, // 16% of 1000 + total: 1160, + }), + ); + }); + + it('should calculate totals for multiple line items', async () => { + invoiceRepo.count.mockResolvedValue(0); + invoiceRepo.create.mockImplementation((data) => data as Invoice); + invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice)); + + await service.createInvoice(mockTenantId, 'sub-001', [ + { description: 'Pro Plan', quantity: 1, unit_price: 100 }, + { description: 'Extra Users', quantity: 5, unit_price: 10 }, + { description: 'Storage Add-on', quantity: 2, unit_price: 25 }, + ]); + + // 100 + 50 + 50 = 200 subtotal + // 200 * 0.16 = 32 tax + // 200 + 32 = 232 total + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + subtotal: 200, + tax: 32, + total: 232, + }), + ); + }); + + it('should generate unique invoice number', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-03-15')); + + invoiceRepo.count.mockResolvedValue(42); + invoiceRepo.create.mockImplementation((data) => data as Invoice); + invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice)); + + await service.createInvoice(mockTenantId, 'sub-001', [ + { description: 'Pro Plan', quantity: 1, unit_price: 100 }, + ]); + + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + invoice_number: 'INV-202603-000043', // count + 1 + }), + ); + + jest.useRealTimers(); + }); + + it('should set due date 15 days from creation', async () => { + const now = new Date('2026-01-10T12:00:00Z'); + jest.useFakeTimers(); + jest.setSystemTime(now); + + invoiceRepo.count.mockResolvedValue(0); + invoiceRepo.create.mockImplementation((data) => data as Invoice); + invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice)); + + await service.createInvoice(mockTenantId, 'sub-001', [ + { description: 'Pro Plan', quantity: 1, unit_price: 100 }, + ]); + + const expectedDueDate = new Date('2026-01-25T12:00:00Z'); + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + due_date: expectedDueDate, + }), + ); + + jest.useRealTimers(); + }); + + it('should handle zero quantity line items', async () => { + invoiceRepo.count.mockResolvedValue(0); + invoiceRepo.create.mockImplementation((data) => data as Invoice); + invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice)); + + await service.createInvoice(mockTenantId, 'sub-001', [ + { description: 'Pro Plan', quantity: 0, unit_price: 100 }, + ]); + + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + subtotal: 0, + tax: 0, + total: 0, + }), + ); + }); + + it('should handle empty line items array', async () => { + invoiceRepo.count.mockResolvedValue(0); + invoiceRepo.create.mockImplementation((data) => data as Invoice); + invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice)); + + await service.createInvoice(mockTenantId, 'sub-001', []); + + expect(invoiceRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + subtotal: 0, + tax: 0, + total: 0, + line_items: [], + }), + ); + }); + }); + + describe('getInvoices - Edge Cases', () => { + it('should return empty array when no invoices exist', async () => { + invoiceRepo.findAndCount.mockResolvedValue([[], 0]); + + const result = await service.getInvoices(mockTenantId); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('should handle high page numbers with no results', async () => { + invoiceRepo.findAndCount.mockResolvedValue([[], 0]); + + const result = await service.getInvoices(mockTenantId, { page: 999, limit: 10 }); + + expect(result.data).toHaveLength(0); + expect(result.page).toBe(999); + }); + + it('should handle custom limit values', async () => { + const invoices = Array(50) + .fill(null) + .map((_, i) => createMockInvoice({ id: `inv-${i}` })); + + invoiceRepo.findAndCount.mockResolvedValue([invoices.slice(0, 25), 50]); + + const result = await service.getInvoices(mockTenantId, { page: 1, limit: 25 }); + + expect(result.data).toHaveLength(25); + expect(result.limit).toBe(25); + expect(result.total).toBe(50); + }); + }); + + describe('voidInvoice - Edge Cases', () => { + it('should void draft invoice', async () => { + const draftInvoice = createMockInvoice({ status: InvoiceStatus.DRAFT }); + + invoiceRepo.findOne.mockResolvedValue(draftInvoice); + invoiceRepo.save.mockResolvedValue(createMockInvoice({ status: InvoiceStatus.VOID })); + + const result = await service.voidInvoice('inv-001', mockTenantId); + + expect(result.status).toBe(InvoiceStatus.VOID); + }); + + it('should void open invoice', async () => { + const openInvoice = createMockInvoice({ status: InvoiceStatus.OPEN }); + + invoiceRepo.findOne.mockResolvedValue(openInvoice); + invoiceRepo.save.mockResolvedValue(createMockInvoice({ status: InvoiceStatus.VOID })); + + const result = await service.voidInvoice('inv-001', mockTenantId); + + expect(result.status).toBe(InvoiceStatus.VOID); + }); + + it('should throw when voiding already voided invoice', async () => { + const voidedInvoice = createMockInvoice({ status: InvoiceStatus.VOID }); + + invoiceRepo.findOne.mockResolvedValue(voidedInvoice); + invoiceRepo.save.mockResolvedValue(voidedInvoice); + + // Note: Current implementation doesn't prevent re-voiding + // This test documents the current behavior + const result = await service.voidInvoice('inv-001', mockTenantId); + expect(result.status).toBe(InvoiceStatus.VOID); + }); + }); + + describe('markInvoicePaid - Edge Cases', () => { + it('should mark draft invoice as paid', async () => { + const draftInvoice = createMockInvoice({ status: InvoiceStatus.DRAFT }); + + invoiceRepo.findOne.mockResolvedValue(draftInvoice); + invoiceRepo.save.mockResolvedValue( + createMockInvoice({ status: InvoiceStatus.PAID, paid_at: new Date() }), + ); + + const result = await service.markInvoicePaid('inv-001', mockTenantId); + + expect(result.status).toBe(InvoiceStatus.PAID); + expect(result.paid_at).toBeDefined(); + }); + + it('should update paid_at timestamp', async () => { + const now = new Date('2026-01-15T14:30:00Z'); + jest.useFakeTimers(); + jest.setSystemTime(now); + + const openInvoice = createMockInvoice({ status: InvoiceStatus.OPEN }); + + invoiceRepo.findOne.mockResolvedValue(openInvoice); + invoiceRepo.save.mockImplementation((invoice) => Promise.resolve(invoice as Invoice)); + + const result = await service.markInvoicePaid('inv-001', mockTenantId); + + expect(result.paid_at).toEqual(now); + + jest.useRealTimers(); + }); + }); + + // ==================== Payment Method Edge Cases ==================== + + describe('addPaymentMethod - Edge Cases', () => { + it('should add non-default payment method without updating others', async () => { + const newMethod = createMockPaymentMethod({ + id: 'pm-002', + is_default: false, + }); + + paymentMethodRepo.create.mockReturnValue(newMethod); + paymentMethodRepo.save.mockResolvedValue(newMethod); + + const dto = { + type: PaymentMethodType.CARD, + card_last_four: '1234', + card_brand: 'mastercard', + is_default: false, + }; + + await service.addPaymentMethod(mockTenantId, dto); + + expect(paymentMethodRepo.update).not.toHaveBeenCalled(); + }); + + it('should handle bank_transfer payment method type', async () => { + const bankMethod = createMockPaymentMethod({ + id: 'pm-003', + type: PaymentMethodType.BANK_TRANSFER, + }); + + paymentMethodRepo.create.mockReturnValue(bankMethod); + paymentMethodRepo.save.mockResolvedValue(bankMethod); + + const dto = { + type: PaymentMethodType.BANK_TRANSFER, + is_default: false, + }; + + await service.addPaymentMethod(mockTenantId, dto); + + expect(paymentMethodRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + type: PaymentMethodType.BANK_TRANSFER, + }), + ); + }); + }); + + describe('getPaymentMethods - Edge Cases', () => { + it('should return empty array when no payment methods exist', async () => { + paymentMethodRepo.find.mockResolvedValue([]); + + const result = await service.getPaymentMethods(mockTenantId); + + expect(result).toHaveLength(0); + }); + + it('should return only active payment methods', async () => { + const activeMethod = createMockPaymentMethod({ is_active: true }); + + paymentMethodRepo.find.mockResolvedValue([activeMethod]); + + const result = await service.getPaymentMethods(mockTenantId); + + expect(result).toHaveLength(1); + expect(paymentMethodRepo.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId, is_active: true }, + order: { is_default: 'DESC', created_at: 'DESC' }, + }); + }); + + it('should order payment methods with default first', async () => { + const methods = [ + createMockPaymentMethod({ id: 'pm-001', is_default: false, created_at: new Date('2026-01-01') }), + createMockPaymentMethod({ id: 'pm-002', is_default: true, created_at: new Date('2026-01-02') }), + createMockPaymentMethod({ id: 'pm-003', is_default: false, created_at: new Date('2026-01-03') }), + ]; + + paymentMethodRepo.find.mockResolvedValue(methods); + + await service.getPaymentMethods(mockTenantId); + + expect(paymentMethodRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + order: { is_default: 'DESC', created_at: 'DESC' }, + }), + ); + }); + }); + + describe('setDefaultPaymentMethod - Edge Cases', () => { + it('should unset previous default when setting new default', async () => { + const newDefault = createMockPaymentMethod({ + id: 'pm-002', + is_default: false, + }); + + paymentMethodRepo.findOne.mockResolvedValue(newDefault); + paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any); + paymentMethodRepo.save.mockResolvedValue( + createMockPaymentMethod({ id: 'pm-002', is_default: true }), + ); + + await service.setDefaultPaymentMethod('pm-002', mockTenantId); + + expect(paymentMethodRepo.update).toHaveBeenCalledWith( + { tenant_id: mockTenantId, is_default: true }, + { is_default: false }, + ); + }); + + it('should handle setting already default payment method as default', async () => { + const alreadyDefault = createMockPaymentMethod({ is_default: true }); + + paymentMethodRepo.findOne.mockResolvedValue(alreadyDefault); + paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any); + paymentMethodRepo.save.mockResolvedValue(alreadyDefault); + + const result = await service.setDefaultPaymentMethod('pm-001', mockTenantId); + + expect(result.is_default).toBe(true); + }); + }); + + describe('removePaymentMethod - Edge Cases', () => { + it('should deactivate instead of delete payment method', async () => { + const nonDefaultMethod = createMockPaymentMethod({ + id: 'pm-002', + is_default: false, + is_active: true, + }); + + paymentMethodRepo.findOne.mockResolvedValue(nonDefaultMethod); + paymentMethodRepo.save.mockImplementation((pm) => Promise.resolve(pm as PaymentMethod)); + + await service.removePaymentMethod('pm-002', mockTenantId); + + expect(paymentMethodRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + is_active: false, + }), + ); + }); + + it('should throw when trying to remove default payment method', async () => { + const defaultMethod = createMockPaymentMethod({ + is_default: true, + is_active: true, + }); + + paymentMethodRepo.findOne.mockResolvedValue(defaultMethod); + + await expect( + service.removePaymentMethod('pm-001', mockTenantId), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ==================== Billing Summary Edge Cases ==================== + + describe('getBillingSummary - Edge Cases', () => { + it('should return null values when no subscription or payment method', async () => { + subscriptionRepo.findOne.mockResolvedValue(null); + paymentMethodRepo.findOne.mockResolvedValue(null); + invoiceRepo.find.mockResolvedValue([]); + + const result = await service.getBillingSummary(mockTenantId); + + expect(result.subscription).toBeNull(); + expect(result.defaultPaymentMethod).toBeNull(); + expect(result.pendingInvoices).toBe(0); + expect(result.totalDue).toBe(0); + }); + + it('should calculate total due from multiple pending invoices', async () => { + const pendingInvoices = [ + createMockInvoice({ id: 'inv-001', total: 116, status: InvoiceStatus.OPEN }), + createMockInvoice({ id: 'inv-002', total: 58, status: InvoiceStatus.OPEN }), + createMockInvoice({ id: 'inv-003', total: 232, status: InvoiceStatus.OPEN }), + ]; + + subscriptionRepo.findOne.mockResolvedValue(createMockSubscription()); + paymentMethodRepo.findOne.mockResolvedValue(createMockPaymentMethod()); + invoiceRepo.find.mockResolvedValue(pendingInvoices); + + const result = await service.getBillingSummary(mockTenantId); + + expect(result.pendingInvoices).toBe(3); + expect(result.totalDue).toBe(406); // 116 + 58 + 232 + }); + + it('should handle decimal totals correctly', async () => { + const pendingInvoices = [ + createMockInvoice({ id: 'inv-001', total: 116.5 as unknown as number, status: InvoiceStatus.OPEN }), + createMockInvoice({ id: 'inv-002', total: 58.25 as unknown as number, status: InvoiceStatus.OPEN }), + ]; + + subscriptionRepo.findOne.mockResolvedValue(createMockSubscription()); + paymentMethodRepo.findOne.mockResolvedValue(createMockPaymentMethod()); + invoiceRepo.find.mockResolvedValue(pendingInvoices); + + const result = await service.getBillingSummary(mockTenantId); + + expect(result.totalDue).toBe(174.75); + }); + }); + + describe('checkSubscriptionStatus - Edge Cases', () => { + it('should return zero days remaining when period has ended', async () => { + const expiredSub = createMockSubscription({ + current_period_end: new Date('2025-12-01'), // Past date + }); + + subscriptionRepo.findOne.mockResolvedValue(expiredSub); + + const result = await service.checkSubscriptionStatus(mockTenantId); + + expect(result.daysRemaining).toBe(0); + }); + + it('should calculate days remaining correctly', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-01-10')); + + const activeSub = createMockSubscription({ + current_period_end: new Date('2026-01-25'), + }); + + subscriptionRepo.findOne.mockResolvedValue(activeSub); + + const result = await service.checkSubscriptionStatus(mockTenantId); + + expect(result.daysRemaining).toBe(15); + expect(result.isActive).toBe(true); + + jest.useRealTimers(); + }); + + it('should return inactive for past_due subscription', async () => { + const pastDueSub = createMockSubscription({ + status: SubscriptionStatus.PAST_DUE, + current_period_end: new Date('2026-02-01'), + }); + + subscriptionRepo.findOne.mockResolvedValue(pastDueSub); + + const result = await service.checkSubscriptionStatus(mockTenantId); + + expect(result.isActive).toBe(false); + expect(result.status).toBe(SubscriptionStatus.PAST_DUE); + }); + + it('should return inactive for cancelled subscription', async () => { + const cancelledSub = createMockSubscription({ + status: SubscriptionStatus.CANCELLED, + current_period_end: new Date('2026-02-01'), + }); + + subscriptionRepo.findOne.mockResolvedValue(cancelledSub); + + const result = await service.checkSubscriptionStatus(mockTenantId); + + expect(result.isActive).toBe(false); + expect(result.status).toBe(SubscriptionStatus.CANCELLED); + }); + + it('should return active for trial subscription', async () => { + const trialSub = createMockSubscription({ + status: SubscriptionStatus.TRIAL, + current_period_end: new Date('2026-02-01'), + trial_end: new Date('2026-01-15'), + }); + + subscriptionRepo.findOne.mockResolvedValue(trialSub); + + const result = await service.checkSubscriptionStatus(mockTenantId); + + expect(result.isActive).toBe(true); + expect(result.status).toBe(SubscriptionStatus.TRIAL); + }); + }); +}); diff --git a/src/modules/billing/__tests__/billing.controller.spec.ts b/src/modules/billing/__tests__/billing.controller.spec.ts new file mode 100644 index 0000000..c5696a5 --- /dev/null +++ b/src/modules/billing/__tests__/billing.controller.spec.ts @@ -0,0 +1,268 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { BillingController } from '../billing.controller'; +import { BillingService } from '../services/billing.service'; +import { RbacService } from '../../rbac/services/rbac.service'; + +describe('BillingController', () => { + let controller: BillingController; + let service: jest.Mocked; + + const mockRequestUser = { + id: 'user-123', + sub: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + role: 'admin', + }; + + const mockSubscription = { + id: 'sub-123', + tenant_id: 'tenant-123', + plan_id: 'plan-123', + status: 'active', + current_period_start: new Date('2026-01-01'), + current_period_end: new Date('2026-02-01'), + }; + + const mockInvoice = { + id: 'inv-123', + tenant_id: 'tenant-123', + status: 'paid', + total: 99.99, + }; + + const mockPaymentMethod = { + id: 'pm-123', + tenant_id: 'tenant-123', + type: 'card', + card_last_four: '4242', + is_default: true, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BillingController], + providers: [ + { + provide: BillingService, + useValue: { + getSubscription: jest.fn(), + checkSubscriptionStatus: jest.fn(), + createSubscription: jest.fn(), + updateSubscription: jest.fn(), + cancelSubscription: jest.fn(), + changePlan: jest.fn(), + getInvoices: jest.fn(), + getInvoice: jest.fn(), + markInvoicePaid: jest.fn(), + voidInvoice: jest.fn(), + getPaymentMethods: jest.fn(), + addPaymentMethod: jest.fn(), + setDefaultPaymentMethod: jest.fn(), + removePaymentMethod: jest.fn(), + getBillingSummary: jest.fn(), + }, + }, + { + provide: RbacService, + useValue: { + userHasPermission: jest.fn().mockResolvedValue(true), + userHasAnyPermission: jest.fn().mockResolvedValue(true), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(BillingController); + service = module.get(BillingService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getSubscription', () => { + it('should return current subscription', async () => { + service.getSubscription.mockResolvedValue(mockSubscription as any); + + const result = await controller.getSubscription(mockRequestUser); + + expect(result).toEqual(mockSubscription); + expect(service.getSubscription).toHaveBeenCalledWith('tenant-123'); + }); + }); + + describe('getSubscriptionStatus', () => { + it('should return subscription status', async () => { + const status = { active: true, plan: 'pro', daysRemaining: 25 }; + service.checkSubscriptionStatus.mockResolvedValue(status as any); + + const result = await controller.getSubscriptionStatus(mockRequestUser); + + expect(result).toEqual(status); + expect(service.checkSubscriptionStatus).toHaveBeenCalledWith('tenant-123'); + }); + }); + + describe('createSubscription', () => { + it('should create subscription', async () => { + const createDto = { plan_id: 'plan-123', tenant_id: '' }; + service.createSubscription.mockResolvedValue(mockSubscription as any); + + const result = await controller.createSubscription(createDto as any, mockRequestUser); + + expect(result).toEqual(mockSubscription); + expect(createDto.tenant_id).toBe('tenant-123'); + }); + }); + + describe('updateSubscription', () => { + it('should update subscription', async () => { + const updateDto = { plan_id: 'plan-456' }; + const updated = { ...mockSubscription, plan_id: 'plan-456' }; + service.updateSubscription.mockResolvedValue(updated as any); + + const result = await controller.updateSubscription(updateDto as any, mockRequestUser); + + expect(result.plan_id).toBe('plan-456'); + expect(service.updateSubscription).toHaveBeenCalledWith('tenant-123', updateDto); + }); + }); + + describe('cancelSubscription', () => { + it('should cancel subscription', async () => { + const cancelDto = { immediately: false, reason: 'Not needed' }; + const cancelled = { ...mockSubscription, status: 'cancelled' }; + service.cancelSubscription.mockResolvedValue(cancelled as any); + + const result = await controller.cancelSubscription(cancelDto as any, mockRequestUser); + + expect(result.status).toBe('cancelled'); + expect(service.cancelSubscription).toHaveBeenCalledWith('tenant-123', cancelDto); + }); + }); + + describe('changePlan', () => { + it('should change plan', async () => { + const updated = { ...mockSubscription, plan_id: 'plan-456' }; + service.changePlan.mockResolvedValue(updated as any); + + const result = await controller.changePlan('plan-456', mockRequestUser); + + expect(result.plan_id).toBe('plan-456'); + expect(service.changePlan).toHaveBeenCalledWith('tenant-123', 'plan-456'); + }); + }); + + describe('getInvoices', () => { + it('should return paginated invoices', async () => { + const invoicesResult = { data: [mockInvoice], total: 1, page: 1, limit: 10 }; + service.getInvoices.mockResolvedValue(invoicesResult as any); + + const result = await controller.getInvoices(mockRequestUser, 1, 10); + + expect(result).toEqual(invoicesResult); + expect(service.getInvoices).toHaveBeenCalledWith('tenant-123', { page: 1, limit: 10 }); + }); + }); + + describe('getInvoice', () => { + it('should return invoice by id', async () => { + service.getInvoice.mockResolvedValue(mockInvoice as any); + + const result = await controller.getInvoice('inv-123', mockRequestUser); + + expect(result).toEqual(mockInvoice); + expect(service.getInvoice).toHaveBeenCalledWith('inv-123', 'tenant-123'); + }); + }); + + describe('markInvoicePaid', () => { + it('should mark invoice as paid', async () => { + const paidInvoice = { ...mockInvoice, status: 'paid' }; + service.markInvoicePaid.mockResolvedValue(paidInvoice as any); + + const result = await controller.markInvoicePaid('inv-123', mockRequestUser); + + expect(result.status).toBe('paid'); + expect(service.markInvoicePaid).toHaveBeenCalledWith('inv-123', 'tenant-123'); + }); + }); + + describe('voidInvoice', () => { + it('should void invoice', async () => { + const voidedInvoice = { ...mockInvoice, status: 'void' }; + service.voidInvoice.mockResolvedValue(voidedInvoice as any); + + const result = await controller.voidInvoice('inv-123', mockRequestUser); + + expect(result.status).toBe('void'); + expect(service.voidInvoice).toHaveBeenCalledWith('inv-123', 'tenant-123'); + }); + }); + + describe('getPaymentMethods', () => { + it('should return payment methods', async () => { + service.getPaymentMethods.mockResolvedValue([mockPaymentMethod] as any); + + const result = await controller.getPaymentMethods(mockRequestUser); + + expect(result).toEqual([mockPaymentMethod]); + expect(service.getPaymentMethods).toHaveBeenCalledWith('tenant-123'); + }); + }); + + describe('addPaymentMethod', () => { + it('should add payment method', async () => { + const createDto = { type: 'card', external_payment_method_id: 'pm_test' }; + service.addPaymentMethod.mockResolvedValue(mockPaymentMethod as any); + + const result = await controller.addPaymentMethod(createDto as any, mockRequestUser); + + expect(result).toEqual(mockPaymentMethod); + expect(service.addPaymentMethod).toHaveBeenCalledWith('tenant-123', createDto); + }); + }); + + describe('setDefaultPaymentMethod', () => { + it('should set default payment method', async () => { + const updatedMethod = { ...mockPaymentMethod, is_default: true }; + service.setDefaultPaymentMethod.mockResolvedValue(updatedMethod as any); + + const result = await controller.setDefaultPaymentMethod('pm-123', mockRequestUser); + + expect(result.is_default).toBe(true); + expect(service.setDefaultPaymentMethod).toHaveBeenCalledWith('pm-123', 'tenant-123'); + }); + }); + + describe('removePaymentMethod', () => { + it('should remove payment method', async () => { + service.removePaymentMethod.mockResolvedValue(undefined); + + const result = await controller.removePaymentMethod('pm-123', mockRequestUser); + + expect(result.message).toBe('Payment method removed'); + expect(service.removePaymentMethod).toHaveBeenCalledWith('pm-123', 'tenant-123'); + }); + }); + + describe('getBillingSummary', () => { + it('should return billing summary', async () => { + const summary = { + subscription: mockSubscription, + nextInvoice: mockInvoice, + paymentMethods: [mockPaymentMethod], + }; + service.getBillingSummary.mockResolvedValue(summary as any); + + const result = await controller.getBillingSummary(mockRequestUser); + + expect(result).toEqual(summary); + expect(service.getBillingSummary).toHaveBeenCalledWith('tenant-123'); + }); + }); +}); diff --git a/src/modules/billing/__tests__/billing.service.spec.ts b/src/modules/billing/__tests__/billing.service.spec.ts new file mode 100644 index 0000000..c4df805 --- /dev/null +++ b/src/modules/billing/__tests__/billing.service.spec.ts @@ -0,0 +1,552 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { BillingService } from '../services/billing.service'; +import { Subscription, SubscriptionStatus } from '../entities/subscription.entity'; +import { Invoice, InvoiceStatus } from '../entities/invoice.entity'; +import { PaymentMethod, PaymentMethodType } from '../entities/payment-method.entity'; + +describe('BillingService', () => { + let service: BillingService; + let subscriptionRepo: jest.Mocked>; + let invoiceRepo: jest.Mocked>; + let paymentMethodRepo: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockPlanId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockSubscription: Partial = { + id: 'sub-001', + tenant_id: mockTenantId, + plan_id: mockPlanId, + status: SubscriptionStatus.ACTIVE, + current_period_start: new Date('2026-01-01'), + current_period_end: new Date('2026-02-01'), + payment_provider: 'stripe', + metadata: {}, + }; + + const mockInvoice: Partial = { + id: 'inv-001', + tenant_id: mockTenantId, + subscription_id: 'sub-001', + invoice_number: 'INV-202601-000001', + status: InvoiceStatus.OPEN, + subtotal: 100, + tax: 16, + total: 116, + due_date: new Date('2026-01-15'), + line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }], + }; + + const mockPaymentMethod: Partial = { + id: 'pm-001', + tenant_id: mockTenantId, + type: PaymentMethodType.CARD, + card_last_four: '4242', + card_brand: 'visa', + is_default: true, + is_active: true, + }; + + beforeEach(async () => { + const mockSubscriptionRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + }; + + const mockInvoiceRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + findAndCount: jest.fn(), + count: jest.fn(), + }; + + const mockPaymentMethodRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BillingService, + { provide: getRepositoryToken(Subscription), useValue: mockSubscriptionRepo }, + { provide: getRepositoryToken(Invoice), useValue: mockInvoiceRepo }, + { provide: getRepositoryToken(PaymentMethod), useValue: mockPaymentMethodRepo }, + ], + }).compile(); + + service = module.get(BillingService); + subscriptionRepo = module.get(getRepositoryToken(Subscription)); + invoiceRepo = module.get(getRepositoryToken(Invoice)); + paymentMethodRepo = module.get(getRepositoryToken(PaymentMethod)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== Subscription Tests ==================== + + describe('createSubscription', () => { + it('should create a subscription successfully', async () => { + subscriptionRepo.create.mockReturnValue(mockSubscription as Subscription); + subscriptionRepo.save.mockResolvedValue(mockSubscription as Subscription); + + const dto = { + tenant_id: mockTenantId, + plan_id: mockPlanId, + payment_provider: 'stripe', + }; + + const result = await service.createSubscription(dto); + + expect(result).toEqual(mockSubscription); + expect(subscriptionRepo.create).toHaveBeenCalled(); + expect(subscriptionRepo.save).toHaveBeenCalled(); + }); + + it('should create trial subscription when trial_end provided', async () => { + const trialSub = { + ...mockSubscription, + status: SubscriptionStatus.TRIAL, + trial_end: new Date('2026-01-15'), + }; + subscriptionRepo.create.mockReturnValue(trialSub as Subscription); + subscriptionRepo.save.mockResolvedValue(trialSub as Subscription); + + const dto = { + tenant_id: mockTenantId, + plan_id: mockPlanId, + payment_provider: 'stripe', + trial_end: '2026-01-15', + }; + + const result = await service.createSubscription(dto); + + expect(result.status).toBe(SubscriptionStatus.TRIAL); + }); + }); + + describe('getSubscription', () => { + it('should return subscription for tenant', async () => { + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await service.getSubscription(mockTenantId); + + expect(result).toEqual(mockSubscription); + expect(subscriptionRepo.findOne).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + order: { created_at: 'DESC' }, + }); + }); + + it('should return null if no subscription found', async () => { + subscriptionRepo.findOne.mockResolvedValue(null); + + const result = await service.getSubscription(mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('updateSubscription', () => { + it('should update subscription successfully', async () => { + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + subscriptionRepo.save.mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.PAST_DUE, + } as Subscription); + + const result = await service.updateSubscription(mockTenantId, { + status: SubscriptionStatus.PAST_DUE, + }); + + expect(result.status).toBe(SubscriptionStatus.PAST_DUE); + }); + + it('should throw NotFoundException if subscription not found', async () => { + subscriptionRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateSubscription(mockTenantId, { status: SubscriptionStatus.ACTIVE }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('cancelSubscription', () => { + it('should cancel subscription immediately', async () => { + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + subscriptionRepo.save.mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.CANCELLED, + cancelled_at: new Date(), + } as Subscription); + + const result = await service.cancelSubscription(mockTenantId, { + immediately: true, + reason: 'User requested', + }); + + expect(result.status).toBe(SubscriptionStatus.CANCELLED); + expect(result.cancelled_at).toBeDefined(); + }); + + it('should schedule cancellation at period end', async () => { + const activeSub = { ...mockSubscription, status: SubscriptionStatus.ACTIVE }; + const savedSub = { ...activeSub, cancelled_at: new Date() }; + subscriptionRepo.findOne.mockResolvedValue(activeSub as Subscription); + subscriptionRepo.save.mockResolvedValue(savedSub as Subscription); + + const result = await service.cancelSubscription(mockTenantId, { + immediately: false, + }); + + expect(result.cancelled_at).toBeDefined(); + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + }); + + it('should throw NotFoundException if subscription not found', async () => { + subscriptionRepo.findOne.mockResolvedValue(null); + + await expect( + service.cancelSubscription(mockTenantId, { immediately: true }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('changePlan', () => { + it('should change plan successfully', async () => { + const newPlanId = 'new-plan-id'; + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + subscriptionRepo.save.mockResolvedValue({ + ...mockSubscription, + plan_id: newPlanId, + } as Subscription); + + const result = await service.changePlan(mockTenantId, newPlanId); + + expect(result.plan_id).toBe(newPlanId); + }); + + it('should throw NotFoundException if subscription not found', async () => { + subscriptionRepo.findOne.mockResolvedValue(null); + + await expect(service.changePlan(mockTenantId, 'new-plan')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('renewSubscription', () => { + it('should renew subscription successfully', async () => { + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + subscriptionRepo.save.mockResolvedValue({ + ...mockSubscription, + current_period_start: mockSubscription.current_period_end, + current_period_end: new Date('2026-03-01'), + status: SubscriptionStatus.ACTIVE, + } as Subscription); + + const result = await service.renewSubscription(mockTenantId); + + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + }); + }); + + // ==================== Invoice Tests ==================== + + describe('createInvoice', () => { + it('should create invoice with correct calculations', async () => { + invoiceRepo.count.mockResolvedValue(0); + invoiceRepo.create.mockReturnValue(mockInvoice as Invoice); + invoiceRepo.save.mockResolvedValue(mockInvoice as Invoice); + + const result = await service.createInvoice(mockTenantId, 'sub-001', [ + { description: 'Pro Plan', quantity: 1, unit_price: 100 }, + ]); + + expect(result).toEqual(mockInvoice); + expect(invoiceRepo.create).toHaveBeenCalled(); + }); + }); + + describe('getInvoices', () => { + it('should return paginated invoices', async () => { + invoiceRepo.findAndCount.mockResolvedValue([[mockInvoice as Invoice], 1]); + + const result = await service.getInvoices(mockTenantId, { page: 1, limit: 10 }); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); + + it('should use default pagination values', async () => { + invoiceRepo.findAndCount.mockResolvedValue([[mockInvoice as Invoice], 1]); + + const result = await service.getInvoices(mockTenantId); + + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + }); + }); + + describe('getInvoice', () => { + it('should return invoice by id', async () => { + invoiceRepo.findOne.mockResolvedValue(mockInvoice as Invoice); + + const result = await service.getInvoice('inv-001', mockTenantId); + + expect(result).toEqual(mockInvoice); + }); + + it('should throw NotFoundException if invoice not found', async () => { + invoiceRepo.findOne.mockResolvedValue(null); + + await expect(service.getInvoice('invalid-id', mockTenantId)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('markInvoicePaid', () => { + it('should mark invoice as paid', async () => { + const openInvoice = { ...mockInvoice, status: InvoiceStatus.OPEN }; + invoiceRepo.findOne.mockResolvedValue(openInvoice as Invoice); + invoiceRepo.save.mockResolvedValue({ + ...openInvoice, + status: InvoiceStatus.PAID, + paid_at: new Date(), + } as Invoice); + + const result = await service.markInvoicePaid('inv-001', mockTenantId); + + expect(result.status).toBe(InvoiceStatus.PAID); + expect(result.paid_at).toBeDefined(); + }); + }); + + describe('voidInvoice', () => { + it('should void open invoice', async () => { + const openInvoice = { ...mockInvoice, status: InvoiceStatus.OPEN }; + invoiceRepo.findOne.mockResolvedValue(openInvoice as Invoice); + invoiceRepo.save.mockResolvedValue({ + ...openInvoice, + status: InvoiceStatus.VOID, + } as Invoice); + + const result = await service.voidInvoice('inv-001', mockTenantId); + + expect(result.status).toBe(InvoiceStatus.VOID); + }); + + it('should throw BadRequestException for paid invoice', async () => { + invoiceRepo.findOne.mockResolvedValue({ + ...mockInvoice, + status: InvoiceStatus.PAID, + } as Invoice); + + await expect(service.voidInvoice('inv-001', mockTenantId)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // ==================== Payment Method Tests ==================== + + describe('addPaymentMethod', () => { + it('should add payment method successfully', async () => { + paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any); + paymentMethodRepo.create.mockReturnValue(mockPaymentMethod as PaymentMethod); + paymentMethodRepo.save.mockResolvedValue(mockPaymentMethod as PaymentMethod); + + const dto = { + type: PaymentMethodType.CARD, + card_last_four: '4242', + card_brand: 'visa', + is_default: true, + }; + + const result = await service.addPaymentMethod(mockTenantId, dto); + + expect(result).toEqual(mockPaymentMethod); + }); + + it('should unset other defaults when adding default', async () => { + paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any); + paymentMethodRepo.create.mockReturnValue(mockPaymentMethod as PaymentMethod); + paymentMethodRepo.save.mockResolvedValue(mockPaymentMethod as PaymentMethod); + + const dto = { + type: PaymentMethodType.CARD, + card_last_four: '4242', + card_brand: 'visa', + is_default: true, + }; + + await service.addPaymentMethod(mockTenantId, dto); + + expect(paymentMethodRepo.update).toHaveBeenCalledWith( + { tenant_id: mockTenantId, is_default: true }, + { is_default: false }, + ); + }); + }); + + describe('getPaymentMethods', () => { + it('should return active payment methods', async () => { + paymentMethodRepo.find.mockResolvedValue([mockPaymentMethod as PaymentMethod]); + + const result = await service.getPaymentMethods(mockTenantId); + + expect(result).toHaveLength(1); + expect(paymentMethodRepo.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId, is_active: true }, + order: { is_default: 'DESC', created_at: 'DESC' }, + }); + }); + }); + + describe('getDefaultPaymentMethod', () => { + it('should return default payment method', async () => { + paymentMethodRepo.findOne.mockResolvedValue(mockPaymentMethod as PaymentMethod); + + const result = await service.getDefaultPaymentMethod(mockTenantId); + + expect(result).toEqual(mockPaymentMethod); + }); + + it('should return null if no default', async () => { + paymentMethodRepo.findOne.mockResolvedValue(null); + + const result = await service.getDefaultPaymentMethod(mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('setDefaultPaymentMethod', () => { + it('should set payment method as default', async () => { + paymentMethodRepo.findOne.mockResolvedValue(mockPaymentMethod as PaymentMethod); + paymentMethodRepo.update.mockResolvedValue({ affected: 1 } as any); + paymentMethodRepo.save.mockResolvedValue({ + ...mockPaymentMethod, + is_default: true, + } as PaymentMethod); + + const result = await service.setDefaultPaymentMethod('pm-001', mockTenantId); + + expect(result.is_default).toBe(true); + }); + + it('should throw NotFoundException if not found', async () => { + paymentMethodRepo.findOne.mockResolvedValue(null); + + await expect( + service.setDefaultPaymentMethod('invalid-id', mockTenantId), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('removePaymentMethod', () => { + it('should deactivate non-default payment method', async () => { + paymentMethodRepo.findOne.mockResolvedValue({ + ...mockPaymentMethod, + is_default: false, + } as PaymentMethod); + paymentMethodRepo.save.mockResolvedValue({} as PaymentMethod); + + await service.removePaymentMethod('pm-001', mockTenantId); + + expect(paymentMethodRepo.save).toHaveBeenCalled(); + }); + + it('should throw BadRequestException for default payment method', async () => { + paymentMethodRepo.findOne.mockResolvedValue(mockPaymentMethod as PaymentMethod); + + await expect( + service.removePaymentMethod('pm-001', mockTenantId), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException if not found', async () => { + paymentMethodRepo.findOne.mockResolvedValue(null); + + await expect( + service.removePaymentMethod('invalid-id', mockTenantId), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Summary Tests ==================== + + describe('getBillingSummary', () => { + it('should return billing summary', async () => { + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + paymentMethodRepo.findOne.mockResolvedValue(mockPaymentMethod as PaymentMethod); + invoiceRepo.find.mockResolvedValue([mockInvoice as Invoice]); + + const result = await service.getBillingSummary(mockTenantId); + + expect(result.subscription).toEqual(mockSubscription); + expect(result.defaultPaymentMethod).toEqual(mockPaymentMethod); + expect(result.pendingInvoices).toBe(1); + expect(result.totalDue).toBe(116); + }); + }); + + describe('checkSubscriptionStatus', () => { + it('should return active subscription status', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 15); + + subscriptionRepo.findOne.mockResolvedValue({ + ...mockSubscription, + current_period_end: futureDate, + } as Subscription); + + const result = await service.checkSubscriptionStatus(mockTenantId); + + expect(result.isActive).toBe(true); + expect(result.daysRemaining).toBeGreaterThan(0); + expect(result.status).toBe(SubscriptionStatus.ACTIVE); + }); + + it('should return expired status when no subscription', async () => { + subscriptionRepo.findOne.mockResolvedValue(null); + + const result = await service.checkSubscriptionStatus(mockTenantId); + + expect(result.isActive).toBe(false); + expect(result.daysRemaining).toBe(0); + expect(result.status).toBe(SubscriptionStatus.EXPIRED); + }); + + it('should return active for trial subscription', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 10); + + subscriptionRepo.findOne.mockResolvedValue({ + ...mockSubscription, + status: SubscriptionStatus.TRIAL, + current_period_end: futureDate, + } as Subscription); + + const result = await service.checkSubscriptionStatus(mockTenantId); + + expect(result.isActive).toBe(true); + expect(result.status).toBe(SubscriptionStatus.TRIAL); + }); + }); +}); diff --git a/src/modules/billing/__tests__/plans.controller.spec.ts b/src/modules/billing/__tests__/plans.controller.spec.ts new file mode 100644 index 0000000..9b3a595 --- /dev/null +++ b/src/modules/billing/__tests__/plans.controller.spec.ts @@ -0,0 +1,854 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { PlansController } from '../controllers/plans.controller'; +import { PlansService } from '../services/plans.service'; +import { PlanResponseDto, PlanDetailResponseDto } from '../dto/plan-response.dto'; +import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator'; + +describe('PlansController', () => { + let controller: PlansController; + let service: jest.Mocked; + let reflector: Reflector; + + // Mock plan response data + const mockFreePlanResponse: PlanResponseDto = { + id: '550e8400-e29b-41d4-a716-446655440001', + name: 'Free', + slug: 'free', + display_name: 'Free', + description: 'Perfect for getting started', + price_monthly: 0, + price_yearly: 0, + currency: 'USD', + features: ['Up to 3 users', 'Basic features', 'Community support'], + limits: { max_users: 3, storage_gb: 1 }, + is_popular: false, + trial_days: 0, + }; + + const mockProPlanResponse: PlanResponseDto = { + id: '550e8400-e29b-41d4-a716-446655440002', + name: 'Professional', + slug: 'professional', + display_name: 'Professional', + description: 'For growing businesses', + tagline: 'Best for teams', + price_monthly: 79, + price_yearly: 790, + currency: 'USD', + features: [ + 'Up to 50 users', + 'Storage: 100 GB', + 'API access', + 'Priority support', + 'Custom integrations', + ], + limits: { max_users: 50, storage_gb: 100 }, + is_popular: true, + trial_days: 14, + }; + + const mockEnterprisePlanResponse: PlanResponseDto = { + id: '550e8400-e29b-41d4-a716-446655440003', + name: 'Enterprise', + slug: 'enterprise', + display_name: 'Enterprise', + description: 'For large organizations', + tagline: 'Custom solutions', + price_monthly: 0, + price_yearly: 0, + currency: 'USD', + features: ['Unlimited users', 'SSO/SAML', 'Dedicated support', 'SLA guarantee'], + limits: {}, + is_popular: false, + trial_days: 30, + }; + + const mockProPlanDetailResponse: PlanDetailResponseDto = { + ...mockProPlanResponse, + is_enterprise: false, + detailed_features: [ + { name: 'Up to 50 users', value: true, highlight: false }, + { name: 'Storage', value: '100 GB', highlight: true }, + { name: 'API access', value: true }, + ], + metadata: { promotion: 'summer2026' }, + }; + + const mockEnterprisePlanDetailResponse: PlanDetailResponseDto = { + ...mockEnterprisePlanResponse, + is_enterprise: true, + detailed_features: [ + { name: 'Unlimited users', value: true }, + { name: 'SSO/SAML', value: true }, + ], + metadata: undefined, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [PlansController], + providers: [ + { + provide: PlansService, + useValue: { + findAll: jest.fn(), + findOne: jest.fn(), + findBySlug: jest.fn(), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(PlansController); + service = module.get(PlansService); + reflector = module.get(Reflector); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Controller Definition', () => { + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should have PlansService injected', () => { + expect(service).toBeDefined(); + }); + }); + + // ============================================================ + // PUBLIC ENDPOINT DECORATOR TESTS + // ============================================================ + describe('Public Endpoint Decorators', () => { + it('should have @Public decorator on findAll method', () => { + const isPublic = reflector.get( + IS_PUBLIC_KEY, + PlansController.prototype.findAll, + ); + expect(isPublic).toBe(true); + }); + + it('should have @Public decorator on findOne method', () => { + const isPublic = reflector.get( + IS_PUBLIC_KEY, + PlansController.prototype.findOne, + ); + expect(isPublic).toBe(true); + }); + }); + + // ============================================================ + // findAll() - GET /plans + // ============================================================ + describe('findAll', () => { + describe('Success Cases', () => { + it('should return all available plans', async () => { + const plans = [mockFreePlanResponse, mockProPlanResponse, mockEnterprisePlanResponse]; + service.findAll.mockResolvedValue(plans); + + const result = await controller.findAll(); + + expect(result).toEqual(plans); + expect(result).toHaveLength(3); + expect(service.findAll).toHaveBeenCalledTimes(1); + expect(service.findAll).toHaveBeenCalledWith(); + }); + + it('should return empty array when no plans exist', async () => { + service.findAll.mockResolvedValue([]); + + const result = await controller.findAll(); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + expect(service.findAll).toHaveBeenCalledTimes(1); + }); + + it('should return plans with all required DTO fields', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const result = await controller.findAll(); + + expect(result[0]).toHaveProperty('id'); + expect(result[0]).toHaveProperty('name'); + expect(result[0]).toHaveProperty('slug'); + expect(result[0]).toHaveProperty('display_name'); + expect(result[0]).toHaveProperty('description'); + expect(result[0]).toHaveProperty('price_monthly'); + expect(result[0]).toHaveProperty('price_yearly'); + expect(result[0]).toHaveProperty('currency'); + expect(result[0]).toHaveProperty('features'); + }); + + it('should return plans with optional fields when present', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const result = await controller.findAll(); + + expect(result[0].tagline).toBe('Best for teams'); + expect(result[0].limits).toEqual({ max_users: 50, storage_gb: 100 }); + expect(result[0].is_popular).toBe(true); + expect(result[0].trial_days).toBe(14); + }); + + it('should return plans without optional fields when not present', async () => { + const planWithoutOptionals: PlanResponseDto = { + id: 'plan-123', + name: 'Basic', + slug: 'basic', + display_name: 'Basic', + description: 'Basic plan', + price_monthly: 10, + price_yearly: 100, + currency: 'USD', + features: ['Feature 1'], + }; + service.findAll.mockResolvedValue([planWithoutOptionals]); + + const result = await controller.findAll(); + + expect(result[0].tagline).toBeUndefined(); + expect(result[0].limits).toBeUndefined(); + expect(result[0].is_popular).toBeUndefined(); + expect(result[0].trial_days).toBeUndefined(); + }); + + it('should return plans with features as string array', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const result = await controller.findAll(); + + expect(Array.isArray(result[0].features)).toBe(true); + expect(result[0].features).toContain('Up to 50 users'); + expect(result[0].features).toContain('Storage: 100 GB'); + expect(result[0].features).toContain('API access'); + }); + + it('should return single plan when only one exists', async () => { + service.findAll.mockResolvedValue([mockFreePlanResponse]); + + const result = await controller.findAll(); + + expect(result).toHaveLength(1); + expect(result[0].slug).toBe('free'); + }); + }); + + describe('Error Cases', () => { + it('should propagate service errors', async () => { + const error = new Error('Database connection failed'); + service.findAll.mockRejectedValue(error); + + await expect(controller.findAll()).rejects.toThrow('Database connection failed'); + expect(service.findAll).toHaveBeenCalledTimes(1); + }); + + it('should handle unexpected service errors', async () => { + service.findAll.mockRejectedValue(new Error('Unexpected error')); + + await expect(controller.findAll()).rejects.toThrow(); + }); + }); + + describe('Data Integrity', () => { + it('should preserve plan ordering from service', async () => { + const orderedPlans = [ + { ...mockFreePlanResponse, slug: 'free' }, + { ...mockProPlanResponse, slug: 'professional' }, + { ...mockEnterprisePlanResponse, slug: 'enterprise' }, + ]; + service.findAll.mockResolvedValue(orderedPlans); + + const result = await controller.findAll(); + + expect(result[0].slug).toBe('free'); + expect(result[1].slug).toBe('professional'); + expect(result[2].slug).toBe('enterprise'); + }); + + it('should return exact data from service without modification', async () => { + const plans = [mockProPlanResponse]; + service.findAll.mockResolvedValue(plans); + + const result = await controller.findAll(); + + expect(result).toBe(plans); + }); + }); + }); + + // ============================================================ + // findOne() - GET /plans/:id + // ============================================================ + describe('findOne', () => { + describe('Success Cases', () => { + it('should return plan by ID', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002'); + + expect(result).toEqual(mockProPlanDetailResponse); + expect(service.findOne).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440002'); + expect(service.findOne).toHaveBeenCalledTimes(1); + }); + + it('should return detailed plan response with additional fields', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002'); + + expect(result).toHaveProperty('detailed_features'); + expect(result).toHaveProperty('metadata'); + expect(result.detailed_features).toEqual([ + { name: 'Up to 50 users', value: true, highlight: false }, + { name: 'Storage', value: '100 GB', highlight: true }, + { name: 'API access', value: true }, + ]); + expect(result.metadata).toEqual({ promotion: 'summer2026' }); + }); + + it('should return enterprise plan with is_enterprise flag', async () => { + service.findOne.mockResolvedValue(mockEnterprisePlanDetailResponse); + + const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440003'); + + expect(result.is_enterprise).toBe(true); + expect(result.name).toBe('Enterprise'); + }); + + it('should return non-enterprise plan without is_enterprise flag', async () => { + const nonEnterprisePlan: PlanDetailResponseDto = { + ...mockProPlanDetailResponse, + is_enterprise: undefined, + }; + service.findOne.mockResolvedValue(nonEnterprisePlan); + + const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002'); + + expect(result.is_enterprise).toBeUndefined(); + }); + + it('should handle plan with all optional fields', async () => { + const fullPlan: PlanDetailResponseDto = { + ...mockProPlanDetailResponse, + tagline: 'Best value', + is_popular: true, + trial_days: 30, + is_enterprise: false, + metadata: { custom_field: 'value' }, + }; + service.findOne.mockResolvedValue(fullPlan); + + const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002'); + + expect(result.tagline).toBe('Best value'); + expect(result.is_popular).toBe(true); + expect(result.trial_days).toBe(30); + expect(result.metadata).toEqual({ custom_field: 'value' }); + }); + + it('should handle plan with empty detailed_features', async () => { + const planWithEmptyFeatures: PlanDetailResponseDto = { + ...mockProPlanDetailResponse, + detailed_features: [], + }; + service.findOne.mockResolvedValue(planWithEmptyFeatures); + + const result = await controller.findOne('550e8400-e29b-41d4-a716-446655440002'); + + expect(result.detailed_features).toEqual([]); + }); + + it('should handle UUID format IDs', async () => { + const uuid = '123e4567-e89b-12d3-a456-426614174000'; + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + await controller.findOne(uuid); + + expect(service.findOne).toHaveBeenCalledWith(uuid); + }); + }); + + describe('Error Cases - NotFoundException', () => { + it('should throw NotFoundException when plan not found', async () => { + const notFoundError = new NotFoundException('Plan with ID "non-existent-id" not found'); + service.findOne.mockRejectedValue(notFoundError); + + await expect(controller.findOne('non-existent-id')).rejects.toThrow(NotFoundException); + expect(service.findOne).toHaveBeenCalledWith('non-existent-id'); + }); + + it('should include ID in NotFoundException message', async () => { + const planId = 'missing-plan-123'; + service.findOne.mockRejectedValue( + new NotFoundException(`Plan with ID "${planId}" not found`), + ); + + await expect(controller.findOne(planId)).rejects.toThrow( + `Plan with ID "${planId}" not found`, + ); + }); + + it('should throw NotFoundException for invalid UUID', async () => { + service.findOne.mockRejectedValue( + new NotFoundException('Plan with ID "invalid-uuid" not found'), + ); + + await expect(controller.findOne('invalid-uuid')).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException for empty string ID', async () => { + service.findOne.mockRejectedValue(new NotFoundException('Plan with ID "" not found')); + + await expect(controller.findOne('')).rejects.toThrow(NotFoundException); + }); + }); + + describe('Error Cases - Other Errors', () => { + it('should propagate database errors', async () => { + service.findOne.mockRejectedValue(new Error('Database connection error')); + + await expect(controller.findOne('plan-123')).rejects.toThrow('Database connection error'); + }); + + it('should propagate generic errors', async () => { + service.findOne.mockRejectedValue(new Error('Unexpected error')); + + await expect(controller.findOne('plan-123')).rejects.toThrow('Unexpected error'); + }); + }); + + describe('Parameter Handling', () => { + it('should pass ID parameter to service without modification', async () => { + const testId = 'test-plan-id-123'; + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + await controller.findOne(testId); + + expect(service.findOne).toHaveBeenCalledWith(testId); + }); + + it('should handle special characters in ID', async () => { + const specialId = 'plan-with-special_chars.123'; + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + await controller.findOne(specialId); + + expect(service.findOne).toHaveBeenCalledWith(specialId); + }); + }); + }); + + // ============================================================ + // PLAN FEATURES MANAGEMENT TESTS + // ============================================================ + describe('Plan Features', () => { + describe('Features Array Structure', () => { + it('should return features as string array in findAll response', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const result = await controller.findAll(); + + expect(Array.isArray(result[0].features)).toBe(true); + result[0].features.forEach((feature) => { + expect(typeof feature).toBe('string'); + }); + }); + + it('should return detailed_features in findOne response', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const result = await controller.findOne('plan-123'); + + expect(Array.isArray(result.detailed_features)).toBe(true); + expect(result.detailed_features?.[0]).toHaveProperty('name'); + expect(result.detailed_features?.[0]).toHaveProperty('value'); + }); + + it('should handle detailed_features with highlight flag', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const result = await controller.findOne('plan-123'); + + const highlightedFeature = result.detailed_features?.find((f) => f.highlight === true); + expect(highlightedFeature).toBeDefined(); + expect(highlightedFeature?.name).toBe('Storage'); + }); + + it('should handle detailed_features with boolean values', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const result = await controller.findOne('plan-123'); + + const booleanFeature = result.detailed_features?.find( + (f) => typeof f.value === 'boolean', + ); + expect(booleanFeature).toBeDefined(); + expect(booleanFeature?.value).toBe(true); + }); + + it('should handle detailed_features with string values', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const result = await controller.findOne('plan-123'); + + const stringFeature = result.detailed_features?.find((f) => typeof f.value === 'string'); + expect(stringFeature).toBeDefined(); + expect(stringFeature?.value).toBe('100 GB'); + }); + }); + + describe('Empty Features', () => { + it('should handle plan with empty features array', async () => { + const planWithNoFeatures: PlanResponseDto = { + ...mockProPlanResponse, + features: [], + }; + service.findAll.mockResolvedValue([planWithNoFeatures]); + + const result = await controller.findAll(); + + expect(result[0].features).toEqual([]); + }); + + it('should handle detailed plan with undefined detailed_features', async () => { + const planWithoutDetailedFeatures: PlanDetailResponseDto = { + ...mockProPlanDetailResponse, + detailed_features: undefined, + }; + service.findOne.mockResolvedValue(planWithoutDetailedFeatures); + + const result = await controller.findOne('plan-123'); + + expect(result.detailed_features).toBeUndefined(); + }); + }); + }); + + // ============================================================ + // PLAN LIMITS TESTS + // ============================================================ + describe('Plan Limits', () => { + it('should return limits as Record', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const result = await controller.findAll(); + + expect(typeof result[0].limits).toBe('object'); + expect(result[0].limits?.max_users).toBe(50); + expect(result[0].limits?.storage_gb).toBe(100); + }); + + it('should handle plan with empty limits', async () => { + service.findAll.mockResolvedValue([mockEnterprisePlanResponse]); + + const result = await controller.findAll(); + + expect(result[0].limits).toEqual({}); + }); + + it('should handle plan with undefined limits', async () => { + const planWithNoLimits: PlanResponseDto = { + ...mockProPlanResponse, + limits: undefined, + }; + service.findAll.mockResolvedValue([planWithNoLimits]); + + const result = await controller.findAll(); + + expect(result[0].limits).toBeUndefined(); + }); + }); + + // ============================================================ + // PRICING TESTS + // ============================================================ + describe('Plan Pricing', () => { + it('should return correct monthly price', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const result = await controller.findAll(); + + expect(result[0].price_monthly).toBe(79); + }); + + it('should return correct yearly price', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const result = await controller.findAll(); + + expect(result[0].price_yearly).toBe(790); + }); + + it('should handle free plan with zero prices', async () => { + service.findAll.mockResolvedValue([mockFreePlanResponse]); + + const result = await controller.findAll(); + + expect(result[0].price_monthly).toBe(0); + expect(result[0].price_yearly).toBe(0); + }); + + it('should handle enterprise plan with contact pricing (zero)', async () => { + service.findAll.mockResolvedValue([mockEnterprisePlanResponse]); + + const result = await controller.findAll(); + + expect(result[0].price_monthly).toBe(0); + expect(result[0].price_yearly).toBe(0); + }); + + it('should return currency code', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const result = await controller.findAll(); + + expect(result[0].currency).toBe('USD'); + }); + }); + + // ============================================================ + // METADATA TESTS + // ============================================================ + describe('Plan Metadata', () => { + it('should return metadata in detailed plan response', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const result = await controller.findOne('plan-123'); + + expect(result.metadata).toBeDefined(); + expect(result.metadata?.promotion).toBe('summer2026'); + }); + + it('should handle plan without metadata', async () => { + const planWithoutMetadata: PlanDetailResponseDto = { + ...mockProPlanDetailResponse, + metadata: undefined, + }; + service.findOne.mockResolvedValue(planWithoutMetadata); + + const result = await controller.findOne('plan-123'); + + expect(result.metadata).toBeUndefined(); + }); + + it('should handle plan with complex metadata', async () => { + const planWithComplexMetadata: PlanDetailResponseDto = { + ...mockProPlanDetailResponse, + metadata: { + promotion: 'summer2026', + discount_percentage: 20, + valid_until: '2026-12-31', + eligible_countries: ['US', 'CA', 'MX'], + }, + }; + service.findOne.mockResolvedValue(planWithComplexMetadata); + + const result = await controller.findOne('plan-123'); + + expect(result.metadata?.promotion).toBe('summer2026'); + expect(result.metadata?.discount_percentage).toBe(20); + expect(result.metadata?.eligible_countries).toContain('US'); + }); + }); + + // ============================================================ + // SERVICE INTERACTION TESTS + // ============================================================ + describe('Service Interaction', () => { + it('should call service.findAll exactly once', async () => { + service.findAll.mockResolvedValue([]); + + await controller.findAll(); + + expect(service.findAll).toHaveBeenCalledTimes(1); + }); + + it('should call service.findOne exactly once', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + await controller.findOne('plan-123'); + + expect(service.findOne).toHaveBeenCalledTimes(1); + }); + + it('should not call findOne when calling findAll', async () => { + service.findAll.mockResolvedValue([]); + + await controller.findAll(); + + expect(service.findOne).not.toHaveBeenCalled(); + }); + + it('should not call findAll when calling findOne', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + await controller.findOne('plan-123'); + + expect(service.findAll).not.toHaveBeenCalled(); + }); + + it('should return the exact value from service.findAll', async () => { + const plans = [mockFreePlanResponse, mockProPlanResponse]; + service.findAll.mockResolvedValue(plans); + + const result = await controller.findAll(); + + expect(result).toBe(plans); + }); + + it('should return the exact value from service.findOne', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const result = await controller.findOne('plan-123'); + + expect(result).toBe(mockProPlanDetailResponse); + }); + }); + + // ============================================================ + // CONCURRENT ACCESS TESTS + // ============================================================ + describe('Concurrent Access', () => { + it('should handle multiple concurrent findAll requests', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + + const results = await Promise.all([ + controller.findAll(), + controller.findAll(), + controller.findAll(), + ]); + + expect(results).toHaveLength(3); + results.forEach((result) => { + expect(result).toEqual([mockProPlanResponse]); + }); + expect(service.findAll).toHaveBeenCalledTimes(3); + }); + + it('should handle multiple concurrent findOne requests', async () => { + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const ids = ['plan-1', 'plan-2', 'plan-3']; + const results = await Promise.all(ids.map((id) => controller.findOne(id))); + + expect(results).toHaveLength(3); + expect(service.findOne).toHaveBeenCalledTimes(3); + }); + + it('should handle mixed concurrent requests', async () => { + service.findAll.mockResolvedValue([mockProPlanResponse]); + service.findOne.mockResolvedValue(mockProPlanDetailResponse); + + const results = await Promise.all([ + controller.findAll(), + controller.findOne('plan-1'), + controller.findAll(), + controller.findOne('plan-2'), + ]); + + expect(results).toHaveLength(4); + expect(service.findAll).toHaveBeenCalledTimes(2); + expect(service.findOne).toHaveBeenCalledTimes(2); + }); + }); + + // ============================================================ + // EDGE CASES + // ============================================================ + describe('Edge Cases', () => { + it('should handle very long plan name', async () => { + const longNamePlan: PlanResponseDto = { + ...mockProPlanResponse, + name: 'A'.repeat(100), + display_name: 'A'.repeat(100), + }; + service.findAll.mockResolvedValue([longNamePlan]); + + const result = await controller.findAll(); + + expect(result[0].name).toHaveLength(100); + }); + + it('should handle very long description', async () => { + const longDescriptionPlan: PlanResponseDto = { + ...mockProPlanResponse, + description: 'B'.repeat(1000), + }; + service.findAll.mockResolvedValue([longDescriptionPlan]); + + const result = await controller.findAll(); + + expect(result[0].description).toHaveLength(1000); + }); + + it('should handle plan with many features', async () => { + const manyFeatures = Array.from({ length: 50 }, (_, i) => `Feature ${i + 1}`); + const planWithManyFeatures: PlanResponseDto = { + ...mockProPlanResponse, + features: manyFeatures, + }; + service.findAll.mockResolvedValue([planWithManyFeatures]); + + const result = await controller.findAll(); + + expect(result[0].features).toHaveLength(50); + }); + + it('should handle plan with special characters in slug', async () => { + const planWithSpecialSlug: PlanResponseDto = { + ...mockProPlanResponse, + slug: 'professional-2026', + }; + service.findAll.mockResolvedValue([planWithSpecialSlug]); + + const result = await controller.findAll(); + + expect(result[0].slug).toBe('professional-2026'); + }); + + it('should handle plan with large numeric limits', async () => { + const planWithLargeLimits: PlanResponseDto = { + ...mockProPlanResponse, + limits: { max_users: 999999, storage_gb: 10000 }, + }; + service.findAll.mockResolvedValue([planWithLargeLimits]); + + const result = await controller.findAll(); + + expect(result[0].limits?.max_users).toBe(999999); + expect(result[0].limits?.storage_gb).toBe(10000); + }); + + it('should handle plan with high prices', async () => { + const expensivePlan: PlanResponseDto = { + ...mockProPlanResponse, + price_monthly: 9999.99, + price_yearly: 99999.99, + }; + service.findAll.mockResolvedValue([expensivePlan]); + + const result = await controller.findAll(); + + expect(result[0].price_monthly).toBe(9999.99); + expect(result[0].price_yearly).toBe(99999.99); + }); + + it('should handle different currency codes', async () => { + const eurPlan: PlanResponseDto = { + ...mockProPlanResponse, + currency: 'EUR', + }; + service.findAll.mockResolvedValue([eurPlan]); + + const result = await controller.findAll(); + + expect(result[0].currency).toBe('EUR'); + }); + }); +}); diff --git a/src/modules/billing/__tests__/plans.service.spec.ts b/src/modules/billing/__tests__/plans.service.spec.ts new file mode 100644 index 0000000..52ffe38 --- /dev/null +++ b/src/modules/billing/__tests__/plans.service.spec.ts @@ -0,0 +1,313 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { PlansService } from '../services/plans.service'; +import { Plan } from '../entities/plan.entity'; + +describe('PlansService', () => { + let service: PlansService; + let planRepo: jest.Mocked>; + + const mockPlan: Partial = { + id: '550e8400-e29b-41d4-a716-446655440001', + name: 'Professional', + slug: 'professional', + description: 'For growing businesses', + tagline: 'Best for teams', + price_monthly: 79, + price_yearly: 790, + currency: 'USD', + features: [ + { name: 'Up to 50 users', value: true, highlight: false }, + { name: 'Storage', value: '100 GB', highlight: true }, + { name: 'API access', value: true }, + ], + included_features: ['Priority support', 'Custom integrations'], + limits: { max_users: 50, storage_gb: 100 }, + is_popular: true, + is_enterprise: false, + is_active: true, + is_visible: true, + sort_order: 2, + trial_days: 14, + metadata: { promotion: 'summer2026' }, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + const mockFreePlan: Partial = { + id: '550e8400-e29b-41d4-a716-446655440002', + name: 'Free', + slug: 'free', + description: 'Perfect for getting started', + tagline: null, + price_monthly: 0, + price_yearly: 0, + currency: 'USD', + features: [ + { name: 'Up to 3 users', value: true }, + { name: 'Basic features', value: true }, + ], + included_features: ['Community support'], + limits: { max_users: 3, storage_gb: 1 }, + is_popular: false, + is_enterprise: false, + is_active: true, + is_visible: true, + sort_order: 1, + trial_days: 0, + metadata: null, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + const mockEnterprisePlan: Partial = { + id: '550e8400-e29b-41d4-a716-446655440003', + name: 'Enterprise', + slug: 'enterprise', + description: 'For large organizations', + tagline: 'Custom solutions', + price_monthly: null, + price_yearly: null, + currency: 'USD', + features: [ + { name: 'Unlimited users', value: true }, + { name: 'SSO/SAML', value: true }, + ], + included_features: ['Dedicated support', 'SLA guarantee'], + limits: {}, + is_popular: false, + is_enterprise: true, + is_active: true, + is_visible: true, + sort_order: 4, + trial_days: 30, + metadata: null, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + beforeEach(async () => { + const mockPlanRepo = { + find: jest.fn(), + findOne: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PlansService, + { provide: getRepositoryToken(Plan), useValue: mockPlanRepo }, + ], + }).compile(); + + service = module.get(PlansService); + planRepo = module.get(getRepositoryToken(Plan)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all visible and active plans', async () => { + const plans = [mockFreePlan, mockPlan, mockEnterprisePlan]; + planRepo.find.mockResolvedValue(plans as Plan[]); + + const result = await service.findAll(); + + expect(result).toHaveLength(3); + expect(planRepo.find).toHaveBeenCalledWith({ + where: { + is_active: true, + is_visible: true, + }, + order: { + sort_order: 'ASC', + }, + }); + }); + + it('should return empty array when no plans exist', async () => { + planRepo.find.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(result).toHaveLength(0); + expect(result).toEqual([]); + }); + + it('should transform plan entity to response DTO correctly', async () => { + planRepo.find.mockResolvedValue([mockPlan as Plan]); + + const result = await service.findAll(); + + expect(result[0]).toMatchObject({ + id: mockPlan.id, + name: mockPlan.name, + slug: mockPlan.slug, + display_name: mockPlan.name, + description: mockPlan.description, + tagline: mockPlan.tagline, + price_monthly: 79, + price_yearly: 790, + currency: 'USD', + is_popular: true, + trial_days: 14, + }); + }); + + it('should extract features as string array', async () => { + planRepo.find.mockResolvedValue([mockPlan as Plan]); + + const result = await service.findAll(); + + // Features should include both features array items and included_features + expect(result[0].features).toContain('Up to 50 users'); + expect(result[0].features).toContain('Storage: 100 GB'); + expect(result[0].features).toContain('API access'); + expect(result[0].features).toContain('Priority support'); + expect(result[0].features).toContain('Custom integrations'); + }); + + it('should handle null prices correctly', async () => { + planRepo.find.mockResolvedValue([mockEnterprisePlan as Plan]); + + const result = await service.findAll(); + + expect(result[0].price_monthly).toBe(0); + expect(result[0].price_yearly).toBe(0); + }); + + it('should include limits in response', async () => { + planRepo.find.mockResolvedValue([mockPlan as Plan]); + + const result = await service.findAll(); + + expect(result[0].limits).toEqual({ max_users: 50, storage_gb: 100 }); + }); + }); + + describe('findOne', () => { + it('should return plan by ID', async () => { + planRepo.findOne.mockResolvedValue(mockPlan as Plan); + + const result = await service.findOne(mockPlan.id!); + + expect(result).toBeDefined(); + expect(result.id).toBe(mockPlan.id); + expect(planRepo.findOne).toHaveBeenCalledWith({ + where: { id: mockPlan.id }, + }); + }); + + it('should throw NotFoundException when plan not found', async () => { + planRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne('non-existent-id')).rejects.toThrow( + NotFoundException, + ); + await expect(service.findOne('non-existent-id')).rejects.toThrow( + 'Plan with ID "non-existent-id" not found', + ); + }); + + it('should return detailed DTO with additional fields', async () => { + planRepo.findOne.mockResolvedValue(mockPlan as Plan); + + const result = await service.findOne(mockPlan.id!); + + // is_enterprise is false so it becomes undefined due to || undefined pattern + expect(result.is_enterprise).toBeUndefined(); + expect(result.detailed_features).toEqual(mockPlan.features); + expect(result.metadata).toEqual({ promotion: 'summer2026' }); + }); + + it('should include is_enterprise flag for enterprise plans', async () => { + planRepo.findOne.mockResolvedValue(mockEnterprisePlan as Plan); + + const result = await service.findOne(mockEnterprisePlan.id!); + + expect(result.is_enterprise).toBe(true); + }); + }); + + describe('findBySlug', () => { + it('should return plan by slug', async () => { + planRepo.findOne.mockResolvedValue(mockPlan as Plan); + + const result = await service.findBySlug('professional'); + + expect(result).toBeDefined(); + expect(result.slug).toBe('professional'); + expect(planRepo.findOne).toHaveBeenCalledWith({ + where: { slug: 'professional' }, + }); + }); + + it('should throw NotFoundException when plan not found by slug', async () => { + planRepo.findOne.mockResolvedValue(null); + + await expect(service.findBySlug('non-existent')).rejects.toThrow( + NotFoundException, + ); + await expect(service.findBySlug('non-existent')).rejects.toThrow( + 'Plan with slug "non-existent" not found', + ); + }); + }); + + describe('feature extraction', () => { + it('should handle empty features array', async () => { + const planWithNoFeatures = { + ...mockPlan, + features: [], + included_features: [], + }; + planRepo.find.mockResolvedValue([planWithNoFeatures as Plan]); + + const result = await service.findAll(); + + expect(result[0].features).toEqual([]); + }); + + it('should handle boolean feature values', async () => { + const planWithBooleanFeatures = { + ...mockPlan, + features: [ + { name: 'Feature enabled', value: true }, + { name: 'Feature disabled', value: false }, + ], + included_features: [], + }; + planRepo.find.mockResolvedValue([planWithBooleanFeatures as Plan]); + + const result = await service.findAll(); + + // Only true boolean features should be included + expect(result[0].features).toContain('Feature enabled'); + expect(result[0].features).not.toContain('Feature disabled'); + }); + + it('should handle string feature values', async () => { + const planWithStringFeatures = { + ...mockPlan, + features: [{ name: 'Storage', value: '500 GB' }], + included_features: [], + }; + planRepo.find.mockResolvedValue([planWithStringFeatures as Plan]); + + const result = await service.findAll(); + + expect(result[0].features).toContain('Storage: 500 GB'); + }); + + it('should handle null tagline', async () => { + planRepo.find.mockResolvedValue([mockFreePlan as Plan]); + + const result = await service.findAll(); + + expect(result[0].tagline).toBeUndefined(); + }); + }); +}); diff --git a/src/modules/billing/__tests__/stripe-webhook.controller.spec.ts b/src/modules/billing/__tests__/stripe-webhook.controller.spec.ts new file mode 100644 index 0000000..25b3d12 --- /dev/null +++ b/src/modules/billing/__tests__/stripe-webhook.controller.spec.ts @@ -0,0 +1,873 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpStatus } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { StripeWebhookController } from '../controllers/stripe-webhook.controller'; +import { StripeService } from '../services/stripe.service'; +import { StripeWebhookEventType } from '../dto/stripe-webhook.dto'; +import Stripe from 'stripe'; + +describe('StripeWebhookController', () => { + let controller: StripeWebhookController; + let stripeService: jest.Mocked; + + // Mock request and response + const createMockRequest = (rawBody?: Buffer, signature?: string) => { + return { + rawBody, + headers: { + 'stripe-signature': signature, + }, + } as unknown as Request & { rawBody?: Buffer }; + }; + + const createMockResponse = () => { + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + } as unknown as Response; + return res; + }; + + // Sample Stripe events + const createStripeEvent = (type: string, data: object = {}): Stripe.Event => ({ + id: 'evt_test123', + type, + data: { object: data }, + api_version: '2025-02-24.acacia', + created: Math.floor(Date.now() / 1000), + livemode: false, + object: 'event', + pending_webhooks: 0, + request: null, + } as Stripe.Event); + + beforeEach(async () => { + const mockStripeService = { + constructWebhookEvent: jest.fn(), + handleWebhookEvent: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [StripeWebhookController], + providers: [ + { + provide: StripeService, + useValue: mockStripeService, + }, + ], + }).compile(); + + controller = module.get(StripeWebhookController); + stripeService = module.get(StripeService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handleWebhook', () => { + describe('Signature Verification', () => { + it('should reject request without stripe-signature header', async () => { + const req = createMockRequest(Buffer.from('{}')); + const res = createMockResponse(); + + await controller.handleWebhook(req as any, res, undefined as any); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Missing stripe-signature header', + }); + expect(stripeService.constructWebhookEvent).not.toHaveBeenCalled(); + }); + + it('should reject request without raw body', async () => { + const req = createMockRequest(undefined, 'valid_signature'); + const res = createMockResponse(); + + await controller.handleWebhook(req as any, res, 'valid_signature'); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Missing raw body', + }); + expect(stripeService.constructWebhookEvent).not.toHaveBeenCalled(); + }); + + it('should reject request with invalid signature', async () => { + const rawBody = Buffer.from('{"test":"data"}'); + const req = createMockRequest(rawBody, 'invalid_signature'); + const res = createMockResponse(); + + const signatureError = new Error('Invalid signature'); + (signatureError as any).type = 'StripeSignatureVerificationError'; + stripeService.constructWebhookEvent.mockImplementation(() => { + throw signatureError; + }); + + await controller.handleWebhook(req as any, res, 'invalid_signature'); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Invalid signature', + }); + }); + + it('should accept request with valid signature', async () => { + const rawBody = Buffer.from('{"test":"data"}'); + const signature = 'valid_signature_123'; + const req = createMockRequest(rawBody, signature); + const res = createMockResponse(); + + const mockEvent = createStripeEvent('customer.created', { id: 'cus_123' }); + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, signature); + + expect(stripeService.constructWebhookEvent).toHaveBeenCalledWith(rawBody, signature); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(res.json).toHaveBeenCalledWith({ + received: true, + event_type: 'customer.created', + }); + }); + }); + + describe('Event Handlers', () => { + const validRawBody = Buffer.from('{"valid":"payload"}'); + const validSignature = 'whsec_valid_signature'; + + describe('checkout.session.completed', () => { + it('should handle checkout session completed event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const checkoutSession = { + id: 'cs_test123', + subscription: 'sub_test123', + customer: 'cus_test123', + metadata: { tenant_id: 'tenant-123' }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED, + checkoutSession, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(res.json).toHaveBeenCalledWith({ + received: true, + event_type: StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED, + }); + }); + }); + + describe('customer.subscription.created', () => { + it('should handle subscription created event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const subscription = { + id: 'sub_test123', + customer: 'cus_test123', + status: 'active', + metadata: { tenant_id: 'tenant-123' }, + current_period_start: Math.floor(Date.now() / 1000), + current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.SUBSCRIPTION_CREATED, + subscription, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('customer.subscription.updated', () => { + it('should handle subscription updated event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const subscription = { + id: 'sub_test123', + customer: 'cus_test123', + status: 'active', + cancel_at_period_end: true, + metadata: { tenant_id: 'tenant-123' }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.SUBSCRIPTION_UPDATED, + subscription, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + + it('should handle subscription status change to trialing', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const subscription = { + id: 'sub_test123', + status: 'trialing', + trial_end: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60, + metadata: { tenant_id: 'tenant-123' }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.SUBSCRIPTION_UPDATED, + subscription, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('customer.subscription.deleted', () => { + it('should handle subscription deleted event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const subscription = { + id: 'sub_test123', + customer: 'cus_test123', + status: 'canceled', + canceled_at: Math.floor(Date.now() / 1000), + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.SUBSCRIPTION_DELETED, + subscription, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('invoice.paid', () => { + it('should handle invoice paid event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const invoice = { + id: 'in_test123', + customer: 'cus_test123', + subscription: 'sub_test123', + number: 'INV-001', + subtotal: 10000, + tax: 1600, + total: 11600, + status: 'paid', + subscription_details: { metadata: { tenant_id: 'tenant-123' } }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.INVOICE_PAID, + invoice, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('invoice.payment_failed', () => { + it('should handle invoice payment failed event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const invoice = { + id: 'in_test123', + customer: 'cus_test123', + subscription: 'sub_test123', + attempt_count: 1, + next_payment_attempt: Math.floor(Date.now() / 1000) + 3 * 24 * 60 * 60, + subscription_details: { metadata: { tenant_id: 'tenant-123' } }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.INVOICE_PAYMENT_FAILED, + invoice, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('payment_method.attached', () => { + it('should handle payment method attached event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const paymentMethod = { + id: 'pm_test123', + customer: 'cus_test123', + type: 'card', + card: { + brand: 'visa', + last4: '4242', + exp_month: 12, + exp_year: 2028, + }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.PAYMENT_METHOD_ATTACHED, + paymentMethod, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('payment_method.detached', () => { + it('should handle payment method detached event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const paymentMethod = { + id: 'pm_test123', + type: 'card', + card: { + brand: 'visa', + last4: '4242', + }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.PAYMENT_METHOD_DETACHED, + paymentMethod, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('customer events', () => { + it('should handle customer created event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const customer = { + id: 'cus_test123', + email: 'test@example.com', + metadata: { tenant_id: 'tenant-123' }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.CUSTOMER_CREATED, + customer, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + + it('should handle customer updated event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const customer = { + id: 'cus_test123', + email: 'updated@example.com', + metadata: { tenant_id: 'tenant-123' }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.CUSTOMER_UPDATED, + customer, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('subscription trial events', () => { + it('should handle subscription trial will end event', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const subscription = { + id: 'sub_test123', + customer: 'cus_test123', + status: 'trialing', + trial_end: Math.floor(Date.now() / 1000) + 3 * 24 * 60 * 60, // 3 days + metadata: { tenant_id: 'tenant-123' }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.SUBSCRIPTION_TRIAL_WILL_END, + subscription, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + }); + + describe('Error Handling', () => { + const validRawBody = Buffer.from('{"valid":"payload"}'); + const validSignature = 'whsec_valid_signature'; + + it('should return 500 for unexpected errors during event handling', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const mockEvent = createStripeEvent('customer.subscription.updated', { + id: 'sub_test123', + }); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockRejectedValue(new Error('Database connection failed')); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Database connection failed', + }); + }); + + it('should handle stripe signature verification error specifically', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const signatureError = new Error('Webhook signature verification failed'); + (signatureError as any).type = 'StripeSignatureVerificationError'; + stripeService.constructWebhookEvent.mockImplementation(() => { + throw signatureError; + }); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Invalid signature', + }); + }); + + it('should handle non-signature stripe errors', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const stripeError = new Error('Stripe API unavailable'); + (stripeError as any).type = 'StripeAPIError'; + stripeService.constructWebhookEvent.mockImplementation(() => { + throw stripeError; + }); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Stripe API unavailable', + }); + }); + + it('should handle timeout errors gracefully', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const mockEvent = createStripeEvent('invoice.paid', { id: 'in_test123' }); + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockRejectedValue(new Error('Operation timed out')); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Operation timed out', + }); + }); + }); + + describe('Edge Cases and Security Scenarios', () => { + const validRawBody = Buffer.from('{"valid":"payload"}'); + const validSignature = 'whsec_valid_signature'; + + it('should handle empty signature string', async () => { + const req = createMockRequest(validRawBody, ''); + const res = createMockResponse(); + + await controller.handleWebhook(req as any, res, ''); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Missing stripe-signature header', + }); + }); + + it('should handle empty raw body (Buffer of length 0)', async () => { + const emptyBuffer = Buffer.from(''); + const req = { + rawBody: emptyBuffer, + } as unknown as Request & { rawBody?: Buffer }; + const res = createMockResponse(); + + // Empty buffer should still pass the rawBody check but may fail signature verification + const signatureError = new Error('Invalid payload'); + (signatureError as any).type = 'StripeSignatureVerificationError'; + stripeService.constructWebhookEvent.mockImplementation(() => { + throw signatureError; + }); + + await controller.handleWebhook(req as any, res, validSignature); + + // Empty buffer is truthy, so it will attempt to construct event + expect(stripeService.constructWebhookEvent).toHaveBeenCalled(); + }); + + it('should handle malformed JSON in request body', async () => { + const malformedBody = Buffer.from('{ invalid json }'); + const req = createMockRequest(malformedBody, validSignature); + const res = createMockResponse(); + + const parseError = new Error('Unexpected token i in JSON'); + stripeService.constructWebhookEvent.mockImplementation(() => { + throw parseError; + }); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(res.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(res.json).toHaveBeenCalledWith({ + received: false, + error: 'Unexpected token i in JSON', + }); + }); + + it('should handle very large payload', async () => { + // Create a large buffer (simulating a large webhook payload) + const largePayload = Buffer.alloc(1024 * 100, 'a'); // 100KB + const req = createMockRequest(largePayload, validSignature); + const res = createMockResponse(); + + const mockEvent = createStripeEvent('customer.subscription.updated', { + id: 'sub_test123', + }); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.constructWebhookEvent).toHaveBeenCalledWith(largePayload, validSignature); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + + it('should handle replay attack (duplicate event ID)', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + // Same event ID as before - service should handle idempotency + const mockEvent = createStripeEvent('invoice.paid', { id: 'in_test123' }); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + // The controller should still process - idempotency is handled at service level + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + + it('should handle webhook with missing event data object', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const incompleteEvent = { + id: 'evt_test123', + type: 'customer.subscription.updated', + data: {}, // Missing object + api_version: '2025-02-24.acacia', + created: Math.floor(Date.now() / 1000), + } as unknown as Stripe.Event; + + stripeService.constructWebhookEvent.mockReturnValue(incompleteEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(incompleteEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + + it('should handle unrecognized event types gracefully', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const unknownEvent = createStripeEvent('unknown.event.type', { id: 'obj_test123' }); + + stripeService.constructWebhookEvent.mockReturnValue(unknownEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(unknownEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(res.json).toHaveBeenCalledWith({ + received: true, + event_type: 'unknown.event.type', + }); + }); + + it('should handle webhook from test mode vs live mode', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const testModeEvent = { + ...createStripeEvent('customer.subscription.created', { id: 'sub_test123' }), + livemode: false, + }; + + stripeService.constructWebhookEvent.mockReturnValue(testModeEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(testModeEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + + it('should handle concurrent webhook processing', async () => { + const req1 = createMockRequest(validRawBody, validSignature); + const req2 = createMockRequest(validRawBody, validSignature); + const res1 = createMockResponse(); + const res2 = createMockResponse(); + + const event1 = createStripeEvent('invoice.paid', { id: 'in_1' }); + const event2 = createStripeEvent('invoice.paid', { id: 'in_2' }); + + stripeService.constructWebhookEvent + .mockReturnValueOnce(event1) + .mockReturnValueOnce(event2); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + // Process both webhooks concurrently + await Promise.all([ + controller.handleWebhook(req1 as any, res1, validSignature), + controller.handleWebhook(req2 as any, res2, validSignature), + ]); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledTimes(2); + expect(res1.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(res2.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + + it('should handle webhook with special characters in metadata', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const subscription = { + id: 'sub_test123', + metadata: { + tenant_id: 'tenant-123', + special_chars: '', + unicode: '\u0000\u001f', + long_value: 'a'.repeat(1000), + }, + }; + + const mockEvent = createStripeEvent( + StripeWebhookEventType.SUBSCRIPTION_UPDATED, + subscription, + ); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + }); + }); + + describe('All Event Types Coverage', () => { + const validRawBody = Buffer.from('{"valid":"payload"}'); + const validSignature = 'whsec_valid_signature'; + + const eventTypes = [ + { type: StripeWebhookEventType.CUSTOMER_CREATED, data: { id: 'cus_123' } }, + { type: StripeWebhookEventType.CUSTOMER_UPDATED, data: { id: 'cus_123' } }, + { type: StripeWebhookEventType.CUSTOMER_DELETED, data: { id: 'cus_123' } }, + { type: StripeWebhookEventType.SUBSCRIPTION_CREATED, data: { id: 'sub_123' } }, + { type: StripeWebhookEventType.SUBSCRIPTION_UPDATED, data: { id: 'sub_123' } }, + { type: StripeWebhookEventType.SUBSCRIPTION_DELETED, data: { id: 'sub_123' } }, + { type: StripeWebhookEventType.SUBSCRIPTION_TRIAL_WILL_END, data: { id: 'sub_123' } }, + { type: StripeWebhookEventType.INVOICE_CREATED, data: { id: 'in_123' } }, + { type: StripeWebhookEventType.INVOICE_PAID, data: { id: 'in_123' } }, + { type: StripeWebhookEventType.INVOICE_PAYMENT_FAILED, data: { id: 'in_123' } }, + { type: StripeWebhookEventType.INVOICE_FINALIZED, data: { id: 'in_123' } }, + { type: StripeWebhookEventType.INVOICE_VOIDED, data: { id: 'in_123' } }, + { type: StripeWebhookEventType.PAYMENT_INTENT_SUCCEEDED, data: { id: 'pi_123' } }, + { type: StripeWebhookEventType.PAYMENT_INTENT_FAILED, data: { id: 'pi_123' } }, + { type: StripeWebhookEventType.PAYMENT_METHOD_ATTACHED, data: { id: 'pm_123' } }, + { type: StripeWebhookEventType.PAYMENT_METHOD_DETACHED, data: { id: 'pm_123' } }, + { type: StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED, data: { id: 'cs_123' } }, + { type: StripeWebhookEventType.CHECKOUT_SESSION_EXPIRED, data: { id: 'cs_123' } }, + ]; + + eventTypes.forEach(({ type, data }) => { + it(`should process ${type} event`, async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const mockEvent = createStripeEvent(type, data); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(stripeService.handleWebhookEvent).toHaveBeenCalledWith(mockEvent); + expect(res.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(res.json).toHaveBeenCalledWith({ + received: true, + event_type: type, + }); + }); + }); + }); + + describe('Response Format', () => { + const validRawBody = Buffer.from('{"valid":"payload"}'); + const validSignature = 'whsec_valid_signature'; + + it('should return correct response structure on success', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + const mockEvent = createStripeEvent('customer.subscription.updated', { + id: 'sub_test123', + }); + + stripeService.constructWebhookEvent.mockReturnValue(mockEvent); + stripeService.handleWebhookEvent.mockResolvedValue(undefined); + + await controller.handleWebhook(req as any, res, validSignature); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + received: true, + event_type: expect.any(String), + }), + ); + }); + + it('should return correct response structure on validation error', async () => { + const req = createMockRequest(validRawBody); + const res = createMockResponse(); + + await controller.handleWebhook(req as any, res, undefined as any); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + received: false, + error: expect.any(String), + }), + ); + }); + + it('should not include event_type in error response', async () => { + const req = createMockRequest(validRawBody, validSignature); + const res = createMockResponse(); + + stripeService.constructWebhookEvent.mockImplementation(() => { + const error = new Error('Invalid signature'); + (error as any).type = 'StripeSignatureVerificationError'; + throw error; + }); + + await controller.handleWebhook(req as any, res, validSignature); + + const jsonCall = (res.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall).not.toHaveProperty('event_type'); + expect(jsonCall).toHaveProperty('received', false); + expect(jsonCall).toHaveProperty('error'); + }); + }); + }); +}); diff --git a/src/modules/billing/__tests__/stripe.controller.spec.ts b/src/modules/billing/__tests__/stripe.controller.spec.ts new file mode 100644 index 0000000..87201a3 --- /dev/null +++ b/src/modules/billing/__tests__/stripe.controller.spec.ts @@ -0,0 +1,1049 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StripeController } from '../controllers/stripe.controller'; +import { StripeService } from '../services/stripe.service'; +import { RbacService } from '../../rbac/services/rbac.service'; +import { RequestUser } from '../../auth/strategies/jwt.strategy'; +import Stripe from 'stripe'; + +describe('StripeController', () => { + let controller: StripeController; + let stripeService: jest.Mocked; + let rbacService: jest.Mocked; + + // Mock user for authenticated requests + const mockRequestUser: RequestUser = { + id: 'user-123', + email: 'test@example.com', + tenant_id: 'tenant-123', + }; + + // Mock Stripe Customer + const mockStripeCustomer: Partial = { + id: 'cus_test123', + email: 'customer@example.com', + name: 'Test Customer', + created: Math.floor(Date.now() / 1000), + metadata: { tenant_id: 'tenant-123' }, + }; + + // Mock Stripe Checkout Session + const mockCheckoutSession: Partial = { + id: 'cs_test123', + url: 'https://checkout.stripe.com/session/cs_test123', + mode: 'subscription', + customer: 'cus_test123', + }; + + // Mock Stripe Billing Portal Session + const mockBillingPortalSession: Partial = { + id: 'bps_test123', + url: 'https://billing.stripe.com/session/bps_test123', + customer: 'cus_test123', + }; + + // Mock Stripe Setup Intent + const mockSetupIntent: Partial = { + id: 'seti_test123', + client_secret: 'seti_test123_secret_abc123', + customer: 'cus_test123', + status: 'requires_payment_method', + }; + + // Mock Stripe Payment Methods + const mockPaymentMethods: Partial[] = [ + { + id: 'pm_test123', + type: 'card', + card: { + brand: 'visa', + last4: '4242', + exp_month: 12, + exp_year: 2028, + funding: 'credit', + country: 'US', + fingerprint: 'fp_123', + generated_from: null, + networks: { available: ['visa'], preferred: null }, + three_d_secure_usage: { supported: true }, + wallet: null, + display_brand: 'visa', + regulated_status: 'unregulated', + checks: { address_line1_check: null, address_postal_code_check: null, cvc_check: 'pass' }, + }, + created: Math.floor(Date.now() / 1000), + }, + { + id: 'pm_test456', + type: 'card', + card: { + brand: 'mastercard', + last4: '5555', + exp_month: 6, + exp_year: 2027, + funding: 'credit', + country: 'US', + fingerprint: 'fp_456', + generated_from: null, + networks: { available: ['mastercard'], preferred: null }, + three_d_secure_usage: { supported: true }, + wallet: null, + display_brand: 'mastercard', + regulated_status: 'unregulated', + checks: { address_line1_check: null, address_postal_code_check: null, cvc_check: 'pass' }, + }, + created: Math.floor(Date.now() / 1000) - 86400, + }, + ]; + + // Mock Stripe Prices + const mockPrices: Partial[] = [ + { + id: 'price_basic_monthly', + currency: 'usd', + unit_amount: 999, + product: { name: 'Basic Plan' } as any, + recurring: { + interval: 'month', + interval_count: 1, + usage_type: 'licensed', + aggregate_usage: null, + meter: null, + trial_period_days: null, + }, + }, + { + id: 'price_pro_monthly', + currency: 'usd', + unit_amount: 2999, + product: { name: 'Pro Plan' } as any, + recurring: { + interval: 'month', + interval_count: 1, + usage_type: 'licensed', + aggregate_usage: null, + meter: null, + trial_period_days: null, + }, + }, + { + id: 'price_enterprise_yearly', + currency: 'usd', + unit_amount: 99900, + product: 'prod_enterprise' as any, // String product ID + recurring: { + interval: 'year', + interval_count: 1, + usage_type: 'licensed', + aggregate_usage: null, + meter: null, + trial_period_days: null, + }, + }, + ]; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [StripeController], + providers: [ + { + provide: StripeService, + useValue: { + createCheckoutSession: jest.fn(), + createBillingPortalSession: jest.fn(), + createSetupIntent: jest.fn(), + findCustomerByTenantId: jest.fn(), + createCustomer: jest.fn(), + listPrices: jest.fn(), + listPaymentMethods: jest.fn(), + }, + }, + { + provide: RbacService, + useValue: { + userHasPermission: jest.fn().mockResolvedValue(true), + userHasAnyPermission: jest.fn().mockResolvedValue(true), + userHasRole: jest.fn().mockResolvedValue(false), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(StripeController); + stripeService = module.get(StripeService); + rbacService = module.get(RbacService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ============================================================================= + // CHECKOUT SESSION TESTS + // ============================================================================= + describe('createCheckoutSession', () => { + const createCheckoutDto = { + tenant_id: '', + price_id: 'price_basic_monthly', + success_url: 'https://app.example.com/billing/success', + cancel_url: 'https://app.example.com/billing/cancel', + }; + + it('should create a checkout session successfully', async () => { + stripeService.createCheckoutSession.mockResolvedValue( + mockCheckoutSession as Stripe.Checkout.Session, + ); + + const result = await controller.createCheckoutSession(createCheckoutDto, mockRequestUser); + + expect(result).toEqual({ + session_id: 'cs_test123', + url: 'https://checkout.stripe.com/session/cs_test123', + }); + expect(createCheckoutDto.tenant_id).toBe('tenant-123'); + expect(stripeService.createCheckoutSession).toHaveBeenCalledWith( + expect.objectContaining({ + tenant_id: 'tenant-123', + price_id: 'price_basic_monthly', + }), + ); + }); + + it('should set tenant_id from the authenticated user', async () => { + stripeService.createCheckoutSession.mockResolvedValue( + mockCheckoutSession as Stripe.Checkout.Session, + ); + + const dto = { ...createCheckoutDto, tenant_id: 'should-be-overwritten' }; + await controller.createCheckoutSession(dto, mockRequestUser); + + expect(dto.tenant_id).toBe('tenant-123'); + }); + + it('should create checkout session with trial period', async () => { + const dtoWithTrial = { + ...createCheckoutDto, + trial_period_days: 14, + }; + stripeService.createCheckoutSession.mockResolvedValue( + mockCheckoutSession as Stripe.Checkout.Session, + ); + + await controller.createCheckoutSession(dtoWithTrial, mockRequestUser); + + expect(stripeService.createCheckoutSession).toHaveBeenCalledWith( + expect.objectContaining({ + trial_period_days: 14, + }), + ); + }); + + it('should throw NotFoundException when customer not found', async () => { + stripeService.createCheckoutSession.mockRejectedValue( + new NotFoundException('Stripe customer not found for tenant'), + ); + + await expect( + controller.createCheckoutSession(createCheckoutDto, mockRequestUser), + ).rejects.toThrow(NotFoundException); + }); + + it('should propagate service errors', async () => { + stripeService.createCheckoutSession.mockRejectedValue( + new Error('Stripe API unavailable'), + ); + + await expect( + controller.createCheckoutSession(createCheckoutDto, mockRequestUser), + ).rejects.toThrow('Stripe API unavailable'); + }); + }); + + // ============================================================================= + // BILLING PORTAL SESSION TESTS + // ============================================================================= + describe('createBillingPortalSession', () => { + const createPortalDto = { + tenant_id: '', + return_url: 'https://app.example.com/billing', + }; + + it('should create a billing portal session successfully', async () => { + stripeService.createBillingPortalSession.mockResolvedValue( + mockBillingPortalSession as Stripe.BillingPortal.Session, + ); + + const result = await controller.createBillingPortalSession(createPortalDto, mockRequestUser); + + expect(result).toEqual({ + url: 'https://billing.stripe.com/session/bps_test123', + }); + expect(createPortalDto.tenant_id).toBe('tenant-123'); + }); + + it('should set tenant_id from the authenticated user', async () => { + stripeService.createBillingPortalSession.mockResolvedValue( + mockBillingPortalSession as Stripe.BillingPortal.Session, + ); + + const dto = { ...createPortalDto, tenant_id: 'should-be-overwritten' }; + await controller.createBillingPortalSession(dto, mockRequestUser); + + expect(dto.tenant_id).toBe('tenant-123'); + }); + + it('should throw NotFoundException when customer not found', async () => { + stripeService.createBillingPortalSession.mockRejectedValue( + new NotFoundException('Stripe customer not found for tenant'), + ); + + await expect( + controller.createBillingPortalSession(createPortalDto, mockRequestUser), + ).rejects.toThrow(NotFoundException); + }); + + it('should handle different return URLs', async () => { + const dtoWithDifferentUrl = { + ...createPortalDto, + return_url: 'https://app.example.com/settings/billing', + }; + stripeService.createBillingPortalSession.mockResolvedValue( + mockBillingPortalSession as Stripe.BillingPortal.Session, + ); + + await controller.createBillingPortalSession(dtoWithDifferentUrl, mockRequestUser); + + expect(stripeService.createBillingPortalSession).toHaveBeenCalledWith( + expect.objectContaining({ + return_url: 'https://app.example.com/settings/billing', + }), + ); + }); + }); + + // ============================================================================= + // SETUP INTENT TESTS + // ============================================================================= + describe('createSetupIntent', () => { + it('should create a setup intent successfully', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.createSetupIntent.mockResolvedValue( + mockSetupIntent as Stripe.SetupIntent, + ); + + const result = await controller.createSetupIntent(mockRequestUser); + + expect(result).toEqual({ + client_secret: 'seti_test123_secret_abc123', + }); + expect(stripeService.findCustomerByTenantId).toHaveBeenCalledWith('tenant-123'); + expect(stripeService.createSetupIntent).toHaveBeenCalledWith('cus_test123'); + }); + + it('should return error when customer not found', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue(null); + + const result = await controller.createSetupIntent(mockRequestUser); + + expect(result).toEqual({ + error: 'Stripe customer not found', + client_secret: null, + }); + expect(stripeService.createSetupIntent).not.toHaveBeenCalled(); + }); + + it('should handle service errors', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.createSetupIntent.mockRejectedValue( + new Error('Failed to create setup intent'), + ); + + await expect(controller.createSetupIntent(mockRequestUser)).rejects.toThrow( + 'Failed to create setup intent', + ); + }); + }); + + // ============================================================================= + // LIST PRICES TESTS + // ============================================================================= + describe('listPrices', () => { + it('should list all prices without product filter', async () => { + stripeService.listPrices.mockResolvedValue(mockPrices as Stripe.Price[]); + + const result = await controller.listPrices(); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + id: 'price_basic_monthly', + product: 'Basic Plan', + currency: 'usd', + unit_amount: 999, + interval: 'month', + interval_count: 1, + }); + expect(stripeService.listPrices).toHaveBeenCalledWith(undefined); + }); + + it('should list prices filtered by product_id', async () => { + const filteredPrices = [mockPrices[0]]; + stripeService.listPrices.mockResolvedValue(filteredPrices as Stripe.Price[]); + + const result = await controller.listPrices('prod_basic'); + + expect(stripeService.listPrices).toHaveBeenCalledWith('prod_basic'); + expect(result).toHaveLength(1); + }); + + it('should handle prices with string product IDs', async () => { + stripeService.listPrices.mockResolvedValue([mockPrices[2]] as Stripe.Price[]); + + const result = await controller.listPrices(); + + // When product is a string, it should use the string directly + expect(result[0].product).toBe('prod_enterprise'); + }); + + it('should return empty array when no prices found', async () => { + stripeService.listPrices.mockResolvedValue([]); + + const result = await controller.listPrices(); + + expect(result).toEqual([]); + }); + + it('should handle prices without recurring information', async () => { + const oneTimePrice: Partial = { + id: 'price_onetime', + currency: 'usd', + unit_amount: 5000, + product: { name: 'One-time Product' } as any, + recurring: null, + }; + stripeService.listPrices.mockResolvedValue([oneTimePrice] as Stripe.Price[]); + + const result = await controller.listPrices(); + + expect(result[0]).toEqual({ + id: 'price_onetime', + product: 'One-time Product', + currency: 'usd', + unit_amount: 5000, + interval: undefined, + interval_count: undefined, + }); + }); + }); + + // ============================================================================= + // GET CUSTOMER TESTS + // ============================================================================= + describe('getCustomer', () => { + it('should return customer when found', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + + const result = await controller.getCustomer(mockRequestUser); + + expect(result).toEqual({ + exists: true, + customer: { + id: 'cus_test123', + email: 'customer@example.com', + name: 'Test Customer', + created: mockStripeCustomer.created, + }, + }); + expect(stripeService.findCustomerByTenantId).toHaveBeenCalledWith('tenant-123'); + }); + + it('should return exists: false when customer not found', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue(null); + + const result = await controller.getCustomer(mockRequestUser); + + expect(result).toEqual({ + exists: false, + customer: null, + }); + }); + + it('should handle customer without name', async () => { + const customerWithoutName: Partial = { + ...mockStripeCustomer, + name: null, + }; + stripeService.findCustomerByTenantId.mockResolvedValue( + customerWithoutName as Stripe.Customer, + ); + + const result = await controller.getCustomer(mockRequestUser); + + expect(result.customer?.name).toBeNull(); + }); + + it('should handle service errors', async () => { + stripeService.findCustomerByTenantId.mockRejectedValue( + new Error('Stripe API error'), + ); + + await expect(controller.getCustomer(mockRequestUser)).rejects.toThrow( + 'Stripe API error', + ); + }); + }); + + // ============================================================================= + // CREATE CUSTOMER TESTS + // ============================================================================= + describe('createCustomer', () => { + const createCustomerBody = { + email: 'new@example.com', + name: 'New Customer', + }; + + it('should create a new customer successfully', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue(null); + const newCustomer: Partial = { + id: 'cus_new123', + email: 'new@example.com', + name: 'New Customer', + }; + stripeService.createCustomer.mockResolvedValue(newCustomer as Stripe.Customer); + + const result = await controller.createCustomer(mockRequestUser, createCustomerBody); + + expect(result).toEqual({ + created: true, + customer: { + id: 'cus_new123', + email: 'new@example.com', + name: 'New Customer', + }, + }); + expect(stripeService.createCustomer).toHaveBeenCalledWith({ + tenant_id: 'tenant-123', + email: 'new@example.com', + name: 'New Customer', + }); + }); + + it('should return existing customer when already exists', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + + const result = await controller.createCustomer(mockRequestUser, createCustomerBody); + + expect(result).toEqual({ + created: false, + customer: { + id: 'cus_test123', + email: 'customer@example.com', + name: 'Test Customer', + }, + message: 'Customer already exists', + }); + expect(stripeService.createCustomer).not.toHaveBeenCalled(); + }); + + it('should create customer without name', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue(null); + const customerWithoutName: Partial = { + id: 'cus_new123', + email: 'new@example.com', + name: undefined, + }; + stripeService.createCustomer.mockResolvedValue(customerWithoutName as Stripe.Customer); + + const result = await controller.createCustomer(mockRequestUser, { email: 'new@example.com' }); + + expect(stripeService.createCustomer).toHaveBeenCalledWith({ + tenant_id: 'tenant-123', + email: 'new@example.com', + name: undefined, + }); + expect(result.created).toBe(true); + }); + + it('should handle service errors during customer creation', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue(null); + stripeService.createCustomer.mockRejectedValue( + new Error('Invalid email address'), + ); + + await expect( + controller.createCustomer(mockRequestUser, createCustomerBody), + ).rejects.toThrow('Invalid email address'); + }); + }); + + // ============================================================================= + // LIST PAYMENT METHODS TESTS + // ============================================================================= + describe('listPaymentMethods', () => { + it('should return payment methods for customer', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.listPaymentMethods.mockResolvedValue( + mockPaymentMethods as Stripe.PaymentMethod[], + ); + + const result = await controller.listPaymentMethods(mockRequestUser); + + expect(result.payment_methods).toHaveLength(2); + expect(result.payment_methods[0]).toEqual({ + id: 'pm_test123', + type: 'card', + card: { + brand: 'visa', + last4: '4242', + exp_month: 12, + exp_year: 2028, + }, + created: mockPaymentMethods[0].created, + }); + expect(stripeService.listPaymentMethods).toHaveBeenCalledWith('cus_test123'); + }); + + it('should return empty array when customer not found', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue(null); + + const result = await controller.listPaymentMethods(mockRequestUser); + + expect(result).toEqual({ payment_methods: [] }); + expect(stripeService.listPaymentMethods).not.toHaveBeenCalled(); + }); + + it('should return empty array when customer has no payment methods', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.listPaymentMethods.mockResolvedValue([]); + + const result = await controller.listPaymentMethods(mockRequestUser); + + expect(result.payment_methods).toEqual([]); + }); + + it('should handle payment methods without card information', async () => { + const bankPaymentMethod: Partial = { + id: 'pm_bank123', + type: 'us_bank_account', + card: undefined, + created: Math.floor(Date.now() / 1000), + }; + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.listPaymentMethods.mockResolvedValue([ + bankPaymentMethod as Stripe.PaymentMethod, + ]); + + const result = await controller.listPaymentMethods(mockRequestUser); + + expect(result.payment_methods[0]).toEqual({ + id: 'pm_bank123', + type: 'us_bank_account', + card: null, + created: bankPaymentMethod.created, + }); + }); + }); + + // ============================================================================= + // GUARD BEHAVIOR TESTS + // ============================================================================= + describe('Guard Behavior', () => { + describe('JwtAuthGuard', () => { + it('should require authentication for all endpoints', () => { + // Verify that JwtAuthGuard is applied at controller level + const guards = Reflect.getMetadata('__guards__', StripeController); + expect(guards).toBeDefined(); + // JwtAuthGuard should be in the guards array + }); + }); + + describe('PermissionsGuard', () => { + it('should require billing:manage permission for createCheckoutSession', () => { + const permissions = Reflect.getMetadata( + 'permissions', + controller.createCheckoutSession, + ); + expect(permissions).toContain('billing:manage'); + }); + + it('should require billing:manage permission for createBillingPortalSession', () => { + const permissions = Reflect.getMetadata( + 'permissions', + controller.createBillingPortalSession, + ); + expect(permissions).toContain('billing:manage'); + }); + + it('should require billing:manage permission for createSetupIntent', () => { + const permissions = Reflect.getMetadata( + 'permissions', + controller.createSetupIntent, + ); + expect(permissions).toContain('billing:manage'); + }); + + it('should require billing:manage permission for createCustomer', () => { + const permissions = Reflect.getMetadata( + 'permissions', + controller.createCustomer, + ); + expect(permissions).toContain('billing:manage'); + }); + + it('should NOT require special permissions for listPrices (read-only)', () => { + const permissions = Reflect.getMetadata('permissions', controller.listPrices); + expect(permissions).toBeUndefined(); + }); + + it('should NOT require special permissions for getCustomer (read-only)', () => { + const permissions = Reflect.getMetadata('permissions', controller.getCustomer); + expect(permissions).toBeUndefined(); + }); + + it('should NOT require special permissions for listPaymentMethods (read-only)', () => { + const permissions = Reflect.getMetadata( + 'permissions', + controller.listPaymentMethods, + ); + expect(permissions).toBeUndefined(); + }); + }); + }); + + // ============================================================================= + // ERROR HANDLING TESTS + // ============================================================================= + describe('Error Handling', () => { + it('should propagate NotFoundException from service', async () => { + stripeService.createCheckoutSession.mockRejectedValue( + new NotFoundException('Stripe customer not found for tenant'), + ); + + await expect( + controller.createCheckoutSession( + { tenant_id: '', price_id: 'price_123', success_url: 'url', cancel_url: 'url' }, + mockRequestUser, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('should propagate generic errors from service', async () => { + stripeService.createBillingPortalSession.mockRejectedValue( + new Error('Network error'), + ); + + await expect( + controller.createBillingPortalSession( + { tenant_id: '', return_url: 'url' }, + mockRequestUser, + ), + ).rejects.toThrow('Network error'); + }); + + it('should handle Stripe configuration errors', async () => { + const configError = new Error('Stripe is not configured'); + stripeService.listPrices.mockRejectedValue(configError); + + await expect(controller.listPrices()).rejects.toThrow('Stripe is not configured'); + }); + }); + + // ============================================================================= + // EDGE CASES TESTS + // ============================================================================= + describe('Edge Cases', () => { + it('should handle user with empty tenant_id', async () => { + const userWithEmptyTenant = { ...mockRequestUser, tenant_id: '' }; + stripeService.findCustomerByTenantId.mockResolvedValue(null); + + const result = await controller.getCustomer(userWithEmptyTenant); + + expect(stripeService.findCustomerByTenantId).toHaveBeenCalledWith(''); + expect(result.exists).toBe(false); + }); + + it('should handle special characters in URLs', async () => { + const dtoWithSpecialChars = { + tenant_id: '', + return_url: 'https://app.example.com/billing?param=value&other=123', + }; + stripeService.createBillingPortalSession.mockResolvedValue( + mockBillingPortalSession as Stripe.BillingPortal.Session, + ); + + await controller.createBillingPortalSession(dtoWithSpecialChars, mockRequestUser); + + expect(stripeService.createBillingPortalSession).toHaveBeenCalledWith( + expect.objectContaining({ + return_url: 'https://app.example.com/billing?param=value&other=123', + }), + ); + }); + + it('should handle very long price IDs', async () => { + const longPriceId = 'price_' + 'a'.repeat(100); + const dto = { + tenant_id: '', + price_id: longPriceId, + success_url: 'https://app.example.com/success', + cancel_url: 'https://app.example.com/cancel', + }; + stripeService.createCheckoutSession.mockResolvedValue( + mockCheckoutSession as Stripe.Checkout.Session, + ); + + await controller.createCheckoutSession(dto, mockRequestUser); + + expect(stripeService.createCheckoutSession).toHaveBeenCalledWith( + expect.objectContaining({ price_id: longPriceId }), + ); + }); + + it('should handle concurrent requests', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.listPaymentMethods.mockResolvedValue( + mockPaymentMethods as Stripe.PaymentMethod[], + ); + + const user1 = { ...mockRequestUser, tenant_id: 'tenant-1' }; + const user2 = { ...mockRequestUser, tenant_id: 'tenant-2' }; + + const [result1, result2] = await Promise.all([ + controller.listPaymentMethods(user1), + controller.listPaymentMethods(user2), + ]); + + expect(stripeService.findCustomerByTenantId).toHaveBeenCalledWith('tenant-1'); + expect(stripeService.findCustomerByTenantId).toHaveBeenCalledWith('tenant-2'); + expect(result1.payment_methods).toBeDefined(); + expect(result2.payment_methods).toBeDefined(); + }); + }); + + // ============================================================================= + // INTEGRATION-LIKE SCENARIO TESTS + // ============================================================================= + describe('Integration Scenarios', () => { + it('should handle complete checkout flow: create customer then checkout', async () => { + // Step 1: Check if customer exists (no) + stripeService.findCustomerByTenantId.mockResolvedValueOnce(null); + + // Step 2: Create customer + const newCustomer: Partial = { + id: 'cus_new123', + email: 'new@example.com', + name: 'New Customer', + }; + stripeService.createCustomer.mockResolvedValue(newCustomer as Stripe.Customer); + + const createResult = await controller.createCustomer(mockRequestUser, { + email: 'new@example.com', + name: 'New Customer', + }); + + expect(createResult.created).toBe(true); + + // Step 3: Create checkout session + stripeService.createCheckoutSession.mockResolvedValue( + mockCheckoutSession as Stripe.Checkout.Session, + ); + + const checkoutResult = await controller.createCheckoutSession( + { + tenant_id: '', + price_id: 'price_basic_monthly', + success_url: 'https://app.example.com/success', + cancel_url: 'https://app.example.com/cancel', + }, + mockRequestUser, + ); + + expect(checkoutResult.url).toBeDefined(); + }); + + it('should handle viewing billing info: get customer, prices, and payment methods', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.listPrices.mockResolvedValue(mockPrices as Stripe.Price[]); + stripeService.listPaymentMethods.mockResolvedValue( + mockPaymentMethods as Stripe.PaymentMethod[], + ); + + const [customerResult, pricesResult, paymentMethodsResult] = await Promise.all([ + controller.getCustomer(mockRequestUser), + controller.listPrices(), + controller.listPaymentMethods(mockRequestUser), + ]); + + expect(customerResult.exists).toBe(true); + expect(pricesResult.length).toBeGreaterThan(0); + expect(paymentMethodsResult.payment_methods.length).toBeGreaterThan(0); + }); + + it('should handle adding payment method flow: setup intent then portal', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.createSetupIntent.mockResolvedValue( + mockSetupIntent as Stripe.SetupIntent, + ); + stripeService.createBillingPortalSession.mockResolvedValue( + mockBillingPortalSession as Stripe.BillingPortal.Session, + ); + + // Get setup intent for adding card + const setupResult = await controller.createSetupIntent(mockRequestUser); + expect(setupResult.client_secret).toBeDefined(); + + // After card is added, user can access billing portal + const portalResult = await controller.createBillingPortalSession( + { tenant_id: '', return_url: 'https://app.example.com/billing' }, + mockRequestUser, + ); + expect(portalResult.url).toBeDefined(); + }); + }); + + // ============================================================================= + // RESPONSE FORMAT TESTS + // ============================================================================= + describe('Response Format', () => { + it('createCheckoutSession should return session_id and url', async () => { + stripeService.createCheckoutSession.mockResolvedValue( + mockCheckoutSession as Stripe.Checkout.Session, + ); + + const result = await controller.createCheckoutSession( + { + tenant_id: '', + price_id: 'price_123', + success_url: 'url', + cancel_url: 'url', + }, + mockRequestUser, + ); + + expect(result).toHaveProperty('session_id'); + expect(result).toHaveProperty('url'); + expect(Object.keys(result)).toHaveLength(2); + }); + + it('createBillingPortalSession should return only url', async () => { + stripeService.createBillingPortalSession.mockResolvedValue( + mockBillingPortalSession as Stripe.BillingPortal.Session, + ); + + const result = await controller.createBillingPortalSession( + { tenant_id: '', return_url: 'url' }, + mockRequestUser, + ); + + expect(result).toHaveProperty('url'); + expect(Object.keys(result)).toHaveLength(1); + }); + + it('createSetupIntent should return client_secret on success', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.createSetupIntent.mockResolvedValue( + mockSetupIntent as Stripe.SetupIntent, + ); + + const result = await controller.createSetupIntent(mockRequestUser); + + expect(result).toHaveProperty('client_secret'); + expect(Object.keys(result)).toHaveLength(1); + }); + + it('createSetupIntent should return error and client_secret:null on failure', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue(null); + + const result = await controller.createSetupIntent(mockRequestUser); + + expect(result).toHaveProperty('error'); + expect(result).toHaveProperty('client_secret', null); + expect(Object.keys(result)).toHaveLength(2); + }); + + it('getCustomer should return exists flag and customer object', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + + const result = await controller.getCustomer(mockRequestUser); + + expect(result).toHaveProperty('exists', true); + expect(result).toHaveProperty('customer'); + expect(result.customer).toHaveProperty('id'); + expect(result.customer).toHaveProperty('email'); + expect(result.customer).toHaveProperty('name'); + expect(result.customer).toHaveProperty('created'); + }); + + it('createCustomer should return created flag and customer', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue(null); + const newCustomer: Partial = { + id: 'cus_new', + email: 'new@example.com', + name: 'New', + }; + stripeService.createCustomer.mockResolvedValue(newCustomer as Stripe.Customer); + + const result = await controller.createCustomer(mockRequestUser, { + email: 'new@example.com', + }); + + expect(result).toHaveProperty('created', true); + expect(result).toHaveProperty('customer'); + }); + + it('listPaymentMethods should return payment_methods array', async () => { + stripeService.findCustomerByTenantId.mockResolvedValue( + mockStripeCustomer as Stripe.Customer, + ); + stripeService.listPaymentMethods.mockResolvedValue( + mockPaymentMethods as Stripe.PaymentMethod[], + ); + + const result = await controller.listPaymentMethods(mockRequestUser); + + expect(result).toHaveProperty('payment_methods'); + expect(Array.isArray(result.payment_methods)).toBe(true); + }); + + it('listPrices should return array of formatted prices', async () => { + stripeService.listPrices.mockResolvedValue(mockPrices as Stripe.Price[]); + + const result = await controller.listPrices(); + + expect(Array.isArray(result)).toBe(true); + result.forEach((price) => { + expect(price).toHaveProperty('id'); + expect(price).toHaveProperty('product'); + expect(price).toHaveProperty('currency'); + expect(price).toHaveProperty('unit_amount'); + expect(price).toHaveProperty('interval'); + expect(price).toHaveProperty('interval_count'); + }); + }); + }); +}); diff --git a/src/modules/billing/__tests__/stripe.service.spec.ts b/src/modules/billing/__tests__/stripe.service.spec.ts new file mode 100644 index 0000000..75d515f --- /dev/null +++ b/src/modules/billing/__tests__/stripe.service.spec.ts @@ -0,0 +1,1429 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { StripeService } from '../services/stripe.service'; +import { Subscription, SubscriptionStatus } from '../entities/subscription.entity'; +import { Invoice, InvoiceStatus } from '../entities/invoice.entity'; +import { PaymentMethod } from '../entities/payment-method.entity'; +import { StripeWebhookEventType } from '../dto/stripe-webhook.dto'; +import Stripe from 'stripe'; + +// Mock Stripe module +jest.mock('stripe', () => { + const mockStripe = { + customers: { + create: jest.fn(), + retrieve: jest.fn(), + update: jest.fn(), + search: jest.fn(), + }, + subscriptions: { + create: jest.fn(), + retrieve: jest.fn(), + update: jest.fn(), + cancel: jest.fn(), + }, + checkout: { + sessions: { + create: jest.fn(), + }, + }, + billingPortal: { + sessions: { + create: jest.fn(), + }, + }, + paymentMethods: { + attach: jest.fn(), + detach: jest.fn(), + list: jest.fn(), + }, + prices: { + list: jest.fn(), + retrieve: jest.fn(), + }, + setupIntents: { + create: jest.fn(), + }, + webhooks: { + constructEvent: jest.fn(), + }, + }; + + return jest.fn(() => mockStripe); +}); + +describe('StripeService', () => { + let service: StripeService; + let configService: jest.Mocked; + let subscriptionRepo: jest.Mocked>; + let invoiceRepo: jest.Mocked>; + let paymentMethodRepo: jest.Mocked>; + let mockStripeInstance: any; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockCustomerId = 'cus_test123'; + const mockSubscriptionId = 'sub_test123'; + const mockPriceId = 'price_test123'; + + beforeEach(async () => { + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === 'STRIPE_SECRET_KEY') return 'sk_test_123'; + if (key === 'STRIPE_WEBHOOK_SECRET') return 'whsec_test123'; + return null; + }), + }; + + const mockSubscriptionRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }; + + const mockInvoiceRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }; + + const mockPaymentMethodRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StripeService, + { provide: ConfigService, useValue: mockConfigService }, + { provide: getRepositoryToken(Subscription), useValue: mockSubscriptionRepo }, + { provide: getRepositoryToken(Invoice), useValue: mockInvoiceRepo }, + { provide: getRepositoryToken(PaymentMethod), useValue: mockPaymentMethodRepo }, + ], + }).compile(); + + service = module.get(StripeService); + configService = module.get(ConfigService); + subscriptionRepo = module.get(getRepositoryToken(Subscription)); + invoiceRepo = module.get(getRepositoryToken(Invoice)); + paymentMethodRepo = module.get(getRepositoryToken(PaymentMethod)); + + // Initialize the service (this sets up the Stripe client) + service.onModuleInit(); + + // Get the mock Stripe instance + mockStripeInstance = (service as any).stripe; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== Configuration Tests ==================== + + describe('onModuleInit', () => { + it('should initialize Stripe client when API key is configured', () => { + expect(mockStripeInstance).toBeDefined(); + }); + + it('should warn when Stripe API key is not configured', () => { + const warnSpy = jest.spyOn(service['logger'], 'warn'); + configService.get.mockReturnValue(undefined); + + service.onModuleInit(); + + expect(warnSpy).toHaveBeenCalledWith( + 'STRIPE_SECRET_KEY not configured - Stripe integration disabled', + ); + }); + }); + + describe('ensureStripeConfigured', () => { + it('should throw BadRequestException when Stripe is not configured', () => { + (service as any).stripe = null; + + expect(() => (service as any).ensureStripeConfigured()).toThrow( + BadRequestException, + ); + }); + }); + + // ==================== Customer Management Tests ==================== + + describe('createCustomer', () => { + it('should create a Stripe customer successfully', async () => { + const mockCustomer = { + id: mockCustomerId, + email: 'test@example.com', + name: 'Test User', + metadata: { tenant_id: mockTenantId }, + }; + + mockStripeInstance.customers.create.mockResolvedValue(mockCustomer); + + const result = await service.createCustomer({ + tenant_id: mockTenantId, + email: 'test@example.com', + name: 'Test User', + }); + + expect(result).toEqual(mockCustomer); + expect(mockStripeInstance.customers.create).toHaveBeenCalledWith({ + email: 'test@example.com', + name: 'Test User', + metadata: { + tenant_id: mockTenantId, + }, + }); + }); + + it('should create customer with additional metadata', async () => { + const mockCustomer = { + id: mockCustomerId, + email: 'test@example.com', + metadata: { tenant_id: mockTenantId, company: 'Acme Inc' }, + }; + + mockStripeInstance.customers.create.mockResolvedValue(mockCustomer); + + await service.createCustomer({ + tenant_id: mockTenantId, + email: 'test@example.com', + metadata: { company: 'Acme Inc' }, + }); + + expect(mockStripeInstance.customers.create).toHaveBeenCalledWith({ + email: 'test@example.com', + name: undefined, + metadata: { + tenant_id: mockTenantId, + company: 'Acme Inc', + }, + }); + }); + }); + + describe('getCustomer', () => { + it('should retrieve a customer by ID', async () => { + const mockCustomer = { id: mockCustomerId, email: 'test@example.com' }; + mockStripeInstance.customers.retrieve.mockResolvedValue(mockCustomer); + + const result = await service.getCustomer(mockCustomerId); + + expect(result).toEqual(mockCustomer); + }); + + it('should return null when customer not found', async () => { + mockStripeInstance.customers.retrieve.mockRejectedValue({ + code: 'resource_missing', + }); + + const result = await service.getCustomer('invalid_id'); + + expect(result).toBeNull(); + }); + + it('should throw error for non-resource-missing errors', async () => { + mockStripeInstance.customers.retrieve.mockRejectedValue(new Error('API Error')); + + await expect(service.getCustomer('cus_123')).rejects.toThrow('API Error'); + }); + }); + + describe('findCustomerByTenantId', () => { + it('should find customer by tenant ID metadata', async () => { + const mockCustomer = { + id: mockCustomerId, + metadata: { tenant_id: mockTenantId }, + }; + mockStripeInstance.customers.search.mockResolvedValue({ + data: [mockCustomer], + }); + + const result = await service.findCustomerByTenantId(mockTenantId); + + expect(result).toEqual(mockCustomer); + expect(mockStripeInstance.customers.search).toHaveBeenCalledWith({ + query: `metadata['tenant_id']:'${mockTenantId}'`, + }); + }); + + it('should return null when no customer found', async () => { + mockStripeInstance.customers.search.mockResolvedValue({ data: [] }); + + const result = await service.findCustomerByTenantId('unknown_tenant'); + + expect(result).toBeNull(); + }); + }); + + // ==================== Subscription Management Tests ==================== + + describe('createSubscription', () => { + it('should create subscription without trial', async () => { + const mockSubscription = { + id: mockSubscriptionId, + customer: mockCustomerId, + items: { data: [{ price: mockPriceId }] }, + status: 'active', + }; + + mockStripeInstance.subscriptions.create.mockResolvedValue(mockSubscription); + + const result = await service.createSubscription({ + customer_id: mockCustomerId, + price_id: mockPriceId, + }); + + expect(result).toEqual(mockSubscription); + expect(mockStripeInstance.subscriptions.create).toHaveBeenCalledWith({ + customer: mockCustomerId, + items: [{ price: mockPriceId }], + payment_behavior: 'default_incomplete', + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + expand: ['latest_invoice.payment_intent'], + metadata: undefined, + }); + }); + + it('should create subscription with trial period', async () => { + const mockSubscription = { + id: mockSubscriptionId, + customer: mockCustomerId, + status: 'trialing', + trial_end: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60, + }; + + mockStripeInstance.subscriptions.create.mockResolvedValue(mockSubscription); + + const result = await service.createSubscription({ + customer_id: mockCustomerId, + price_id: mockPriceId, + trial_period_days: 14, + }); + + expect(result.status).toBe('trialing'); + expect(mockStripeInstance.subscriptions.create).toHaveBeenCalledWith( + expect.objectContaining({ + trial_period_days: 14, + }), + ); + }); + + it('should create subscription with custom metadata', async () => { + const mockSubscription = { + id: mockSubscriptionId, + metadata: { tenant_id: mockTenantId, plan: 'pro' }, + }; + + mockStripeInstance.subscriptions.create.mockResolvedValue(mockSubscription); + + await service.createSubscription({ + customer_id: mockCustomerId, + price_id: mockPriceId, + metadata: { tenant_id: mockTenantId, plan: 'pro' }, + }); + + expect(mockStripeInstance.subscriptions.create).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { tenant_id: mockTenantId, plan: 'pro' }, + }), + ); + }); + }); + + describe('getStripeSubscription', () => { + it('should retrieve subscription by ID', async () => { + const mockSubscription = { id: mockSubscriptionId, status: 'active' }; + mockStripeInstance.subscriptions.retrieve.mockResolvedValue(mockSubscription); + + const result = await service.getStripeSubscription(mockSubscriptionId); + + expect(result).toEqual(mockSubscription); + }); + + it('should return null when subscription not found', async () => { + mockStripeInstance.subscriptions.retrieve.mockRejectedValue({ + code: 'resource_missing', + }); + + const result = await service.getStripeSubscription('invalid_sub'); + + expect(result).toBeNull(); + }); + }); + + describe('cancelStripeSubscription', () => { + it('should cancel subscription immediately', async () => { + const mockCancelled = { + id: mockSubscriptionId, + status: 'canceled', + canceled_at: Date.now() / 1000, + }; + + mockStripeInstance.subscriptions.cancel.mockResolvedValue(mockCancelled); + + const result = await service.cancelStripeSubscription(mockSubscriptionId, { + immediately: true, + }); + + expect(result.status).toBe('canceled'); + expect(mockStripeInstance.subscriptions.cancel).toHaveBeenCalledWith( + mockSubscriptionId, + ); + }); + + it('should schedule cancellation at period end', async () => { + const mockScheduled = { + id: mockSubscriptionId, + status: 'active', + cancel_at_period_end: true, + }; + + mockStripeInstance.subscriptions.update.mockResolvedValue(mockScheduled); + + const result = await service.cancelStripeSubscription(mockSubscriptionId, { + immediately: false, + }); + + expect(result.cancel_at_period_end).toBe(true); + expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith( + mockSubscriptionId, + { cancel_at_period_end: true }, + ); + }); + + it('should default to end-of-period cancellation', async () => { + mockStripeInstance.subscriptions.update.mockResolvedValue({ + id: mockSubscriptionId, + cancel_at_period_end: true, + }); + + await service.cancelStripeSubscription(mockSubscriptionId); + + expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith( + mockSubscriptionId, + { cancel_at_period_end: true }, + ); + }); + }); + + describe('updateStripeSubscription (upgrade/downgrade)', () => { + it('should upgrade subscription with proration', async () => { + const currentSubscription = { + id: mockSubscriptionId, + items: { data: [{ id: 'si_123', price: { id: 'price_basic' } }] }, + }; + + const upgradedSubscription = { + id: mockSubscriptionId, + items: { data: [{ id: 'si_123', price: { id: 'price_pro' } }] }, + }; + + mockStripeInstance.subscriptions.retrieve.mockResolvedValue(currentSubscription); + mockStripeInstance.subscriptions.update.mockResolvedValue(upgradedSubscription); + + const result = await service.updateStripeSubscription( + mockSubscriptionId, + 'price_pro', + ); + + expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith( + mockSubscriptionId, + { + items: [{ id: 'si_123', price: 'price_pro' }], + proration_behavior: 'create_prorations', + }, + ); + expect(result.items.data[0].price.id).toBe('price_pro'); + }); + + it('should downgrade subscription with proration', async () => { + const currentSubscription = { + id: mockSubscriptionId, + items: { data: [{ id: 'si_123', price: { id: 'price_pro' } }] }, + }; + + mockStripeInstance.subscriptions.retrieve.mockResolvedValue(currentSubscription); + mockStripeInstance.subscriptions.update.mockResolvedValue({ + id: mockSubscriptionId, + items: { data: [{ id: 'si_123', price: { id: 'price_basic' } }] }, + }); + + const result = await service.updateStripeSubscription( + mockSubscriptionId, + 'price_basic', + ); + + expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith( + mockSubscriptionId, + expect.objectContaining({ + proration_behavior: 'create_prorations', + }), + ); + }); + }); + + // ==================== Checkout & Portal Tests ==================== + + describe('createCheckoutSession', () => { + it('should create checkout session successfully', async () => { + const mockSession = { + id: 'cs_test123', + url: 'https://checkout.stripe.com/...', + }; + + mockStripeInstance.customers.search.mockResolvedValue({ + data: [{ id: mockCustomerId }], + }); + mockStripeInstance.checkout.sessions.create.mockResolvedValue(mockSession); + + const result = await service.createCheckoutSession({ + tenant_id: mockTenantId, + price_id: mockPriceId, + success_url: 'https://app.example.com/success', + cancel_url: 'https://app.example.com/cancel', + }); + + expect(result).toEqual(mockSession); + expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith({ + customer: mockCustomerId, + mode: 'subscription', + line_items: [{ price: mockPriceId, quantity: 1 }], + success_url: 'https://app.example.com/success', + cancel_url: 'https://app.example.com/cancel', + subscription_data: { + metadata: { tenant_id: mockTenantId }, + }, + }); + }); + + it('should create checkout session with trial period', async () => { + mockStripeInstance.customers.search.mockResolvedValue({ + data: [{ id: mockCustomerId }], + }); + mockStripeInstance.checkout.sessions.create.mockResolvedValue({ + id: 'cs_test123', + }); + + await service.createCheckoutSession({ + tenant_id: mockTenantId, + price_id: mockPriceId, + success_url: 'https://app.example.com/success', + cancel_url: 'https://app.example.com/cancel', + trial_period_days: 14, + }); + + expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + subscription_data: expect.objectContaining({ + trial_period_days: 14, + }), + }), + ); + }); + + it('should throw NotFoundException when customer not found', async () => { + mockStripeInstance.customers.search.mockResolvedValue({ data: [] }); + + await expect( + service.createCheckoutSession({ + tenant_id: 'unknown_tenant', + price_id: mockPriceId, + success_url: 'https://app.example.com/success', + cancel_url: 'https://app.example.com/cancel', + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('createBillingPortalSession', () => { + it('should create billing portal session successfully', async () => { + const mockSession = { + id: 'bps_test123', + url: 'https://billing.stripe.com/...', + }; + + mockStripeInstance.customers.search.mockResolvedValue({ + data: [{ id: mockCustomerId }], + }); + mockStripeInstance.billingPortal.sessions.create.mockResolvedValue(mockSession); + + const result = await service.createBillingPortalSession({ + tenant_id: mockTenantId, + return_url: 'https://app.example.com/billing', + }); + + expect(result).toEqual(mockSession); + expect(mockStripeInstance.billingPortal.sessions.create).toHaveBeenCalledWith({ + customer: mockCustomerId, + return_url: 'https://app.example.com/billing', + }); + }); + + it('should throw NotFoundException when customer not found', async () => { + mockStripeInstance.customers.search.mockResolvedValue({ data: [] }); + + await expect( + service.createBillingPortalSession({ + tenant_id: 'unknown_tenant', + return_url: 'https://app.example.com/billing', + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Payment Methods Tests ==================== + + describe('attachPaymentMethod', () => { + it('should attach payment method to customer', async () => { + const mockPaymentMethod = { + id: 'pm_test123', + customer: mockCustomerId, + type: 'card', + }; + + mockStripeInstance.paymentMethods.attach.mockResolvedValue(mockPaymentMethod); + + const result = await service.attachPaymentMethod('pm_test123', mockCustomerId); + + expect(result).toEqual(mockPaymentMethod); + expect(mockStripeInstance.paymentMethods.attach).toHaveBeenCalledWith( + 'pm_test123', + { customer: mockCustomerId }, + ); + }); + }); + + describe('detachPaymentMethod', () => { + it('should detach payment method', async () => { + const mockPaymentMethod = { + id: 'pm_test123', + customer: null, + }; + + mockStripeInstance.paymentMethods.detach.mockResolvedValue(mockPaymentMethod); + + const result = await service.detachPaymentMethod('pm_test123'); + + expect(result.customer).toBeNull(); + }); + }); + + describe('listPaymentMethods', () => { + it('should list customer payment methods', async () => { + const mockPaymentMethods = { + data: [ + { id: 'pm_1', type: 'card', card: { last4: '4242' } }, + { id: 'pm_2', type: 'card', card: { last4: '1234' } }, + ], + }; + + mockStripeInstance.paymentMethods.list.mockResolvedValue(mockPaymentMethods); + + const result = await service.listPaymentMethods(mockCustomerId); + + expect(result).toHaveLength(2); + expect(mockStripeInstance.paymentMethods.list).toHaveBeenCalledWith({ + customer: mockCustomerId, + type: 'card', + }); + }); + }); + + describe('setDefaultPaymentMethod', () => { + it('should set default payment method for customer', async () => { + const mockCustomer = { + id: mockCustomerId, + invoice_settings: { default_payment_method: 'pm_test123' }, + }; + + mockStripeInstance.customers.update.mockResolvedValue(mockCustomer); + + const result = await service.setDefaultPaymentMethod(mockCustomerId, 'pm_test123'); + + expect(result.invoice_settings.default_payment_method).toBe('pm_test123'); + expect(mockStripeInstance.customers.update).toHaveBeenCalledWith(mockCustomerId, { + invoice_settings: { default_payment_method: 'pm_test123' }, + }); + }); + }); + + // ==================== Webhook Handling Tests ==================== + + describe('constructWebhookEvent', () => { + it('should construct webhook event with valid signature', () => { + const mockEvent = { + id: 'evt_test123', + type: 'customer.subscription.updated', + data: { object: {} }, + }; + + mockStripeInstance.webhooks.constructEvent.mockReturnValue(mockEvent); + + const payload = Buffer.from(JSON.stringify(mockEvent)); + const signature = 'test_signature'; + + const result = service.constructWebhookEvent(payload, signature); + + expect(result).toEqual(mockEvent); + expect(mockStripeInstance.webhooks.constructEvent).toHaveBeenCalledWith( + payload, + signature, + 'whsec_test123', + ); + }); + + it('should throw BadRequestException when webhook secret not configured', () => { + configService.get.mockImplementation((key: string) => { + if (key === 'STRIPE_SECRET_KEY') return 'sk_test_123'; + if (key === 'STRIPE_WEBHOOK_SECRET') return undefined; + return null; + }); + + const payload = Buffer.from('{}'); + + expect(() => service.constructWebhookEvent(payload, 'sig')).toThrow( + BadRequestException, + ); + }); + + it('should throw error for invalid signature', () => { + mockStripeInstance.webhooks.constructEvent.mockImplementation(() => { + throw new Error('Invalid signature'); + }); + + const payload = Buffer.from('{}'); + + expect(() => service.constructWebhookEvent(payload, 'invalid_sig')).toThrow( + 'Invalid signature', + ); + }); + }); + + describe('handleWebhookEvent', () => { + describe('customer.subscription.updated', () => { + it('should sync subscription on update event', async () => { + const stripeSubscription = { + id: mockSubscriptionId, + status: 'active', + current_period_start: Math.floor(Date.now() / 1000), + current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + metadata: { tenant_id: mockTenantId }, + items: { data: [{ price: { id: mockPriceId, product: 'prod_123' } }] }, + customer: mockCustomerId, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.SUBSCRIPTION_UPDATED, + data: { object: stripeSubscription }, + } as unknown as Stripe.Event; + + subscriptionRepo.findOne.mockResolvedValue(null); + subscriptionRepo.create.mockReturnValue({} as Subscription); + subscriptionRepo.save.mockResolvedValue({} as Subscription); + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).toHaveBeenCalled(); + }); + + it('should update existing subscription', async () => { + const stripeSubscription = { + id: mockSubscriptionId, + status: 'active', + current_period_start: Math.floor(Date.now() / 1000), + current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + metadata: { tenant_id: mockTenantId }, + items: { data: [{ price: { id: mockPriceId, product: 'prod_123' } }] }, + customer: mockCustomerId, + }; + + const existingSubscription = { + id: 'local-sub-123', + tenant_id: mockTenantId, + external_subscription_id: mockSubscriptionId, + status: SubscriptionStatus.TRIAL, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.SUBSCRIPTION_UPDATED, + data: { object: stripeSubscription }, + } as unknown as Stripe.Event; + + subscriptionRepo.findOne.mockResolvedValue(existingSubscription as Subscription); + subscriptionRepo.save.mockResolvedValue({ + ...existingSubscription, + status: SubscriptionStatus.ACTIVE, + } as Subscription); + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: SubscriptionStatus.ACTIVE, + }), + ); + }); + + it('should handle subscription with trial_end', async () => { + const trialEnd = Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60; + const stripeSubscription = { + id: mockSubscriptionId, + status: 'trialing', + current_period_start: Math.floor(Date.now() / 1000), + current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + trial_end: trialEnd, + metadata: { tenant_id: mockTenantId }, + items: { data: [{ price: { id: mockPriceId, product: 'prod_123' } }] }, + customer: mockCustomerId, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.SUBSCRIPTION_UPDATED, + data: { object: stripeSubscription }, + } as unknown as Stripe.Event; + + subscriptionRepo.findOne.mockResolvedValue(null); + subscriptionRepo.create.mockReturnValue({} as Subscription); + subscriptionRepo.save.mockResolvedValue({} as Subscription); + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + trial_end: new Date(trialEnd * 1000), + }), + ); + }); + + it('should skip subscription without tenant_id metadata', async () => { + const stripeSubscription = { + id: mockSubscriptionId, + status: 'active', + metadata: {}, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.SUBSCRIPTION_UPDATED, + data: { object: stripeSubscription }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('customer.subscription.deleted', () => { + it('should mark subscription as cancelled', async () => { + const stripeSubscription = { + id: mockSubscriptionId, + status: 'canceled', + }; + + const existingSubscription = { + id: 'local-sub-123', + external_subscription_id: mockSubscriptionId, + status: SubscriptionStatus.ACTIVE, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.SUBSCRIPTION_DELETED, + data: { object: stripeSubscription }, + } as unknown as Stripe.Event; + + subscriptionRepo.findOne.mockResolvedValue(existingSubscription as Subscription); + subscriptionRepo.save.mockResolvedValue({} as Subscription); + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: SubscriptionStatus.CANCELLED, + cancelled_at: expect.any(Date), + }), + ); + }); + + it('should handle deletion when subscription not found locally', async () => { + const stripeSubscription = { + id: 'unknown_sub_id', + status: 'canceled', + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.SUBSCRIPTION_DELETED, + data: { object: stripeSubscription }, + } as unknown as Stripe.Event; + + subscriptionRepo.findOne.mockResolvedValue(null); + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('invoice.paid', () => { + it('should create and mark invoice as paid', async () => { + const stripeInvoice = { + id: 'in_test123', + number: 'INV-001', + subtotal: 10000, // $100.00 in cents + tax: 1600, // $16.00 in cents + total: 11600, // $116.00 in cents + due_date: Math.floor(Date.now() / 1000) + 15 * 24 * 60 * 60, + subscription: mockSubscriptionId, + subscription_details: { metadata: { tenant_id: mockTenantId } }, + invoice_pdf: 'https://stripe.com/invoice.pdf', + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.INVOICE_PAID, + data: { object: stripeInvoice }, + } as unknown as Stripe.Event; + + invoiceRepo.findOne.mockResolvedValue(null); + invoiceRepo.create.mockReturnValue({} as Invoice); + invoiceRepo.save.mockResolvedValue({} as Invoice); + + await service.handleWebhookEvent(event); + + expect(invoiceRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: InvoiceStatus.PAID, + paid_at: expect.any(Date), + external_invoice_id: 'in_test123', + pdf_url: 'https://stripe.com/invoice.pdf', + }), + ); + }); + + it('should update existing invoice when paid', async () => { + const stripeInvoice = { + id: 'in_test123', + number: 'INV-001', + subtotal: 10000, + total: 11600, + subscription_details: { metadata: { tenant_id: mockTenantId } }, + invoice_pdf: 'https://stripe.com/invoice.pdf', + }; + + const existingInvoice = { + id: 'local-inv-123', + invoice_number: 'INV-001', + status: InvoiceStatus.OPEN, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.INVOICE_PAID, + data: { object: stripeInvoice }, + } as unknown as Stripe.Event; + + invoiceRepo.findOne.mockResolvedValue(existingInvoice as Invoice); + invoiceRepo.save.mockResolvedValue({} as Invoice); + + await service.handleWebhookEvent(event); + + expect(invoiceRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: InvoiceStatus.PAID, + }), + ); + }); + + it('should skip invoice without tenant_id', async () => { + const stripeInvoice = { + id: 'in_test123', + subscription_details: { metadata: {} }, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.INVOICE_PAID, + data: { object: stripeInvoice }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(event); + + expect(invoiceRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('invoice.payment_failed', () => { + it('should mark subscription as past_due on payment failure', async () => { + const stripeInvoice = { + id: 'in_test123', + subscription_details: { metadata: { tenant_id: mockTenantId } }, + }; + + const existingSubscription = { + id: 'local-sub-123', + tenant_id: mockTenantId, + status: SubscriptionStatus.ACTIVE, + metadata: {}, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.INVOICE_PAYMENT_FAILED, + data: { object: stripeInvoice }, + } as unknown as Stripe.Event; + + subscriptionRepo.findOne.mockResolvedValue(existingSubscription as Subscription); + subscriptionRepo.save.mockResolvedValue({} as Subscription); + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: SubscriptionStatus.PAST_DUE, + metadata: expect.objectContaining({ + payment_failed_at: expect.any(String), + failed_invoice_id: 'in_test123', + }), + }), + ); + }); + + it('should skip when no tenant_id in invoice', async () => { + const stripeInvoice = { + id: 'in_test123', + subscription_details: {}, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.INVOICE_PAYMENT_FAILED, + data: { object: stripeInvoice }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).not.toHaveBeenCalled(); + }); + + it('should handle payment failure when subscription not found', async () => { + const stripeInvoice = { + id: 'in_test123', + subscription_details: { metadata: { tenant_id: mockTenantId } }, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.INVOICE_PAYMENT_FAILED, + data: { object: stripeInvoice }, + } as unknown as Stripe.Event; + + subscriptionRepo.findOne.mockResolvedValue(null); + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('payment_method.attached', () => { + it('should sync payment method when attached', async () => { + const stripePaymentMethod = { + id: 'pm_test123', + customer: mockCustomerId, + type: 'card', + card: { + brand: 'visa', + last4: '4242', + exp_month: 12, + exp_year: 2025, + }, + }; + + const mockCustomer = { + id: mockCustomerId, + metadata: { tenant_id: mockTenantId }, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.PAYMENT_METHOD_ATTACHED, + data: { object: stripePaymentMethod }, + } as unknown as Stripe.Event; + + // Create a mock object that will be mutated by the service + const mockPaymentMethodObject = { + tenant_id: mockTenantId, + external_payment_method_id: 'pm_test123', + } as PaymentMethod; + + mockStripeInstance.customers.retrieve.mockResolvedValue(mockCustomer); + paymentMethodRepo.findOne.mockResolvedValue(null); + paymentMethodRepo.create.mockReturnValue(mockPaymentMethodObject); + paymentMethodRepo.save.mockResolvedValue({} as PaymentMethod); + + await service.handleWebhookEvent(event); + + // Verify that create was called with the initial values + expect(paymentMethodRepo.create).toHaveBeenCalledWith({ + tenant_id: mockTenantId, + external_payment_method_id: 'pm_test123', + }); + + // Verify that save was called (the object is mutated in place) + expect(paymentMethodRepo.save).toHaveBeenCalled(); + const savedObject = (paymentMethodRepo.save as jest.Mock).mock.calls[0][0]; + expect(savedObject.card_brand).toBe('visa'); + expect(savedObject.card_last_four).toBe('4242'); + expect(savedObject.card_exp_month).toBe(12); + expect(savedObject.card_exp_year).toBe(2025); + expect(savedObject.payment_provider).toBe('stripe'); + expect(savedObject.is_active).toBe(true); + }); + + it('should update existing payment method', async () => { + const stripePaymentMethod = { + id: 'pm_test123', + customer: mockCustomerId, + type: 'card', + card: { + brand: 'mastercard', + last4: '5555', + exp_month: 6, + exp_year: 2026, + }, + }; + + const existingPaymentMethod = { + id: 'local-pm-123', + external_payment_method_id: 'pm_test123', + card_brand: 'visa', + }; + + const mockCustomer = { + id: mockCustomerId, + metadata: { tenant_id: mockTenantId }, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.PAYMENT_METHOD_ATTACHED, + data: { object: stripePaymentMethod }, + } as unknown as Stripe.Event; + + mockStripeInstance.customers.retrieve.mockResolvedValue(mockCustomer); + paymentMethodRepo.findOne.mockResolvedValue(existingPaymentMethod as PaymentMethod); + paymentMethodRepo.save.mockResolvedValue({} as PaymentMethod); + + await service.handleWebhookEvent(event); + + expect(paymentMethodRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + card_brand: 'mastercard', + card_last_four: '5555', + }), + ); + }); + + it('should skip when no customer on payment method', async () => { + const stripePaymentMethod = { + id: 'pm_test123', + customer: null, + type: 'card', + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.PAYMENT_METHOD_ATTACHED, + data: { object: stripePaymentMethod }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(event); + + expect(paymentMethodRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('payment_method.detached', () => { + it('should deactivate payment method when detached', async () => { + const stripePaymentMethod = { + id: 'pm_test123', + }; + + const existingPaymentMethod = { + id: 'local-pm-123', + external_payment_method_id: 'pm_test123', + is_active: true, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.PAYMENT_METHOD_DETACHED, + data: { object: stripePaymentMethod }, + } as unknown as Stripe.Event; + + paymentMethodRepo.findOne.mockResolvedValue(existingPaymentMethod as PaymentMethod); + paymentMethodRepo.save.mockResolvedValue({} as PaymentMethod); + + await service.handleWebhookEvent(event); + + expect(paymentMethodRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + is_active: false, + }), + ); + }); + + it('should handle detachment when payment method not found locally', async () => { + const stripePaymentMethod = { + id: 'unknown_pm_id', + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.PAYMENT_METHOD_DETACHED, + data: { object: stripePaymentMethod }, + } as unknown as Stripe.Event; + + paymentMethodRepo.findOne.mockResolvedValue(null); + + await service.handleWebhookEvent(event); + + expect(paymentMethodRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('checkout.session.completed', () => { + it('should sync subscription on checkout completion', async () => { + const checkoutSession = { + id: 'cs_test123', + subscription: mockSubscriptionId, + metadata: { tenant_id: mockTenantId }, + }; + + const stripeSubscription = { + id: mockSubscriptionId, + status: 'active', + current_period_start: Math.floor(Date.now() / 1000), + current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60, + metadata: { tenant_id: mockTenantId }, + items: { data: [{ price: { id: mockPriceId, product: 'prod_123' } }] }, + customer: mockCustomerId, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED, + data: { object: checkoutSession }, + } as unknown as Stripe.Event; + + mockStripeInstance.subscriptions.retrieve.mockResolvedValue(stripeSubscription); + subscriptionRepo.findOne.mockResolvedValue(null); + subscriptionRepo.create.mockReturnValue({} as Subscription); + subscriptionRepo.save.mockResolvedValue({} as Subscription); + + await service.handleWebhookEvent(event); + + expect(mockStripeInstance.subscriptions.retrieve).toHaveBeenCalledWith( + mockSubscriptionId, + ); + expect(subscriptionRepo.save).toHaveBeenCalled(); + }); + + it('should skip when no subscription in checkout session', async () => { + const checkoutSession = { + id: 'cs_test123', + subscription: null, + metadata: { tenant_id: mockTenantId }, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED, + data: { object: checkoutSession }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).not.toHaveBeenCalled(); + }); + + it('should skip when no tenant_id in checkout session', async () => { + const checkoutSession = { + id: 'cs_test123', + subscription: mockSubscriptionId, + metadata: {}, + }; + + const event: Stripe.Event = { + id: 'evt_test123', + type: StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED, + data: { object: checkoutSession }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(event); + + expect(subscriptionRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('unhandled events', () => { + it('should log unhandled event types', async () => { + const logSpy = jest.spyOn(service['logger'], 'log'); + + const event: Stripe.Event = { + id: 'evt_test123', + type: 'some.unknown.event', + data: { object: {} }, + } as unknown as Stripe.Event; + + await service.handleWebhookEvent(event); + + expect(logSpy).toHaveBeenCalledWith('Unhandled event type: some.unknown.event'); + }); + }); + }); + + // ==================== Price & Product Tests ==================== + + describe('listPrices', () => { + it('should list all active prices', async () => { + const mockPrices = { + data: [ + { id: 'price_basic', unit_amount: 999 }, + { id: 'price_pro', unit_amount: 2999 }, + ], + }; + + mockStripeInstance.prices.list.mockResolvedValue(mockPrices); + + const result = await service.listPrices(); + + expect(result).toHaveLength(2); + expect(mockStripeInstance.prices.list).toHaveBeenCalledWith({ + active: true, + expand: ['data.product'], + }); + }); + + it('should filter prices by product ID', async () => { + mockStripeInstance.prices.list.mockResolvedValue({ data: [] }); + + await service.listPrices('prod_123'); + + expect(mockStripeInstance.prices.list).toHaveBeenCalledWith({ + active: true, + expand: ['data.product'], + product: 'prod_123', + }); + }); + }); + + describe('getPrice', () => { + it('should retrieve price by ID', async () => { + const mockPrice = { id: mockPriceId, unit_amount: 2999 }; + mockStripeInstance.prices.retrieve.mockResolvedValue(mockPrice); + + const result = await service.getPrice(mockPriceId); + + expect(result).toEqual(mockPrice); + expect(mockStripeInstance.prices.retrieve).toHaveBeenCalledWith(mockPriceId, { + expand: ['product'], + }); + }); + + it('should return null when price not found', async () => { + mockStripeInstance.prices.retrieve.mockRejectedValue({ + code: 'resource_missing', + }); + + const result = await service.getPrice('invalid_price'); + + expect(result).toBeNull(); + }); + }); + + // ==================== Setup Intent Tests ==================== + + describe('createSetupIntent', () => { + it('should create setup intent for customer', async () => { + const mockSetupIntent = { + id: 'seti_test123', + client_secret: 'seti_test123_secret', + }; + + mockStripeInstance.setupIntents.create.mockResolvedValue(mockSetupIntent); + + const result = await service.createSetupIntent(mockCustomerId); + + expect(result).toEqual(mockSetupIntent); + expect(mockStripeInstance.setupIntents.create).toHaveBeenCalledWith({ + customer: mockCustomerId, + payment_method_types: ['card'], + }); + }); + }); + + // ==================== Status Mapping Tests ==================== + + describe('mapStripeStatus', () => { + it('should map trialing to TRIAL', () => { + const result = (service as any).mapStripeStatus('trialing'); + expect(result).toBe(SubscriptionStatus.TRIAL); + }); + + it('should map active to ACTIVE', () => { + const result = (service as any).mapStripeStatus('active'); + expect(result).toBe(SubscriptionStatus.ACTIVE); + }); + + it('should map past_due to PAST_DUE', () => { + const result = (service as any).mapStripeStatus('past_due'); + expect(result).toBe(SubscriptionStatus.PAST_DUE); + }); + + it('should map canceled to CANCELLED', () => { + const result = (service as any).mapStripeStatus('canceled'); + expect(result).toBe(SubscriptionStatus.CANCELLED); + }); + + it('should map unpaid to PAST_DUE', () => { + const result = (service as any).mapStripeStatus('unpaid'); + expect(result).toBe(SubscriptionStatus.PAST_DUE); + }); + + it('should map incomplete to TRIAL', () => { + const result = (service as any).mapStripeStatus('incomplete'); + expect(result).toBe(SubscriptionStatus.TRIAL); + }); + + it('should map incomplete_expired to EXPIRED', () => { + const result = (service as any).mapStripeStatus('incomplete_expired'); + expect(result).toBe(SubscriptionStatus.EXPIRED); + }); + + it('should map paused to CANCELLED', () => { + const result = (service as any).mapStripeStatus('paused'); + expect(result).toBe(SubscriptionStatus.CANCELLED); + }); + + it('should default to ACTIVE for unknown status', () => { + const result = (service as any).mapStripeStatus('unknown_status'); + expect(result).toBe(SubscriptionStatus.ACTIVE); + }); + }); +}); diff --git a/src/modules/billing/billing.controller.ts b/src/modules/billing/billing.controller.ts new file mode 100644 index 0000000..358d25e --- /dev/null +++ b/src/modules/billing/billing.controller.ts @@ -0,0 +1,191 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { BillingService } from './services/billing.service'; +import { + CreateSubscriptionDto, + UpdateSubscriptionDto, + CancelSubscriptionDto, + CreatePaymentMethodDto, +} from './dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PermissionsGuard, RequirePermissions } from '../rbac/guards/permissions.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; + +@ApiTags('billing') +@Controller('billing') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class BillingController { + constructor(private readonly billingService: BillingService) {} + + // ==================== Subscription ==================== + + @Get('subscription') + @ApiOperation({ summary: 'Get current subscription' }) + async getSubscription(@CurrentUser() user: RequestUser) { + return this.billingService.getSubscription(user.tenant_id); + } + + @Get('subscription/status') + @ApiOperation({ summary: 'Check subscription status' }) + async getSubscriptionStatus(@CurrentUser() user: RequestUser) { + return this.billingService.checkSubscriptionStatus(user.tenant_id); + } + + @Post('subscription') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Create subscription (admin)' }) + async createSubscription( + @Body() dto: CreateSubscriptionDto, + @CurrentUser() user: RequestUser, + ) { + // Override tenant_id with the current user's tenant + dto.tenant_id = user.tenant_id; + return this.billingService.createSubscription(dto); + } + + @Patch('subscription') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Update subscription' }) + async updateSubscription( + @Body() dto: UpdateSubscriptionDto, + @CurrentUser() user: RequestUser, + ) { + return this.billingService.updateSubscription(user.tenant_id, dto); + } + + @Post('subscription/cancel') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Cancel subscription' }) + async cancelSubscription( + @Body() dto: CancelSubscriptionDto, + @CurrentUser() user: RequestUser, + ) { + return this.billingService.cancelSubscription(user.tenant_id, dto); + } + + @Post('subscription/change-plan/:planId') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Change subscription plan' }) + async changePlan( + @Param('planId') planId: string, + @CurrentUser() user: RequestUser, + ) { + return this.billingService.changePlan(user.tenant_id, planId); + } + + // ==================== Invoices ==================== + + @Get('invoices') + @ApiOperation({ summary: 'Get invoices' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getInvoices( + @CurrentUser() user: RequestUser, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.billingService.getInvoices(user.tenant_id, { page, limit }); + } + + @Get('invoices/:id') + @ApiOperation({ summary: 'Get invoice by ID' }) + async getInvoice( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + return this.billingService.getInvoice(id, user.tenant_id); + } + + @Post('invoices/:id/mark-paid') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Mark invoice as paid (admin)' }) + async markInvoicePaid( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + return this.billingService.markInvoicePaid(id, user.tenant_id); + } + + @Post('invoices/:id/void') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Void invoice (admin)' }) + async voidInvoice( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + return this.billingService.voidInvoice(id, user.tenant_id); + } + + // ==================== Payment Methods ==================== + + @Get('payment-methods') + @ApiOperation({ summary: 'Get payment methods' }) + async getPaymentMethods(@CurrentUser() user: RequestUser) { + return this.billingService.getPaymentMethods(user.tenant_id); + } + + @Post('payment-methods') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Add payment method' }) + async addPaymentMethod( + @Body() dto: CreatePaymentMethodDto, + @CurrentUser() user: RequestUser, + ) { + return this.billingService.addPaymentMethod(user.tenant_id, dto); + } + + @Post('payment-methods/:id/set-default') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Set default payment method' }) + async setDefaultPaymentMethod( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + return this.billingService.setDefaultPaymentMethod(id, user.tenant_id); + } + + @Delete('payment-methods/:id') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Remove payment method' }) + async removePaymentMethod( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + await this.billingService.removePaymentMethod(id, user.tenant_id); + return { message: 'Payment method removed' }; + } + + // ==================== Summary ==================== + + @Get('summary') + @ApiOperation({ summary: 'Get billing summary' }) + async getBillingSummary(@CurrentUser() user: RequestUser) { + return this.billingService.getBillingSummary(user.tenant_id); + } +} diff --git a/src/modules/billing/billing.module.ts b/src/modules/billing/billing.module.ts new file mode 100644 index 0000000..174a8c6 --- /dev/null +++ b/src/modules/billing/billing.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { BillingController } from './billing.controller'; +import { BillingService, StripeService, PlansService } from './services'; +import { StripeController, StripeWebhookController, PlansController } from './controllers'; +import { Subscription, Invoice, PaymentMethod, Plan } from './entities'; +import { RbacModule } from '../rbac/rbac.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Subscription, Invoice, PaymentMethod, Plan]), + ConfigModule, + RbacModule, + ], + controllers: [BillingController, StripeController, StripeWebhookController, PlansController], + providers: [BillingService, StripeService, PlansService], + exports: [BillingService, StripeService, PlansService], +}) +export class BillingModule {} diff --git a/src/modules/billing/controllers/index.ts b/src/modules/billing/controllers/index.ts new file mode 100644 index 0000000..a8ef656 --- /dev/null +++ b/src/modules/billing/controllers/index.ts @@ -0,0 +1,3 @@ +export * from './stripe.controller'; +export * from './stripe-webhook.controller'; +export * from './plans.controller'; diff --git a/src/modules/billing/controllers/plans.controller.ts b/src/modules/billing/controllers/plans.controller.ts new file mode 100644 index 0000000..765e5f6 --- /dev/null +++ b/src/modules/billing/controllers/plans.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { PlansService } from '../services/plans.service'; +import { PlanResponseDto, PlanDetailResponseDto } from '../dto/plan-response.dto'; +import { Public } from '../../auth/decorators/public.decorator'; + +@ApiTags('plans') +@Controller('plans') +export class PlansController { + constructor(private readonly plansService: PlansService) {} + + @Get() + @Public() + @ApiOperation({ + summary: 'List all available plans', + description: 'Returns all visible and active pricing plans ordered by sort_order', + }) + @ApiResponse({ + status: 200, + description: 'List of available plans', + type: [PlanResponseDto], + }) + async findAll(): Promise { + return this.plansService.findAll(); + } + + @Get(':id') + @Public() + @ApiOperation({ + summary: 'Get a single plan by ID', + description: 'Returns detailed information about a specific plan', + }) + @ApiParam({ + name: 'id', + description: 'Plan UUID', + type: 'string', + }) + @ApiResponse({ + status: 200, + description: 'Plan details', + type: PlanDetailResponseDto, + }) + @ApiResponse({ + status: 404, + description: 'Plan not found', + }) + async findOne(@Param('id') id: string): Promise { + return this.plansService.findOne(id); + } +} diff --git a/src/modules/billing/controllers/stripe-webhook.controller.ts b/src/modules/billing/controllers/stripe-webhook.controller.ts new file mode 100644 index 0000000..564ddc4 --- /dev/null +++ b/src/modules/billing/controllers/stripe-webhook.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Post, + Headers, + Req, + Res, + HttpCode, + HttpStatus, + Logger, + RawBodyRequest, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiHeader, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { StripeService } from '../services/stripe.service'; +import { StripeWebhookResponseDto } from '../dto/stripe-webhook.dto'; + +@ApiTags('stripe-webhooks') +@Controller('webhooks/stripe') +export class StripeWebhookController { + private readonly logger = new Logger(StripeWebhookController.name); + + constructor(private readonly stripeService: StripeService) {} + + @Post() + @HttpCode(HttpStatus.OK) + @ApiExcludeEndpoint() + @ApiOperation({ summary: 'Handle Stripe webhook events' }) + @ApiHeader({ + name: 'stripe-signature', + description: 'Stripe webhook signature', + required: true, + }) + async handleWebhook( + @Req() req: RawBodyRequest, + @Res() res: Response, + @Headers('stripe-signature') signature: string, + ): Promise { + const response: StripeWebhookResponseDto = { + received: false, + }; + + if (!signature) { + this.logger.warn('Webhook received without signature'); + res.status(HttpStatus.BAD_REQUEST).json({ + received: false, + error: 'Missing stripe-signature header', + }); + return; + } + + const rawBody = req.rawBody; + if (!rawBody) { + this.logger.warn('Webhook received without raw body'); + res.status(HttpStatus.BAD_REQUEST).json({ + received: false, + error: 'Missing raw body', + }); + return; + } + + try { + const event = this.stripeService.constructWebhookEvent(rawBody, signature); + + this.logger.log(`Received webhook event: ${event.type} (${event.id})`); + + await this.stripeService.handleWebhookEvent(event); + + response.received = true; + response.event_type = event.type; + + res.status(HttpStatus.OK).json(response); + } catch (error) { + this.logger.error(`Webhook error: ${error.message}`, error.stack); + + if (error.type === 'StripeSignatureVerificationError') { + res.status(HttpStatus.BAD_REQUEST).json({ + received: false, + error: 'Invalid signature', + }); + return; + } + + res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + received: false, + error: error.message, + }); + } + } +} diff --git a/src/modules/billing/controllers/stripe.controller.ts b/src/modules/billing/controllers/stripe.controller.ts new file mode 100644 index 0000000..3877669 --- /dev/null +++ b/src/modules/billing/controllers/stripe.controller.ts @@ -0,0 +1,186 @@ +import { + Controller, + Get, + Post, + Body, + UseGuards, + Query, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { StripeService } from '../services/stripe.service'; +import { + CreateCheckoutSessionDto, + CreateBillingPortalSessionDto, +} from '../dto/stripe-webhook.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { PermissionsGuard, RequirePermissions } from '../../rbac/guards/permissions.guard'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { RequestUser } from '../../auth/strategies/jwt.strategy'; + +@ApiTags('stripe') +@Controller('stripe') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class StripeController { + constructor(private readonly stripeService: StripeService) {} + + @Post('checkout-session') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Create Stripe checkout session' }) + async createCheckoutSession( + @Body() dto: CreateCheckoutSessionDto, + @CurrentUser() user: RequestUser, + ) { + dto.tenant_id = user.tenant_id; + const session = await this.stripeService.createCheckoutSession(dto); + return { + session_id: session.id, + url: session.url, + }; + } + + @Post('billing-portal') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Create Stripe billing portal session' }) + async createBillingPortalSession( + @Body() dto: CreateBillingPortalSessionDto, + @CurrentUser() user: RequestUser, + ) { + dto.tenant_id = user.tenant_id; + const session = await this.stripeService.createBillingPortalSession(dto); + return { + url: session.url, + }; + } + + @Post('setup-intent') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Create setup intent for adding payment method' }) + async createSetupIntent(@CurrentUser() user: RequestUser) { + const customer = await this.stripeService.findCustomerByTenantId(user.tenant_id); + + if (!customer) { + return { + error: 'Stripe customer not found', + client_secret: null, + }; + } + + const setupIntent = await this.stripeService.createSetupIntent(customer.id); + return { + client_secret: setupIntent.client_secret, + }; + } + + @Get('prices') + @ApiOperation({ summary: 'List available Stripe prices/plans' }) + @ApiQuery({ name: 'product_id', required: false }) + async listPrices(@Query('product_id') productId?: string) { + const prices = await this.stripeService.listPrices(productId); + return prices.map((price) => ({ + id: price.id, + product: typeof price.product === 'string' ? price.product : (price.product as any)?.name, + currency: price.currency, + unit_amount: price.unit_amount, + interval: price.recurring?.interval, + interval_count: price.recurring?.interval_count, + })); + } + + @Get('customer') + @ApiOperation({ summary: 'Get Stripe customer for current tenant' }) + async getCustomer(@CurrentUser() user: RequestUser) { + const customer = await this.stripeService.findCustomerByTenantId(user.tenant_id); + + if (!customer) { + return { + exists: false, + customer: null, + }; + } + + return { + exists: true, + customer: { + id: customer.id, + email: customer.email, + name: customer.name, + created: customer.created, + }, + }; + } + + @Post('customer') + @UseGuards(PermissionsGuard) + @RequirePermissions('billing:manage') + @ApiOperation({ summary: 'Create Stripe customer for tenant' }) + async createCustomer( + @CurrentUser() user: RequestUser, + @Body() body: { email: string; name?: string }, + ) { + const existingCustomer = await this.stripeService.findCustomerByTenantId(user.tenant_id); + + if (existingCustomer) { + return { + created: false, + customer: { + id: existingCustomer.id, + email: existingCustomer.email, + name: existingCustomer.name, + }, + message: 'Customer already exists', + }; + } + + const customer = await this.stripeService.createCustomer({ + tenant_id: user.tenant_id, + email: body.email, + name: body.name, + }); + + return { + created: true, + customer: { + id: customer.id, + email: customer.email, + name: customer.name, + }, + }; + } + + @Get('payment-methods') + @ApiOperation({ summary: 'List Stripe payment methods' }) + async listPaymentMethods(@CurrentUser() user: RequestUser) { + const customer = await this.stripeService.findCustomerByTenantId(user.tenant_id); + + if (!customer) { + return { payment_methods: [] }; + } + + const paymentMethods = await this.stripeService.listPaymentMethods(customer.id); + + return { + payment_methods: paymentMethods.map((pm) => ({ + id: pm.id, + type: pm.type, + card: pm.card + ? { + brand: pm.card.brand, + last4: pm.card.last4, + exp_month: pm.card.exp_month, + exp_year: pm.card.exp_year, + } + : null, + created: pm.created, + })), + }; + } +} diff --git a/src/modules/billing/dto/create-payment-method.dto.ts b/src/modules/billing/dto/create-payment-method.dto.ts new file mode 100644 index 0000000..5354003 --- /dev/null +++ b/src/modules/billing/dto/create-payment-method.dto.ts @@ -0,0 +1,43 @@ +import { IsEnum, IsOptional, IsString, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaymentMethodType } from '../entities/payment-method.entity'; + +export class CreatePaymentMethodDto { + @ApiProperty({ description: 'Payment method type', enum: PaymentMethodType }) + @IsEnum(PaymentMethodType) + type: PaymentMethodType; + + @ApiPropertyOptional({ description: 'Set as default payment method' }) + @IsOptional() + @IsBoolean() + is_default?: boolean; + + @ApiPropertyOptional({ description: 'External payment method ID from provider' }) + @IsOptional() + @IsString() + external_payment_method_id?: string; + + @ApiPropertyOptional({ description: 'Payment provider (stripe, conekta, etc)' }) + @IsOptional() + @IsString() + payment_provider?: string; + + // Card details (when adding a card) + @ApiPropertyOptional({ description: 'Last 4 digits of card' }) + @IsOptional() + @IsString() + card_last_four?: string; + + @ApiPropertyOptional({ description: 'Card brand (visa, mastercard, etc)' }) + @IsOptional() + @IsString() + card_brand?: string; + + @ApiPropertyOptional({ description: 'Card expiry month' }) + @IsOptional() + card_exp_month?: number; + + @ApiPropertyOptional({ description: 'Card expiry year' }) + @IsOptional() + card_exp_year?: number; +} diff --git a/src/modules/billing/dto/create-subscription.dto.ts b/src/modules/billing/dto/create-subscription.dto.ts new file mode 100644 index 0000000..c9c283d --- /dev/null +++ b/src/modules/billing/dto/create-subscription.dto.ts @@ -0,0 +1,22 @@ +import { IsUUID, IsOptional, IsDateString, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateSubscriptionDto { + @ApiProperty({ description: 'Tenant ID' }) + @IsUUID() + tenant_id: string; + + @ApiProperty({ description: 'Plan ID' }) + @IsUUID() + plan_id: string; + + @ApiPropertyOptional({ description: 'Trial end date' }) + @IsOptional() + @IsDateString() + trial_end?: string; + + @ApiPropertyOptional({ description: 'External payment provider' }) + @IsOptional() + @IsString() + payment_provider?: string; +} diff --git a/src/modules/billing/dto/index.ts b/src/modules/billing/dto/index.ts new file mode 100644 index 0000000..82f914e --- /dev/null +++ b/src/modules/billing/dto/index.ts @@ -0,0 +1,5 @@ +export * from './create-subscription.dto'; +export * from './update-subscription.dto'; +export * from './create-payment-method.dto'; +export * from './stripe-webhook.dto'; +export * from './plan-response.dto'; diff --git a/src/modules/billing/dto/plan-response.dto.ts b/src/modules/billing/dto/plan-response.dto.ts new file mode 100644 index 0000000..1e9199e --- /dev/null +++ b/src/modules/billing/dto/plan-response.dto.ts @@ -0,0 +1,75 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * DTO for Plan response - matches frontend Plan interface + */ +export class PlanResponseDto { + @ApiProperty({ description: 'Plan unique identifier' }) + id: string; + + @ApiProperty({ description: 'Plan name' }) + name: string; + + @ApiProperty({ description: 'Plan slug for URL/code reference' }) + slug: string; + + @ApiProperty({ description: 'Plan display name' }) + display_name: string; + + @ApiProperty({ description: 'Plan description' }) + description: string; + + @ApiPropertyOptional({ description: 'Short tagline for the plan' }) + tagline?: string; + + @ApiProperty({ description: 'Monthly price' }) + price_monthly: number; + + @ApiProperty({ description: 'Yearly price' }) + price_yearly: number; + + @ApiProperty({ description: 'Currency code (e.g., USD)' }) + currency: string; + + @ApiProperty({ + description: 'List of feature descriptions', + type: [String], + }) + features: string[]; + + @ApiPropertyOptional({ + description: 'Plan limits (e.g., max_users, storage_gb)', + additionalProperties: { type: 'number' }, + }) + limits?: Record; + + @ApiPropertyOptional({ description: 'Whether this is the most popular plan' }) + is_popular?: boolean; + + @ApiPropertyOptional({ description: 'Number of trial days' }) + trial_days?: number; +} + +/** + * DTO for detailed Plan response with additional fields + */ +export class PlanDetailResponseDto extends PlanResponseDto { + @ApiPropertyOptional({ description: 'Whether this is an enterprise plan' }) + is_enterprise?: boolean; + + @ApiPropertyOptional({ + description: 'Detailed features with name, value, and highlight flag', + type: 'array', + }) + detailed_features?: Array<{ + name: string; + value: string | boolean; + highlight?: boolean; + }>; + + @ApiPropertyOptional({ + description: 'Additional metadata', + additionalProperties: true, + }) + metadata?: Record; +} diff --git a/src/modules/billing/dto/stripe-webhook.dto.ts b/src/modules/billing/dto/stripe-webhook.dto.ts new file mode 100644 index 0000000..dacf4a4 --- /dev/null +++ b/src/modules/billing/dto/stripe-webhook.dto.ts @@ -0,0 +1,148 @@ +import { IsString, IsOptional, IsObject, IsNumber, IsEnum, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum StripeWebhookEventType { + // Customer events + CUSTOMER_CREATED = 'customer.created', + CUSTOMER_UPDATED = 'customer.updated', + CUSTOMER_DELETED = 'customer.deleted', + + // Subscription events + SUBSCRIPTION_CREATED = 'customer.subscription.created', + SUBSCRIPTION_UPDATED = 'customer.subscription.updated', + SUBSCRIPTION_DELETED = 'customer.subscription.deleted', + SUBSCRIPTION_TRIAL_WILL_END = 'customer.subscription.trial_will_end', + + // Invoice events + INVOICE_CREATED = 'invoice.created', + INVOICE_PAID = 'invoice.paid', + INVOICE_PAYMENT_FAILED = 'invoice.payment_failed', + INVOICE_FINALIZED = 'invoice.finalized', + INVOICE_VOIDED = 'invoice.voided', + + // Payment Intent events + PAYMENT_INTENT_SUCCEEDED = 'payment_intent.succeeded', + PAYMENT_INTENT_FAILED = 'payment_intent.payment_failed', + + // Payment Method events + PAYMENT_METHOD_ATTACHED = 'payment_method.attached', + PAYMENT_METHOD_DETACHED = 'payment_method.detached', + + // Checkout events + CHECKOUT_SESSION_COMPLETED = 'checkout.session.completed', + CHECKOUT_SESSION_EXPIRED = 'checkout.session.expired', +} + +export class StripeWebhookDto { + @ApiProperty({ description: 'Webhook event ID from Stripe' }) + @IsString() + id: string; + + @ApiProperty({ description: 'Event type', enum: StripeWebhookEventType }) + @IsString() + type: string; + + @ApiProperty({ description: 'Event data object' }) + @IsObject() + data: { + object: Record; + previous_attributes?: Record; + }; + + @ApiProperty({ description: 'API version used' }) + @IsString() + api_version: string; + + @ApiProperty({ description: 'Unix timestamp of event creation' }) + @IsNumber() + created: number; + + @ApiPropertyOptional({ description: 'Whether this is a live mode event' }) + @IsOptional() + @IsBoolean() + livemode?: boolean; +} + +export class CreateStripeCustomerDto { + @ApiProperty({ description: 'Tenant ID to link customer' }) + @IsString() + tenant_id: string; + + @ApiProperty({ description: 'Customer email' }) + @IsString() + email: string; + + @ApiPropertyOptional({ description: 'Customer name' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class CreateStripeSubscriptionDto { + @ApiProperty({ description: 'Stripe customer ID' }) + @IsString() + customer_id: string; + + @ApiProperty({ description: 'Stripe price ID' }) + @IsString() + price_id: string; + + @ApiPropertyOptional({ description: 'Trial period in days' }) + @IsOptional() + @IsNumber() + trial_period_days?: number; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class CreateCheckoutSessionDto { + @ApiProperty({ description: 'Tenant ID' }) + @IsString() + tenant_id: string; + + @ApiProperty({ description: 'Stripe price ID' }) + @IsString() + price_id: string; + + @ApiProperty({ description: 'Success redirect URL' }) + @IsString() + success_url: string; + + @ApiProperty({ description: 'Cancel redirect URL' }) + @IsString() + cancel_url: string; + + @ApiPropertyOptional({ description: 'Trial period in days' }) + @IsOptional() + @IsNumber() + trial_period_days?: number; +} + +export class CreateBillingPortalSessionDto { + @ApiProperty({ description: 'Tenant ID' }) + @IsString() + tenant_id: string; + + @ApiProperty({ description: 'Return URL after portal session' }) + @IsString() + return_url: string; +} + +export class StripeWebhookResponseDto { + @ApiProperty({ description: 'Whether the webhook was processed successfully' }) + received: boolean; + + @ApiPropertyOptional({ description: 'Event type processed' }) + event_type?: string; + + @ApiPropertyOptional({ description: 'Error message if failed' }) + error?: string; +} diff --git a/src/modules/billing/dto/update-subscription.dto.ts b/src/modules/billing/dto/update-subscription.dto.ts new file mode 100644 index 0000000..1ef59d1 --- /dev/null +++ b/src/modules/billing/dto/update-subscription.dto.ts @@ -0,0 +1,31 @@ +import { IsUUID, IsOptional, IsEnum, IsString } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { SubscriptionStatus } from '../entities/subscription.entity'; + +export class UpdateSubscriptionDto { + @ApiPropertyOptional({ description: 'New plan ID' }) + @IsOptional() + @IsUUID() + plan_id?: string; + + @ApiPropertyOptional({ description: 'Subscription status', enum: SubscriptionStatus }) + @IsOptional() + @IsEnum(SubscriptionStatus) + status?: SubscriptionStatus; + + @ApiPropertyOptional({ description: 'External subscription ID' }) + @IsOptional() + @IsString() + external_subscription_id?: string; +} + +export class CancelSubscriptionDto { + @ApiPropertyOptional({ description: 'Cancel immediately or at period end' }) + @IsOptional() + immediately?: boolean; + + @ApiPropertyOptional({ description: 'Cancellation reason' }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/src/modules/billing/entities/index.ts b/src/modules/billing/entities/index.ts new file mode 100644 index 0000000..015e61d --- /dev/null +++ b/src/modules/billing/entities/index.ts @@ -0,0 +1,4 @@ +export * from './subscription.entity'; +export * from './invoice.entity'; +export * from './payment-method.entity'; +export * from './plan.entity'; diff --git a/src/modules/billing/entities/invoice.entity.ts b/src/modules/billing/entities/invoice.entity.ts new file mode 100644 index 0000000..55cbcb5 --- /dev/null +++ b/src/modules/billing/entities/invoice.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum InvoiceStatus { + DRAFT = 'draft', + OPEN = 'open', + PAID = 'paid', + VOID = 'void', + UNCOLLECTIBLE = 'uncollectible', +} + +@Entity({ name: 'invoices', schema: 'billing' }) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + subscription_id: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + invoice_number: string; + + @Column({ + type: 'enum', + enum: InvoiceStatus, + default: InvoiceStatus.DRAFT, + }) + status: InvoiceStatus; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 }) + tax: number; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + total: number; + + @Column({ type: 'timestamp with time zone' }) + due_date: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + paid_at: Date; + + @Column({ type: 'varchar', length: 255, nullable: true }) + external_invoice_id: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + pdf_url: string | null; + + @Column({ type: 'jsonb', nullable: true }) + line_items: Array<{ + description: string; + quantity: number; + unit_price: number; + amount: number; + }>; + + @Column({ type: 'jsonb', nullable: true }) + billing_details: { + name?: string; + email?: string; + address?: string; + tax_id?: string; + }; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/billing/entities/payment-method.entity.ts b/src/modules/billing/entities/payment-method.entity.ts new file mode 100644 index 0000000..882f624 --- /dev/null +++ b/src/modules/billing/entities/payment-method.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum PaymentMethodType { + CARD = 'card', + BANK_TRANSFER = 'bank_transfer', + OXXO = 'oxxo', +} + +@Entity({ name: 'payment_methods', schema: 'billing' }) +export class PaymentMethod { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ + type: 'enum', + enum: PaymentMethodType, + default: PaymentMethodType.CARD, + }) + type: PaymentMethodType; + + @Column({ type: 'boolean', default: false }) + is_default: boolean; + + @Column({ type: 'varchar', length: 255, nullable: true }) + external_payment_method_id: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + payment_provider: string; + + // Card details (masked) + @Column({ type: 'varchar', length: 4, nullable: true }) + card_last_four: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + card_brand: string; + + @Column({ type: 'smallint', nullable: true }) + card_exp_month: number; + + @Column({ type: 'smallint', nullable: true }) + card_exp_year: number; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/billing/entities/plan.entity.ts b/src/modules/billing/entities/plan.entity.ts new file mode 100644 index 0000000..6cae73c --- /dev/null +++ b/src/modules/billing/entities/plan.entity.ts @@ -0,0 +1,84 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity({ name: 'plans', schema: 'plans' }) +export class Plan { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + slug: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 200, nullable: true }) + tagline: string | null; + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + price_monthly: number | null; + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + price_yearly: number | null; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + stripe_product_id: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + stripe_price_id_monthly: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + stripe_price_id_yearly: string | null; + + @Column({ type: 'jsonb', default: [] }) + features: Array<{ name: string; value: string | boolean; highlight?: boolean }>; + + @Column({ type: 'jsonb', default: {} }) + limits: Record; + + @Column({ type: 'jsonb', default: [] }) + included_features: string[]; + + @Column({ type: 'boolean', default: false }) + is_popular: boolean; + + @Column({ type: 'boolean', default: false }) + is_enterprise: boolean; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @Column({ type: 'boolean', default: true }) + is_visible: boolean; + + @Column({ type: 'int', default: 0 }) + sort_order: number; + + @Column({ type: 'int', default: 14 }) + trial_days: number; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + // Helper getter for display name (alias for name) + get display_name(): string { + return this.name; + } +} diff --git a/src/modules/billing/entities/subscription.entity.ts b/src/modules/billing/entities/subscription.entity.ts new file mode 100644 index 0000000..162bc97 --- /dev/null +++ b/src/modules/billing/entities/subscription.entity.ts @@ -0,0 +1,68 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Plan } from './plan.entity'; + +export enum SubscriptionStatus { + TRIAL = 'trial', + ACTIVE = 'active', + PAST_DUE = 'past_due', + CANCELLED = 'cancelled', + EXPIRED = 'expired', +} + +@Entity({ name: 'subscriptions', schema: 'billing' }) +export class Subscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + plan_id: string; + + @ManyToOne(() => Plan, { nullable: true }) + @JoinColumn({ name: 'plan_id' }) + plan: Plan | null; + + @Column({ + type: 'enum', + enum: SubscriptionStatus, + default: SubscriptionStatus.TRIAL, + }) + status: SubscriptionStatus; + + @Column({ type: 'timestamp with time zone' }) + current_period_start: Date; + + @Column({ type: 'timestamp with time zone' }) + current_period_end: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + trial_end: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + cancelled_at: Date | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + external_subscription_id: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + payment_provider: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/billing/index.ts b/src/modules/billing/index.ts new file mode 100644 index 0000000..5cdbf7a --- /dev/null +++ b/src/modules/billing/index.ts @@ -0,0 +1,5 @@ +export * from './billing.module'; +export * from './billing.controller'; +export * from './services'; +export * from './entities'; +export * from './dto'; diff --git a/src/modules/billing/services/billing.service.ts b/src/modules/billing/services/billing.service.ts new file mode 100644 index 0000000..0915f12 --- /dev/null +++ b/src/modules/billing/services/billing.service.ts @@ -0,0 +1,355 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { + Subscription, + SubscriptionStatus, +} from '../entities/subscription.entity'; +import { Invoice, InvoiceStatus } from '../entities/invoice.entity'; +import { PaymentMethod } from '../entities/payment-method.entity'; +import { CreateSubscriptionDto } from '../dto/create-subscription.dto'; +import { + UpdateSubscriptionDto, + CancelSubscriptionDto, +} from '../dto/update-subscription.dto'; +import { CreatePaymentMethodDto } from '../dto/create-payment-method.dto'; + +@Injectable() +export class BillingService { + constructor( + @InjectRepository(Subscription) + private readonly subscriptionRepo: Repository, + @InjectRepository(Invoice) + private readonly invoiceRepo: Repository, + @InjectRepository(PaymentMethod) + private readonly paymentMethodRepo: Repository, + ) {} + + // ==================== Subscriptions ==================== + + async createSubscription(dto: CreateSubscriptionDto): Promise { + const now = new Date(); + const periodEnd = new Date(now); + periodEnd.setMonth(periodEnd.getMonth() + 1); + + const subscription = this.subscriptionRepo.create({ + tenant_id: dto.tenant_id, + plan_id: dto.plan_id, + status: dto.trial_end ? SubscriptionStatus.TRIAL : SubscriptionStatus.ACTIVE, + current_period_start: now, + current_period_end: periodEnd, + trial_end: dto.trial_end ? new Date(dto.trial_end) : null, + payment_provider: dto.payment_provider, + }); + + return this.subscriptionRepo.save(subscription); + } + + async getSubscription(tenantId: string): Promise { + return this.subscriptionRepo.findOne({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + }); + } + + async updateSubscription( + tenantId: string, + dto: UpdateSubscriptionDto, + ): Promise { + const subscription = await this.getSubscription(tenantId); + if (!subscription) { + throw new NotFoundException('Subscription not found'); + } + + Object.assign(subscription, dto); + return this.subscriptionRepo.save(subscription); + } + + async cancelSubscription( + tenantId: string, + dto: CancelSubscriptionDto, + ): Promise { + const subscription = await this.getSubscription(tenantId); + if (!subscription) { + throw new NotFoundException('Subscription not found'); + } + + subscription.cancelled_at = new Date(); + + if (dto.immediately) { + subscription.status = SubscriptionStatus.CANCELLED; + } + + if (dto.reason) { + subscription.metadata = { + ...subscription.metadata, + cancellation_reason: dto.reason, + }; + } + + return this.subscriptionRepo.save(subscription); + } + + async changePlan(tenantId: string, newPlanId: string): Promise { + const subscription = await this.getSubscription(tenantId); + if (!subscription) { + throw new NotFoundException('Subscription not found'); + } + + subscription.plan_id = newPlanId; + subscription.metadata = { + ...subscription.metadata, + plan_changed_at: new Date().toISOString(), + }; + + return this.subscriptionRepo.save(subscription); + } + + async renewSubscription(tenantId: string): Promise { + const subscription = await this.getSubscription(tenantId); + if (!subscription) { + throw new NotFoundException('Subscription not found'); + } + + const now = new Date(); + const newPeriodEnd = new Date(subscription.current_period_end); + newPeriodEnd.setMonth(newPeriodEnd.getMonth() + 1); + + subscription.current_period_start = subscription.current_period_end; + subscription.current_period_end = newPeriodEnd; + subscription.status = SubscriptionStatus.ACTIVE; + + return this.subscriptionRepo.save(subscription); + } + + // ==================== Invoices ==================== + + async createInvoice( + tenantId: string, + subscriptionId: string, + lineItems: Array<{ + description: string; + quantity: number; + unit_price: number; + }>, + ): Promise { + const invoiceNumber = await this.generateInvoiceNumber(); + + const items = lineItems.map((item) => ({ + ...item, + amount: item.quantity * item.unit_price, + })); + + const subtotal = items.reduce((sum, item) => sum + item.amount, 0); + const tax = subtotal * 0.16; // 16% IVA + const total = subtotal + tax; + + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + 15); + + const invoice = this.invoiceRepo.create({ + tenant_id: tenantId, + subscription_id: subscriptionId, + invoice_number: invoiceNumber, + status: InvoiceStatus.OPEN, + subtotal, + tax, + total, + due_date: dueDate, + line_items: items, + }); + + return this.invoiceRepo.save(invoice); + } + + async getInvoices( + tenantId: string, + options?: { page?: number; limit?: number }, + ): Promise<{ data: Invoice[]; total: number; page: number; limit: number }> { + const page = options?.page || 1; + const limit = options?.limit || 10; + + const [data, total] = await this.invoiceRepo.findAndCount({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total, page, limit }; + } + + async getInvoice(invoiceId: string, tenantId: string): Promise { + const invoice = await this.invoiceRepo.findOne({ + where: { id: invoiceId, tenant_id: tenantId }, + }); + + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + + return invoice; + } + + async markInvoicePaid(invoiceId: string, tenantId: string): Promise { + const invoice = await this.getInvoice(invoiceId, tenantId); + invoice.status = InvoiceStatus.PAID; + invoice.paid_at = new Date(); + return this.invoiceRepo.save(invoice); + } + + async voidInvoice(invoiceId: string, tenantId: string): Promise { + const invoice = await this.getInvoice(invoiceId, tenantId); + + if (invoice.status === InvoiceStatus.PAID) { + throw new BadRequestException('Cannot void a paid invoice'); + } + + invoice.status = InvoiceStatus.VOID; + return this.invoiceRepo.save(invoice); + } + + private async generateInvoiceNumber(): Promise { + const year = new Date().getFullYear(); + const month = String(new Date().getMonth() + 1).padStart(2, '0'); + + const count = await this.invoiceRepo.count(); + const sequence = String(count + 1).padStart(6, '0'); + + return `INV-${year}${month}-${sequence}`; + } + + // ==================== Payment Methods ==================== + + async addPaymentMethod( + tenantId: string, + dto: CreatePaymentMethodDto, + ): Promise { + // If this is set as default, unset other defaults + if (dto.is_default) { + await this.paymentMethodRepo.update( + { tenant_id: tenantId, is_default: true }, + { is_default: false }, + ); + } + + const paymentMethod = this.paymentMethodRepo.create({ + tenant_id: tenantId, + ...dto, + }); + + return this.paymentMethodRepo.save(paymentMethod); + } + + async getPaymentMethods(tenantId: string): Promise { + return this.paymentMethodRepo.find({ + where: { tenant_id: tenantId, is_active: true }, + order: { is_default: 'DESC', created_at: 'DESC' }, + }); + } + + async getDefaultPaymentMethod(tenantId: string): Promise { + return this.paymentMethodRepo.findOne({ + where: { tenant_id: tenantId, is_default: true, is_active: true }, + }); + } + + async setDefaultPaymentMethod( + paymentMethodId: string, + tenantId: string, + ): Promise { + const paymentMethod = await this.paymentMethodRepo.findOne({ + where: { id: paymentMethodId, tenant_id: tenantId }, + }); + + if (!paymentMethod) { + throw new NotFoundException('Payment method not found'); + } + + // Unset current default + await this.paymentMethodRepo.update( + { tenant_id: tenantId, is_default: true }, + { is_default: false }, + ); + + paymentMethod.is_default = true; + return this.paymentMethodRepo.save(paymentMethod); + } + + async removePaymentMethod( + paymentMethodId: string, + tenantId: string, + ): Promise { + const paymentMethod = await this.paymentMethodRepo.findOne({ + where: { id: paymentMethodId, tenant_id: tenantId }, + }); + + if (!paymentMethod) { + throw new NotFoundException('Payment method not found'); + } + + if (paymentMethod.is_default) { + throw new BadRequestException('Cannot remove default payment method'); + } + + paymentMethod.is_active = false; + await this.paymentMethodRepo.save(paymentMethod); + } + + // ==================== Usage & Billing Summary ==================== + + async getBillingSummary(tenantId: string): Promise<{ + subscription: Subscription | null; + defaultPaymentMethod: PaymentMethod | null; + pendingInvoices: number; + totalDue: number; + }> { + const subscription = await this.getSubscription(tenantId); + const defaultPaymentMethod = await this.getDefaultPaymentMethod(tenantId); + + const pendingInvoices = await this.invoiceRepo.find({ + where: { tenant_id: tenantId, status: InvoiceStatus.OPEN }, + }); + + const totalDue = pendingInvoices.reduce( + (sum, inv) => sum + Number(inv.total), + 0, + ); + + return { + subscription, + defaultPaymentMethod, + pendingInvoices: pendingInvoices.length, + totalDue, + }; + } + + async checkSubscriptionStatus(tenantId: string): Promise<{ + isActive: boolean; + daysRemaining: number; + status: SubscriptionStatus; + }> { + const subscription = await this.getSubscription(tenantId); + + if (!subscription) { + return { isActive: false, daysRemaining: 0, status: SubscriptionStatus.EXPIRED }; + } + + const now = new Date(); + const periodEnd = new Date(subscription.current_period_end); + const daysRemaining = Math.ceil( + (periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), + ); + + const isActive = [ + SubscriptionStatus.ACTIVE, + SubscriptionStatus.TRIAL, + ].includes(subscription.status); + + return { + isActive, + daysRemaining: Math.max(0, daysRemaining), + status: subscription.status, + }; + } +} diff --git a/src/modules/billing/services/index.ts b/src/modules/billing/services/index.ts new file mode 100644 index 0000000..18db127 --- /dev/null +++ b/src/modules/billing/services/index.ts @@ -0,0 +1,3 @@ +export * from './billing.service'; +export * from './stripe.service'; +export * from './plans.service'; diff --git a/src/modules/billing/services/plans.service.ts b/src/modules/billing/services/plans.service.ts new file mode 100644 index 0000000..cc4b091 --- /dev/null +++ b/src/modules/billing/services/plans.service.ts @@ -0,0 +1,139 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Plan } from '../entities/plan.entity'; +import { PlanResponseDto, PlanDetailResponseDto } from '../dto/plan-response.dto'; + +@Injectable() +export class PlansService { + constructor( + @InjectRepository(Plan) + private readonly planRepo: Repository, + ) {} + + /** + * Get all visible and active plans + * Returns plans ordered by sort_order ascending + */ + async findAll(): Promise { + const plans = await this.planRepo.find({ + where: { + is_active: true, + is_visible: true, + }, + order: { + sort_order: 'ASC', + }, + }); + + return plans.map((plan) => this.toResponseDto(plan)); + } + + /** + * Get a single plan by ID + * Returns detailed plan information including features + */ + async findOne(id: string): Promise { + const plan = await this.planRepo.findOne({ + where: { id }, + }); + + if (!plan) { + throw new NotFoundException(`Plan with ID "${id}" not found`); + } + + return this.toDetailResponseDto(plan); + } + + /** + * Get a single plan by slug + * Returns detailed plan information including features + */ + async findBySlug(slug: string): Promise { + const plan = await this.planRepo.findOne({ + where: { slug }, + }); + + if (!plan) { + throw new NotFoundException(`Plan with slug "${slug}" not found`); + } + + return this.toDetailResponseDto(plan); + } + + /** + * Transform Plan entity to PlanResponseDto + * Extracts feature descriptions from the features array + */ + private toResponseDto(plan: Plan): PlanResponseDto { + // Extract feature descriptions from the features array + // The entity has features as Array<{ name, value, highlight }> + // Frontend expects features as string[] + const featureDescriptions = this.extractFeatureDescriptions(plan); + + return { + id: plan.id, + name: plan.name, + slug: plan.slug, + display_name: plan.name, // Use name as display_name + description: plan.description || '', + tagline: plan.tagline || undefined, + price_monthly: plan.price_monthly ? Number(plan.price_monthly) : 0, + price_yearly: plan.price_yearly ? Number(plan.price_yearly) : 0, + currency: plan.currency, + features: featureDescriptions, + limits: plan.limits || undefined, + is_popular: plan.is_popular || undefined, + trial_days: plan.trial_days || undefined, + }; + } + + /** + * Transform Plan entity to PlanDetailResponseDto + * Includes additional fields like is_enterprise and detailed_features + */ + private toDetailResponseDto(plan: Plan): PlanDetailResponseDto { + const baseDto = this.toResponseDto(plan); + + return { + ...baseDto, + is_enterprise: plan.is_enterprise || undefined, + detailed_features: plan.features || undefined, + metadata: plan.metadata || undefined, + }; + } + + /** + * Extract feature descriptions from plan + * Combines features array and included_features array + */ + private extractFeatureDescriptions(plan: Plan): string[] { + const descriptions: string[] = []; + + // Add features from the features array (name or value as description) + if (plan.features && Array.isArray(plan.features)) { + for (const feature of plan.features) { + if (typeof feature === 'object' && feature.name) { + // For boolean values, just use the name + // For string values, combine name and value + if (typeof feature.value === 'boolean') { + if (feature.value) { + descriptions.push(feature.name); + } + } else if (typeof feature.value === 'string') { + descriptions.push(`${feature.name}: ${feature.value}`); + } else { + descriptions.push(feature.name); + } + } + } + } + + // Add included_features as-is (they are already strings) + if (plan.included_features && Array.isArray(plan.included_features)) { + descriptions.push(...plan.included_features); + } + + return descriptions; + } +} diff --git a/src/modules/billing/services/stripe.service.ts b/src/modules/billing/services/stripe.service.ts new file mode 100644 index 0000000..192971a --- /dev/null +++ b/src/modules/billing/services/stripe.service.ts @@ -0,0 +1,576 @@ +import { + Injectable, + BadRequestException, + NotFoundException, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import Stripe from 'stripe'; +import { Subscription, SubscriptionStatus } from '../entities/subscription.entity'; +import { Invoice, InvoiceStatus } from '../entities/invoice.entity'; +import { PaymentMethod } from '../entities/payment-method.entity'; +import { + CreateStripeCustomerDto, + CreateStripeSubscriptionDto, + CreateCheckoutSessionDto, + CreateBillingPortalSessionDto, + StripeWebhookEventType, +} from '../dto/stripe-webhook.dto'; + +@Injectable() +export class StripeService implements OnModuleInit { + private stripe: Stripe; + private readonly logger = new Logger(StripeService.name); + + constructor( + private readonly configService: ConfigService, + @InjectRepository(Subscription) + private readonly subscriptionRepo: Repository, + @InjectRepository(Invoice) + private readonly invoiceRepo: Repository, + @InjectRepository(PaymentMethod) + private readonly paymentMethodRepo: Repository, + ) {} + + onModuleInit() { + const apiKey = this.configService.get('STRIPE_SECRET_KEY'); + if (!apiKey) { + this.logger.warn('STRIPE_SECRET_KEY not configured - Stripe integration disabled'); + return; + } + + this.stripe = new Stripe(apiKey, { + apiVersion: '2025-02-24.acacia', + typescript: true, + }); + this.logger.log('Stripe client initialized'); + } + + private ensureStripeConfigured(): void { + if (!this.stripe) { + throw new BadRequestException('Stripe is not configured'); + } + } + + // ==================== Customer Management ==================== + + async createCustomer(dto: CreateStripeCustomerDto): Promise { + this.ensureStripeConfigured(); + + const customer = await this.stripe.customers.create({ + email: dto.email, + name: dto.name, + metadata: { + tenant_id: dto.tenant_id, + ...dto.metadata, + }, + }); + + this.logger.log(`Created Stripe customer ${customer.id} for tenant ${dto.tenant_id}`); + return customer; + } + + async getCustomer(customerId: string): Promise { + this.ensureStripeConfigured(); + + try { + const customer = await this.stripe.customers.retrieve(customerId); + return customer as Stripe.Customer; + } catch (error) { + if (error.code === 'resource_missing') { + return null; + } + throw error; + } + } + + async findCustomerByTenantId(tenantId: string): Promise { + this.ensureStripeConfigured(); + + const customers = await this.stripe.customers.search({ + query: `metadata['tenant_id']:'${tenantId}'`, + }); + + return customers.data[0] || null; + } + + async updateCustomer( + customerId: string, + data: Partial<{ email: string; name: string; metadata: Record }>, + ): Promise { + this.ensureStripeConfigured(); + + return this.stripe.customers.update(customerId, data); + } + + // ==================== Subscription Management ==================== + + async createSubscription(dto: CreateStripeSubscriptionDto): Promise { + this.ensureStripeConfigured(); + + const subscriptionData: Stripe.SubscriptionCreateParams = { + customer: dto.customer_id, + items: [{ price: dto.price_id }], + payment_behavior: 'default_incomplete', + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + expand: ['latest_invoice.payment_intent'], + metadata: dto.metadata, + }; + + if (dto.trial_period_days) { + subscriptionData.trial_period_days = dto.trial_period_days; + } + + const subscription = await this.stripe.subscriptions.create(subscriptionData); + + this.logger.log(`Created Stripe subscription ${subscription.id}`); + return subscription; + } + + async getStripeSubscription(subscriptionId: string): Promise { + this.ensureStripeConfigured(); + + try { + return await this.stripe.subscriptions.retrieve(subscriptionId); + } catch (error) { + if (error.code === 'resource_missing') { + return null; + } + throw error; + } + } + + async cancelStripeSubscription( + subscriptionId: string, + options?: { immediately?: boolean }, + ): Promise { + this.ensureStripeConfigured(); + + if (options?.immediately) { + return this.stripe.subscriptions.cancel(subscriptionId); + } + + return this.stripe.subscriptions.update(subscriptionId, { + cancel_at_period_end: true, + }); + } + + async updateStripeSubscription( + subscriptionId: string, + priceId: string, + ): Promise { + this.ensureStripeConfigured(); + + const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); + + return this.stripe.subscriptions.update(subscriptionId, { + items: [ + { + id: subscription.items.data[0].id, + price: priceId, + }, + ], + proration_behavior: 'create_prorations', + }); + } + + // ==================== Checkout & Portal ==================== + + async createCheckoutSession(dto: CreateCheckoutSessionDto): Promise { + this.ensureStripeConfigured(); + + const customer = await this.findCustomerByTenantId(dto.tenant_id); + + if (!customer) { + throw new NotFoundException('Stripe customer not found for tenant'); + } + + const sessionParams: Stripe.Checkout.SessionCreateParams = { + customer: customer.id, + mode: 'subscription', + line_items: [ + { + price: dto.price_id, + quantity: 1, + }, + ], + success_url: dto.success_url, + cancel_url: dto.cancel_url, + subscription_data: { + metadata: { + tenant_id: dto.tenant_id, + }, + ...(dto.trial_period_days && { trial_period_days: dto.trial_period_days }), + }, + }; + + const session = await this.stripe.checkout.sessions.create(sessionParams); + + this.logger.log(`Created checkout session ${session.id} for tenant ${dto.tenant_id}`); + return session; + } + + async createBillingPortalSession(dto: CreateBillingPortalSessionDto): Promise { + this.ensureStripeConfigured(); + + const customer = await this.findCustomerByTenantId(dto.tenant_id); + + if (!customer) { + throw new NotFoundException('Stripe customer not found for tenant'); + } + + const session = await this.stripe.billingPortal.sessions.create({ + customer: customer.id, + return_url: dto.return_url, + }); + + this.logger.log(`Created billing portal session for tenant ${dto.tenant_id}`); + return session; + } + + // ==================== Payment Methods ==================== + + async attachPaymentMethod( + paymentMethodId: string, + customerId: string, + ): Promise { + this.ensureStripeConfigured(); + + return this.stripe.paymentMethods.attach(paymentMethodId, { + customer: customerId, + }); + } + + async detachPaymentMethod(paymentMethodId: string): Promise { + this.ensureStripeConfigured(); + + return this.stripe.paymentMethods.detach(paymentMethodId); + } + + async listPaymentMethods(customerId: string): Promise { + this.ensureStripeConfigured(); + + const paymentMethods = await this.stripe.paymentMethods.list({ + customer: customerId, + type: 'card', + }); + + return paymentMethods.data; + } + + async setDefaultPaymentMethod( + customerId: string, + paymentMethodId: string, + ): Promise { + this.ensureStripeConfigured(); + + return this.stripe.customers.update(customerId, { + invoice_settings: { + default_payment_method: paymentMethodId, + }, + }); + } + + // ==================== Webhook Handling ==================== + + constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event { + this.ensureStripeConfigured(); + + const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET'); + if (!webhookSecret) { + throw new BadRequestException('Webhook secret not configured'); + } + + return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret); + } + + async handleWebhookEvent(event: Stripe.Event): Promise { + this.logger.log(`Processing webhook event: ${event.type}`); + + switch (event.type) { + case StripeWebhookEventType.SUBSCRIPTION_CREATED: + case StripeWebhookEventType.SUBSCRIPTION_UPDATED: + await this.syncSubscription(event.data.object as Stripe.Subscription); + break; + + case StripeWebhookEventType.SUBSCRIPTION_DELETED: + await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription); + break; + + case StripeWebhookEventType.INVOICE_PAID: + await this.handleInvoicePaid(event.data.object as Stripe.Invoice); + break; + + case StripeWebhookEventType.INVOICE_PAYMENT_FAILED: + await this.handleInvoicePaymentFailed(event.data.object as Stripe.Invoice); + break; + + case StripeWebhookEventType.PAYMENT_METHOD_ATTACHED: + await this.syncPaymentMethod(event.data.object as Stripe.PaymentMethod); + break; + + case StripeWebhookEventType.PAYMENT_METHOD_DETACHED: + await this.handlePaymentMethodDetached(event.data.object as Stripe.PaymentMethod); + break; + + case StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED: + await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); + break; + + default: + this.logger.log(`Unhandled event type: ${event.type}`); + } + } + + // ==================== Sync Methods ==================== + + private async syncSubscription(stripeSubscription: Stripe.Subscription): Promise { + const tenantId = stripeSubscription.metadata?.tenant_id; + if (!tenantId) { + this.logger.warn(`Subscription ${stripeSubscription.id} has no tenant_id metadata`); + return; + } + + let subscription = await this.subscriptionRepo.findOne({ + where: { external_subscription_id: stripeSubscription.id }, + }); + + const status = this.mapStripeStatus(stripeSubscription.status); + + if (!subscription) { + subscription = this.subscriptionRepo.create({ + tenant_id: tenantId, + plan_id: stripeSubscription.items.data[0]?.price?.product as string, + external_subscription_id: stripeSubscription.id, + payment_provider: 'stripe', + }); + } + + subscription.status = status; + subscription.current_period_start = new Date(stripeSubscription.current_period_start * 1000); + subscription.current_period_end = new Date(stripeSubscription.current_period_end * 1000); + + if (stripeSubscription.trial_end) { + subscription.trial_end = new Date(stripeSubscription.trial_end * 1000); + } + + if (stripeSubscription.canceled_at) { + subscription.cancelled_at = new Date(stripeSubscription.canceled_at * 1000); + } + + subscription.metadata = { + ...subscription.metadata, + stripe_customer_id: stripeSubscription.customer as string, + stripe_price_id: stripeSubscription.items.data[0]?.price?.id, + }; + + await this.subscriptionRepo.save(subscription); + this.logger.log(`Synced subscription ${stripeSubscription.id} for tenant ${tenantId}`); + } + + private async handleSubscriptionDeleted(stripeSubscription: Stripe.Subscription): Promise { + const subscription = await this.subscriptionRepo.findOne({ + where: { external_subscription_id: stripeSubscription.id }, + }); + + if (subscription) { + subscription.status = SubscriptionStatus.CANCELLED; + subscription.cancelled_at = new Date(); + await this.subscriptionRepo.save(subscription); + this.logger.log(`Cancelled subscription ${stripeSubscription.id}`); + } + } + + private async handleInvoicePaid(stripeInvoice: Stripe.Invoice): Promise { + const tenantId = stripeInvoice.subscription_details?.metadata?.tenant_id || + stripeInvoice.metadata?.tenant_id; + + if (!tenantId) { + this.logger.warn(`Invoice ${stripeInvoice.id} has no tenant_id`); + return; + } + + let invoice = await this.invoiceRepo.findOne({ + where: { + tenant_id: tenantId, + invoice_number: stripeInvoice.number || stripeInvoice.id, + }, + }); + + if (!invoice) { + invoice = this.invoiceRepo.create({ + tenant_id: tenantId, + subscription_id: stripeInvoice.subscription as string, + invoice_number: stripeInvoice.number || stripeInvoice.id, + subtotal: stripeInvoice.subtotal / 100, + tax: stripeInvoice.tax ? stripeInvoice.tax / 100 : 0, + total: stripeInvoice.total / 100, + due_date: stripeInvoice.due_date + ? new Date(stripeInvoice.due_date * 1000) + : new Date(), + }); + } + + invoice.status = InvoiceStatus.PAID; + invoice.paid_at = new Date(); + invoice.external_invoice_id = stripeInvoice.id; + invoice.pdf_url = stripeInvoice.invoice_pdf || stripeInvoice.hosted_invoice_url || null; + + await this.invoiceRepo.save(invoice); + this.logger.log(`Recorded paid invoice ${stripeInvoice.id} for tenant ${tenantId}`); + } + + private async handleInvoicePaymentFailed(stripeInvoice: Stripe.Invoice): Promise { + const tenantId = stripeInvoice.subscription_details?.metadata?.tenant_id; + + if (!tenantId) { + return; + } + + const subscription = await this.subscriptionRepo.findOne({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + }); + + if (subscription) { + subscription.status = SubscriptionStatus.PAST_DUE; + subscription.metadata = { + ...subscription.metadata, + payment_failed_at: new Date().toISOString(), + failed_invoice_id: stripeInvoice.id, + }; + await this.subscriptionRepo.save(subscription); + this.logger.log(`Marked subscription as past_due for tenant ${tenantId}`); + } + } + + private async syncPaymentMethod(stripePaymentMethod: Stripe.PaymentMethod): Promise { + const customerId = stripePaymentMethod.customer as string; + if (!customerId) return; + + const customer = await this.getCustomer(customerId); + const tenantId = customer?.metadata?.tenant_id; + + if (!tenantId) return; + + let paymentMethod = await this.paymentMethodRepo.findOne({ + where: { external_payment_method_id: stripePaymentMethod.id }, + }); + + if (!paymentMethod) { + paymentMethod = this.paymentMethodRepo.create({ + tenant_id: tenantId, + external_payment_method_id: stripePaymentMethod.id, + }); + } + + const card = stripePaymentMethod.card; + if (card) { + paymentMethod.type = 'card' as any; + paymentMethod.card_brand = card.brand; + paymentMethod.card_last_four = card.last4; + paymentMethod.card_exp_month = card.exp_month; + paymentMethod.card_exp_year = card.exp_year; + } + paymentMethod.payment_provider = 'stripe'; + + paymentMethod.is_active = true; + + await this.paymentMethodRepo.save(paymentMethod); + this.logger.log(`Synced payment method ${stripePaymentMethod.id} for tenant ${tenantId}`); + } + + private async handlePaymentMethodDetached(stripePaymentMethod: Stripe.PaymentMethod): Promise { + const paymentMethod = await this.paymentMethodRepo.findOne({ + where: { external_payment_method_id: stripePaymentMethod.id }, + }); + + if (paymentMethod) { + paymentMethod.is_active = false; + await this.paymentMethodRepo.save(paymentMethod); + this.logger.log(`Deactivated payment method ${stripePaymentMethod.id}`); + } + } + + private async handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise { + const tenantId = session.metadata?.tenant_id || + (session as any).subscription_data?.metadata?.tenant_id; + + if (!tenantId || !session.subscription) { + return; + } + + const stripeSubscription = await this.getStripeSubscription(session.subscription as string); + if (stripeSubscription) { + await this.syncSubscription(stripeSubscription); + } + + this.logger.log(`Processed checkout completion for tenant ${tenantId}`); + } + + // ==================== Utility Methods ==================== + + private mapStripeStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus { + const statusMap: Record = { + trialing: SubscriptionStatus.TRIAL, + active: SubscriptionStatus.ACTIVE, + past_due: SubscriptionStatus.PAST_DUE, + canceled: SubscriptionStatus.CANCELLED, + unpaid: SubscriptionStatus.PAST_DUE, + incomplete: SubscriptionStatus.TRIAL, + incomplete_expired: SubscriptionStatus.EXPIRED, + paused: SubscriptionStatus.CANCELLED, + }; + + return statusMap[stripeStatus] || SubscriptionStatus.ACTIVE; + } + + // ==================== Price & Product Management ==================== + + async listPrices(productId?: string): Promise { + this.ensureStripeConfigured(); + + const params: Stripe.PriceListParams = { + active: true, + expand: ['data.product'], + }; + + if (productId) { + params.product = productId; + } + + const prices = await this.stripe.prices.list(params); + return prices.data; + } + + async getPrice(priceId: string): Promise { + this.ensureStripeConfigured(); + + try { + return await this.stripe.prices.retrieve(priceId, { + expand: ['product'], + }); + } catch (error) { + if (error.code === 'resource_missing') { + return null; + } + throw error; + } + } + + // ==================== Setup Intent (for adding cards without charging) ==================== + + async createSetupIntent(customerId: string): Promise { + this.ensureStripeConfigured(); + + return this.stripe.setupIntents.create({ + customer: customerId, + payment_method_types: ['card'], + }); + } +} diff --git a/src/modules/email/__tests__/email.service.spec.ts b/src/modules/email/__tests__/email.service.spec.ts new file mode 100644 index 0000000..9ed99e3 --- /dev/null +++ b/src/modules/email/__tests__/email.service.spec.ts @@ -0,0 +1,408 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { EmailService } from '../services/email.service'; + +// Mock fetch globally +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe('EmailService', () => { + let service: EmailService; + let configService: jest.Mocked; + + const defaultEmailConfig = { + provider: 'sendgrid', + from: 'noreply@example.com', + fromName: 'Test App', + replyTo: 'support@example.com', + sendgridApiKey: 'SG.test-api-key', + sesRegion: 'us-east-1', + sesAccessKeyId: '', + sesSecretAccessKey: '', + smtpHost: '', + smtpPort: 587, + smtpUser: '', + smtpPassword: '', + smtpSecure: false, + }; + + beforeEach(async () => { + mockFetch.mockReset(); + + const mockConfigService = { + get: jest.fn().mockReturnValue(defaultEmailConfig), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { provide: ConfigService, useValue: mockConfigService }, + ], + }).compile(); + + service = module.get(EmailService); + configService = module.get(ConfigService); + + // Trigger onModuleInit + service.onModuleInit(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initialization', () => { + it('should initialize with sendgrid provider', () => { + expect(service.getProvider()).toBe('sendgrid'); + expect(service.isEnabled()).toBe(true); + }); + + it('should not be enabled when no API key is configured', async () => { + configService.get.mockReturnValue({ + ...defaultEmailConfig, + sendgridApiKey: '', + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + const unconfiguredService = module.get(EmailService); + unconfiguredService.onModuleInit(); + + expect(unconfiguredService.isEnabled()).toBe(false); + }); + }); + + describe('sendEmail', () => { + const sendEmailDto = { + to: { email: 'user@example.com', name: 'Test User' }, + subject: 'Test Subject', + html: '

Hello

', + text: 'Hello', + }; + + describe('SendGrid provider', () => { + it('should send email successfully via SendGrid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { + get: jest.fn().mockReturnValue('sg-msg-123'), + }, + }); + + const result = await service.sendEmail(sendEmailDto); + + expect(result.success).toBe(true); + expect(result.provider).toBe('sendgrid'); + expect(result.messageId).toBeDefined(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.sendgrid.com/v3/mail/send', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': 'Bearer SG.test-api-key', + }), + }), + ); + }); + + it('should handle SendGrid API error', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + text: jest.fn().mockResolvedValue('Unauthorized'), + }); + + const result = await service.sendEmail(sendEmailDto); + + expect(result.success).toBe(false); + expect(result.error).toContain('SendGrid error'); + }); + + it('should include CC and BCC recipients', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: jest.fn().mockReturnValue('msg-123') }, + }); + + const dtoWithCcBcc = { + ...sendEmailDto, + cc: [{ email: 'cc@example.com' }], + bcc: [{ email: 'bcc@example.com' }], + }; + + await service.sendEmail(dtoWithCcBcc); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('cc@example.com'), + }), + ); + }); + + it('should include attachments', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: jest.fn().mockReturnValue('msg-123') }, + }); + + const dtoWithAttachment = { + ...sendEmailDto, + attachments: [{ + filename: 'test.pdf', + content: 'base64content', + contentType: 'application/pdf', + }], + }; + + await service.sendEmail(dtoWithAttachment); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('test.pdf'), + }), + ); + }); + }); + + describe('when not configured', () => { + it('should log email instead of sending', async () => { + configService.get.mockReturnValue({ + ...defaultEmailConfig, + sendgridApiKey: '', + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + const unconfiguredService = module.get(EmailService); + unconfiguredService.onModuleInit(); + + const result = await unconfiguredService.sendEmail(sendEmailDto); + + expect(result.success).toBe(true); + expect(result.messageId).toMatch(/^mock-/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('sendTemplateEmail', () => { + it('should send email from welcome template', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: jest.fn().mockReturnValue('msg-123') }, + }); + + const result = await service.sendTemplateEmail({ + to: { email: 'user@example.com' }, + templateKey: 'welcome', + variables: { + userName: 'John', + appName: 'Test App', + }, + }); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('Welcome'), + }), + ); + }); + + it('should send email from password_reset template', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: jest.fn().mockReturnValue('msg-123') }, + }); + + const result = await service.sendTemplateEmail({ + to: { email: 'user@example.com' }, + templateKey: 'password_reset', + variables: { + userName: 'John', + resetLink: 'https://app.com/reset?token=abc', + expiresIn: '1 hour', + }, + }); + + expect(result.success).toBe(true); + }); + + it('should send email from invitation template', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: jest.fn().mockReturnValue('msg-123') }, + }); + + const result = await service.sendTemplateEmail({ + to: { email: 'user@example.com' }, + templateKey: 'invitation', + variables: { + inviterName: 'Admin', + tenantName: 'Acme Corp', + appName: 'Test App', + inviteLink: 'https://app.com/invite?code=xyz', + expiresIn: '7 days', + }, + }); + + expect(result.success).toBe(true); + }); + + it('should return error for unknown template', async () => { + const result = await service.sendTemplateEmail({ + to: { email: 'user@example.com' }, + templateKey: 'unknown_template', + variables: {}, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Template not found'); + }); + + it('should handle conditional blocks in templates', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: jest.fn().mockReturnValue('msg-123') }, + }); + + // With actionUrl + await service.sendTemplateEmail({ + to: { email: 'user@example.com' }, + templateKey: 'notification', + variables: { + title: 'New Message', + message: 'You have a new message', + actionUrl: 'https://app.com/messages', + actionText: 'View Message', + }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.stringContaining('View Message'), + }), + ); + }); + }); + + describe('sendBulkEmails', () => { + it('should send multiple emails in batches', async () => { + mockFetch.mockResolvedValue({ + ok: true, + headers: { get: jest.fn().mockReturnValue('msg-123') }, + }); + + const emails = Array(15).fill(null).map((_, i) => ({ + to: { email: `user${i}@example.com` }, + subject: `Email ${i}`, + text: `Content ${i}`, + })); + + const results = await service.sendBulkEmails(emails); + + expect(results).toHaveLength(15); + expect(results.every(r => r.success)).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(15); + }); + + it('should handle partial failures in bulk send', async () => { + mockFetch + .mockResolvedValueOnce({ ok: true, headers: { get: () => 'msg-1' } }) + .mockResolvedValueOnce({ ok: false, status: 500, text: () => 'Error' }) + .mockResolvedValueOnce({ ok: true, headers: { get: () => 'msg-3' } }); + + const emails = [ + { to: { email: 'user1@example.com' }, subject: 'Test 1', text: 'Content' }, + { to: { email: 'user2@example.com' }, subject: 'Test 2', text: 'Content' }, + { to: { email: 'user3@example.com' }, subject: 'Test 3', text: 'Content' }, + ]; + + const results = await service.sendBulkEmails(emails); + + expect(results[0].success).toBe(true); + expect(results[1].success).toBe(false); + expect(results[2].success).toBe(true); + }); + }); + + describe('AWS SES provider', () => { + beforeEach(async () => { + configService.get.mockReturnValue({ + ...defaultEmailConfig, + provider: 'ses', + sendgridApiKey: '', + sesAccessKeyId: 'AKIATEST', + sesSecretAccessKey: 'secret', + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + service = module.get(EmailService); + service.onModuleInit(); + }); + + it('should use SES provider when configured', () => { + expect(service.getProvider()).toBe('ses'); + expect(service.isEnabled()).toBe(true); + }); + }); + + describe('SMTP provider', () => { + beforeEach(async () => { + configService.get.mockReturnValue({ + ...defaultEmailConfig, + provider: 'smtp', + sendgridApiKey: '', + smtpHost: 'smtp.example.com', + smtpUser: 'user', + smtpPassword: 'password', + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + service = module.get(EmailService); + service.onModuleInit(); + }); + + it('should use SMTP provider when configured', () => { + expect(service.getProvider()).toBe('smtp'); + expect(service.isEnabled()).toBe(true); + }); + + it('should fallback to logging for SMTP (nodemailer not implemented)', async () => { + const result = await service.sendEmail({ + to: { email: 'user@example.com' }, + subject: 'Test', + text: 'Hello', + }); + + expect(result.success).toBe(true); + expect(result.messageId).toMatch(/^smtp-/); + }); + }); +}); diff --git a/src/modules/email/dto/index.ts b/src/modules/email/dto/index.ts new file mode 100644 index 0000000..aef6a39 --- /dev/null +++ b/src/modules/email/dto/index.ts @@ -0,0 +1 @@ +export * from './send-email.dto'; diff --git a/src/modules/email/dto/send-email.dto.ts b/src/modules/email/dto/send-email.dto.ts new file mode 100644 index 0000000..3827879 --- /dev/null +++ b/src/modules/email/dto/send-email.dto.ts @@ -0,0 +1,104 @@ +import { IsString, IsEmail, IsOptional, IsArray, ValidateNested, IsObject } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class EmailAddressDto { + @IsEmail() + email: string; + + @IsOptional() + @IsString() + name?: string; +} + +export class AttachmentDto { + @IsString() + filename: string; + + @IsString() + content: string; // Base64 encoded + + @IsOptional() + @IsString() + contentType?: string; +} + +export class SendEmailDto { + @ValidateNested() + @Type(() => EmailAddressDto) + to: EmailAddressDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailAddressDto) + cc?: EmailAddressDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailAddressDto) + bcc?: EmailAddressDto[]; + + @IsString() + subject: string; + + @IsOptional() + @IsString() + text?: string; + + @IsOptional() + @IsString() + html?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AttachmentDto) + attachments?: AttachmentDto[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class SendTemplateEmailDto { + @ValidateNested() + @Type(() => EmailAddressDto) + to: EmailAddressDto; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailAddressDto) + cc?: EmailAddressDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => EmailAddressDto) + bcc?: EmailAddressDto[]; + + @IsString() + templateKey: string; + + @IsOptional() + @IsObject() + variables?: Record; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AttachmentDto) + attachments?: AttachmentDto[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class BulkSendEmailDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SendEmailDto) + emails: SendEmailDto[]; +} diff --git a/src/modules/email/email.module.ts b/src/modules/email/email.module.ts new file mode 100644 index 0000000..cd78fb2 --- /dev/null +++ b/src/modules/email/email.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EmailService } from './services'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/src/modules/email/index.ts b/src/modules/email/index.ts new file mode 100644 index 0000000..c41aa8c --- /dev/null +++ b/src/modules/email/index.ts @@ -0,0 +1,3 @@ +export * from './email.module'; +export * from './services'; +export * from './dto'; diff --git a/src/modules/email/services/email.service.ts b/src/modules/email/services/email.service.ts new file mode 100644 index 0000000..88cc1fe --- /dev/null +++ b/src/modules/email/services/email.service.ts @@ -0,0 +1,432 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SendEmailDto, SendTemplateEmailDto, EmailAddressDto, AttachmentDto } from '../dto'; + +export type EmailProvider = 'sendgrid' | 'ses' | 'smtp'; + +export interface EmailResult { + success: boolean; + messageId?: string; + provider: EmailProvider; + error?: string; +} + +interface SendGridAttachment { + content: string; + filename: string; + type?: string; + disposition?: string; +} + +interface SendGridPersonalization { + to: { email: string; name?: string }[]; + cc?: { email: string; name?: string }[]; + bcc?: { email: string; name?: string }[]; +} + +interface SendGridMessage { + personalizations: SendGridPersonalization[]; + from: { email: string; name?: string }; + reply_to?: { email: string; name?: string }; + subject: string; + content: { type: string; value: string }[]; + attachments?: SendGridAttachment[]; +} + +@Injectable() +export class EmailService implements OnModuleInit { + private readonly logger = new Logger(EmailService.name); + private provider: EmailProvider; + private isConfigured = false; + + // SendGrid + private sendgridApiKey: string; + + // AWS SES + private sesRegion: string; + private sesAccessKeyId: string; + private sesSecretAccessKey: string; + + // SMTP + private smtpHost: string; + private smtpPort: number; + private smtpUser: string; + private smtpPassword: string; + private smtpSecure: boolean; + + // Common + private fromEmail: string; + private fromName: string; + private replyTo: string; + + constructor(private configService: ConfigService) {} + + onModuleInit() { + const emailConfig = this.configService.get('email'); + + this.provider = emailConfig?.provider || 'sendgrid'; + this.fromEmail = emailConfig?.from || 'noreply@example.com'; + this.fromName = emailConfig?.fromName || 'Template SaaS'; + this.replyTo = emailConfig?.replyTo || ''; + + // SendGrid config + this.sendgridApiKey = emailConfig?.sendgridApiKey || ''; + + // AWS SES config + this.sesRegion = emailConfig?.sesRegion || 'us-east-1'; + this.sesAccessKeyId = emailConfig?.sesAccessKeyId || ''; + this.sesSecretAccessKey = emailConfig?.sesSecretAccessKey || ''; + + // SMTP config + this.smtpHost = emailConfig?.smtpHost || ''; + this.smtpPort = emailConfig?.smtpPort || 587; + this.smtpUser = emailConfig?.smtpUser || ''; + this.smtpPassword = emailConfig?.smtpPassword || ''; + this.smtpSecure = emailConfig?.smtpSecure || false; + + // Check if configured + this.isConfigured = this.checkConfiguration(); + + if (this.isConfigured) { + this.logger.log(`Email service initialized with provider: ${this.provider}`); + } else { + this.logger.warn('Email service not configured - emails will be logged only'); + } + } + + private checkConfiguration(): boolean { + switch (this.provider) { + case 'sendgrid': + return !!this.sendgridApiKey; + case 'ses': + return !!(this.sesAccessKeyId && this.sesSecretAccessKey); + case 'smtp': + return !!(this.smtpHost && this.smtpUser && this.smtpPassword); + default: + return false; + } + } + + async sendEmail(dto: SendEmailDto): Promise { + this.logger.debug(`Sending email to ${dto.to.email}: ${dto.subject}`); + + if (!this.isConfigured) { + this.logger.warn('Email not configured, logging email instead'); + this.logEmail(dto); + return { + success: true, + messageId: `mock-${Date.now()}`, + provider: this.provider, + }; + } + + try { + switch (this.provider) { + case 'sendgrid': + return await this.sendViaSendGrid(dto); + case 'ses': + return await this.sendViaSES(dto); + case 'smtp': + return await this.sendViaSMTP(dto); + default: + throw new Error(`Unknown email provider: ${this.provider}`); + } + } catch (error) { + this.logger.error(`Failed to send email: ${error.message}`, error.stack); + return { + success: false, + provider: this.provider, + error: error.message, + }; + } + } + + async sendTemplateEmail(dto: SendTemplateEmailDto): Promise { + // TODO: Integrate with notification_templates table + // For now, we'll use a simple template rendering approach + const { templateKey, variables, ...emailData } = dto; + + const template = await this.getTemplate(templateKey); + if (!template) { + return { + success: false, + provider: this.provider, + error: `Template not found: ${templateKey}`, + }; + } + + const renderedSubject = this.renderTemplate(template.subject, variables || {}); + const renderedHtml = this.renderTemplate(template.html, variables || {}); + const renderedText = template.text + ? this.renderTemplate(template.text, variables || {}) + : undefined; + + return this.sendEmail({ + ...emailData, + subject: renderedSubject, + html: renderedHtml, + text: renderedText, + }); + } + + async sendBulkEmails(emails: SendEmailDto[]): Promise { + const results: EmailResult[] = []; + + // Process in batches of 10 + const batchSize = 10; + for (let i = 0; i < emails.length; i += batchSize) { + const batch = emails.slice(i, i + batchSize); + const batchResults = await Promise.all( + batch.map((email) => this.sendEmail(email)), + ); + results.push(...batchResults); + } + + return results; + } + + // SendGrid implementation + private async sendViaSendGrid(dto: SendEmailDto): Promise { + const message: SendGridMessage = { + personalizations: [ + { + to: [{ email: dto.to.email, name: dto.to.name }], + ...(dto.cc?.length && { + cc: dto.cc.map((c) => ({ email: c.email, name: c.name })), + }), + ...(dto.bcc?.length && { + bcc: dto.bcc.map((b) => ({ email: b.email, name: b.name })), + }), + }, + ], + from: { email: this.fromEmail, name: this.fromName }, + ...(this.replyTo && { reply_to: { email: this.replyTo } }), + subject: dto.subject, + content: [], + }; + + if (dto.text) { + message.content.push({ type: 'text/plain', value: dto.text }); + } + if (dto.html) { + message.content.push({ type: 'text/html', value: dto.html }); + } + + if (dto.attachments?.length) { + message.attachments = dto.attachments.map((a) => ({ + content: a.content, + filename: a.filename, + type: a.contentType, + disposition: 'attachment', + })); + } + + const response = await fetch('https://api.sendgrid.com/v3/mail/send', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.sendgridApiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`SendGrid error: ${response.status} - ${errorBody}`); + } + + const messageId = response.headers.get('x-message-id') || `sg-${Date.now()}`; + + return { + success: true, + messageId, + provider: 'sendgrid', + }; + } + + // AWS SES implementation using AWS SDK v3 pattern (fetch-based) + private async sendViaSES(dto: SendEmailDto): Promise { + // Using AWS SES v2 API via HTTPS + const endpoint = `https://email.${this.sesRegion}.amazonaws.com/v2/email/outbound-emails`; + + const body = { + FromEmailAddress: `${this.fromName} <${this.fromEmail}>`, + Destination: { + ToAddresses: [`${dto.to.name || ''} <${dto.to.email}>`], + ...(dto.cc?.length && { + CcAddresses: dto.cc.map((c) => `${c.name || ''} <${c.email}>`), + }), + ...(dto.bcc?.length && { + BccAddresses: dto.bcc.map((b) => `${b.name || ''} <${b.email}>`), + }), + }, + Content: { + Simple: { + Subject: { Data: dto.subject }, + Body: { + ...(dto.text && { Text: { Data: dto.text } }), + ...(dto.html && { Html: { Data: dto.html } }), + }, + }, + }, + ...(this.replyTo && { ReplyToAddresses: [this.replyTo] }), + }; + + // AWS Signature V4 signing would be needed here + // For production, use @aws-sdk/client-sesv2 + // This is a simplified implementation + const timestamp = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); + const date = timestamp.slice(0, 8); + + const headers = { + 'Content-Type': 'application/json', + 'X-Amz-Date': timestamp, + 'Host': `email.${this.sesRegion}.amazonaws.com`, + }; + + // Note: In production, implement proper AWS Signature V4 signing + // or use @aws-sdk/client-sesv2 + this.logger.warn('AWS SES: Using simplified implementation - consider using @aws-sdk/client-sesv2'); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + ...headers, + // AWS credentials would need proper signature + 'X-Amz-Security-Token': '', // Add if using temporary credentials + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`SES error: ${response.status} - ${errorBody}`); + } + + const result = await response.json(); + + return { + success: true, + messageId: result.MessageId || `ses-${Date.now()}`, + provider: 'ses', + }; + } + + // SMTP implementation using nodemailer-compatible approach + private async sendViaSMTP(dto: SendEmailDto): Promise { + // For SMTP, we need nodemailer - this is a placeholder + // In production, install nodemailer and use it here + this.logger.warn('SMTP: Requires nodemailer package - install with: npm install nodemailer'); + + // Fallback to logging + this.logEmail(dto); + + return { + success: true, + messageId: `smtp-${Date.now()}`, + provider: 'smtp', + }; + } + + // Template handling + private async getTemplate( + templateKey: string, + ): Promise<{ subject: string; html: string; text?: string } | null> { + // TODO: Fetch from notification_templates table + // For now, return built-in templates + const templates: Record = { + welcome: { + subject: 'Welcome to {{appName}}!', + html: ` +

Welcome, {{userName}}!

+

Thank you for joining {{appName}}. We're excited to have you on board.

+

Get started by exploring our features.

+

Best regards,
The {{appName}} Team

+ `, + text: 'Welcome, {{userName}}! Thank you for joining {{appName}}.', + }, + password_reset: { + subject: 'Reset your password - {{appName}}', + html: ` +

Password Reset Request

+

Hi {{userName}},

+

We received a request to reset your password. Click the link below to proceed:

+

Reset Password

+

This link expires in {{expiresIn}}.

+

If you didn't request this, please ignore this email.

+ `, + text: 'Hi {{userName}}, Reset your password here: {{resetLink}}', + }, + invitation: { + subject: "You've been invited to {{tenantName}}", + html: ` +

You're Invited!

+

{{inviterName}} has invited you to join {{tenantName}} on {{appName}}.

+

Accept Invitation

+

This invitation expires in {{expiresIn}}.

+ `, + text: "You've been invited to {{tenantName}}. Accept here: {{inviteLink}}", + }, + notification: { + subject: '{{title}}', + html: ` +

{{title}}

+

{{message}}

+ {{#if actionUrl}} +

{{actionText}}

+ {{/if}} + `, + text: '{{title}}: {{message}}', + }, + }; + + return templates[templateKey] || null; + } + + private renderTemplate( + template: string, + variables: Record, + ): string { + let result = template; + + // Simple variable replacement: {{variableName}} + for (const [key, value] of Object.entries(variables)) { + const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'g'); + result = result.replace(regex, String(value ?? '')); + } + + // Handle conditional blocks: {{#if condition}}...{{/if}} + result = result.replace( + /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, + (_, condition, content) => { + return variables[condition] ? content : ''; + }, + ); + + return result; + } + + private logEmail(dto: SendEmailDto): void { + this.logger.log('=== EMAIL (NOT SENT - NO PROVIDER CONFIGURED) ==='); + this.logger.log(`To: ${dto.to.email}`); + if (dto.cc?.length) { + this.logger.log(`CC: ${dto.cc.map((c) => c.email).join(', ')}`); + } + if (dto.bcc?.length) { + this.logger.log(`BCC: ${dto.bcc.map((b) => b.email).join(', ')}`); + } + this.logger.log(`Subject: ${dto.subject}`); + this.logger.log(`Body (text): ${dto.text?.substring(0, 200) || '(none)'}`); + this.logger.log(`Body (html): ${dto.html?.substring(0, 200) || '(none)'}`); + this.logger.log('================================================'); + } + + // Utility methods + isEnabled(): boolean { + return this.isConfigured; + } + + getProvider(): EmailProvider { + return this.provider; + } +} diff --git a/src/modules/email/services/index.ts b/src/modules/email/services/index.ts new file mode 100644 index 0000000..cfab1be --- /dev/null +++ b/src/modules/email/services/index.ts @@ -0,0 +1 @@ +export * from './email.service'; diff --git a/src/modules/feature-flags/__tests__/feature-flags.controller.spec.ts b/src/modules/feature-flags/__tests__/feature-flags.controller.spec.ts new file mode 100644 index 0000000..7a614d9 --- /dev/null +++ b/src/modules/feature-flags/__tests__/feature-flags.controller.spec.ts @@ -0,0 +1,284 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FeatureFlagsController } from '../feature-flags.controller'; +import { FeatureFlagsService } from '../services/feature-flags.service'; + +describe('FeatureFlagsController', () => { + let controller: FeatureFlagsController; + let service: jest.Mocked; + + const mockRequestUser = { + id: 'user-123', + sub: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + role: 'admin', + }; + + const mockFlag = { + id: 'flag-123', + key: 'feature_new_dashboard', + name: 'New Dashboard', + description: 'Enable new dashboard UI', + is_enabled: true, + default_value: false, + created_at: new Date('2026-01-01'), + }; + + const mockTenantFlag = { + id: 'tf-123', + tenant_id: 'tenant-123', + flag_id: 'flag-123', + enabled: true, + }; + + const mockUserFlag = { + id: 'uf-123', + user_id: 'user-456', + flag_id: 'flag-123', + enabled: true, + }; + + const mockEvaluation = { + key: 'feature_new_dashboard', + enabled: true, + source: 'tenant', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FeatureFlagsController], + providers: [ + { + provide: FeatureFlagsService, + useValue: { + createFlag: jest.fn(), + getAllFlags: jest.fn(), + getFlagById: jest.fn(), + updateFlag: jest.fn(), + deleteFlag: jest.fn(), + toggleFlag: jest.fn(), + getTenantFlags: jest.fn(), + setTenantFlag: jest.fn(), + removeTenantFlag: jest.fn(), + getUserFlags: jest.fn(), + setUserFlag: jest.fn(), + removeUserFlag: jest.fn(), + evaluateFlag: jest.fn(), + evaluateAllFlags: jest.fn(), + isEnabled: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(FeatureFlagsController); + service = module.get(FeatureFlagsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // Flag Management Tests + describe('createFlag', () => { + it('should create a flag', async () => { + const dto = { key: 'new_feature', name: 'New Feature', enabled: true }; + service.createFlag.mockResolvedValue(mockFlag as any); + + const result = await controller.createFlag(dto as any); + + expect(result).toEqual(mockFlag); + expect(service.createFlag).toHaveBeenCalledWith(dto); + }); + }); + + describe('getAllFlags', () => { + it('should return all flags', async () => { + service.getAllFlags.mockResolvedValue([mockFlag] as any); + + const result = await controller.getAllFlags(); + + expect(result).toEqual([mockFlag]); + expect(service.getAllFlags).toHaveBeenCalled(); + }); + }); + + describe('getFlagById', () => { + it('should return flag by id', async () => { + service.getFlagById.mockResolvedValue(mockFlag as any); + + const result = await controller.getFlagById('flag-123'); + + expect(result).toEqual(mockFlag); + expect(service.getFlagById).toHaveBeenCalledWith('flag-123'); + }); + }); + + describe('updateFlag', () => { + it('should update a flag', async () => { + const dto = { name: 'Updated Feature', enabled: false }; + const updated = { ...mockFlag, ...dto }; + service.updateFlag.mockResolvedValue(updated as any); + + const result = await controller.updateFlag('flag-123', dto as any); + + expect(result.name).toBe('Updated Feature'); + expect(service.updateFlag).toHaveBeenCalledWith('flag-123', dto); + }); + }); + + describe('deleteFlag', () => { + it('should delete a flag', async () => { + service.deleteFlag.mockResolvedValue(undefined); + + const result = await controller.deleteFlag('flag-123'); + + expect(result).toEqual({ success: true }); + expect(service.deleteFlag).toHaveBeenCalledWith('flag-123'); + }); + }); + + describe('toggleFlag', () => { + it('should toggle flag to enabled', async () => { + const toggled = { ...mockFlag, is_enabled: true }; + service.toggleFlag.mockResolvedValue(toggled as any); + + const result = await controller.toggleFlag('flag-123', 'true'); + + expect(result.is_enabled).toBe(true); + expect(service.toggleFlag).toHaveBeenCalledWith('flag-123', true); + }); + + it('should toggle flag to disabled', async () => { + const toggled = { ...mockFlag, is_enabled: false }; + service.toggleFlag.mockResolvedValue(toggled as any); + + const result = await controller.toggleFlag('flag-123', 'false'); + + expect(result.is_enabled).toBe(false); + expect(service.toggleFlag).toHaveBeenCalledWith('flag-123', false); + }); + }); + + // Tenant Flags Tests + describe('getTenantFlags', () => { + it('should return tenant flag overrides', async () => { + service.getTenantFlags.mockResolvedValue([mockTenantFlag] as any); + + const result = await controller.getTenantFlags(mockRequestUser); + + expect(result).toEqual([mockTenantFlag]); + expect(service.getTenantFlags).toHaveBeenCalledWith('tenant-123'); + }); + }); + + describe('setTenantFlag', () => { + it('should set tenant flag override', async () => { + const dto = { flag_id: 'flag-123', enabled: true }; + service.setTenantFlag.mockResolvedValue(mockTenantFlag as any); + + const result = await controller.setTenantFlag(mockRequestUser, dto as any); + + expect(result).toEqual(mockTenantFlag); + expect(service.setTenantFlag).toHaveBeenCalledWith('tenant-123', dto); + }); + }); + + describe('removeTenantFlag', () => { + it('should remove tenant flag override', async () => { + service.removeTenantFlag.mockResolvedValue(undefined); + + const result = await controller.removeTenantFlag(mockRequestUser, 'flag-123'); + + expect(result).toEqual({ success: true }); + expect(service.removeTenantFlag).toHaveBeenCalledWith('tenant-123', 'flag-123'); + }); + }); + + // User Flags Tests + describe('getUserFlags', () => { + it('should return user flag overrides', async () => { + service.getUserFlags.mockResolvedValue([mockUserFlag] as any); + + const result = await controller.getUserFlags(mockRequestUser, 'user-456'); + + expect(result).toEqual([mockUserFlag]); + expect(service.getUserFlags).toHaveBeenCalledWith('tenant-123', 'user-456'); + }); + }); + + describe('setUserFlag', () => { + it('should set user flag override', async () => { + const dto = { user_id: 'user-456', flag_id: 'flag-123', enabled: true }; + service.setUserFlag.mockResolvedValue(mockUserFlag as any); + + const result = await controller.setUserFlag(mockRequestUser, dto as any); + + expect(result).toEqual(mockUserFlag); + expect(service.setUserFlag).toHaveBeenCalledWith('tenant-123', dto); + }); + }); + + describe('removeUserFlag', () => { + it('should remove user flag override', async () => { + service.removeUserFlag.mockResolvedValue(undefined); + + const result = await controller.removeUserFlag('user-456', 'flag-123'); + + expect(result).toEqual({ success: true }); + expect(service.removeUserFlag).toHaveBeenCalledWith('user-456', 'flag-123'); + }); + }); + + // Evaluation Tests + describe('evaluateFlag', () => { + it('should evaluate a single flag', async () => { + service.evaluateFlag.mockResolvedValue(mockEvaluation as any); + + const result = await controller.evaluateFlag(mockRequestUser, 'feature_new_dashboard'); + + expect(result).toEqual(mockEvaluation); + expect(service.evaluateFlag).toHaveBeenCalledWith('feature_new_dashboard', { + tenantId: 'tenant-123', + userId: 'user-123', + }); + }); + }); + + describe('evaluateAllFlags', () => { + it('should evaluate all flags', async () => { + const allEvaluations = { feature_new_dashboard: true, feature_beta: false }; + service.evaluateAllFlags.mockResolvedValue(allEvaluations as any); + + const result = await controller.evaluateAllFlags(mockRequestUser); + + expect(result).toEqual(allEvaluations); + expect(service.evaluateAllFlags).toHaveBeenCalledWith({ + tenantId: 'tenant-123', + userId: 'user-123', + }); + }); + }); + + describe('isEnabled', () => { + it('should return true when flag is enabled', async () => { + service.isEnabled.mockResolvedValue(true); + + const result = await controller.isEnabled(mockRequestUser, 'feature_new_dashboard'); + + expect(result).toEqual({ key: 'feature_new_dashboard', enabled: true }); + expect(service.isEnabled).toHaveBeenCalledWith('feature_new_dashboard', { + tenantId: 'tenant-123', + userId: 'user-123', + }); + }); + + it('should return false when flag is disabled', async () => { + service.isEnabled.mockResolvedValue(false); + + const result = await controller.isEnabled(mockRequestUser, 'feature_beta'); + + expect(result).toEqual({ key: 'feature_beta', enabled: false }); + }); + }); +}); diff --git a/src/modules/feature-flags/__tests__/feature-flags.service.spec.ts b/src/modules/feature-flags/__tests__/feature-flags.service.spec.ts new file mode 100644 index 0000000..771350a --- /dev/null +++ b/src/modules/feature-flags/__tests__/feature-flags.service.spec.ts @@ -0,0 +1,567 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, ConflictException } from '@nestjs/common'; +import { FeatureFlagsService, EvaluationContext } from '../services/feature-flags.service'; +import { FeatureFlag, FlagType, FlagScope } from '../entities/feature-flag.entity'; +import { TenantFlag } from '../entities/tenant-flag.entity'; +import { UserFlag } from '../entities/user-flag.entity'; + +describe('FeatureFlagsService', () => { + let service: FeatureFlagsService; + let flagRepository: jest.Mocked>; + let tenantFlagRepository: jest.Mocked>; + let userFlagRepository: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440000'; + + const mockFlag: Partial = { + id: 'flag-001', + key: 'new_feature', + name: 'New Feature', + description: 'Test feature flag', + flag_type: FlagType.BOOLEAN, + scope: FlagScope.GLOBAL, + is_enabled: true, + default_value: true, + category: 'features', + rollout_percentage: undefined, + }; + + const mockTenantFlag: Partial = { + id: 'tf-001', + tenant_id: mockTenantId, + flag_id: 'flag-001', + is_enabled: false, + value: null, + }; + + const mockUserFlag: Partial = { + id: 'uf-001', + tenant_id: mockTenantId, + user_id: mockUserId, + flag_id: 'flag-001', + is_enabled: true, + value: { customSetting: true }, + }; + + beforeEach(async () => { + const mockFlagRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + }; + + const mockTenantFlagRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + }; + + const mockUserFlagRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeatureFlagsService, + { provide: getRepositoryToken(FeatureFlag), useValue: mockFlagRepo }, + { provide: getRepositoryToken(TenantFlag), useValue: mockTenantFlagRepo }, + { provide: getRepositoryToken(UserFlag), useValue: mockUserFlagRepo }, + ], + }).compile(); + + service = module.get(FeatureFlagsService); + flagRepository = module.get(getRepositoryToken(FeatureFlag)); + tenantFlagRepository = module.get(getRepositoryToken(TenantFlag)); + userFlagRepository = module.get(getRepositoryToken(UserFlag)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createFlag', () => { + const createDto = { + key: 'new_feature', + name: 'New Feature', + description: 'A new feature flag', + flag_type: FlagType.BOOLEAN, + scope: FlagScope.GLOBAL, + default_value: true, + }; + + it('should create a new flag successfully', async () => { + flagRepository.findOne.mockResolvedValue(null); + flagRepository.create.mockReturnValue(mockFlag as FeatureFlag); + flagRepository.save.mockResolvedValue(mockFlag as FeatureFlag); + + const result = await service.createFlag(createDto); + + expect(result).toHaveProperty('key', 'new_feature'); + expect(flagRepository.create).toHaveBeenCalledWith(createDto); + expect(flagRepository.save).toHaveBeenCalled(); + }); + + it('should throw ConflictException if key already exists', async () => { + flagRepository.findOne.mockResolvedValue(mockFlag as FeatureFlag); + + await expect(service.createFlag(createDto)).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('updateFlag', () => { + const updateDto = { + name: 'Updated Feature', + is_enabled: false, + }; + + it('should update flag successfully', async () => { + flagRepository.findOne.mockResolvedValue(mockFlag as FeatureFlag); + flagRepository.save.mockResolvedValue({ + ...mockFlag, + ...updateDto, + } as FeatureFlag); + + const result = await service.updateFlag('flag-001', updateDto); + + expect(result.name).toBe('Updated Feature'); + expect(result.is_enabled).toBe(false); + }); + + it('should throw NotFoundException for invalid flag ID', async () => { + flagRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateFlag('invalid-id', updateDto), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('deleteFlag', () => { + it('should delete flag successfully', async () => { + flagRepository.findOne.mockResolvedValue(mockFlag as FeatureFlag); + flagRepository.remove.mockResolvedValue(mockFlag as FeatureFlag); + + await service.deleteFlag('flag-001'); + + expect(flagRepository.remove).toHaveBeenCalledWith(mockFlag); + }); + + it('should throw NotFoundException for invalid flag ID', async () => { + flagRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteFlag('invalid-id')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getAllFlags', () => { + it('should return all flags ordered by category and key', async () => { + const flags = [mockFlag as FeatureFlag]; + flagRepository.find.mockResolvedValue(flags); + + const result = await service.getAllFlags(); + + expect(result).toHaveLength(1); + expect(flagRepository.find).toHaveBeenCalledWith({ + order: { category: 'ASC', key: 'ASC' }, + }); + }); + }); + + describe('toggleFlag', () => { + it('should enable flag', async () => { + flagRepository.findOne.mockResolvedValue({ + ...mockFlag, + is_enabled: false, + } as FeatureFlag); + flagRepository.save.mockResolvedValue({ + ...mockFlag, + is_enabled: true, + } as FeatureFlag); + + const result = await service.toggleFlag('flag-001', true); + + expect(result.is_enabled).toBe(true); + }); + + it('should disable flag', async () => { + flagRepository.findOne.mockResolvedValue(mockFlag as FeatureFlag); + flagRepository.save.mockResolvedValue({ + ...mockFlag, + is_enabled: false, + } as FeatureFlag); + + const result = await service.toggleFlag('flag-001', false); + + expect(result.is_enabled).toBe(false); + }); + + it('should throw NotFoundException for invalid flag ID', async () => { + flagRepository.findOne.mockResolvedValue(null); + + await expect(service.toggleFlag('invalid-id', true)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('setTenantFlag', () => { + const setDto = { + flag_id: 'flag-001', + is_enabled: false, + value: { custom: 'value' }, + }; + + it('should create new tenant flag', async () => { + flagRepository.findOne.mockResolvedValue(mockFlag as FeatureFlag); + tenantFlagRepository.findOne.mockResolvedValue(null); + tenantFlagRepository.create.mockReturnValue(mockTenantFlag as TenantFlag); + tenantFlagRepository.save.mockResolvedValue(mockTenantFlag as TenantFlag); + + const result = await service.setTenantFlag(mockTenantId, setDto); + + expect(result).toHaveProperty('tenant_id', mockTenantId); + expect(tenantFlagRepository.create).toHaveBeenCalled(); + }); + + it('should update existing tenant flag', async () => { + flagRepository.findOne.mockResolvedValue(mockFlag as FeatureFlag); + tenantFlagRepository.findOne.mockResolvedValue(mockTenantFlag as TenantFlag); + tenantFlagRepository.save.mockResolvedValue({ + ...mockTenantFlag, + ...setDto, + } as TenantFlag); + + const result = await service.setTenantFlag(mockTenantId, setDto); + + expect(result.is_enabled).toBe(false); + expect(tenantFlagRepository.create).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException for invalid flag ID', async () => { + flagRepository.findOne.mockResolvedValue(null); + + await expect( + service.setTenantFlag(mockTenantId, setDto), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('removeTenantFlag', () => { + it('should remove tenant flag if exists', async () => { + tenantFlagRepository.findOne.mockResolvedValue(mockTenantFlag as TenantFlag); + tenantFlagRepository.remove.mockResolvedValue(mockTenantFlag as TenantFlag); + + await service.removeTenantFlag(mockTenantId, 'flag-001'); + + expect(tenantFlagRepository.remove).toHaveBeenCalledWith(mockTenantFlag); + }); + + it('should not throw if tenant flag does not exist', async () => { + tenantFlagRepository.findOne.mockResolvedValue(null); + + await expect( + service.removeTenantFlag(mockTenantId, 'flag-001'), + ).resolves.not.toThrow(); + }); + }); + + describe('setUserFlag', () => { + const setDto = { + user_id: mockUserId, + flag_id: 'flag-001', + is_enabled: true, + value: { beta: true }, + }; + + it('should create new user flag', async () => { + flagRepository.findOne.mockResolvedValue(mockFlag as FeatureFlag); + userFlagRepository.findOne.mockResolvedValue(null); + userFlagRepository.create.mockReturnValue(mockUserFlag as UserFlag); + userFlagRepository.save.mockResolvedValue(mockUserFlag as UserFlag); + + const result = await service.setUserFlag(mockTenantId, setDto); + + expect(result).toHaveProperty('user_id', mockUserId); + }); + + it('should update existing user flag', async () => { + flagRepository.findOne.mockResolvedValue(mockFlag as FeatureFlag); + userFlagRepository.findOne.mockResolvedValue(mockUserFlag as UserFlag); + userFlagRepository.save.mockResolvedValue({ + ...mockUserFlag, + ...setDto, + } as UserFlag); + + const result = await service.setUserFlag(mockTenantId, setDto); + + expect(result.is_enabled).toBe(true); + }); + }); + + describe('evaluateFlag', () => { + const context: EvaluationContext = { + tenantId: mockTenantId, + userId: mockUserId, + }; + + beforeEach(() => { + // Reset mocks for each evaluation test + flagRepository.findOne.mockReset(); + userFlagRepository.findOne.mockReset(); + tenantFlagRepository.findOne.mockReset(); + }); + + it('should return default for unknown flag', async () => { + flagRepository.findOne.mockResolvedValue(null); + + const result = await service.evaluateFlag('unknown_flag', context); + + expect(result.enabled).toBe(false); + expect(result.source).toBe('default'); + }); + + it('should return global disabled when flag is disabled', async () => { + flagRepository.findOne.mockResolvedValue({ + ...mockFlag, + is_enabled: false, + } as FeatureFlag); + + const result = await service.evaluateFlag('new_feature', context); + + expect(result.enabled).toBe(false); + expect(result.source).toBe('global'); + }); + + it('should evaluate flag with user context', async () => { + const flagWithNoRollout: Partial = { + ...mockFlag, + rollout_percentage: 100, + }; + flagRepository.findOne.mockResolvedValue(flagWithNoRollout as FeatureFlag); + userFlagRepository.findOne.mockResolvedValue(null); + tenantFlagRepository.findOne.mockResolvedValue(null); + + const result = await service.evaluateFlag('new_feature', context); + + expect(result).toHaveProperty('key', 'new_feature'); + expect(result).toHaveProperty('enabled'); + expect(result).toHaveProperty('source'); + }); + + it('should evaluate flag with tenant context only', async () => { + const flagWithNoRollout: Partial = { + ...mockFlag, + rollout_percentage: 100, + }; + flagRepository.findOne.mockResolvedValue(flagWithNoRollout as FeatureFlag); + tenantFlagRepository.findOne.mockResolvedValue(null); + + const result = await service.evaluateFlag('new_feature', { tenantId: mockTenantId }); + + expect(result).toHaveProperty('key', 'new_feature'); + expect(result).toHaveProperty('enabled'); + expect(result).toHaveProperty('source'); + }); + + it('should return global when no overrides exist', async () => { + // Create a flag with no rollout (100% or null means global) + const globalFlag: Partial = { + id: 'flag-global', + key: 'global_feature', + name: 'Global Feature', + flag_type: FlagType.BOOLEAN, + scope: FlagScope.GLOBAL, + is_enabled: true, + default_value: true, + rollout_percentage: 100, // 100% means everyone gets it (global) + }; + flagRepository.findOne.mockResolvedValue(globalFlag as FeatureFlag); + userFlagRepository.findOne.mockResolvedValue(null); + tenantFlagRepository.findOne.mockResolvedValue(null); + + const result = await service.evaluateFlag('global_feature', context); + + expect(result.enabled).toBe(true); + expect(result.source).toBe('global'); + }); + + it('should evaluate rollout percentage', async () => { + const rolloutFlag: Partial = { + id: 'flag-rollout', + key: 'rollout_feature', + name: 'Rollout Feature', + flag_type: FlagType.BOOLEAN, + scope: FlagScope.GLOBAL, + is_enabled: true, + default_value: true, + rollout_percentage: 50, + }; + flagRepository.findOne.mockResolvedValue(rolloutFlag as FeatureFlag); + userFlagRepository.findOne.mockResolvedValue(null); + tenantFlagRepository.findOne.mockResolvedValue(null); + + const result = await service.evaluateFlag('rollout_feature', context); + + expect(result.source).toBe('rollout'); + expect(typeof result.enabled).toBe('boolean'); + }); + + it('should be deterministic for same user/flag combination', async () => { + const rolloutFlag: Partial = { + id: 'flag-rollout', + key: 'rollout_feature', + name: 'Rollout Feature', + flag_type: FlagType.BOOLEAN, + scope: FlagScope.GLOBAL, + is_enabled: true, + default_value: true, + rollout_percentage: 50, + }; + flagRepository.findOne.mockResolvedValue(rolloutFlag as FeatureFlag); + userFlagRepository.findOne.mockResolvedValue(null); + tenantFlagRepository.findOne.mockResolvedValue(null); + + const result1 = await service.evaluateFlag('rollout_feature', context); + const result2 = await service.evaluateFlag('rollout_feature', context); + + expect(result1.enabled).toBe(result2.enabled); + }); + }); + + describe('evaluateAllFlags', () => { + beforeEach(() => { + flagRepository.find.mockReset(); + flagRepository.findOne.mockReset(); + userFlagRepository.findOne.mockReset(); + tenantFlagRepository.findOne.mockReset(); + }); + + it('should evaluate all flags', async () => { + const flags = [ + { ...mockFlag, key: 'flag1' }, + { ...mockFlag, key: 'flag2', is_enabled: false }, + ]; + flagRepository.find.mockResolvedValue(flags as FeatureFlag[]); + flagRepository.findOne + .mockResolvedValueOnce(flags[0] as FeatureFlag) + .mockResolvedValueOnce(flags[1] as FeatureFlag); + userFlagRepository.findOne.mockResolvedValue(null); + tenantFlagRepository.findOne.mockResolvedValue(null); + + const result = await service.evaluateAllFlags({ + tenantId: mockTenantId, + }); + + expect(Object.keys(result)).toHaveLength(2); + expect(result['flag1']).toBeDefined(); + expect(result['flag2']).toBeDefined(); + }); + }); + + describe('isEnabled', () => { + beforeEach(() => { + flagRepository.findOne.mockReset(); + userFlagRepository.findOne.mockReset(); + tenantFlagRepository.findOne.mockReset(); + }); + + it('should return false for unknown flag', async () => { + flagRepository.findOne.mockResolvedValue(null); + + const result = await service.isEnabled('unknown_flag', { + tenantId: mockTenantId, + }); + + expect(result).toBe(false); + }); + + it('should return false for disabled flag', async () => { + flagRepository.findOne.mockResolvedValue({ + ...mockFlag, + is_enabled: false, + } as FeatureFlag); + + const result = await service.isEnabled('new_feature', { + tenantId: mockTenantId, + }); + + expect(result).toBe(false); + }); + }); + + describe('getValue', () => { + beforeEach(() => { + flagRepository.findOne.mockReset(); + userFlagRepository.findOne.mockReset(); + tenantFlagRepository.findOne.mockReset(); + }); + + it('should return default value for unknown flag', async () => { + flagRepository.findOne.mockResolvedValue(null); + + const result = await service.getValue('unknown', { + tenantId: mockTenantId, + }, { limit: 50 }); + + expect(result).toEqual({ limit: 50 }); + }); + + it('should return default value when disabled', async () => { + flagRepository.findOne.mockResolvedValue({ + ...mockFlag, + is_enabled: false, + } as FeatureFlag); + + const result = await service.getValue('new_feature', { + tenantId: mockTenantId, + }, { limit: 0 }); + + expect(result).toEqual({ limit: 0 }); + }); + }); + + describe('getTenantFlags', () => { + it('should return tenant flags with relations', async () => { + const tenantFlags = [mockTenantFlag as TenantFlag]; + tenantFlagRepository.find.mockResolvedValue(tenantFlags); + + const result = await service.getTenantFlags(mockTenantId); + + expect(result).toHaveLength(1); + expect(tenantFlagRepository.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + relations: ['flag'], + }); + }); + }); + + describe('getUserFlags', () => { + it('should return user flags with relations', async () => { + const userFlags = [mockUserFlag as UserFlag]; + userFlagRepository.find.mockResolvedValue(userFlags); + + const result = await service.getUserFlags(mockTenantId, mockUserId); + + expect(result).toHaveLength(1); + expect(userFlagRepository.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId, user_id: mockUserId }, + relations: ['flag'], + }); + }); + }); +}); diff --git a/src/modules/feature-flags/dto/create-flag.dto.ts b/src/modules/feature-flags/dto/create-flag.dto.ts new file mode 100644 index 0000000..74b9181 --- /dev/null +++ b/src/modules/feature-flags/dto/create-flag.dto.ts @@ -0,0 +1,73 @@ +import { + IsString, + IsEnum, + IsOptional, + IsBoolean, + IsNumber, + Min, + Max, + MaxLength, + Matches, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { FlagType, FlagScope } from '../entities/feature-flag.entity'; + +export class CreateFlagDto { + @ApiProperty({ description: 'Unique flag key (lowercase, underscores allowed)' }) + @IsString() + @MaxLength(100) + @Matches(/^[a-z][a-z0-9_]*$/, { + message: 'Key must start with lowercase letter and contain only lowercase letters, numbers, and underscores', + }) + key: string; + + @ApiProperty({ description: 'Human-readable flag name' }) + @IsString() + @MaxLength(255) + name: string; + + @ApiPropertyOptional({ description: 'Flag description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Flag value type', enum: FlagType }) + @IsOptional() + @IsEnum(FlagType) + flag_type?: FlagType = FlagType.BOOLEAN; + + @ApiPropertyOptional({ description: 'Flag scope', enum: FlagScope }) + @IsOptional() + @IsEnum(FlagScope) + scope?: FlagScope = FlagScope.GLOBAL; + + @ApiPropertyOptional({ description: 'Default value for the flag' }) + @IsOptional() + default_value?: any; + + @ApiPropertyOptional({ description: 'Is flag enabled globally' }) + @IsOptional() + @IsBoolean() + is_enabled?: boolean = false; + + @ApiPropertyOptional({ description: 'Targeting rules for conditional evaluation' }) + @IsOptional() + targeting_rules?: Record; + + @ApiPropertyOptional({ description: 'Rollout percentage (0-100)' }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + rollout_percentage?: number; + + @ApiPropertyOptional({ description: 'Category for grouping flags' }) + @IsOptional() + @IsString() + @MaxLength(100) + category?: string; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + metadata?: Record; +} diff --git a/src/modules/feature-flags/dto/index.ts b/src/modules/feature-flags/dto/index.ts new file mode 100644 index 0000000..0f2dfcf --- /dev/null +++ b/src/modules/feature-flags/dto/index.ts @@ -0,0 +1,3 @@ +export * from './create-flag.dto'; +export * from './update-flag.dto'; +export * from './set-tenant-flag.dto'; diff --git a/src/modules/feature-flags/dto/set-tenant-flag.dto.ts b/src/modules/feature-flags/dto/set-tenant-flag.dto.ts new file mode 100644 index 0000000..64fb603 --- /dev/null +++ b/src/modules/feature-flags/dto/set-tenant-flag.dto.ts @@ -0,0 +1,44 @@ +import { IsUUID, IsOptional, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class SetTenantFlagDto { + @ApiProperty({ description: 'Flag ID' }) + @IsUUID() + flag_id: string; + + @ApiPropertyOptional({ description: 'Is flag enabled for this tenant' }) + @IsOptional() + @IsBoolean() + is_enabled?: boolean = true; + + @ApiPropertyOptional({ description: 'Custom value for this tenant' }) + @IsOptional() + value?: any; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + metadata?: Record; +} + +export class SetUserFlagDto { + @ApiProperty({ description: 'Flag ID' }) + @IsUUID() + flag_id: string; + + @ApiProperty({ description: 'User ID' }) + @IsUUID() + user_id: string; + + @ApiPropertyOptional({ description: 'Is flag enabled for this user' }) + @IsOptional() + @IsBoolean() + is_enabled?: boolean = true; + + @ApiPropertyOptional({ description: 'Custom value for this user' }) + @IsOptional() + value?: any; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + metadata?: Record; +} diff --git a/src/modules/feature-flags/dto/update-flag.dto.ts b/src/modules/feature-flags/dto/update-flag.dto.ts new file mode 100644 index 0000000..c7d492b --- /dev/null +++ b/src/modules/feature-flags/dto/update-flag.dto.ts @@ -0,0 +1,65 @@ +import { + IsString, + IsEnum, + IsOptional, + IsBoolean, + IsNumber, + Min, + Max, + MaxLength, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { FlagType, FlagScope } from '../entities/feature-flag.entity'; + +export class UpdateFlagDto { + @ApiPropertyOptional({ description: 'Human-readable flag name' }) + @IsOptional() + @IsString() + @MaxLength(255) + name?: string; + + @ApiPropertyOptional({ description: 'Flag description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Flag value type', enum: FlagType }) + @IsOptional() + @IsEnum(FlagType) + flag_type?: FlagType; + + @ApiPropertyOptional({ description: 'Flag scope', enum: FlagScope }) + @IsOptional() + @IsEnum(FlagScope) + scope?: FlagScope; + + @ApiPropertyOptional({ description: 'Default value for the flag' }) + @IsOptional() + default_value?: any; + + @ApiPropertyOptional({ description: 'Is flag enabled globally' }) + @IsOptional() + @IsBoolean() + is_enabled?: boolean; + + @ApiPropertyOptional({ description: 'Targeting rules for conditional evaluation' }) + @IsOptional() + targeting_rules?: Record; + + @ApiPropertyOptional({ description: 'Rollout percentage (0-100)' }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + rollout_percentage?: number; + + @ApiPropertyOptional({ description: 'Category for grouping flags' }) + @IsOptional() + @IsString() + @MaxLength(100) + category?: string; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + metadata?: Record; +} diff --git a/src/modules/feature-flags/entities/feature-flag.entity.ts b/src/modules/feature-flags/entities/feature-flag.entity.ts new file mode 100644 index 0000000..d5fdd9f --- /dev/null +++ b/src/modules/feature-flags/entities/feature-flag.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum FlagType { + BOOLEAN = 'boolean', + STRING = 'string', + NUMBER = 'number', + JSON = 'json', +} + +export enum FlagScope { + GLOBAL = 'global', + TENANT = 'tenant', + USER = 'user', + PLAN = 'plan', +} + +@Entity({ name: 'flags', schema: 'feature_flags' }) +@Index(['key'], { unique: true }) +@Index(['is_enabled']) +export class FeatureFlag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100, unique: true }) + key: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: FlagType, + default: FlagType.BOOLEAN, + }) + flag_type: FlagType; + + @Column({ + type: 'enum', + enum: FlagScope, + default: FlagScope.GLOBAL, + }) + scope: FlagScope; + + @Column({ type: 'jsonb', nullable: true }) + default_value: any; + + @Column({ type: 'boolean', default: false }) + is_enabled: boolean; + + @Column({ type: 'jsonb', nullable: true }) + targeting_rules: Record; + + @Column({ type: 'integer', nullable: true }) + rollout_percentage: number; + + @Column({ type: 'varchar', length: 100, nullable: true }) + category: string; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/feature-flags/entities/index.ts b/src/modules/feature-flags/entities/index.ts new file mode 100644 index 0000000..fe807b7 --- /dev/null +++ b/src/modules/feature-flags/entities/index.ts @@ -0,0 +1,3 @@ +export * from './feature-flag.entity'; +export * from './tenant-flag.entity'; +export * from './user-flag.entity'; diff --git a/src/modules/feature-flags/entities/tenant-flag.entity.ts b/src/modules/feature-flags/entities/tenant-flag.entity.ts new file mode 100644 index 0000000..21e354b --- /dev/null +++ b/src/modules/feature-flags/entities/tenant-flag.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { FeatureFlag } from './feature-flag.entity'; + +@Entity({ name: 'tenant_flags', schema: 'feature_flags' }) +@Index(['tenant_id', 'flag_id'], { unique: true }) +export class TenantFlag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + flag_id: string; + + @ManyToOne(() => FeatureFlag, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: FeatureFlag; + + @Column({ type: 'boolean', default: true }) + is_enabled: boolean; + + @Column({ type: 'jsonb', nullable: true }) + value: any; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/feature-flags/entities/user-flag.entity.ts b/src/modules/feature-flags/entities/user-flag.entity.ts new file mode 100644 index 0000000..1bf244c --- /dev/null +++ b/src/modules/feature-flags/entities/user-flag.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { FeatureFlag } from './feature-flag.entity'; + +@Entity({ name: 'user_flags', schema: 'feature_flags' }) +@Index(['user_id', 'flag_id'], { unique: true }) +@Index(['tenant_id', 'user_id']) +export class UserFlag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @Column({ type: 'uuid' }) + flag_id: string; + + @ManyToOne(() => FeatureFlag, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: FeatureFlag; + + @Column({ type: 'boolean', default: true }) + is_enabled: boolean; + + @Column({ type: 'jsonb', nullable: true }) + value: any; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/feature-flags/feature-flags.controller.ts b/src/modules/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000..3446eb7 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.controller.ts @@ -0,0 +1,202 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { FeatureFlagsService, EvaluationContext } from './services/feature-flags.service'; +import { CreateFlagDto } from './dto/create-flag.dto'; +import { UpdateFlagDto } from './dto/update-flag.dto'; +import { SetTenantFlagDto, SetUserFlagDto } from './dto/set-tenant-flag.dto'; +import { JwtAuthGuard } from '../auth/guards'; +import { CurrentUser } from '../auth/decorators'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; + +@ApiTags('Feature Flags') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('feature-flags') +export class FeatureFlagsController { + constructor(private readonly featureFlagsService: FeatureFlagsService) {} + + // ==================== FLAG MANAGEMENT (Admin) ==================== + + @Post() + @ApiOperation({ summary: 'Create a new feature flag' }) + @ApiResponse({ status: 201, description: 'Flag created successfully' }) + @ApiResponse({ status: 409, description: 'Flag with this key already exists' }) + async createFlag(@Body() dto: CreateFlagDto) { + return this.featureFlagsService.createFlag(dto); + } + + @Get() + @ApiOperation({ summary: 'Get all feature flags' }) + @ApiResponse({ status: 200, description: 'List of all flags' }) + async getAllFlags() { + return this.featureFlagsService.getAllFlags(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get flag by ID' }) + @ApiParam({ name: 'id', description: 'Flag ID' }) + @ApiResponse({ status: 200, description: 'Flag details' }) + @ApiResponse({ status: 404, description: 'Flag not found' }) + async getFlagById(@Param('id') id: string) { + return this.featureFlagsService.getFlagById(id); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a feature flag' }) + @ApiParam({ name: 'id', description: 'Flag ID' }) + @ApiResponse({ status: 200, description: 'Flag updated successfully' }) + @ApiResponse({ status: 404, description: 'Flag not found' }) + async updateFlag(@Param('id') id: string, @Body() dto: UpdateFlagDto) { + return this.featureFlagsService.updateFlag(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a feature flag' }) + @ApiParam({ name: 'id', description: 'Flag ID' }) + @ApiResponse({ status: 200, description: 'Flag deleted successfully' }) + @ApiResponse({ status: 404, description: 'Flag not found' }) + async deleteFlag(@Param('id') id: string) { + await this.featureFlagsService.deleteFlag(id); + return { success: true }; + } + + @Post(':id/toggle') + @ApiOperation({ summary: 'Toggle flag enabled state' }) + @ApiParam({ name: 'id', description: 'Flag ID' }) + @ApiQuery({ name: 'enabled', type: 'boolean', required: true }) + @ApiResponse({ status: 200, description: 'Flag toggled successfully' }) + async toggleFlag( + @Param('id') id: string, + @Query('enabled') enabled: string, + ) { + return this.featureFlagsService.toggleFlag(id, enabled === 'true'); + } + + // ==================== TENANT FLAGS ==================== + + @Get('tenant/overrides') + @ApiOperation({ summary: 'Get all flag overrides for current tenant' }) + @ApiResponse({ status: 200, description: 'List of tenant flag overrides' }) + async getTenantFlags(@CurrentUser() user: RequestUser) { + return this.featureFlagsService.getTenantFlags(user.tenant_id); + } + + @Post('tenant/override') + @ApiOperation({ summary: 'Set flag override for current tenant' }) + @ApiResponse({ status: 201, description: 'Tenant flag set successfully' }) + async setTenantFlag( + @CurrentUser() user: RequestUser, + @Body() dto: SetTenantFlagDto, + ) { + return this.featureFlagsService.setTenantFlag(user.tenant_id, dto); + } + + @Delete('tenant/override/:flagId') + @ApiOperation({ summary: 'Remove flag override for current tenant' }) + @ApiParam({ name: 'flagId', description: 'Flag ID' }) + @ApiResponse({ status: 200, description: 'Tenant flag removed successfully' }) + async removeTenantFlag( + @CurrentUser() user: RequestUser, + @Param('flagId') flagId: string, + ) { + await this.featureFlagsService.removeTenantFlag(user.tenant_id, flagId); + return { success: true }; + } + + // ==================== USER FLAGS ==================== + + @Get('user/:userId/overrides') + @ApiOperation({ summary: 'Get all flag overrides for a user' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiResponse({ status: 200, description: 'List of user flag overrides' }) + async getUserFlags( + @CurrentUser() user: RequestUser, + @Param('userId') userId: string, + ) { + return this.featureFlagsService.getUserFlags(user.tenant_id, userId); + } + + @Post('user/override') + @ApiOperation({ summary: 'Set flag override for a user' }) + @ApiResponse({ status: 201, description: 'User flag set successfully' }) + async setUserFlag( + @CurrentUser() user: RequestUser, + @Body() dto: SetUserFlagDto, + ) { + return this.featureFlagsService.setUserFlag(user.tenant_id, dto); + } + + @Delete('user/:userId/override/:flagId') + @ApiOperation({ summary: 'Remove flag override for a user' }) + @ApiParam({ name: 'userId', description: 'User ID' }) + @ApiParam({ name: 'flagId', description: 'Flag ID' }) + @ApiResponse({ status: 200, description: 'User flag removed successfully' }) + async removeUserFlag( + @Param('userId') userId: string, + @Param('flagId') flagId: string, + ) { + await this.featureFlagsService.removeUserFlag(userId, flagId); + return { success: true }; + } + + // ==================== EVALUATION ==================== + + @Get('evaluate/:key') + @ApiOperation({ summary: 'Evaluate a single flag for current context' }) + @ApiParam({ name: 'key', description: 'Flag key' }) + @ApiResponse({ status: 200, description: 'Flag evaluation result' }) + async evaluateFlag( + @CurrentUser() user: RequestUser, + @Param('key') key: string, + ) { + const context: EvaluationContext = { + tenantId: user.tenant_id, + userId: user.id, + }; + return this.featureFlagsService.evaluateFlag(key, context); + } + + @Get('evaluate') + @ApiOperation({ summary: 'Evaluate all flags for current context' }) + @ApiResponse({ status: 200, description: 'All flag evaluations' }) + async evaluateAllFlags(@CurrentUser() user: RequestUser) { + const context: EvaluationContext = { + tenantId: user.tenant_id, + userId: user.id, + }; + return this.featureFlagsService.evaluateAllFlags(context); + } + + @Get('check/:key') + @ApiOperation({ summary: 'Quick check if flag is enabled' }) + @ApiParam({ name: 'key', description: 'Flag key' }) + @ApiResponse({ status: 200, description: 'Boolean enabled status' }) + async isEnabled( + @CurrentUser() user: RequestUser, + @Param('key') key: string, + ) { + const context: EvaluationContext = { + tenantId: user.tenant_id, + userId: user.id, + }; + const enabled = await this.featureFlagsService.isEnabled(key, context); + return { key, enabled }; + } +} diff --git a/src/modules/feature-flags/feature-flags.module.ts b/src/modules/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..be94b80 --- /dev/null +++ b/src/modules/feature-flags/feature-flags.module.ts @@ -0,0 +1,14 @@ +import { Module, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FeatureFlagsController } from './feature-flags.controller'; +import { FeatureFlagsService } from './services/feature-flags.service'; +import { FeatureFlag, TenantFlag, UserFlag } from './entities'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([FeatureFlag, TenantFlag, UserFlag])], + controllers: [FeatureFlagsController], + providers: [FeatureFlagsService], + exports: [FeatureFlagsService], +}) +export class FeatureFlagsModule {} diff --git a/src/modules/feature-flags/index.ts b/src/modules/feature-flags/index.ts new file mode 100644 index 0000000..88c93c1 --- /dev/null +++ b/src/modules/feature-flags/index.ts @@ -0,0 +1,5 @@ +export * from './feature-flags.module'; +export * from './feature-flags.controller'; +export * from './services'; +export * from './entities'; +export * from './dto'; diff --git a/src/modules/feature-flags/services/feature-flags.service.ts b/src/modules/feature-flags/services/feature-flags.service.ts new file mode 100644 index 0000000..3998137 --- /dev/null +++ b/src/modules/feature-flags/services/feature-flags.service.ts @@ -0,0 +1,308 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FeatureFlag, FlagScope } from '../entities/feature-flag.entity'; +import { TenantFlag } from '../entities/tenant-flag.entity'; +import { UserFlag } from '../entities/user-flag.entity'; +import { CreateFlagDto } from '../dto/create-flag.dto'; +import { UpdateFlagDto } from '../dto/update-flag.dto'; +import { SetTenantFlagDto, SetUserFlagDto } from '../dto/set-tenant-flag.dto'; +import * as crypto from 'crypto'; + +export interface EvaluationContext { + tenantId?: string; + userId?: string; + planId?: string; + attributes?: Record; +} + +export interface FlagEvaluation { + key: string; + enabled: boolean; + value: any; + source: 'default' | 'global' | 'tenant' | 'user' | 'rollout'; +} + +@Injectable() +export class FeatureFlagsService { + constructor( + @InjectRepository(FeatureFlag) + private readonly flagRepository: Repository, + @InjectRepository(TenantFlag) + private readonly tenantFlagRepository: Repository, + @InjectRepository(UserFlag) + private readonly userFlagRepository: Repository, + ) {} + + // ==================== FLAG MANAGEMENT ==================== + + async createFlag(dto: CreateFlagDto): Promise { + const existing = await this.flagRepository.findOne({ + where: { key: dto.key }, + }); + + if (existing) { + throw new ConflictException(`Flag with key '${dto.key}' already exists`); + } + + const flag = this.flagRepository.create(dto); + return this.flagRepository.save(flag); + } + + async updateFlag(id: string, dto: UpdateFlagDto): Promise { + const flag = await this.flagRepository.findOne({ where: { id } }); + if (!flag) { + throw new NotFoundException(`Flag with ID '${id}' not found`); + } + + Object.assign(flag, dto); + return this.flagRepository.save(flag); + } + + async deleteFlag(id: string): Promise { + const flag = await this.flagRepository.findOne({ where: { id } }); + if (!flag) { + throw new NotFoundException(`Flag with ID '${id}' not found`); + } + + await this.flagRepository.remove(flag); + } + + async getAllFlags(): Promise { + return this.flagRepository.find({ + order: { category: 'ASC', key: 'ASC' }, + }); + } + + async getFlagByKey(key: string): Promise { + return this.flagRepository.findOne({ where: { key } }); + } + + async getFlagById(id: string): Promise { + return this.flagRepository.findOne({ where: { id } }); + } + + async toggleFlag(id: string, enabled: boolean): Promise { + const flag = await this.flagRepository.findOne({ where: { id } }); + if (!flag) { + throw new NotFoundException(`Flag with ID '${id}' not found`); + } + + flag.is_enabled = enabled; + return this.flagRepository.save(flag); + } + + // ==================== TENANT FLAGS ==================== + + async setTenantFlag(tenantId: string, dto: SetTenantFlagDto): Promise { + const flag = await this.flagRepository.findOne({ where: { id: dto.flag_id } }); + if (!flag) { + throw new NotFoundException(`Flag with ID '${dto.flag_id}' not found`); + } + + let tenantFlag = await this.tenantFlagRepository.findOne({ + where: { tenant_id: tenantId, flag_id: dto.flag_id }, + }); + + if (tenantFlag) { + tenantFlag.is_enabled = dto.is_enabled ?? tenantFlag.is_enabled; + tenantFlag.value = dto.value ?? tenantFlag.value; + tenantFlag.metadata = dto.metadata ?? tenantFlag.metadata; + } else { + tenantFlag = this.tenantFlagRepository.create({ + tenant_id: tenantId, + flag_id: dto.flag_id, + is_enabled: dto.is_enabled ?? true, + value: dto.value, + metadata: dto.metadata, + }); + } + + return this.tenantFlagRepository.save(tenantFlag); + } + + async removeTenantFlag(tenantId: string, flagId: string): Promise { + const tenantFlag = await this.tenantFlagRepository.findOne({ + where: { tenant_id: tenantId, flag_id: flagId }, + }); + + if (tenantFlag) { + await this.tenantFlagRepository.remove(tenantFlag); + } + } + + async getTenantFlags(tenantId: string): Promise { + return this.tenantFlagRepository.find({ + where: { tenant_id: tenantId }, + relations: ['flag'], + }); + } + + // ==================== USER FLAGS ==================== + + async setUserFlag(tenantId: string, dto: SetUserFlagDto): Promise { + const flag = await this.flagRepository.findOne({ where: { id: dto.flag_id } }); + if (!flag) { + throw new NotFoundException(`Flag with ID '${dto.flag_id}' not found`); + } + + let userFlag = await this.userFlagRepository.findOne({ + where: { user_id: dto.user_id, flag_id: dto.flag_id }, + }); + + if (userFlag) { + userFlag.is_enabled = dto.is_enabled ?? userFlag.is_enabled; + userFlag.value = dto.value ?? userFlag.value; + userFlag.metadata = dto.metadata ?? userFlag.metadata; + } else { + userFlag = this.userFlagRepository.create({ + tenant_id: tenantId, + user_id: dto.user_id, + flag_id: dto.flag_id, + is_enabled: dto.is_enabled ?? true, + value: dto.value, + metadata: dto.metadata, + }); + } + + return this.userFlagRepository.save(userFlag); + } + + async removeUserFlag(userId: string, flagId: string): Promise { + const userFlag = await this.userFlagRepository.findOne({ + where: { user_id: userId, flag_id: flagId }, + }); + + if (userFlag) { + await this.userFlagRepository.remove(userFlag); + } + } + + async getUserFlags(tenantId: string, userId: string): Promise { + return this.userFlagRepository.find({ + where: { tenant_id: tenantId, user_id: userId }, + relations: ['flag'], + }); + } + + // ==================== FLAG EVALUATION ==================== + + async evaluateFlag(key: string, context: EvaluationContext): Promise { + const flag = await this.flagRepository.findOne({ where: { key } }); + + if (!flag) { + return { + key, + enabled: false, + value: null, + source: 'default', + }; + } + + // Global flag disabled + if (!flag.is_enabled) { + return { + key, + enabled: false, + value: flag.default_value, + source: 'global', + }; + } + + // Check user-level override + if (context.userId) { + const userFlag = await this.userFlagRepository.findOne({ + where: { user_id: context.userId, flag_id: flag.id }, + }); + + if (userFlag) { + return { + key, + enabled: userFlag.is_enabled, + value: userFlag.value ?? flag.default_value, + source: 'user', + }; + } + } + + // Check tenant-level override + if (context.tenantId) { + const tenantFlag = await this.tenantFlagRepository.findOne({ + where: { tenant_id: context.tenantId, flag_id: flag.id }, + }); + + if (tenantFlag) { + return { + key, + enabled: tenantFlag.is_enabled, + value: tenantFlag.value ?? flag.default_value, + source: 'tenant', + }; + } + } + + // Check rollout percentage + if (flag.rollout_percentage !== null && flag.rollout_percentage < 100) { + const isInRollout = this.isInRollout( + flag.rollout_percentage, + context.userId || context.tenantId || 'anonymous', + flag.key, + ); + + return { + key, + enabled: isInRollout, + value: isInRollout ? flag.default_value : null, + source: 'rollout', + }; + } + + // Return global value + return { + key, + enabled: flag.is_enabled, + value: flag.default_value, + source: 'global', + }; + } + + async evaluateAllFlags(context: EvaluationContext): Promise> { + const flags = await this.flagRepository.find(); + const evaluations: Record = {}; + + for (const flag of flags) { + evaluations[flag.key] = await this.evaluateFlag(flag.key, context); + } + + return evaluations; + } + + async isEnabled(key: string, context: EvaluationContext): Promise { + const evaluation = await this.evaluateFlag(key, context); + return evaluation.enabled; + } + + async getValue(key: string, context: EvaluationContext, defaultValue: T): Promise { + const evaluation = await this.evaluateFlag(key, context); + return evaluation.enabled ? (evaluation.value ?? defaultValue) : defaultValue; + } + + // ==================== HELPERS ==================== + + private isInRollout(percentage: number, identifier: string, flagKey: string): boolean { + // Create deterministic hash for consistent rollout + const hash = crypto + .createHash('md5') + .update(`${flagKey}:${identifier}`) + .digest('hex'); + + const hashInt = parseInt(hash.substring(0, 8), 16); + const normalized = hashInt / 0xffffffff; + + return normalized * 100 < percentage; + } +} diff --git a/src/modules/feature-flags/services/index.ts b/src/modules/feature-flags/services/index.ts new file mode 100644 index 0000000..5073f6c --- /dev/null +++ b/src/modules/feature-flags/services/index.ts @@ -0,0 +1 @@ +export * from './feature-flags.service'; diff --git a/src/modules/health/__tests__/health.controller.spec.ts b/src/modules/health/__tests__/health.controller.spec.ts new file mode 100644 index 0000000..a85db4b --- /dev/null +++ b/src/modules/health/__tests__/health.controller.spec.ts @@ -0,0 +1,119 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HealthController } from '../health.controller'; +import { HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus'; + +describe('HealthController', () => { + let controller: HealthController; + let healthCheckService: jest.Mocked; + let typeOrmHealthIndicator: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: HealthCheckService, + useValue: { + check: jest.fn(), + }, + }, + { + provide: TypeOrmHealthIndicator, + useValue: { + pingCheck: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(HealthController); + healthCheckService = module.get(HealthCheckService); + typeOrmHealthIndicator = module.get(TypeOrmHealthIndicator); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('check', () => { + it('should return health check result', async () => { + const mockResult = { + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, + }; + healthCheckService.check.mockResolvedValue(mockResult as any); + + const result = await controller.check(); + + expect(result).toEqual(mockResult); + expect(healthCheckService.check).toHaveBeenCalled(); + }); + + it('should check database health', async () => { + healthCheckService.check.mockImplementation(async (checks) => { + // Execute the check functions to ensure they're called + for (const check of checks) { + await check(); + } + return { status: 'ok', info: {}, error: {}, details: {} } as any; + }); + typeOrmHealthIndicator.pingCheck.mockResolvedValue({ database: { status: 'up' } } as any); + + await controller.check(); + + expect(typeOrmHealthIndicator.pingCheck).toHaveBeenCalledWith('database'); + }); + }); + + describe('live', () => { + it('should return liveness status', () => { + const result = controller.live(); + + expect(result.status).toBe('ok'); + expect(result.timestamp).toBeDefined(); + }); + + it('should include current timestamp', () => { + const before = new Date().getTime(); + const result = controller.live(); + const after = new Date().getTime(); + + const timestamp = new Date(result.timestamp).getTime(); + expect(timestamp).toBeGreaterThanOrEqual(before); + expect(timestamp).toBeLessThanOrEqual(after); + }); + }); + + describe('ready', () => { + it('should return readiness check result', async () => { + const mockResult = { + status: 'ok', + info: { database: { status: 'up' } }, + error: {}, + details: { database: { status: 'up' } }, + }; + healthCheckService.check.mockResolvedValue(mockResult as any); + + const result = await controller.ready(); + + expect(result).toEqual(mockResult); + expect(healthCheckService.check).toHaveBeenCalled(); + }); + + it('should check database for readiness', async () => { + healthCheckService.check.mockImplementation(async (checks) => { + for (const check of checks) { + await check(); + } + return { status: 'ok', info: {}, error: {}, details: {} } as any; + }); + typeOrmHealthIndicator.pingCheck.mockResolvedValue({ database: { status: 'up' } } as any); + + await controller.ready(); + + expect(typeOrmHealthIndicator.pingCheck).toHaveBeenCalledWith('database'); + }); + }); +}); diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts new file mode 100644 index 0000000..b3fd48e --- /dev/null +++ b/src/modules/health/health.controller.ts @@ -0,0 +1,44 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + HealthCheck, + HealthCheckService, + TypeOrmHealthIndicator, +} from '@nestjs/terminus'; +import { Public } from '../auth/decorators/public.decorator'; + +@ApiTags('health') +@Controller('health') +export class HealthController { + constructor( + private health: HealthCheckService, + private db: TypeOrmHealthIndicator, + ) {} + + @Get() + @Public() + @HealthCheck() + @ApiOperation({ summary: 'Health check' }) + check() { + return this.health.check([ + () => this.db.pingCheck('database'), + ]); + } + + @Get('live') + @Public() + @ApiOperation({ summary: 'Liveness probe' }) + live() { + return { status: 'ok', timestamp: new Date().toISOString() }; + } + + @Get('ready') + @Public() + @HealthCheck() + @ApiOperation({ summary: 'Readiness probe' }) + ready() { + return this.health.check([ + () => this.db.pingCheck('database'), + ]); + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100644 index 0000000..0208ef7 --- /dev/null +++ b/src/modules/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/modules/health/index.ts b/src/modules/health/index.ts new file mode 100644 index 0000000..f0f0421 --- /dev/null +++ b/src/modules/health/index.ts @@ -0,0 +1 @@ +export * from './health.module'; diff --git a/src/modules/notifications/__tests__/devices.controller.spec.ts b/src/modules/notifications/__tests__/devices.controller.spec.ts new file mode 100644 index 0000000..dfcfd68 --- /dev/null +++ b/src/modules/notifications/__tests__/devices.controller.spec.ts @@ -0,0 +1,959 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { DevicesController } from '../controllers/devices.controller'; +import { DevicesService } from '../services/devices.service'; +import { PushNotificationService } from '../services/push-notification.service'; +import { UserDevice } from '../entities'; +import { RegisterDeviceDto, UpdateDeviceDto } from '../dto'; + +describe('DevicesController', () => { + let controller: DevicesController; + let devicesService: jest.Mocked; + let pushService: jest.Mocked; + + // Mock user data + const mockUser = { + id: 'user-123', + tenant_id: 'tenant-456', + }; + + const mockTenantId = 'tenant-456'; + + // Mock device data + const mockDevice: UserDevice = { + id: 'device-1', + tenant_id: 'tenant-456', + user_id: 'user-123', + device_type: 'web', + device_token: JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test-endpoint', + keys: { + p256dh: 'test-p256dh-key', + auth: 'test-auth-key', + }, + }), + device_name: 'Chrome on Windows', + browser: 'Chrome', + browser_version: '120.0', + os: 'Windows', + os_version: '11', + is_active: true, + last_used_at: new Date('2026-01-01T10:00:00Z'), + created_at: new Date('2026-01-01T08:00:00Z'), + }; + + const mockDeviceList: UserDevice[] = [ + mockDevice, + { + ...mockDevice, + id: 'device-2', + device_type: 'mobile', + device_name: 'Safari on iPhone', + browser: 'Safari', + os: 'iOS', + os_version: '17', + is_active: true, + }, + { + ...mockDevice, + id: 'device-3', + device_type: 'desktop', + device_name: 'Firefox on macOS', + browser: 'Firefox', + os: 'macOS', + is_active: false, + }, + ]; + + // Mock services + const mockDevicesService = { + findByUser: jest.fn(), + findActiveByUser: jest.fn(), + findById: jest.fn(), + register: jest.fn(), + update: jest.fn(), + unregister: jest.fn(), + delete: jest.fn(), + countActiveDevices: jest.fn(), + markAsUsed: jest.fn(), + markAsInactive: jest.fn(), + cleanupInactiveDevices: jest.fn(), + }; + + const mockPushService = { + getVapidPublicKey: jest.fn(), + isEnabled: jest.fn(), + validateSubscription: jest.fn(), + sendToUser: jest.fn(), + sendToDevice: jest.fn(), + sendBroadcast: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DevicesController], + providers: [ + { + provide: DevicesService, + useValue: mockDevicesService, + }, + { + provide: PushNotificationService, + useValue: mockPushService, + }, + ], + }).compile(); + + controller = module.get(DevicesController); + devicesService = module.get(DevicesService); + pushService = module.get(PushNotificationService); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('controller initialization', () => { + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should have devicesService injected', () => { + expect(devicesService).toBeDefined(); + }); + + it('should have pushService injected', () => { + expect(pushService).toBeDefined(); + }); + }); + + describe('getVapidKey', () => { + it('should return VAPID public key when push is enabled', () => { + const vapidPublicKey = 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U'; + mockPushService.getVapidPublicKey.mockReturnValue(vapidPublicKey); + mockPushService.isEnabled.mockReturnValue(true); + + const result = controller.getVapidKey(); + + expect(result).toEqual({ + vapidPublicKey, + isEnabled: true, + }); + expect(mockPushService.getVapidPublicKey).toHaveBeenCalledTimes(1); + expect(mockPushService.isEnabled).toHaveBeenCalledTimes(1); + }); + + it('should return null VAPID key when push is disabled', () => { + mockPushService.getVapidPublicKey.mockReturnValue(null); + mockPushService.isEnabled.mockReturnValue(false); + + const result = controller.getVapidKey(); + + expect(result).toEqual({ + vapidPublicKey: null, + isEnabled: false, + }); + }); + + it('should handle enabled state with null key', () => { + mockPushService.getVapidPublicKey.mockReturnValue(null); + mockPushService.isEnabled.mockReturnValue(true); + + const result = controller.getVapidKey(); + + expect(result).toEqual({ + vapidPublicKey: null, + isEnabled: true, + }); + }); + }); + + describe('getDevices', () => { + it('should return all devices for the current user', async () => { + mockDevicesService.findByUser.mockResolvedValue(mockDeviceList); + + const result = await controller.getDevices(mockUser, mockTenantId); + + expect(result).toEqual(mockDeviceList); + expect(mockDevicesService.findByUser).toHaveBeenCalledWith( + mockUser.id, + mockTenantId, + ); + }); + + it('should return empty array when user has no devices', async () => { + mockDevicesService.findByUser.mockResolvedValue([]); + + const result = await controller.getDevices(mockUser, mockTenantId); + + expect(result).toEqual([]); + expect(mockDevicesService.findByUser).toHaveBeenCalledWith( + mockUser.id, + mockTenantId, + ); + }); + + it('should handle undefined user gracefully', async () => { + mockDevicesService.findByUser.mockResolvedValue([]); + + const result = await controller.getDevices(undefined as any, undefined as any); + + expect(result).toEqual([]); + expect(mockDevicesService.findByUser).toHaveBeenCalledWith('', ''); + }); + + it('should use tenant_id from user object as fallback', async () => { + mockDevicesService.findByUser.mockResolvedValue([mockDevice]); + const userWithTenant = { id: 'user-123', tenant_id: 'tenant-from-user' }; + + const result = await controller.getDevices(userWithTenant, undefined as any); + + expect(result).toEqual([mockDevice]); + expect(mockDevicesService.findByUser).toHaveBeenCalledWith( + 'user-123', + 'tenant-from-user', + ); + }); + }); + + describe('registerDevice', () => { + const validSubscription = JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: { + p256dh: 'valid-p256dh-key', + auth: 'valid-auth-key', + }, + }); + + const registerDto: RegisterDeviceDto = { + deviceToken: validSubscription, + deviceType: 'web', + deviceName: 'Chrome on Windows', + browser: 'Chrome', + browserVersion: '120.0', + os: 'Windows', + osVersion: '11', + }; + + it('should register a new device with valid subscription', async () => { + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockResolvedValue(mockDevice); + + const result = await controller.registerDevice( + mockUser, + mockTenantId, + registerDto, + ); + + expect(result).toEqual({ + success: true, + device: { + id: mockDevice.id, + device_type: mockDevice.device_type, + device_name: mockDevice.device_name, + browser: mockDevice.browser, + os: mockDevice.os, + created_at: mockDevice.created_at, + }, + }); + expect(mockPushService.validateSubscription).toHaveBeenCalledWith( + registerDto.deviceToken, + ); + expect(mockDevicesService.register).toHaveBeenCalledWith( + mockUser.id, + mockTenantId, + registerDto, + ); + }); + + it('should return error for invalid subscription format', async () => { + mockPushService.validateSubscription.mockReturnValue(false); + const invalidDto: RegisterDeviceDto = { + deviceToken: 'invalid-token', + }; + + const result = await controller.registerDevice( + mockUser, + mockTenantId, + invalidDto, + ); + + expect(result).toEqual({ + success: false, + error: 'Invalid push subscription format', + }); + expect(mockDevicesService.register).not.toHaveBeenCalled(); + }); + + it('should register device with minimal data', async () => { + const minimalDto: RegisterDeviceDto = { + deviceToken: validSubscription, + }; + const minimalDevice = { + ...mockDevice, + device_name: null, + browser: null, + os: null, + }; + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockResolvedValue(minimalDevice); + + const result = await controller.registerDevice( + mockUser, + mockTenantId, + minimalDto, + ); + + expect(result.success).toBe(true); + expect(result.device).toBeDefined(); + expect(mockDevicesService.register).toHaveBeenCalledWith( + mockUser.id, + mockTenantId, + minimalDto, + ); + }); + + it('should register mobile device type', async () => { + const mobileDto: RegisterDeviceDto = { + deviceToken: validSubscription, + deviceType: 'mobile', + deviceName: 'Safari on iPhone', + browser: 'Safari', + os: 'iOS', + osVersion: '17', + }; + const mobileDevice = { + ...mockDevice, + device_type: 'mobile' as const, + device_name: 'Safari on iPhone', + browser: 'Safari', + os: 'iOS', + }; + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockResolvedValue(mobileDevice); + + const result = await controller.registerDevice( + mockUser, + mockTenantId, + mobileDto, + ); + + expect(result.success).toBe(true); + expect(result.device?.device_type).toBe('mobile'); + }); + + it('should register desktop device type', async () => { + const desktopDto: RegisterDeviceDto = { + deviceToken: validSubscription, + deviceType: 'desktop', + deviceName: 'Electron App', + }; + const desktopDevice = { + ...mockDevice, + device_type: 'desktop' as const, + device_name: 'Electron App', + }; + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockResolvedValue(desktopDevice); + + const result = await controller.registerDevice( + mockUser, + mockTenantId, + desktopDto, + ); + + expect(result.success).toBe(true); + expect(result.device?.device_type).toBe('desktop'); + }); + + it('should handle undefined user gracefully during registration', async () => { + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockResolvedValue(mockDevice); + + const result = await controller.registerDevice( + undefined as any, + undefined as any, + registerDto, + ); + + expect(result.success).toBe(true); + expect(mockDevicesService.register).toHaveBeenCalledWith('', '', registerDto); + }); + }); + + describe('updateDevice', () => { + it('should update device name', async () => { + const updateDto: UpdateDeviceDto = { + deviceName: 'My Work Laptop', + }; + const updatedDevice = { ...mockDevice, device_name: 'My Work Laptop' }; + mockDevicesService.update.mockResolvedValue(updatedDevice); + + const result = await controller.updateDevice( + mockUser, + mockTenantId, + mockDevice.id, + updateDto, + ); + + expect(result).toEqual(updatedDevice); + expect(mockDevicesService.update).toHaveBeenCalledWith( + mockDevice.id, + mockUser.id, + mockTenantId, + updateDto, + ); + }); + + it('should update device active status', async () => { + const updateDto: UpdateDeviceDto = { + isActive: false, + }; + const updatedDevice = { ...mockDevice, is_active: false }; + mockDevicesService.update.mockResolvedValue(updatedDevice); + + const result = await controller.updateDevice( + mockUser, + mockTenantId, + mockDevice.id, + updateDto, + ); + + expect(result).toEqual(updatedDevice); + expect(result.is_active).toBe(false); + }); + + it('should update multiple fields at once', async () => { + const updateDto: UpdateDeviceDto = { + deviceName: 'Updated Device Name', + isActive: true, + }; + const updatedDevice = { + ...mockDevice, + device_name: 'Updated Device Name', + is_active: true, + }; + mockDevicesService.update.mockResolvedValue(updatedDevice); + + const result = await controller.updateDevice( + mockUser, + mockTenantId, + mockDevice.id, + updateDto, + ); + + expect(result.device_name).toBe('Updated Device Name'); + expect(result.is_active).toBe(true); + }); + + it('should throw NotFoundException when device not found', async () => { + const updateDto: UpdateDeviceDto = { deviceName: 'New Name' }; + mockDevicesService.update.mockRejectedValue( + new NotFoundException('Dispositivo no encontrado'), + ); + + await expect( + controller.updateDevice(mockUser, mockTenantId, 'non-existent-id', updateDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should not update device from different user', async () => { + const updateDto: UpdateDeviceDto = { deviceName: 'Hacked Name' }; + mockDevicesService.update.mockRejectedValue( + new NotFoundException('Dispositivo no encontrado'), + ); + + await expect( + controller.updateDevice( + { id: 'different-user', tenant_id: mockTenantId }, + mockTenantId, + mockDevice.id, + updateDto, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('should handle empty update dto', async () => { + const updateDto: UpdateDeviceDto = {}; + mockDevicesService.update.mockResolvedValue(mockDevice); + + const result = await controller.updateDevice( + mockUser, + mockTenantId, + mockDevice.id, + updateDto, + ); + + expect(result).toEqual(mockDevice); + expect(mockDevicesService.update).toHaveBeenCalledWith( + mockDevice.id, + mockUser.id, + mockTenantId, + updateDto, + ); + }); + }); + + describe('unregisterDevice', () => { + it('should unregister device successfully', async () => { + mockDevicesService.unregister.mockResolvedValue(undefined); + + await controller.unregisterDevice(mockUser, mockTenantId, mockDevice.id); + + expect(mockDevicesService.unregister).toHaveBeenCalledWith( + mockDevice.id, + mockUser.id, + mockTenantId, + ); + }); + + it('should throw NotFoundException when device not found', async () => { + mockDevicesService.unregister.mockRejectedValue( + new NotFoundException('Dispositivo no encontrado'), + ); + + await expect( + controller.unregisterDevice(mockUser, mockTenantId, 'non-existent-id'), + ).rejects.toThrow(NotFoundException); + }); + + it('should not unregister device from different user', async () => { + mockDevicesService.unregister.mockRejectedValue( + new NotFoundException('Dispositivo no encontrado'), + ); + + await expect( + controller.unregisterDevice( + { id: 'different-user', tenant_id: mockTenantId }, + mockTenantId, + mockDevice.id, + ), + ).rejects.toThrow(NotFoundException); + }); + + it('should not unregister device from different tenant', async () => { + mockDevicesService.unregister.mockRejectedValue( + new NotFoundException('Dispositivo no encontrado'), + ); + + await expect( + controller.unregisterDevice(mockUser, 'different-tenant', mockDevice.id), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getStats', () => { + it('should return device statistics', async () => { + mockDevicesService.countActiveDevices.mockResolvedValue(2); + mockDevicesService.findByUser.mockResolvedValue(mockDeviceList); + + const result = await controller.getStats(mockUser, mockTenantId); + + expect(result).toEqual({ + total: 3, + active: 2, + inactive: 1, + byType: { + web: 1, + mobile: 1, + desktop: 1, + }, + }); + expect(mockDevicesService.countActiveDevices).toHaveBeenCalledWith( + mockUser.id, + mockTenantId, + ); + expect(mockDevicesService.findByUser).toHaveBeenCalledWith( + mockUser.id, + mockTenantId, + ); + }); + + it('should return zero stats for user with no devices', async () => { + mockDevicesService.countActiveDevices.mockResolvedValue(0); + mockDevicesService.findByUser.mockResolvedValue([]); + + const result = await controller.getStats(mockUser, mockTenantId); + + expect(result).toEqual({ + total: 0, + active: 0, + inactive: 0, + byType: { + web: 0, + mobile: 0, + desktop: 0, + }, + }); + }); + + it('should handle all devices being active', async () => { + const allActiveDevices = mockDeviceList.map((d) => ({ + ...d, + is_active: true, + })); + mockDevicesService.countActiveDevices.mockResolvedValue(3); + mockDevicesService.findByUser.mockResolvedValue(allActiveDevices); + + const result = await controller.getStats(mockUser, mockTenantId); + + expect(result.total).toBe(3); + expect(result.active).toBe(3); + expect(result.inactive).toBe(0); + }); + + it('should handle all devices being inactive', async () => { + const allInactiveDevices = mockDeviceList.map((d) => ({ + ...d, + is_active: false, + })); + mockDevicesService.countActiveDevices.mockResolvedValue(0); + mockDevicesService.findByUser.mockResolvedValue(allInactiveDevices); + + const result = await controller.getStats(mockUser, mockTenantId); + + expect(result.total).toBe(3); + expect(result.active).toBe(0); + expect(result.inactive).toBe(3); + }); + + it('should correctly count devices by type with only web devices', async () => { + const webOnlyDevices = [ + mockDevice, + { ...mockDevice, id: 'device-2' }, + { ...mockDevice, id: 'device-3' }, + ]; + mockDevicesService.countActiveDevices.mockResolvedValue(3); + mockDevicesService.findByUser.mockResolvedValue(webOnlyDevices); + + const result = await controller.getStats(mockUser, mockTenantId); + + expect(result.byType).toEqual({ + web: 3, + mobile: 0, + desktop: 0, + }); + }); + + it('should handle undefined user in stats', async () => { + mockDevicesService.countActiveDevices.mockResolvedValue(0); + mockDevicesService.findByUser.mockResolvedValue([]); + + const result = await controller.getStats(undefined as any, undefined as any); + + expect(result).toEqual({ + total: 0, + active: 0, + inactive: 0, + byType: { + web: 0, + mobile: 0, + desktop: 0, + }, + }); + }); + }); + + describe('error handling', () => { + it('should propagate service errors in getDevices', async () => { + const error = new Error('Database connection failed'); + mockDevicesService.findByUser.mockRejectedValue(error); + + await expect( + controller.getDevices(mockUser, mockTenantId), + ).rejects.toThrow('Database connection failed'); + }); + + it('should propagate service errors in registerDevice', async () => { + const error = new Error('Failed to save device'); + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockRejectedValue(error); + + await expect( + controller.registerDevice(mockUser, mockTenantId, { + deviceToken: JSON.stringify({ + endpoint: 'https://test.com', + keys: { p256dh: 'key', auth: 'auth' }, + }), + }), + ).rejects.toThrow('Failed to save device'); + }); + + it('should propagate service errors in updateDevice', async () => { + const error = new Error('Update failed'); + mockDevicesService.update.mockRejectedValue(error); + + await expect( + controller.updateDevice(mockUser, mockTenantId, mockDevice.id, { + deviceName: 'New Name', + }), + ).rejects.toThrow('Update failed'); + }); + + it('should propagate service errors in unregisterDevice', async () => { + const error = new Error('Unregister failed'); + mockDevicesService.unregister.mockRejectedValue(error); + + await expect( + controller.unregisterDevice(mockUser, mockTenantId, mockDevice.id), + ).rejects.toThrow('Unregister failed'); + }); + + it('should propagate service errors in getStats', async () => { + const error = new Error('Stats query failed'); + mockDevicesService.countActiveDevices.mockRejectedValue(error); + + await expect( + controller.getStats(mockUser, mockTenantId), + ).rejects.toThrow('Stats query failed'); + }); + }); + + describe('guards behavior', () => { + // Note: Guards are applied via decorators and are tested in integration tests + // These tests verify the controller methods work correctly assuming guards pass + + it('should have JwtAuthGuard and TenantGuard on getDevices', () => { + // Verify the method exists and is callable + expect(controller.getDevices).toBeDefined(); + expect(typeof controller.getDevices).toBe('function'); + }); + + it('should have JwtAuthGuard and TenantGuard on registerDevice', () => { + expect(controller.registerDevice).toBeDefined(); + expect(typeof controller.registerDevice).toBe('function'); + }); + + it('should have JwtAuthGuard and TenantGuard on updateDevice', () => { + expect(controller.updateDevice).toBeDefined(); + expect(typeof controller.updateDevice).toBe('function'); + }); + + it('should have JwtAuthGuard and TenantGuard on unregisterDevice', () => { + expect(controller.unregisterDevice).toBeDefined(); + expect(typeof controller.unregisterDevice).toBe('function'); + }); + + it('should have JwtAuthGuard and TenantGuard on getStats', () => { + expect(controller.getStats).toBeDefined(); + expect(typeof controller.getStats).toBe('function'); + }); + + it('should have public access to getVapidKey (no guards)', () => { + // getVapidKey is the only public endpoint + expect(controller.getVapidKey).toBeDefined(); + expect(typeof controller.getVapidKey).toBe('function'); + }); + }); + + describe('push notification integration', () => { + it('should validate subscription before registering device', async () => { + const validToken = JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: { p256dh: 'key', auth: 'auth' }, + }); + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockResolvedValue(mockDevice); + + await controller.registerDevice(mockUser, mockTenantId, { + deviceToken: validToken, + }); + + // Verify validateSubscription was called with the token + expect(mockPushService.validateSubscription).toHaveBeenCalledWith(validToken); + // Verify register was called after validation passed + expect(mockDevicesService.register).toHaveBeenCalled(); + // Verify the order: validateSubscription should be called, then register + const validateCallOrder = mockPushService.validateSubscription.mock.invocationCallOrder[0]; + const registerCallOrder = mockDevicesService.register.mock.invocationCallOrder[0]; + expect(validateCallOrder).toBeLessThan(registerCallOrder); + }); + + it('should reject device with malformed JSON token', async () => { + mockPushService.validateSubscription.mockReturnValue(false); + + const result = await controller.registerDevice(mockUser, mockTenantId, { + deviceToken: 'not-json', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid push subscription format'); + }); + + it('should reject device with missing endpoint', async () => { + mockPushService.validateSubscription.mockReturnValue(false); + + const result = await controller.registerDevice(mockUser, mockTenantId, { + deviceToken: JSON.stringify({ + keys: { p256dh: 'key', auth: 'auth' }, + }), + }); + + expect(result.success).toBe(false); + }); + + it('should reject device with missing keys', async () => { + mockPushService.validateSubscription.mockReturnValue(false); + + const result = await controller.registerDevice(mockUser, mockTenantId, { + deviceToken: JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + }), + }); + + expect(result.success).toBe(false); + }); + }); + + describe('tenant isolation', () => { + it('should pass tenant ID to service methods for getDevices', async () => { + mockDevicesService.findByUser.mockResolvedValue([]); + + await controller.getDevices(mockUser, 'specific-tenant-123'); + + expect(mockDevicesService.findByUser).toHaveBeenCalledWith( + mockUser.id, + 'specific-tenant-123', + ); + }); + + it('should pass tenant ID to service methods for registerDevice', async () => { + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockResolvedValue(mockDevice); + + await controller.registerDevice(mockUser, 'specific-tenant-123', { + deviceToken: JSON.stringify({ + endpoint: 'https://test.com', + keys: { p256dh: 'key', auth: 'auth' }, + }), + }); + + expect(mockDevicesService.register).toHaveBeenCalledWith( + mockUser.id, + 'specific-tenant-123', + expect.any(Object), + ); + }); + + it('should pass tenant ID to service methods for updateDevice', async () => { + mockDevicesService.update.mockResolvedValue(mockDevice); + + await controller.updateDevice( + mockUser, + 'specific-tenant-123', + mockDevice.id, + { deviceName: 'New Name' }, + ); + + expect(mockDevicesService.update).toHaveBeenCalledWith( + mockDevice.id, + mockUser.id, + 'specific-tenant-123', + expect.any(Object), + ); + }); + + it('should pass tenant ID to service methods for unregisterDevice', async () => { + mockDevicesService.unregister.mockResolvedValue(undefined); + + await controller.unregisterDevice( + mockUser, + 'specific-tenant-123', + mockDevice.id, + ); + + expect(mockDevicesService.unregister).toHaveBeenCalledWith( + mockDevice.id, + mockUser.id, + 'specific-tenant-123', + ); + }); + + it('should pass tenant ID to service methods for getStats', async () => { + mockDevicesService.countActiveDevices.mockResolvedValue(0); + mockDevicesService.findByUser.mockResolvedValue([]); + + await controller.getStats(mockUser, 'specific-tenant-123'); + + expect(mockDevicesService.countActiveDevices).toHaveBeenCalledWith( + mockUser.id, + 'specific-tenant-123', + ); + expect(mockDevicesService.findByUser).toHaveBeenCalledWith( + mockUser.id, + 'specific-tenant-123', + ); + }); + }); + + describe('response format validation', () => { + it('should return correct structure for registerDevice success response', async () => { + mockPushService.validateSubscription.mockReturnValue(true); + mockDevicesService.register.mockResolvedValue(mockDevice); + + const result = await controller.registerDevice(mockUser, mockTenantId, { + deviceToken: JSON.stringify({ + endpoint: 'https://test.com', + keys: { p256dh: 'key', auth: 'auth' }, + }), + }); + + expect(result).toHaveProperty('success', true); + expect(result).toHaveProperty('device'); + expect(result.device).toHaveProperty('id'); + expect(result.device).toHaveProperty('device_type'); + expect(result.device).toHaveProperty('device_name'); + expect(result.device).toHaveProperty('browser'); + expect(result.device).toHaveProperty('os'); + expect(result.device).toHaveProperty('created_at'); + // Should not expose sensitive data + expect(result.device).not.toHaveProperty('device_token'); + expect(result.device).not.toHaveProperty('user_id'); + expect(result.device).not.toHaveProperty('tenant_id'); + }); + + it('should return correct structure for registerDevice error response', async () => { + mockPushService.validateSubscription.mockReturnValue(false); + + const result = await controller.registerDevice(mockUser, mockTenantId, { + deviceToken: 'invalid', + }); + + expect(result).toHaveProperty('success', false); + expect(result).toHaveProperty('error'); + expect(result.error).toBe('Invalid push subscription format'); + expect(result).not.toHaveProperty('device'); + }); + + it('should return correct structure for getVapidKey response', () => { + mockPushService.getVapidPublicKey.mockReturnValue('test-key'); + mockPushService.isEnabled.mockReturnValue(true); + + const result = controller.getVapidKey(); + + expect(result).toHaveProperty('vapidPublicKey'); + expect(result).toHaveProperty('isEnabled'); + expect(typeof result.isEnabled).toBe('boolean'); + }); + + it('should return correct structure for getStats response', async () => { + mockDevicesService.countActiveDevices.mockResolvedValue(2); + mockDevicesService.findByUser.mockResolvedValue(mockDeviceList); + + const result = await controller.getStats(mockUser, mockTenantId); + + expect(result).toHaveProperty('total'); + expect(result).toHaveProperty('active'); + expect(result).toHaveProperty('inactive'); + expect(result).toHaveProperty('byType'); + expect(result.byType).toHaveProperty('web'); + expect(result.byType).toHaveProperty('mobile'); + expect(result.byType).toHaveProperty('desktop'); + expect(typeof result.total).toBe('number'); + expect(typeof result.active).toBe('number'); + expect(typeof result.inactive).toBe('number'); + }); + }); +}); diff --git a/src/modules/notifications/__tests__/devices.service.spec.ts b/src/modules/notifications/__tests__/devices.service.spec.ts new file mode 100644 index 0000000..04ac8d7 --- /dev/null +++ b/src/modules/notifications/__tests__/devices.service.spec.ts @@ -0,0 +1,236 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { DevicesService } from '../services/devices.service'; +import { UserDevice } from '../entities'; + +describe('DevicesService', () => { + let service: DevicesService; + let repository: Repository; + + const mockDevice: UserDevice = { + id: 'device-1', + tenant_id: 'tenant-1', + user_id: 'user-1', + device_type: 'web', + device_token: JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: { p256dh: 'test-key', auth: 'test-auth' }, + }), + device_name: 'Chrome on Windows', + browser: 'Chrome', + browser_version: '120', + os: 'Windows', + os_version: '11', + is_active: true, + last_used_at: new Date(), + created_at: new Date(), + }; + + const mockRepository = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + delete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + })), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DevicesService, + { + provide: getRepositoryToken(UserDevice), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(DevicesService); + repository = module.get>( + getRepositoryToken(UserDevice), + ); + + jest.clearAllMocks(); + }); + + describe('findByUser', () => { + it('should return devices for a user', async () => { + mockRepository.find.mockResolvedValue([mockDevice]); + + const result = await service.findByUser('user-1', 'tenant-1'); + + expect(result).toEqual([mockDevice]); + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { user_id: 'user-1', tenant_id: 'tenant-1' }, + order: { last_used_at: 'DESC' }, + }); + }); + + it('should return empty array if no devices', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findByUser('user-1', 'tenant-1'); + + expect(result).toEqual([]); + }); + }); + + describe('findActiveByUser', () => { + it('should return only active devices', async () => { + mockRepository.find.mockResolvedValue([mockDevice]); + + const result = await service.findActiveByUser('user-1', 'tenant-1'); + + expect(result).toEqual([mockDevice]); + expect(mockRepository.find).toHaveBeenCalledWith({ + where: { user_id: 'user-1', tenant_id: 'tenant-1', is_active: true }, + order: { last_used_at: 'DESC' }, + }); + }); + }); + + describe('findById', () => { + it('should return device by id', async () => { + mockRepository.findOne.mockResolvedValue(mockDevice); + + const result = await service.findById('device-1', 'user-1', 'tenant-1'); + + expect(result).toEqual(mockDevice); + }); + + it('should throw NotFoundException if device not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.findById('device-1', 'user-1', 'tenant-1'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('register', () => { + const registerDto = { + deviceToken: mockDevice.device_token, + deviceType: 'web' as const, + deviceName: 'Chrome on Windows', + browser: 'Chrome', + browserVersion: '120', + os: 'Windows', + osVersion: '11', + }; + + it('should create new device if not exists', async () => { + mockRepository.findOne.mockResolvedValue(null); + mockRepository.create.mockReturnValue(mockDevice); + mockRepository.save.mockResolvedValue(mockDevice); + + const result = await service.register('user-1', 'tenant-1', registerDto); + + expect(result).toEqual(mockDevice); + expect(mockRepository.create).toHaveBeenCalled(); + expect(mockRepository.save).toHaveBeenCalled(); + }); + + it('should reactivate existing device', async () => { + const inactiveDevice = { ...mockDevice, is_active: false }; + mockRepository.findOne.mockResolvedValue(inactiveDevice); + mockRepository.save.mockResolvedValue({ ...inactiveDevice, is_active: true }); + + const result = await service.register('user-1', 'tenant-1', registerDto); + + expect(result.is_active).toBe(true); + expect(mockRepository.save).toHaveBeenCalled(); + }); + }); + + describe('unregister', () => { + it('should mark device as inactive', async () => { + mockRepository.findOne.mockResolvedValue(mockDevice); + mockRepository.save.mockResolvedValue({ ...mockDevice, is_active: false }); + + await service.unregister('device-1', 'user-1', 'tenant-1'); + + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ is_active: false }), + ); + }); + + it('should throw NotFoundException if device not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.unregister('device-1', 'user-1', 'tenant-1'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('should delete device', async () => { + mockRepository.delete.mockResolvedValue({ affected: 1 }); + + await service.delete('device-1', 'user-1', 'tenant-1'); + + expect(mockRepository.delete).toHaveBeenCalledWith({ + id: 'device-1', + user_id: 'user-1', + tenant_id: 'tenant-1', + }); + }); + + it('should throw NotFoundException if device not found', async () => { + mockRepository.delete.mockResolvedValue({ affected: 0 }); + + await expect( + service.delete('device-1', 'user-1', 'tenant-1'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('countActiveDevices', () => { + it('should return count of active devices', async () => { + mockRepository.count.mockResolvedValue(3); + + const result = await service.countActiveDevices('user-1', 'tenant-1'); + + expect(result).toBe(3); + expect(mockRepository.count).toHaveBeenCalledWith({ + where: { user_id: 'user-1', tenant_id: 'tenant-1', is_active: true }, + }); + }); + }); + + describe('markAsUsed', () => { + it('should update last_used_at', async () => { + mockRepository.update.mockResolvedValue({ affected: 1 }); + + await service.markAsUsed('device-1'); + + expect(mockRepository.update).toHaveBeenCalledWith( + 'device-1', + expect.objectContaining({ last_used_at: expect.any(Date) }), + ); + }); + }); + + describe('markAsInactive', () => { + it('should set is_active to false', async () => { + mockRepository.update.mockResolvedValue({ affected: 1 }); + + await service.markAsInactive('device-1'); + + expect(mockRepository.update).toHaveBeenCalledWith( + 'device-1', + { is_active: false }, + ); + }); + }); +}); diff --git a/src/modules/notifications/__tests__/notification-queue.service.spec.ts b/src/modules/notifications/__tests__/notification-queue.service.spec.ts new file mode 100644 index 0000000..226856a --- /dev/null +++ b/src/modules/notifications/__tests__/notification-queue.service.spec.ts @@ -0,0 +1,609 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + NotificationQueueService, + QueueItem, + QueueStats, +} from '../services/notification-queue.service'; +import { + NotificationQueue, + NotificationLog, + Notification, + QueueStatus, + NotificationChannel, +} from '../entities'; + +describe('NotificationQueueService', () => { + let service: NotificationQueueService; + let queueRepository: jest.Mocked>; + let logRepository: jest.Mocked>; + let notificationRepository: jest.Mocked>; + + const mockNotificationId = '550e8400-e29b-41d4-a716-446655440000'; + const mockQueueId = '550e8400-e29b-41d4-a716-446655440001'; + const mockTenantId = '550e8400-e29b-41d4-a716-446655440002'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockNotification: Partial = { + id: mockNotificationId, + tenant_id: mockTenantId, + user_id: mockUserId, + type: 'info', + channel: 'email', + title: 'Test Notification', + message: 'This is a test notification', + is_read: false, + delivery_status: 'pending', + created_at: new Date(), + }; + + const mockQueueItem: Partial = { + id: mockQueueId, + notification_id: mockNotificationId, + channel: 'email', + scheduled_for: new Date(), + priority_value: 0, + attempts: 0, + max_attempts: 3, + status: 'queued', + error_count: 0, + metadata: {}, + created_at: new Date(), + }; + + const createMockQueryBuilder = (overrides = {}) => ({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + addGroupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + delete: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + ...overrides, + }); + + const mockQueueRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockLogRepo = { + save: jest.fn(), + }; + + const mockNotificationRepo = { + update: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockQueueRepo.createQueryBuilder.mockReturnValue(createMockQueryBuilder()); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationQueueService, + { + provide: getRepositoryToken(NotificationQueue), + useValue: mockQueueRepo, + }, + { + provide: getRepositoryToken(NotificationLog), + useValue: mockLogRepo, + }, + { + provide: getRepositoryToken(Notification), + useValue: mockNotificationRepo, + }, + ], + }).compile(); + + service = module.get(NotificationQueueService); + queueRepository = module.get(getRepositoryToken(NotificationQueue)); + logRepository = module.get(getRepositoryToken(NotificationLog)); + notificationRepository = module.get(getRepositoryToken(Notification)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('enqueue', () => { + it('should enqueue notification with default priority', async () => { + mockQueueRepo.create.mockReturnValue(mockQueueItem); + mockQueueRepo.save.mockResolvedValue(mockQueueItem); + + const result = await service.enqueue(mockNotificationId, 'email'); + + expect(mockQueueRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + notification_id: mockNotificationId, + channel: 'email', + priority_value: 0, + status: 'queued', + attempts: 0, + max_attempts: 3, + }), + ); + expect(mockQueueRepo.save).toHaveBeenCalled(); + expect(result).toEqual(mockQueueItem); + }); + + it('should enqueue notification with urgent priority', async () => { + const urgentQueueItem = { ...mockQueueItem, priority_value: 10 }; + mockQueueRepo.create.mockReturnValue(urgentQueueItem); + mockQueueRepo.save.mockResolvedValue(urgentQueueItem); + + const result = await service.enqueue( + mockNotificationId, + 'email', + 'urgent', + ); + + expect(mockQueueRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + priority_value: 10, + }), + ); + expect(result.priority_value).toBe(10); + }); + + it('should enqueue notification with high priority', async () => { + const highPriorityItem = { ...mockQueueItem, priority_value: 5 }; + mockQueueRepo.create.mockReturnValue(highPriorityItem); + mockQueueRepo.save.mockResolvedValue(highPriorityItem); + + const result = await service.enqueue(mockNotificationId, 'push', 'high'); + + expect(mockQueueRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + priority_value: 5, + }), + ); + expect(result.priority_value).toBe(5); + }); + + it('should enqueue notification with low priority', async () => { + const lowPriorityItem = { ...mockQueueItem, priority_value: -5 }; + mockQueueRepo.create.mockReturnValue(lowPriorityItem); + mockQueueRepo.save.mockResolvedValue(lowPriorityItem); + + const result = await service.enqueue(mockNotificationId, 'sms', 'low'); + + expect(mockQueueRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + priority_value: -5, + }), + ); + expect(result.priority_value).toBe(-5); + }); + + it('should enqueue notification with scheduled time', async () => { + const futureDate = new Date(Date.now() + 3600000); + const scheduledItem = { ...mockQueueItem, scheduled_for: futureDate }; + mockQueueRepo.create.mockReturnValue(scheduledItem); + mockQueueRepo.save.mockResolvedValue(scheduledItem); + + const result = await service.enqueue( + mockNotificationId, + 'email', + 'normal', + futureDate, + ); + + expect(mockQueueRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + scheduled_for: futureDate, + }), + ); + expect(result.scheduled_for).toEqual(futureDate); + }); + }); + + describe('enqueueBatch', () => { + it('should enqueue notification for multiple channels', async () => { + const channels: NotificationChannel[] = ['email', 'push', 'in_app']; + const queueItems = channels.map((channel) => ({ + ...mockQueueItem, + channel, + })); + + mockQueueRepo.create.mockImplementation((data) => data); + mockQueueRepo.save.mockResolvedValue(queueItems); + + const result = await service.enqueueBatch(mockNotificationId, channels); + + expect(mockQueueRepo.create).toHaveBeenCalledTimes(3); + expect(mockQueueRepo.save).toHaveBeenCalled(); + expect(result).toHaveLength(3); + }); + + it('should enqueue batch with specified priority', async () => { + const channels: NotificationChannel[] = ['email', 'sms']; + mockQueueRepo.create.mockImplementation((data) => data); + mockQueueRepo.save.mockResolvedValue([]); + + await service.enqueueBatch(mockNotificationId, channels, 'urgent'); + + expect(mockQueueRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + priority_value: 10, + }), + ); + }); + }); + + describe('getPendingItems', () => { + it('should return pending queue items', async () => { + const pendingItems = [ + { ...mockQueueItem, notification: mockNotification }, + ]; + const mockQueryBuilder = createMockQueryBuilder({ + getMany: jest.fn().mockResolvedValue(pendingItems), + }); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getPendingItems(); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'q.status IN (:...statuses)', + { statuses: ['queued', 'retrying'] }, + ); + expect(result).toEqual(pendingItems); + }); + + it('should filter by channel when specified', async () => { + const mockQueryBuilder = createMockQueryBuilder(); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + await service.getPendingItems(100, 'email'); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'q.channel = :channel', + { channel: 'email' }, + ); + }); + + it('should limit results', async () => { + const mockQueryBuilder = createMockQueryBuilder(); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + await service.getPendingItems(50); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(50); + }); + }); + + describe('markAsProcessing', () => { + it('should update queue item status to processing', async () => { + mockQueueRepo.update.mockResolvedValue({ affected: 1 }); + + await service.markAsProcessing(mockQueueId); + + expect(mockQueueRepo.update).toHaveBeenCalledWith(mockQueueId, { + status: 'processing', + last_attempt_at: expect.any(Date), + }); + }); + }); + + describe('markAsSent', () => { + it('should mark queue item as sent and update notification', async () => { + mockQueueRepo.findOne.mockResolvedValue(mockQueueItem); + mockQueueRepo.update.mockResolvedValue({ affected: 1 }); + mockNotificationRepo.update.mockResolvedValue({ affected: 1 }); + mockLogRepo.save.mockResolvedValue({}); + + await service.markAsSent( + mockQueueId, + 'sendgrid', + 'msg-123', + { status: 'ok' }, + ); + + expect(mockQueueRepo.update).toHaveBeenCalledWith(mockQueueId, { + status: 'sent', + completed_at: expect.any(Date), + attempts: 1, + }); + expect(mockNotificationRepo.update).toHaveBeenCalledWith( + mockNotificationId, + { + delivery_status: 'sent', + sent_at: expect.any(Date), + }, + ); + expect(mockLogRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + notification_id: mockNotificationId, + queue_id: mockQueueId, + channel: 'email', + status: 'sent', + provider: 'sendgrid', + provider_message_id: 'msg-123', + }), + ); + }); + + it('should do nothing if queue item not found', async () => { + mockQueueRepo.findOne.mockResolvedValue(null); + + await service.markAsSent(mockQueueId); + + expect(mockQueueRepo.update).not.toHaveBeenCalled(); + expect(mockNotificationRepo.update).not.toHaveBeenCalled(); + expect(mockLogRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('markAsFailed', () => { + it('should retry on failure when attempts < max_attempts', async () => { + const queueItemWithAttempts = { ...mockQueueItem, attempts: 0, max_attempts: 3 }; + mockQueueRepo.findOne.mockResolvedValue(queueItemWithAttempts); + mockQueueRepo.update.mockResolvedValue({ affected: 1 }); + mockLogRepo.save.mockResolvedValue({}); + + await service.markAsFailed(mockQueueId, 'Connection timeout', 'sendgrid'); + + expect(mockQueueRepo.update).toHaveBeenCalledWith(mockQueueId, { + status: 'retrying', + attempts: 1, + error_message: 'Connection timeout', + error_count: 1, + next_retry_at: expect.any(Date), + }); + }); + + it('should mark as failed permanently after max retries', async () => { + const queueItemMaxRetries = { ...mockQueueItem, attempts: 2, max_attempts: 3, error_count: 2 }; + mockQueueRepo.findOne.mockResolvedValue(queueItemMaxRetries); + mockQueueRepo.update.mockResolvedValue({ affected: 1 }); + mockNotificationRepo.update.mockResolvedValue({ affected: 1 }); + mockLogRepo.save.mockResolvedValue({}); + + await service.markAsFailed(mockQueueId, 'Final failure', 'sendgrid'); + + expect(mockQueueRepo.update).toHaveBeenCalledWith(mockQueueId, { + status: 'failed', + attempts: 3, + error_message: 'Final failure', + error_count: 3, + completed_at: expect.any(Date), + }); + expect(mockNotificationRepo.update).toHaveBeenCalledWith( + mockNotificationId, + { delivery_status: 'failed' }, + ); + }); + + it('should create log entry on failure', async () => { + mockQueueRepo.findOne.mockResolvedValue(mockQueueItem); + mockQueueRepo.update.mockResolvedValue({ affected: 1 }); + mockLogRepo.save.mockResolvedValue({}); + + await service.markAsFailed(mockQueueId, 'Test error', 'twilio'); + + expect(mockLogRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + notification_id: mockNotificationId, + queue_id: mockQueueId, + channel: 'email', + status: 'failed', + provider: 'twilio', + error_message: 'Test error', + }), + ); + }); + + it('should do nothing if queue item not found', async () => { + mockQueueRepo.findOne.mockResolvedValue(null); + + await service.markAsFailed(mockQueueId, 'Error'); + + expect(mockQueueRepo.update).not.toHaveBeenCalled(); + }); + + it('should calculate exponential backoff for retry', async () => { + const queueItemWithOneAttempt = { ...mockQueueItem, attempts: 1, max_attempts: 3, error_count: 1 }; + mockQueueRepo.findOne.mockResolvedValue(queueItemWithOneAttempt); + mockQueueRepo.update.mockResolvedValue({ affected: 1 }); + mockLogRepo.save.mockResolvedValue({}); + + const beforeCall = Date.now(); + await service.markAsFailed(mockQueueId, 'Retry error'); + const afterCall = Date.now(); + + const updateCall = mockQueueRepo.update.mock.calls[0][1]; + const nextRetryAt = new Date(updateCall.next_retry_at).getTime(); + + // With 1 attempt, delay should be 2^1 * 60000 = 120000ms + const expectedMinDelay = beforeCall + 120000; + const expectedMaxDelay = afterCall + 120000; + + expect(nextRetryAt).toBeGreaterThanOrEqual(expectedMinDelay - 1000); + expect(nextRetryAt).toBeLessThanOrEqual(expectedMaxDelay + 1000); + }); + }); + + describe('getStats', () => { + it('should return queue statistics', async () => { + const mockStats = [ + { status: 'queued', count: '10' }, + { status: 'processing', count: '5' }, + { status: 'sent', count: '100' }, + { status: 'failed', count: '3' }, + { status: 'retrying', count: '2' }, + ]; + const mockQueryBuilder = createMockQueryBuilder({ + getRawMany: jest.fn().mockResolvedValue(mockStats), + }); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getStats(); + + expect(result).toEqual({ + queued: 10, + processing: 5, + sent: 100, + failed: 3, + retrying: 2, + }); + }); + + it('should return zero for missing statuses', async () => { + const mockStats = [ + { status: 'queued', count: '5' }, + ]; + const mockQueryBuilder = createMockQueryBuilder({ + getRawMany: jest.fn().mockResolvedValue(mockStats), + }); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getStats(); + + expect(result).toEqual({ + queued: 5, + processing: 0, + sent: 0, + failed: 0, + retrying: 0, + }); + }); + + it('should return all zeros for empty queue', async () => { + const mockQueryBuilder = createMockQueryBuilder({ + getRawMany: jest.fn().mockResolvedValue([]), + }); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getStats(); + + expect(result).toEqual({ + queued: 0, + processing: 0, + sent: 0, + failed: 0, + retrying: 0, + }); + }); + }); + + describe('getStatsByChannel', () => { + it('should return stats grouped by channel and status', async () => { + const mockStats = [ + { channel: 'email', status: 'sent', count: 50 }, + { channel: 'push', status: 'sent', count: 30 }, + { channel: 'email', status: 'failed', count: 2 }, + ]; + const mockQueryBuilder = createMockQueryBuilder({ + getRawMany: jest.fn().mockResolvedValue(mockStats), + }); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getStatsByChannel(); + + expect(result).toEqual(mockStats); + expect(mockQueryBuilder.groupBy).toHaveBeenCalledWith('q.channel'); + expect(mockQueryBuilder.addGroupBy).toHaveBeenCalledWith('q.status'); + }); + }); + + describe('cleanupOldItems', () => { + it('should delete old completed queue items', async () => { + const mockQueryBuilder = createMockQueryBuilder({ + execute: jest.fn().mockResolvedValue({ affected: 50 }), + }); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.cleanupOldItems(30); + + expect(result).toBe(50); + expect(mockQueryBuilder.delete).toHaveBeenCalled(); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'status IN (:...statuses)', + { statuses: ['sent', 'failed'] }, + ); + }); + + it('should use default 30 days if not specified', async () => { + const mockQueryBuilder = createMockQueryBuilder(); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + await service.cleanupOldItems(); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'completed_at < :cutoff', + expect.objectContaining({ cutoff: expect.any(Date) }), + ); + }); + + it('should return 0 if no items to delete', async () => { + const mockQueryBuilder = createMockQueryBuilder({ + execute: jest.fn().mockResolvedValue({ affected: 0 }), + }); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.cleanupOldItems(7); + + expect(result).toBe(0); + }); + + it('should handle undefined affected count', async () => { + const mockQueryBuilder = createMockQueryBuilder({ + execute: jest.fn().mockResolvedValue({}), + }); + mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.cleanupOldItems(); + + expect(result).toBe(0); + }); + }); + + describe('cancelPending', () => { + it('should cancel pending items for notification', async () => { + mockQueueRepo.update.mockResolvedValue({ affected: 3 }); + + const result = await service.cancelPending(mockNotificationId); + + expect(result).toBe(3); + expect(mockQueueRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + notification_id: mockNotificationId, + }), + expect.objectContaining({ + status: 'failed', + error_message: 'Cancelled', + completed_at: expect.any(Date), + }), + ); + }); + + it('should return 0 if no pending items', async () => { + mockQueueRepo.update.mockResolvedValue({ affected: 0 }); + + const result = await service.cancelPending(mockNotificationId); + + expect(result).toBe(0); + }); + + it('should handle undefined affected count', async () => { + mockQueueRepo.update.mockResolvedValue({}); + + const result = await service.cancelPending(mockNotificationId); + + expect(result).toBe(0); + }); + }); +}); diff --git a/src/modules/notifications/__tests__/notifications.controller.spec.ts b/src/modules/notifications/__tests__/notifications.controller.spec.ts new file mode 100644 index 0000000..892f78e --- /dev/null +++ b/src/modules/notifications/__tests__/notifications.controller.spec.ts @@ -0,0 +1,224 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { NotificationsController } from '../notifications.controller'; +import { NotificationsService } from '../services/notifications.service'; +import { RbacService } from '../../rbac/services/rbac.service'; + +describe('NotificationsController', () => { + let controller: NotificationsController; + let service: jest.Mocked; + + const mockRequestUser = { + id: 'user-123', + sub: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + role: 'admin', + }; + + const mockNotification = { + id: 'notif-123', + user_id: 'user-123', + tenant_id: 'tenant-123', + type: 'info', + title: 'Test Notification', + message: 'This is a test', + is_read: false, + created_at: new Date('2026-01-01'), + }; + + const mockPreferences = { + id: 'pref-123', + user_id: 'user-123', + tenant_id: 'tenant-123', + email_enabled: true, + push_enabled: true, + in_app_enabled: true, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NotificationsController], + providers: [ + { + provide: NotificationsService, + useValue: { + findAllForUser: jest.fn(), + getUnreadCount: jest.fn(), + markAsRead: jest.fn(), + markAllAsRead: jest.fn(), + delete: jest.fn(), + getPreferences: jest.fn(), + updatePreferences: jest.fn(), + create: jest.fn(), + sendFromTemplate: jest.fn(), + findAllTemplates: jest.fn(), + findTemplateByCode: jest.fn(), + }, + }, + { + provide: RbacService, + useValue: { + userHasPermission: jest.fn().mockResolvedValue(true), + userHasAnyPermission: jest.fn().mockResolvedValue(true), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(NotificationsController); + service = module.get(NotificationsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getMyNotifications', () => { + it('should return paginated notifications', async () => { + const result = { + data: [mockNotification], + total: 1, + page: 1, + limit: 20, + }; + service.findAllForUser.mockResolvedValue(result as any); + + const response = await controller.getMyNotifications(mockRequestUser, 1, 20, false); + + expect(response).toEqual(result); + expect(service.findAllForUser).toHaveBeenCalledWith( + 'user-123', + 'tenant-123', + { page: 1, limit: 20, unreadOnly: false }, + ); + }); + + it('should use default values when not provided', async () => { + service.findAllForUser.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20 } as any); + + await controller.getMyNotifications(mockRequestUser); + + expect(service.findAllForUser).toHaveBeenCalledWith( + 'user-123', + 'tenant-123', + { page: 1, limit: 20, unreadOnly: false }, + ); + }); + }); + + describe('getUnreadCount', () => { + it('should return unread count', async () => { + service.getUnreadCount.mockResolvedValue(5); + + const result = await controller.getUnreadCount(mockRequestUser); + + expect(result).toEqual({ count: 5 }); + expect(service.getUnreadCount).toHaveBeenCalledWith('user-123', 'tenant-123'); + }); + }); + + describe('markAsRead', () => { + it('should mark notification as read', async () => { + service.markAsRead.mockResolvedValue({ ...mockNotification, is_read: true } as any); + + const result = await controller.markAsRead('notif-123', mockRequestUser); + + expect(result.is_read).toBe(true); + expect(service.markAsRead).toHaveBeenCalledWith('notif-123', 'user-123', 'tenant-123'); + }); + }); + + describe('markAllAsRead', () => { + it('should mark all notifications as read', async () => { + service.markAllAsRead.mockResolvedValue(10); + + const result = await controller.markAllAsRead(mockRequestUser); + + expect(result.message).toContain('10'); + expect(service.markAllAsRead).toHaveBeenCalledWith('user-123', 'tenant-123'); + }); + }); + + describe('delete', () => { + it('should delete notification', async () => { + service.delete.mockResolvedValue(undefined); + + const result = await controller.delete('notif-123', mockRequestUser); + + expect(result.message).toBe('Notificación eliminada'); + expect(service.delete).toHaveBeenCalledWith('notif-123', 'user-123', 'tenant-123'); + }); + }); + + describe('getPreferences', () => { + it('should return user preferences', async () => { + service.getPreferences.mockResolvedValue(mockPreferences as any); + + const result = await controller.getPreferences(mockRequestUser); + + expect(result).toEqual(mockPreferences); + expect(service.getPreferences).toHaveBeenCalledWith('user-123', 'tenant-123'); + }); + }); + + describe('updatePreferences', () => { + it('should update preferences', async () => { + const updateDto = { emailEnabled: false }; + const updated = { ...mockPreferences, email_enabled: false }; + service.updatePreferences.mockResolvedValue(updated as any); + + const result = await controller.updatePreferences(mockRequestUser, updateDto); + + expect(result.email_enabled).toBe(false); + expect(service.updatePreferences).toHaveBeenCalledWith( + 'user-123', + 'tenant-123', + updateDto, + ); + }); + }); + + describe('sendNotification', () => { + it('should send notification', async () => { + const createDto = { + user_id: 'user-456', + type: 'info', + title: 'New Notification', + message: 'Hello', + }; + service.create.mockResolvedValue(mockNotification as any); + + const result = await controller.sendNotification(createDto as any, mockRequestUser); + + expect(result).toEqual(mockNotification); + expect(service.create).toHaveBeenCalledWith(createDto, 'tenant-123'); + }); + }); + + describe('getTemplates', () => { + it('should return all templates', async () => { + const templates = [{ id: 'tpl-1', code: 'welcome' }]; + service.findAllTemplates.mockResolvedValue(templates as any); + + const result = await controller.getTemplates(); + + expect(result).toEqual(templates); + expect(service.findAllTemplates).toHaveBeenCalled(); + }); + }); + + describe('getTemplate', () => { + it('should return template by code', async () => { + const template = { id: 'tpl-1', code: 'welcome' }; + service.findTemplateByCode.mockResolvedValue(template as any); + + const result = await controller.getTemplate('welcome'); + + expect(result).toEqual(template); + expect(service.findTemplateByCode).toHaveBeenCalledWith('welcome'); + }); + }); +}); diff --git a/src/modules/notifications/__tests__/notifications.gateway.spec.ts b/src/modules/notifications/__tests__/notifications.gateway.spec.ts new file mode 100644 index 0000000..0eeb375 --- /dev/null +++ b/src/modules/notifications/__tests__/notifications.gateway.spec.ts @@ -0,0 +1,523 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationsGateway } from '../gateways/notifications.gateway'; +import { Notification } from '../entities'; + +describe('NotificationsGateway', () => { + let gateway: NotificationsGateway; + let mockServer: any; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockSocketId = 'socket-123'; + + interface MockAuthenticatedSocket { + id: string; + userId?: string; + tenantId?: string; + disconnect: jest.Mock; + join: jest.Mock; + emit: jest.Mock; + handshake: { + auth: { userId?: string; tenantId?: string }; + query: { userId?: string; tenantId?: string }; + }; + } + + const createMockSocket = (overrides: Partial = {}): MockAuthenticatedSocket => ({ + id: mockSocketId, + disconnect: jest.fn(), + join: jest.fn(), + emit: jest.fn(), + handshake: { + auth: { userId: mockUserId, tenantId: mockTenantId }, + query: {}, + }, + ...overrides, + }); + + const mockNotification: Partial = { + id: 'notif-001', + tenant_id: mockTenantId, + user_id: mockUserId, + type: 'info', + channel: 'in_app', + title: 'Test Notification', + message: 'This is a test notification', + is_read: false, + delivery_status: 'delivered', + created_at: new Date(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [NotificationsGateway], + }).compile(); + + gateway = module.get(NotificationsGateway); + + // Setup mock server + mockServer = { + to: jest.fn().mockReturnThis(), + emit: jest.fn(), + }; + + gateway.server = mockServer; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('handleConnection', () => { + it('should accept connection with valid auth in handshake.auth', () => { + const mockSocket = createMockSocket(); + + gateway.handleConnection(mockSocket as any); + + expect(mockSocket.userId).toBe(mockUserId); + expect(mockSocket.tenantId).toBe(mockTenantId); + expect(mockSocket.join).toHaveBeenCalledWith(`tenant:${mockTenantId}`); + expect(mockSocket.join).toHaveBeenCalledWith(`user:${mockUserId}`); + expect(mockSocket.emit).toHaveBeenCalledWith('connected', { + socketId: mockSocketId, + userId: mockUserId, + tenantId: mockTenantId, + }); + }); + + it('should accept connection with valid auth in handshake.query', () => { + const mockSocket = createMockSocket({ + handshake: { + auth: {}, + query: { userId: mockUserId, tenantId: mockTenantId }, + }, + }); + + gateway.handleConnection(mockSocket as any); + + expect(mockSocket.userId).toBe(mockUserId); + expect(mockSocket.tenantId).toBe(mockTenantId); + expect(mockSocket.disconnect).not.toHaveBeenCalled(); + }); + + it('should disconnect client without userId', () => { + const mockSocket = createMockSocket({ + handshake: { + auth: { tenantId: mockTenantId }, + query: {}, + }, + }); + + gateway.handleConnection(mockSocket as any); + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + + it('should disconnect client without tenantId', () => { + const mockSocket = createMockSocket({ + handshake: { + auth: { userId: mockUserId }, + query: {}, + }, + }); + + gateway.handleConnection(mockSocket as any); + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + + it('should disconnect client with no auth', () => { + const mockSocket = createMockSocket({ + handshake: { + auth: {}, + query: {}, + }, + }); + + gateway.handleConnection(mockSocket as any); + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + + it('should track multiple sockets for same user', () => { + const socket1 = createMockSocket({ id: 'socket-1' }); + const socket2 = createMockSocket({ id: 'socket-2' }); + + gateway.handleConnection(socket1 as any); + gateway.handleConnection(socket2 as any); + + expect(gateway.getTotalConnections()).toBe(2); + expect(gateway.getConnectedUsersCount()).toBe(1); + }); + + it('should handle connection error gracefully', () => { + const mockSocket = createMockSocket(); + mockSocket.join = jest.fn().mockImplementation(() => { + throw new Error('Join failed'); + }); + + gateway.handleConnection(mockSocket as any); + + expect(mockSocket.disconnect).toHaveBeenCalled(); + }); + }); + + describe('handleDisconnect', () => { + it('should remove socket from tracking maps', () => { + const mockSocket = createMockSocket(); + + gateway.handleConnection(mockSocket as any); + expect(gateway.getTotalConnections()).toBe(1); + + gateway.handleDisconnect(mockSocket as any); + expect(gateway.getTotalConnections()).toBe(0); + expect(gateway.getConnectedUsersCount()).toBe(0); + }); + + it('should keep user in map if other sockets remain', () => { + const socket1 = createMockSocket({ id: 'socket-1' }); + const socket2 = createMockSocket({ id: 'socket-2' }); + + gateway.handleConnection(socket1 as any); + gateway.handleConnection(socket2 as any); + expect(gateway.getTotalConnections()).toBe(2); + + gateway.handleDisconnect(socket1 as any); + expect(gateway.getTotalConnections()).toBe(1); + expect(gateway.getConnectedUsersCount()).toBe(1); + }); + + it('should handle disconnect of unknown socket gracefully', () => { + const mockSocket = createMockSocket(); + + // Disconnect without connecting first + expect(() => gateway.handleDisconnect(mockSocket as any)).not.toThrow(); + }); + }); + + describe('emitToUser', () => { + it('should emit notification to all user sockets', async () => { + const socket1 = createMockSocket({ id: 'socket-1' }); + const socket2 = createMockSocket({ id: 'socket-2' }); + + gateway.handleConnection(socket1 as any); + gateway.handleConnection(socket2 as any); + + const result = await gateway.emitToUser( + mockTenantId, + mockUserId, + mockNotification, + ); + + expect(result).toBe(2); + expect(mockServer.to).toHaveBeenCalledWith('socket-1'); + expect(mockServer.to).toHaveBeenCalledWith('socket-2'); + expect(mockServer.emit).toHaveBeenCalledWith( + 'notification:created', + mockNotification, + ); + }); + + it('should return 0 if user has no active sockets', async () => { + const result = await gateway.emitToUser( + mockTenantId, + mockUserId, + mockNotification, + ); + + expect(result).toBe(0); + expect(mockServer.to).not.toHaveBeenCalled(); + }); + + it('should return 0 if user key does not exist', async () => { + const result = await gateway.emitToUser( + 'different-tenant', + 'different-user', + mockNotification, + ); + + expect(result).toBe(0); + }); + }); + + describe('emitToTenant', () => { + it('should broadcast event to tenant room', async () => { + const mockSocket = createMockSocket(); + gateway.handleConnection(mockSocket as any); + + await gateway.emitToTenant(mockTenantId, 'announcement', { + message: 'Hello everyone!', + }); + + expect(mockServer.to).toHaveBeenCalledWith(`tenant:${mockTenantId}`); + expect(mockServer.emit).toHaveBeenCalledWith('announcement', { + message: 'Hello everyone!', + }); + }); + + it('should emit custom events to tenant', async () => { + await gateway.emitToTenant(mockTenantId, 'system:maintenance', { + startTime: '2024-01-01T00:00:00Z', + }); + + expect(mockServer.to).toHaveBeenCalledWith(`tenant:${mockTenantId}`); + expect(mockServer.emit).toHaveBeenCalledWith('system:maintenance', { + startTime: '2024-01-01T00:00:00Z', + }); + }); + }); + + describe('handleMarkAsRead', () => { + it('should broadcast read event to other user sockets', () => { + const socket1 = createMockSocket({ id: 'socket-1' }); + const socket2 = createMockSocket({ id: 'socket-2' }); + + gateway.handleConnection(socket1 as any); + gateway.handleConnection(socket2 as any); + + const payload = { notificationId: 'notif-001' }; + socket1.userId = mockUserId; + socket1.tenantId = mockTenantId; + + gateway.handleMarkAsRead(socket1 as any, payload); + + // Should emit to socket-2 but not socket-1 (the sender) + expect(mockServer.to).toHaveBeenCalledWith('socket-2'); + expect(mockServer.to).not.toHaveBeenCalledWith('socket-1'); + expect(mockServer.emit).toHaveBeenCalledWith('notification:read', payload); + }); + + it('should do nothing if client is not authenticated', () => { + const mockSocket = createMockSocket(); + delete mockSocket.userId; + delete mockSocket.tenantId; + + gateway.handleMarkAsRead(mockSocket as any, { notificationId: 'notif-001' }); + + expect(mockServer.to).not.toHaveBeenCalled(); + }); + + it('should handle single socket user', () => { + const mockSocket = createMockSocket(); + gateway.handleConnection(mockSocket as any); + mockSocket.userId = mockUserId; + mockSocket.tenantId = mockTenantId; + + gateway.handleMarkAsRead(mockSocket as any, { notificationId: 'notif-001' }); + + // No other sockets to broadcast to + expect(mockServer.to).not.toHaveBeenCalled(); + }); + }); + + describe('handleMarkAllAsRead', () => { + it('should broadcast read-all event to other user sockets', () => { + const socket1 = createMockSocket({ id: 'socket-1' }); + const socket2 = createMockSocket({ id: 'socket-2' }); + + gateway.handleConnection(socket1 as any); + gateway.handleConnection(socket2 as any); + + socket1.userId = mockUserId; + socket1.tenantId = mockTenantId; + + gateway.handleMarkAllAsRead(socket1 as any); + + expect(mockServer.to).toHaveBeenCalledWith('socket-2'); + expect(mockServer.emit).toHaveBeenCalledWith('notification:read-all', {}); + }); + + it('should do nothing if client is not authenticated', () => { + const mockSocket = createMockSocket(); + delete mockSocket.userId; + delete mockSocket.tenantId; + + gateway.handleMarkAllAsRead(mockSocket as any); + + expect(mockServer.to).not.toHaveBeenCalled(); + }); + }); + + describe('handleGetUnreadCount', () => { + it('should return acknowledgement event', () => { + const mockSocket = createMockSocket(); + mockSocket.userId = mockUserId; + mockSocket.tenantId = mockTenantId; + + const result = gateway.handleGetUnreadCount(mockSocket as any); + + expect(result).toEqual({ event: 'notification:unread-count-requested' }); + }); + }); + + describe('emitUnreadCount', () => { + it('should emit unread count to all user sockets', async () => { + const socket1 = createMockSocket({ id: 'socket-1' }); + const socket2 = createMockSocket({ id: 'socket-2' }); + + gateway.handleConnection(socket1 as any); + gateway.handleConnection(socket2 as any); + + await gateway.emitUnreadCount(mockTenantId, mockUserId, 5); + + expect(mockServer.to).toHaveBeenCalledWith('socket-1'); + expect(mockServer.to).toHaveBeenCalledWith('socket-2'); + expect(mockServer.emit).toHaveBeenCalledWith('notification:unread-count', { + count: 5, + }); + }); + + it('should do nothing if user has no sockets', async () => { + await gateway.emitUnreadCount(mockTenantId, 'unknown-user', 5); + + expect(mockServer.to).not.toHaveBeenCalled(); + }); + }); + + describe('emitNotificationDeleted', () => { + it('should emit deleted event to all user sockets', async () => { + const mockSocket = createMockSocket(); + gateway.handleConnection(mockSocket as any); + + await gateway.emitNotificationDeleted( + mockTenantId, + mockUserId, + 'notif-001', + ); + + expect(mockServer.to).toHaveBeenCalledWith(mockSocketId); + expect(mockServer.emit).toHaveBeenCalledWith('notification:deleted', { + notificationId: 'notif-001', + }); + }); + + it('should do nothing if user has no sockets', async () => { + await gateway.emitNotificationDeleted( + mockTenantId, + 'unknown-user', + 'notif-001', + ); + + expect(mockServer.to).not.toHaveBeenCalled(); + }); + }); + + describe('getConnectedUsersCount', () => { + it('should return count of unique connected users', () => { + const socket1 = createMockSocket({ id: 'socket-1' }); + const socket2 = createMockSocket({ + id: 'socket-2', + handshake: { + auth: { userId: 'user-2', tenantId: mockTenantId }, + query: {}, + }, + }); + + gateway.handleConnection(socket1 as any); + gateway.handleConnection(socket2 as any); + + expect(gateway.getConnectedUsersCount()).toBe(2); + }); + + it('should return 0 when no users connected', () => { + expect(gateway.getConnectedUsersCount()).toBe(0); + }); + }); + + describe('getTotalConnections', () => { + it('should return total socket connections', () => { + const socket1 = createMockSocket({ id: 'socket-1' }); + const socket2 = createMockSocket({ id: 'socket-2' }); + const socket3 = createMockSocket({ id: 'socket-3' }); + + gateway.handleConnection(socket1 as any); + gateway.handleConnection(socket2 as any); + gateway.handleConnection(socket3 as any); + + expect(gateway.getTotalConnections()).toBe(3); + }); + + it('should return 0 when no connections', () => { + expect(gateway.getTotalConnections()).toBe(0); + }); + }); + + describe('isUserOnline', () => { + it('should return true if user has active sockets', () => { + const mockSocket = createMockSocket(); + gateway.handleConnection(mockSocket as any); + + expect(gateway.isUserOnline(mockTenantId, mockUserId)).toBe(true); + }); + + it('should return false if user has no sockets', () => { + expect(gateway.isUserOnline(mockTenantId, mockUserId)).toBe(false); + }); + + it('should return false after user disconnects', () => { + const mockSocket = createMockSocket(); + gateway.handleConnection(mockSocket as any); + gateway.handleDisconnect(mockSocket as any); + + expect(gateway.isUserOnline(mockTenantId, mockUserId)).toBe(false); + }); + + it('should check correct tenant and user combination', () => { + const mockSocket = createMockSocket(); + gateway.handleConnection(mockSocket as any); + + expect(gateway.isUserOnline(mockTenantId, mockUserId)).toBe(true); + expect(gateway.isUserOnline('other-tenant', mockUserId)).toBe(false); + expect(gateway.isUserOnline(mockTenantId, 'other-user')).toBe(false); + }); + }); + + describe('multi-tenant isolation', () => { + it('should isolate users by tenant', async () => { + const tenant1User = createMockSocket({ + id: 'socket-t1', + handshake: { + auth: { userId: 'user-1', tenantId: 'tenant-1' }, + query: {}, + }, + }); + const tenant2User = createMockSocket({ + id: 'socket-t2', + handshake: { + auth: { userId: 'user-1', tenantId: 'tenant-2' }, + query: {}, + }, + }); + + gateway.handleConnection(tenant1User as any); + gateway.handleConnection(tenant2User as any); + + // Emit to tenant-1 user + const result1 = await gateway.emitToUser( + 'tenant-1', + 'user-1', + mockNotification, + ); + + expect(result1).toBe(1); + expect(mockServer.to).toHaveBeenCalledWith('socket-t1'); + expect(mockServer.to).not.toHaveBeenCalledWith('socket-t2'); + }); + + it('should broadcast to correct tenant only', async () => { + const tenant1User = createMockSocket({ + id: 'socket-t1', + handshake: { + auth: { userId: 'user-1', tenantId: 'tenant-1' }, + query: {}, + }, + }); + + gateway.handleConnection(tenant1User as any); + + await gateway.emitToTenant('tenant-1', 'test-event', { data: 'test' }); + + expect(mockServer.to).toHaveBeenCalledWith('tenant:tenant-1'); + }); + }); +}); diff --git a/src/modules/notifications/__tests__/notifications.service.spec.ts b/src/modules/notifications/__tests__/notifications.service.spec.ts new file mode 100644 index 0000000..c006a2a --- /dev/null +++ b/src/modules/notifications/__tests__/notifications.service.spec.ts @@ -0,0 +1,431 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { NotificationsService } from '../services/notifications.service'; +import { + Notification, + NotificationTemplate, + UserNotificationPreference, +} from '../entities'; +import { EmailService } from '@modules/email'; + +describe('NotificationsService', () => { + let service: NotificationsService; + let notificationRepository: jest.Mocked>; + let templateRepository: jest.Mocked>; + let preferenceRepository: jest.Mocked>; + let emailService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440000'; + + const mockNotification: Partial = { + id: 'notif-001', + tenant_id: mockTenantId, + user_id: mockUserId, + type: 'info', + channel: 'in_app', + title: 'Test Notification', + message: 'This is a test notification', + is_read: false, + delivery_status: 'pending', + created_at: new Date(), + }; + + const mockTemplate: Partial = { + id: 'template-001', + code: 'welcome_email', + name: 'Welcome Email', + channel: 'email', + subject: 'Welcome {{userName}}!', + body: 'Hello {{userName}}, welcome to our platform!', + is_active: true, + }; + + const mockPreferences: Partial = { + id: 'pref-001', + user_id: mockUserId, + tenant_id: mockTenantId, + email_enabled: true, + push_enabled: true, + in_app_enabled: true, + sms_enabled: false, + marketing_emails: true, + product_updates: true, + security_alerts: true, + }; + + beforeEach(async () => { + const mockNotificationRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + count: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const mockTemplateRepo = { + find: jest.fn(), + findOne: jest.fn(), + }; + + const mockPreferenceRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockEmailService = { + sendEmail: jest.fn(), + isEnabled: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationsService, + { provide: getRepositoryToken(Notification), useValue: mockNotificationRepo }, + { provide: getRepositoryToken(NotificationTemplate), useValue: mockTemplateRepo }, + { provide: getRepositoryToken(UserNotificationPreference), useValue: mockPreferenceRepo }, + { provide: EmailService, useValue: mockEmailService }, + ], + }).compile(); + + service = module.get(NotificationsService); + notificationRepository = module.get(getRepositoryToken(Notification)); + templateRepository = module.get(getRepositoryToken(NotificationTemplate)); + preferenceRepository = module.get(getRepositoryToken(UserNotificationPreference)); + emailService = module.get(EmailService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + const createDto = { + userId: mockUserId, + title: 'Test Notification', + message: 'This is a test', + type: 'info', + channel: 'in_app', + }; + + it('should create an in_app notification and mark as delivered', async () => { + notificationRepository.create.mockReturnValue(mockNotification as Notification); + notificationRepository.save.mockResolvedValue({ + ...mockNotification, + delivery_status: 'delivered', + sent_at: new Date(), + } as Notification); + + const result = await service.create(createDto as any, mockTenantId); + + expect(result).toHaveProperty('id'); + expect(notificationRepository.create).toHaveBeenCalled(); + expect(notificationRepository.save).toHaveBeenCalled(); + }); + + it('should create an email notification and send via EmailService', async () => { + const emailDto = { + ...createDto, + channel: 'email', + email: 'user@example.com', + userName: 'Test User', + }; + + const emailNotification = { + ...mockNotification, + channel: 'email', + delivery_status: 'pending', + }; + + notificationRepository.create.mockReturnValue(emailNotification as Notification); + notificationRepository.save.mockResolvedValue(emailNotification as Notification); + emailService.sendEmail.mockResolvedValue({ + success: true, + messageId: 'msg-123', + provider: 'sendgrid', + }); + + const result = await service.create(emailDto as any, mockTenantId); + + expect(result).toHaveProperty('id'); + expect(emailService.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: { email: 'user@example.com', name: 'Test User' }, + subject: emailDto.title, + }), + ); + }); + + it('should handle email delivery failure gracefully', async () => { + const emailDto = { + ...createDto, + channel: 'email', + email: 'user@example.com', + }; + + const emailNotification = { + ...mockNotification, + channel: 'email', + delivery_status: 'pending', + }; + + notificationRepository.create.mockReturnValue(emailNotification as Notification); + notificationRepository.save.mockResolvedValue(emailNotification as Notification); + emailService.sendEmail.mockResolvedValue({ + success: false, + provider: 'sendgrid', + error: 'API error', + }); + + const result = await service.create(emailDto as any, mockTenantId); + + expect(result).toHaveProperty('id'); + expect(notificationRepository.save).toHaveBeenLastCalledWith( + expect.objectContaining({ delivery_status: 'failed' }), + ); + }); + }); + + describe('sendFromTemplate', () => { + const templateDto = { + userId: mockUserId, + templateCode: 'welcome_email', + variables: { userName: 'John' }, + }; + + it('should send notification from template', async () => { + templateRepository.findOne.mockResolvedValue(mockTemplate as NotificationTemplate); + notificationRepository.create.mockReturnValue(mockNotification as Notification); + notificationRepository.save.mockResolvedValue(mockNotification as Notification); + + const result = await service.sendFromTemplate(templateDto, mockTenantId); + + expect(result).toHaveProperty('id'); + expect(templateRepository.findOne).toHaveBeenCalledWith({ + where: { code: 'welcome_email', is_active: true }, + }); + }); + + it('should throw NotFoundException for invalid template', async () => { + templateRepository.findOne.mockResolvedValue(null); + + await expect( + service.sendFromTemplate(templateDto, mockTenantId), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('findAllForUser', () => { + it('should return paginated notifications with unread count', async () => { + const notifications = [mockNotification as Notification]; + notificationRepository.findAndCount.mockResolvedValue([notifications, 1]); + notificationRepository.count.mockResolvedValue(1); + + const result = await service.findAllForUser(mockUserId, mockTenantId, { + page: 1, + limit: 20, + }); + + expect(result).toHaveProperty('data'); + expect(result).toHaveProperty('total', 1); + expect(result).toHaveProperty('unread', 1); + expect(notificationRepository.findAndCount).toHaveBeenCalled(); + }); + + it('should filter unread only when specified', async () => { + notificationRepository.findAndCount.mockResolvedValue([[], 0]); + notificationRepository.count.mockResolvedValue(0); + + await service.findAllForUser(mockUserId, mockTenantId, { + unreadOnly: true, + }); + + expect(notificationRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ is_read: false }), + }), + ); + }); + }); + + describe('markAsRead', () => { + it('should mark notification as read', async () => { + notificationRepository.findOne.mockResolvedValue(mockNotification as Notification); + notificationRepository.save.mockResolvedValue({ + ...mockNotification, + is_read: true, + read_at: new Date(), + } as Notification); + + const result = await service.markAsRead( + mockNotification.id!, + mockUserId, + mockTenantId, + ); + + expect(result.is_read).toBe(true); + expect(result.read_at).toBeDefined(); + }); + + it('should throw NotFoundException for invalid notification', async () => { + notificationRepository.findOne.mockResolvedValue(null); + + await expect( + service.markAsRead('invalid-id', mockUserId, mockTenantId), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('markAllAsRead', () => { + it('should mark all notifications as read', async () => { + notificationRepository.update.mockResolvedValue({ affected: 5 } as any); + + const result = await service.markAllAsRead(mockUserId, mockTenantId); + + expect(result).toBe(5); + expect(notificationRepository.update).toHaveBeenCalledWith( + { user_id: mockUserId, tenant_id: mockTenantId, is_read: false }, + expect.objectContaining({ is_read: true }), + ); + }); + }); + + describe('delete', () => { + it('should delete notification successfully', async () => { + notificationRepository.delete.mockResolvedValue({ affected: 1 } as any); + + await service.delete(mockNotification.id!, mockUserId, mockTenantId); + + expect(notificationRepository.delete).toHaveBeenCalledWith({ + id: mockNotification.id, + user_id: mockUserId, + tenant_id: mockTenantId, + }); + }); + + it('should throw NotFoundException for invalid notification', async () => { + notificationRepository.delete.mockResolvedValue({ affected: 0 } as any); + + await expect( + service.delete('invalid-id', mockUserId, mockTenantId), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getUnreadCount', () => { + it('should return unread notification count', async () => { + notificationRepository.count.mockResolvedValue(3); + + const result = await service.getUnreadCount(mockUserId, mockTenantId); + + expect(result).toBe(3); + expect(notificationRepository.count).toHaveBeenCalledWith({ + where: { + user_id: mockUserId, + tenant_id: mockTenantId, + channel: 'in_app', + is_read: false, + }, + }); + }); + }); + + describe('findAllTemplates', () => { + it('should return all active templates', async () => { + templateRepository.find.mockResolvedValue([mockTemplate as NotificationTemplate]); + + const result = await service.findAllTemplates(); + + expect(result).toHaveLength(1); + expect(templateRepository.find).toHaveBeenCalledWith({ + where: { is_active: true }, + order: { category: 'ASC', code: 'ASC' }, + }); + }); + }); + + describe('findTemplateByCode', () => { + it('should return template by code', async () => { + templateRepository.findOne.mockResolvedValue(mockTemplate as NotificationTemplate); + + const result = await service.findTemplateByCode('welcome_email'); + + expect(result).toHaveProperty('code', 'welcome_email'); + }); + + it('should throw NotFoundException for invalid template', async () => { + templateRepository.findOne.mockResolvedValue(null); + + await expect( + service.findTemplateByCode('invalid_code'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getPreferences', () => { + it('should return existing preferences', async () => { + preferenceRepository.findOne.mockResolvedValue( + mockPreferences as UserNotificationPreference, + ); + + const result = await service.getPreferences(mockUserId, mockTenantId); + + expect(result).toHaveProperty('email_enabled', true); + }); + + it('should create default preferences if not exist', async () => { + preferenceRepository.findOne.mockResolvedValue(null); + preferenceRepository.create.mockReturnValue(mockPreferences as UserNotificationPreference); + preferenceRepository.save.mockResolvedValue(mockPreferences as UserNotificationPreference); + + const result = await service.getPreferences(mockUserId, mockTenantId); + + expect(result).toHaveProperty('email_enabled', true); + expect(preferenceRepository.create).toHaveBeenCalled(); + expect(preferenceRepository.save).toHaveBeenCalled(); + }); + }); + + describe('updatePreferences', () => { + const updateDto = { + emailEnabled: false, + pushEnabled: false, + }; + + it('should update preferences', async () => { + preferenceRepository.findOne.mockResolvedValue( + mockPreferences as UserNotificationPreference, + ); + preferenceRepository.save.mockResolvedValue({ + ...mockPreferences, + email_enabled: false, + push_enabled: false, + } as UserNotificationPreference); + + const result = await service.updatePreferences( + mockUserId, + mockTenantId, + updateDto, + ); + + expect(result.email_enabled).toBe(false); + expect(result.push_enabled).toBe(false); + }); + }); + + describe('cleanupOldNotifications', () => { + it('should delete old read notifications', async () => { + notificationRepository.delete.mockResolvedValue({ affected: 10 } as any); + + const result = await service.cleanupOldNotifications(30); + + expect(result).toBe(10); + expect(notificationRepository.delete).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/notifications/__tests__/push-notification.service.spec.ts b/src/modules/notifications/__tests__/push-notification.service.spec.ts new file mode 100644 index 0000000..281cdbd --- /dev/null +++ b/src/modules/notifications/__tests__/push-notification.service.spec.ts @@ -0,0 +1,710 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { PushNotificationService } from '../services/push-notification.service'; +import { UserDevice, NotificationLog } from '../entities'; + +// Import the mock (will be resolved by moduleNameMapper) +import * as webpush from 'web-push'; + +describe('PushNotificationService', () => { + let service: PushNotificationService; + let deviceRepository: Repository; + let logRepository: Repository; + let configService: ConfigService; + + const mockDevice: UserDevice = { + id: 'device-1', + tenant_id: 'tenant-1', + user_id: 'user-1', + device_type: 'web', + device_token: JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: { p256dh: 'test-key', auth: 'test-auth' }, + }), + device_name: 'Chrome on Windows', + browser: 'Chrome', + browser_version: '120', + os: 'Windows', + os_version: '11', + is_active: true, + last_used_at: new Date(), + created_at: new Date(), + }; + + const mockDeviceRepository = { + find: jest.fn(), + save: jest.fn(), + }; + + const mockLogRepository = { + save: jest.fn(), + }; + + const createMockConfigService = () => ({ + get: jest.fn((key: string, defaultValue?: string) => { + const config: Record = { + VAPID_PUBLIC_KEY: 'BN4GvZtEZiZuqFJiLNpT1234567890', + VAPID_PRIVATE_KEY: 'aB3cDefGh4IjKlM5nOpQr6StUvWxYz', + VAPID_SUBJECT: 'mailto:admin@example.com', + }; + return config[key] || defaultValue; + }), + }); + + let mockConfigService: ReturnType; + + beforeEach(async () => { + jest.clearAllMocks(); + mockConfigService = createMockConfigService(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PushNotificationService, + { + provide: getRepositoryToken(UserDevice), + useValue: mockDeviceRepository, + }, + { + provide: getRepositoryToken(NotificationLog), + useValue: mockLogRepository, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + service = module.get(PushNotificationService); + deviceRepository = module.get>( + getRepositoryToken(UserDevice), + ); + logRepository = module.get>( + getRepositoryToken(NotificationLog), + ); + configService = module.get(ConfigService); + + // Call onModuleInit to configure VAPID + service.onModuleInit(); + }); + + describe('onModuleInit', () => { + it('should configure VAPID if keys are provided', () => { + expect(webpush.setVapidDetails).toHaveBeenCalledWith( + 'mailto:admin@example.com', + 'BN4GvZtEZiZuqFJiLNpT1234567890', + 'aB3cDefGh4IjKlM5nOpQr6StUvWxYz', + ); + expect(service.isEnabled()).toBe(true); + }); + + it('should not configure if keys are missing', async () => { + jest.clearAllMocks(); + mockConfigService.get.mockReturnValue(undefined); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PushNotificationService, + { + provide: getRepositoryToken(UserDevice), + useValue: mockDeviceRepository, + }, + { + provide: getRepositoryToken(NotificationLog), + useValue: mockLogRepository, + }, + { + provide: ConfigService, + useValue: { get: () => undefined }, + }, + ], + }).compile(); + + const unconfiguredService = module.get( + PushNotificationService, + ); + unconfiguredService.onModuleInit(); + + expect(unconfiguredService.isEnabled()).toBe(false); + }); + }); + + describe('getVapidPublicKey', () => { + it('should return VAPID public key when configured', () => { + const key = service.getVapidPublicKey(); + expect(key).toBe('BN4GvZtEZiZuqFJiLNpT1234567890'); + }); + }); + + describe('sendToUser', () => { + const payload = { + title: 'Test Notification', + body: 'This is a test', + url: '/test', + }; + + it('should send to all active devices', async () => { + mockDeviceRepository.find.mockResolvedValue([mockDevice]); + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + const results = await service.sendToUser( + 'user-1', + 'tenant-1', + payload, + ); + + expect(results).toHaveLength(1); + expect(results[0].success).toBe(true); + expect(webpush.sendNotification).toHaveBeenCalled(); + }); + + it('should return empty array if no devices', async () => { + mockDeviceRepository.find.mockResolvedValue([]); + + const results = await service.sendToUser( + 'user-1', + 'tenant-1', + payload, + ); + + expect(results).toHaveLength(0); + }); + + it('should handle expired subscription (410)', async () => { + mockDeviceRepository.find.mockResolvedValue([mockDevice]); + (webpush.sendNotification as jest.Mock).mockRejectedValue({ + statusCode: 410, + message: 'Subscription expired', + }); + mockDeviceRepository.save.mockResolvedValue({ + ...mockDevice, + is_active: false, + }); + + const results = await service.sendToUser( + 'user-1', + 'tenant-1', + payload, + ); + + expect(results).toHaveLength(1); + expect(results[0].success).toBe(false); + expect(results[0].statusCode).toBe(410); + expect(mockDeviceRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ is_active: false }), + ); + }); + + it('should create log on success with notificationId', async () => { + mockDeviceRepository.find.mockResolvedValue([mockDevice]); + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + mockLogRepository.save.mockResolvedValue({}); + + await service.sendToUser( + 'user-1', + 'tenant-1', + payload, + 'notification-1', + ); + + expect(mockLogRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + notification_id: 'notification-1', + channel: 'push', + status: 'sent', + provider: 'web-push', + }), + ); + }); + }); + + describe('validateSubscription', () => { + it('should return true for valid subscription', () => { + const validSubscription = JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: { p256dh: 'test-key', auth: 'test-auth' }, + }); + + expect(service.validateSubscription(validSubscription)).toBe(true); + }); + + it('should return false for missing endpoint', () => { + const invalidSubscription = JSON.stringify({ + keys: { p256dh: 'test-key', auth: 'test-auth' }, + }); + + expect(service.validateSubscription(invalidSubscription)).toBe(false); + }); + + it('should return false for missing keys', () => { + const invalidSubscription = JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + }); + + expect(service.validateSubscription(invalidSubscription)).toBe(false); + }); + + it('should return false for invalid JSON', () => { + expect(service.validateSubscription('invalid-json')).toBe(false); + }); + }); + + describe('sendBroadcast', () => { + const payload = { + title: 'Broadcast Test', + body: 'This is a broadcast', + }; + + it('should send to all tenant devices', async () => { + mockDeviceRepository.find.mockResolvedValue([mockDevice, mockDevice]); + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + const result = await service.sendBroadcast('tenant-1', payload); + + expect(result.total).toBe(2); + expect(result.successful).toBe(2); + expect(result.failed).toBe(0); + }); + + it('should count failures correctly', async () => { + mockDeviceRepository.find.mockResolvedValue([mockDevice, mockDevice]); + (webpush.sendNotification as jest.Mock) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('Failed')); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + const result = await service.sendBroadcast('tenant-1', payload); + + expect(result.total).toBe(2); + expect(result.successful).toBe(1); + expect(result.failed).toBe(1); + }); + + it('should return zeros when not configured', async () => { + // Create unconfigured service + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PushNotificationService, + { + provide: getRepositoryToken(UserDevice), + useValue: mockDeviceRepository, + }, + { + provide: getRepositoryToken(NotificationLog), + useValue: mockLogRepository, + }, + { + provide: ConfigService, + useValue: { get: () => undefined }, + }, + ], + }).compile(); + + const unconfiguredService = module.get( + PushNotificationService, + ); + unconfiguredService.onModuleInit(); + + const result = await unconfiguredService.sendBroadcast('tenant-1', payload); + + expect(result).toEqual({ total: 0, successful: 0, failed: 0 }); + expect(mockDeviceRepository.find).not.toHaveBeenCalled(); + }); + + it('should return zeros when no devices in tenant', async () => { + mockDeviceRepository.find.mockResolvedValue([]); + + const result = await service.sendBroadcast('tenant-1', payload); + + expect(result.total).toBe(0); + expect(result.successful).toBe(0); + expect(result.failed).toBe(0); + }); + }); + + describe('sendToDevice', () => { + const testPayload = JSON.stringify({ + title: 'Direct Device Test', + body: 'Testing direct send', + }); + + it('should send push to valid device', async () => { + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + const result = await service.sendToDevice(mockDevice, testPayload); + + expect(result.success).toBe(true); + expect(result.deviceId).toBe('device-1'); + expect(webpush.sendNotification).toHaveBeenCalledWith( + JSON.parse(mockDevice.device_token), + testPayload, + ); + }); + + it('should update last_used_at on success', async () => { + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + await service.sendToDevice(mockDevice, testPayload); + + expect(mockDeviceRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + last_used_at: expect.any(Date), + }), + ); + }); + + it('should create log on success with notificationId', async () => { + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + mockLogRepository.save.mockResolvedValue({}); + + await service.sendToDevice(mockDevice, testPayload, 'notif-123'); + + expect(mockLogRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + notification_id: 'notif-123', + channel: 'push', + status: 'sent', + provider: 'web-push', + device_id: 'device-1', + delivered_at: expect.any(Date), + }), + ); + }); + + it('should not create log when notificationId is not provided', async () => { + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + await service.sendToDevice(mockDevice, testPayload); + + expect(mockLogRepository.save).not.toHaveBeenCalled(); + }); + + it('should handle invalid device (404)', async () => { + (webpush.sendNotification as jest.Mock).mockRejectedValue({ + statusCode: 404, + message: 'Subscription not found', + }); + mockDeviceRepository.save.mockResolvedValue({ + ...mockDevice, + is_active: false, + }); + + const result = await service.sendToDevice(mockDevice, testPayload); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(404); + expect(mockDeviceRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ is_active: false }), + ); + }); + + it('should handle generic send error', async () => { + (webpush.sendNotification as jest.Mock).mockRejectedValue({ + statusCode: 500, + message: 'Internal server error', + }); + + const result = await service.sendToDevice(mockDevice, testPayload); + + expect(result.success).toBe(false); + expect(result.statusCode).toBe(500); + expect(result.error).toBe('Internal server error'); + }); + + it('should create failure log with notificationId', async () => { + (webpush.sendNotification as jest.Mock).mockRejectedValue({ + statusCode: 500, + message: 'Server error', + }); + mockLogRepository.save.mockResolvedValue({}); + + await service.sendToDevice(mockDevice, testPayload, 'notif-456'); + + expect(mockLogRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + notification_id: 'notif-456', + channel: 'push', + status: 'failed', + provider: 'web-push', + device_id: 'device-1', + error_code: '500', + error_message: 'Server error', + }), + ); + }); + + it('should not create failure log without notificationId', async () => { + (webpush.sendNotification as jest.Mock).mockRejectedValue({ + statusCode: 500, + message: 'Server error', + }); + + await service.sendToDevice(mockDevice, testPayload); + + expect(mockLogRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('sendToUser - additional cases', () => { + const payload = { + title: 'Test Notification', + body: 'This is a test', + }; + + it('should return empty array when service not configured', async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PushNotificationService, + { + provide: getRepositoryToken(UserDevice), + useValue: mockDeviceRepository, + }, + { + provide: getRepositoryToken(NotificationLog), + useValue: mockLogRepository, + }, + { + provide: ConfigService, + useValue: { get: () => undefined }, + }, + ], + }).compile(); + + const unconfiguredService = module.get( + PushNotificationService, + ); + unconfiguredService.onModuleInit(); + + const results = await unconfiguredService.sendToUser( + 'user-1', + 'tenant-1', + payload, + ); + + expect(results).toHaveLength(0); + expect(mockDeviceRepository.find).not.toHaveBeenCalled(); + }); + + it('should send to multiple devices and aggregate results', async () => { + const device2: UserDevice = { + ...mockDevice, + id: 'device-2', + device_name: 'Firefox on Linux', + }; + + mockDeviceRepository.find.mockResolvedValue([mockDevice, device2]); + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + const results = await service.sendToUser('user-1', 'tenant-1', payload); + + expect(results).toHaveLength(2); + expect(results.every((r) => r.success)).toBe(true); + expect(webpush.sendNotification).toHaveBeenCalledTimes(2); + }); + + it('should continue sending to other devices after one fails', async () => { + const device2: UserDevice = { + ...mockDevice, + id: 'device-2', + }; + + mockDeviceRepository.find.mockResolvedValue([mockDevice, device2]); + (webpush.sendNotification as jest.Mock) + .mockRejectedValueOnce({ statusCode: 500, message: 'Error' }) + .mockResolvedValueOnce({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + const results = await service.sendToUser('user-1', 'tenant-1', payload); + + expect(results).toHaveLength(2); + expect(results[0].success).toBe(false); + expect(results[1].success).toBe(true); + }); + + it('should include custom payload data and actions', async () => { + const customPayload = { + title: 'Test', + body: 'Test body', + icon: '/custom-icon.png', + badge: '/custom-badge.png', + url: '/custom-url', + data: { orderId: '123' }, + actions: [{ action: 'open', title: 'Open' }], + }; + + mockDeviceRepository.find.mockResolvedValue([mockDevice]); + (webpush.sendNotification as jest.Mock).mockResolvedValue({}); + mockDeviceRepository.save.mockResolvedValue(mockDevice); + + await service.sendToUser('user-1', 'tenant-1', customPayload); + + const sentPayload = JSON.parse( + (webpush.sendNotification as jest.Mock).mock.calls[0][1], + ); + + expect(sentPayload.icon).toBe('/custom-icon.png'); + expect(sentPayload.badge).toBe('/custom-badge.png'); + expect(sentPayload.url).toBe('/custom-url'); + expect(sentPayload.data).toEqual({ orderId: '123' }); + expect(sentPayload.actions).toEqual([{ action: 'open', title: 'Open' }]); + }); + }); + + describe('getVapidPublicKey - additional cases', () => { + it('should return null when not configured', async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PushNotificationService, + { + provide: getRepositoryToken(UserDevice), + useValue: mockDeviceRepository, + }, + { + provide: getRepositoryToken(NotificationLog), + useValue: mockLogRepository, + }, + { + provide: ConfigService, + useValue: { get: () => undefined }, + }, + ], + }).compile(); + + const unconfiguredService = module.get( + PushNotificationService, + ); + unconfiguredService.onModuleInit(); + + expect(unconfiguredService.getVapidPublicKey()).toBeNull(); + }); + }); + + describe('validateSubscription - additional cases', () => { + it('should return false for missing p256dh key', () => { + const invalidSubscription = JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: { auth: 'test-auth' }, + }); + + expect(service.validateSubscription(invalidSubscription)).toBe(false); + }); + + it('should return false for missing auth key', () => { + const invalidSubscription = JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: { p256dh: 'test-key' }, + }); + + expect(service.validateSubscription(invalidSubscription)).toBe(false); + }); + + it('should return false for empty keys object', () => { + const invalidSubscription = JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: {}, + }); + + expect(service.validateSubscription(invalidSubscription)).toBe(false); + }); + + it('should return false for null keys', () => { + const invalidSubscription = JSON.stringify({ + endpoint: 'https://fcm.googleapis.com/fcm/send/test', + keys: null, + }); + + expect(service.validateSubscription(invalidSubscription)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(service.validateSubscription('')).toBe(false); + }); + }); + + describe('onModuleInit - additional cases', () => { + it('should handle setVapidDetails error gracefully', async () => { + jest.clearAllMocks(); + (webpush.setVapidDetails as jest.Mock).mockImplementation(() => { + throw new Error('Invalid VAPID keys'); + }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PushNotificationService, + { + provide: getRepositoryToken(UserDevice), + useValue: mockDeviceRepository, + }, + { + provide: getRepositoryToken(NotificationLog), + useValue: mockLogRepository, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + const errorService = module.get( + PushNotificationService, + ); + + expect(() => errorService.onModuleInit()).not.toThrow(); + expect(errorService.isEnabled()).toBe(false); + }); + + it('should use default VAPID subject when not provided', async () => { + jest.clearAllMocks(); + + const configWithoutSubject = { + get: jest.fn((key: string, defaultValue?: string) => { + const config: Record = { + VAPID_PUBLIC_KEY: 'BN4GvZtEZiZuqFJiLNpT1234567890', + VAPID_PRIVATE_KEY: 'aB3cDefGh4IjKlM5nOpQr6StUvWxYz', + }; + return config[key] || defaultValue; + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PushNotificationService, + { + provide: getRepositoryToken(UserDevice), + useValue: mockDeviceRepository, + }, + { + provide: getRepositoryToken(NotificationLog), + useValue: mockLogRepository, + }, + { + provide: ConfigService, + useValue: configWithoutSubject, + }, + ], + }).compile(); + + const serviceWithDefault = module.get( + PushNotificationService, + ); + serviceWithDefault.onModuleInit(); + + expect(webpush.setVapidDetails).toHaveBeenCalledWith( + 'mailto:admin@example.com', + 'BN4GvZtEZiZuqFJiLNpT1234567890', + 'aB3cDefGh4IjKlM5nOpQr6StUvWxYz', + ); + }); + }); +}); diff --git a/src/modules/notifications/controllers/devices.controller.ts b/src/modules/notifications/controllers/devices.controller.ts new file mode 100644 index 0000000..bd789e6 --- /dev/null +++ b/src/modules/notifications/controllers/devices.controller.ts @@ -0,0 +1,174 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { DevicesService, PushNotificationService } from '../services'; +import { RegisterDeviceDto, UpdateDeviceDto } from '../dto'; + +// These decorators would come from your auth module +// Adjust imports based on your actual auth implementation +interface User { + id: string; + tenant_id: string; +} + +// Placeholder decorators - replace with your actual implementations +const CurrentUser = () => (target: any, key: string, index: number) => {}; +const CurrentTenant = () => (target: any, key: string, index: number) => {}; +const JwtAuthGuard = class {}; +const TenantGuard = class {}; +const Public = () => (target: any, key: string, descriptor: PropertyDescriptor) => {}; + +@ApiTags('Notification Devices') +@Controller('notifications/devices') +export class DevicesController { + constructor( + private readonly devicesService: DevicesService, + private readonly pushService: PushNotificationService, + ) {} + + @Get('vapid-key') + @ApiOperation({ summary: 'Get VAPID public key for push subscription' }) + @ApiResponse({ status: 200, description: 'Returns VAPID public key' }) + getVapidKey() { + const vapidPublicKey = this.pushService.getVapidPublicKey(); + + return { + vapidPublicKey, + isEnabled: this.pushService.isEnabled(), + }; + } + + @Get() + @UseGuards(JwtAuthGuard, TenantGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'List my registered devices' }) + @ApiResponse({ status: 200, description: 'Returns list of devices' }) + async getDevices( + @CurrentUser() user: User, + @CurrentTenant() tenantId: string, + ) { + // In actual implementation, get user and tenant from request + const userId = (user as any)?.id || ''; + const tenant = tenantId || (user as any)?.tenant_id || ''; + + return this.devicesService.findByUser(userId, tenant); + } + + @Post() + @UseGuards(JwtAuthGuard, TenantGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Register device for push notifications' }) + @ApiResponse({ status: 201, description: 'Device registered' }) + @ApiResponse({ status: 400, description: 'Invalid subscription' }) + async registerDevice( + @CurrentUser() user: User, + @CurrentTenant() tenantId: string, + @Body() dto: RegisterDeviceDto, + ) { + const userId = (user as any)?.id || ''; + const tenant = tenantId || (user as any)?.tenant_id || ''; + + // Validate subscription format + if (!this.pushService.validateSubscription(dto.deviceToken)) { + return { + success: false, + error: 'Invalid push subscription format', + }; + } + + const device = await this.devicesService.register(userId, tenant, dto); + + return { + success: true, + device: { + id: device.id, + device_type: device.device_type, + device_name: device.device_name, + browser: device.browser, + os: device.os, + created_at: device.created_at, + }, + }; + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, TenantGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update device' }) + @ApiResponse({ status: 200, description: 'Device updated' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + async updateDevice( + @CurrentUser() user: User, + @CurrentTenant() tenantId: string, + @Param('id') deviceId: string, + @Body() dto: UpdateDeviceDto, + ) { + const userId = (user as any)?.id || ''; + const tenant = tenantId || (user as any)?.tenant_id || ''; + + return this.devicesService.update(deviceId, userId, tenant, dto); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, TenantGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Unregister device' }) + @ApiResponse({ status: 204, description: 'Device unregistered' }) + @ApiResponse({ status: 404, description: 'Device not found' }) + async unregisterDevice( + @CurrentUser() user: User, + @CurrentTenant() tenantId: string, + @Param('id') deviceId: string, + ) { + const userId = (user as any)?.id || ''; + const tenant = tenantId || (user as any)?.tenant_id || ''; + + await this.devicesService.unregister(deviceId, userId, tenant); + } + + @Get('stats') + @UseGuards(JwtAuthGuard, TenantGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get device stats for current user' }) + @ApiResponse({ status: 200, description: 'Returns device statistics' }) + async getStats( + @CurrentUser() user: User, + @CurrentTenant() tenantId: string, + ) { + const userId = (user as any)?.id || ''; + const tenant = tenantId || (user as any)?.tenant_id || ''; + + const activeCount = await this.devicesService.countActiveDevices( + userId, + tenant, + ); + const devices = await this.devicesService.findByUser(userId, tenant); + + return { + total: devices.length, + active: activeCount, + inactive: devices.length - activeCount, + byType: { + web: devices.filter((d) => d.device_type === 'web').length, + mobile: devices.filter((d) => d.device_type === 'mobile').length, + desktop: devices.filter((d) => d.device_type === 'desktop').length, + }, + }; + } +} diff --git a/src/modules/notifications/controllers/index.ts b/src/modules/notifications/controllers/index.ts new file mode 100644 index 0000000..ddc83ed --- /dev/null +++ b/src/modules/notifications/controllers/index.ts @@ -0,0 +1 @@ +export * from './devices.controller'; diff --git a/src/modules/notifications/dto/create-notification.dto.ts b/src/modules/notifications/dto/create-notification.dto.ts new file mode 100644 index 0000000..fae3cd4 --- /dev/null +++ b/src/modules/notifications/dto/create-notification.dto.ts @@ -0,0 +1,208 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsUUID, + IsEnum, + IsObject, + IsBoolean, + IsEmail, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateNotificationDto { + @ApiProperty() + @IsUUID() + @IsNotEmpty() + userId: string; + + @ApiPropertyOptional({ enum: ['info', 'success', 'warning', 'error', 'system'] }) + @IsOptional() + @IsEnum(['info', 'success', 'warning', 'error', 'system']) + type?: string; + + @ApiPropertyOptional({ enum: ['in_app', 'email', 'push', 'sms', 'whatsapp'] }) + @IsOptional() + @IsEnum(['in_app', 'email', 'push', 'sms', 'whatsapp']) + channel?: string; + + @ApiPropertyOptional({ description: 'Phone number for WhatsApp/SMS delivery (E.164 format)' }) + @IsOptional() + @IsString() + phoneNumber?: string; + + @ApiProperty({ example: 'Welcome!' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ example: 'Welcome to our platform!' }) + @IsString() + @IsNotEmpty() + message: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + data?: Record; + + @ApiPropertyOptional({ example: '/dashboard' }) + @IsOptional() + @IsString() + actionUrl?: string; + + @ApiPropertyOptional({ description: 'Email address for email channel delivery' }) + @IsOptional() + @IsEmail() + email?: string; + + @ApiPropertyOptional({ description: 'User name for email personalization' }) + @IsOptional() + @IsString() + userName?: string; +} + +export class SendTemplateNotificationDto { + @ApiProperty() + @IsUUID() + @IsNotEmpty() + userId: string; + + @ApiProperty({ example: 'welcome_email' }) + @IsString() + @IsNotEmpty() + templateCode: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + variables?: Record; +} + +export class UpdatePreferencesDto { + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + emailEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + pushEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + inAppEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + smsEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + whatsappEnabled?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + marketingEmails?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + productUpdates?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + securityAlerts?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + categoryPreferences?: Record; +} + +export class RegisterDeviceDto { + @ApiProperty({ description: 'JSON serialized PushSubscription object' }) + @IsString() + @IsNotEmpty() + deviceToken: string; + + @ApiPropertyOptional({ enum: ['web', 'mobile', 'desktop'], default: 'web' }) + @IsOptional() + @IsEnum(['web', 'mobile', 'desktop']) + deviceType?: 'web' | 'mobile' | 'desktop'; + + @ApiPropertyOptional({ example: 'Chrome on Windows' }) + @IsOptional() + @IsString() + deviceName?: string; + + @ApiPropertyOptional({ example: 'Chrome' }) + @IsOptional() + @IsString() + browser?: string; + + @ApiPropertyOptional({ example: '120.0' }) + @IsOptional() + @IsString() + browserVersion?: string; + + @ApiPropertyOptional({ example: 'Windows' }) + @IsOptional() + @IsString() + os?: string; + + @ApiPropertyOptional({ example: '11' }) + @IsOptional() + @IsString() + osVersion?: string; +} + +export class UpdateDeviceDto { + @ApiPropertyOptional({ example: 'My Work Laptop' }) + @IsOptional() + @IsString() + deviceName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class SendPushNotificationDto { + @ApiProperty() + @IsUUID() + @IsNotEmpty() + userId: string; + + @ApiProperty({ example: 'New Message' }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ example: 'You have a new message' }) + @IsString() + @IsNotEmpty() + body: string; + + @ApiPropertyOptional({ example: '/messages' }) + @IsOptional() + @IsString() + url?: string; + + @ApiPropertyOptional({ example: '/icon.png' }) + @IsOptional() + @IsString() + icon?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + data?: Record; +} diff --git a/src/modules/notifications/dto/index.ts b/src/modules/notifications/dto/index.ts new file mode 100644 index 0000000..025024c --- /dev/null +++ b/src/modules/notifications/dto/index.ts @@ -0,0 +1 @@ +export * from './create-notification.dto'; diff --git a/src/modules/notifications/entities/index.ts b/src/modules/notifications/entities/index.ts new file mode 100644 index 0000000..94de48f --- /dev/null +++ b/src/modules/notifications/entities/index.ts @@ -0,0 +1,6 @@ +export * from './notification.entity'; +export * from './notification-template.entity'; +export * from './user-preference.entity'; +export * from './user-device.entity'; +export * from './notification-queue.entity'; +export * from './notification-log.entity'; diff --git a/src/modules/notifications/entities/notification-log.entity.ts b/src/modules/notifications/entities/notification-log.entity.ts new file mode 100644 index 0000000..96124a0 --- /dev/null +++ b/src/modules/notifications/entities/notification-log.entity.ts @@ -0,0 +1,92 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Notification } from './notification.entity'; +import { NotificationQueue } from './notification-queue.entity'; +import { UserDevice } from './user-device.entity'; + +import { NotificationChannel } from './notification-queue.entity'; + +export type LogStatus = + | 'sent' + | 'delivered' + | 'opened' + | 'clicked' + | 'bounced' + | 'complained' + | 'failed'; + +@Entity({ schema: 'notifications', name: 'notification_logs' }) +export class NotificationLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + notification_id: string; + + @ManyToOne(() => Notification, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'notification_id' }) + notification: Notification; + + @Column({ type: 'uuid', nullable: true }) + @Index() + queue_id: string | null; + + @ManyToOne(() => NotificationQueue, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'queue_id' }) + queue: NotificationQueue | null; + + @Column({ + type: 'enum', + enum: ['email', 'push', 'in_app', 'sms', 'whatsapp'], + enumName: 'notifications.channel', + }) + @Index() + channel: NotificationChannel; + + @Column({ type: 'varchar', length: 30 }) + @Index() + status: LogStatus; + + @Column({ type: 'varchar', length: 50, nullable: true }) + @Index() + provider: string | null; + + @Column({ type: 'varchar', length: 200, nullable: true }) + provider_message_id: string | null; + + @Column({ type: 'jsonb', nullable: true }) + provider_response: Record | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + delivered_at: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + opened_at: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + clicked_at: Date | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + error_code: string | null; + + @Column({ type: 'text', nullable: true }) + error_message: string | null; + + @Column({ type: 'uuid', nullable: true }) + device_id: string | null; + + @ManyToOne(() => UserDevice, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'device_id' }) + device: UserDevice | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/notifications/entities/notification-queue.entity.ts b/src/modules/notifications/entities/notification-queue.entity.ts new file mode 100644 index 0000000..c5b4781 --- /dev/null +++ b/src/modules/notifications/entities/notification-queue.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Notification } from './notification.entity'; + +export type QueueStatus = 'queued' | 'processing' | 'sent' | 'failed' | 'retrying'; +export type NotificationChannel = 'email' | 'push' | 'in_app' | 'sms' | 'whatsapp'; + +@Entity({ schema: 'notifications', name: 'notification_queue' }) +export class NotificationQueue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + notification_id: string; + + @ManyToOne(() => Notification, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'notification_id' }) + notification: Notification; + + @Column({ + type: 'enum', + enum: ['email', 'push', 'in_app', 'sms', 'whatsapp'], + enumName: 'notifications.channel', + }) + channel: NotificationChannel; + + @Column({ type: 'timestamp with time zone', default: () => 'NOW()' }) + scheduled_for: Date; + + @Column({ type: 'int', default: 0 }) + @Index() + priority_value: number; + + @Column({ type: 'int', default: 0 }) + attempts: number; + + @Column({ type: 'int', default: 3 }) + max_attempts: number; + + @Column({ + type: 'enum', + enum: ['queued', 'processing', 'sent', 'failed', 'retrying'], + enumName: 'notifications.queue_status', + default: 'queued', + }) + @Index() + status: QueueStatus; + + @Column({ type: 'timestamp with time zone', nullable: true }) + last_attempt_at: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + @Index() + next_retry_at: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + completed_at: Date | null; + + @Column({ type: 'text', nullable: true }) + error_message: string | null; + + @Column({ type: 'int', default: 0 }) + error_count: number; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/notifications/entities/notification-template.entity.ts b/src/modules/notifications/entities/notification-template.entity.ts new file mode 100644 index 0000000..31259fd --- /dev/null +++ b/src/modules/notifications/entities/notification-template.entity.ts @@ -0,0 +1,56 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'notifications', name: 'templates' }) +export class NotificationTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + @Index({ unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 50 }) + category: string; + + @Column({ + type: 'enum', + enum: ['in_app', 'email', 'push', 'sms'], + enumName: 'notifications.notification_channel', + default: 'email', + }) + channel: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + subject: string | null; + + @Column({ type: 'text' }) + body: string; + + @Column({ type: 'text', nullable: true }) + body_html: string | null; + + @Column({ type: 'jsonb', nullable: true }) + variables: any[] | null; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/notifications/entities/notification.entity.ts b/src/modules/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..616b593 --- /dev/null +++ b/src/modules/notifications/entities/notification.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'notifications', name: 'notifications' }) +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + user_id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'uuid', nullable: true }) + template_id: string | null; + + @Column({ + type: 'enum', + enum: ['info', 'success', 'warning', 'error', 'system'], + enumName: 'notifications.notification_type', + default: 'info', + }) + type: string; + + @Column({ + type: 'enum', + enum: ['in_app', 'email', 'push', 'sms'], + enumName: 'notifications.notification_channel', + default: 'in_app', + }) + channel: string; + + @Column({ type: 'varchar', length: 255 }) + title: string; + + @Column({ type: 'text' }) + message: string; + + @Column({ type: 'jsonb', nullable: true }) + data: Record | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + action_url: string | null; + + @Column({ type: 'boolean', default: false }) + is_read: boolean; + + @Column({ type: 'timestamp with time zone', nullable: true }) + read_at: Date | null; + + @Column({ + type: 'enum', + enum: ['pending', 'sent', 'delivered', 'failed'], + enumName: 'notifications.delivery_status', + default: 'pending', + }) + delivery_status: string; + + @Column({ type: 'timestamp with time zone', nullable: true }) + sent_at: Date | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/notifications/entities/user-device.entity.ts b/src/modules/notifications/entities/user-device.entity.ts new file mode 100644 index 0000000..9405562 --- /dev/null +++ b/src/modules/notifications/entities/user-device.entity.ts @@ -0,0 +1,60 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type DeviceType = 'web' | 'mobile' | 'desktop'; + +@Entity({ schema: 'notifications', name: 'user_devices' }) +@Unique(['user_id', 'device_token']) +export class UserDevice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'uuid' }) + @Index() + user_id: string; + + @Column({ + type: 'enum', + enum: ['web', 'mobile', 'desktop'], + enumName: 'notifications.device_type', + default: 'web', + }) + device_type: DeviceType; + + @Column({ type: 'text' }) + device_token: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + device_name: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + browser: string | null; + + @Column({ type: 'varchar', length: 20, nullable: true }) + browser_version: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + os: string | null; + + @Column({ type: 'varchar', length: 20, nullable: true }) + os_version: string | null; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @Column({ type: 'timestamp with time zone', default: () => 'NOW()' }) + last_used_at: Date; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/notifications/entities/user-preference.entity.ts b/src/modules/notifications/entities/user-preference.entity.ts new file mode 100644 index 0000000..f8c7620 --- /dev/null +++ b/src/modules/notifications/entities/user-preference.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'notifications', name: 'user_preferences' }) +@Index(['user_id', 'tenant_id'], { unique: true }) +export class UserNotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + user_id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'boolean', default: true }) + email_enabled: boolean; + + @Column({ type: 'boolean', default: true }) + push_enabled: boolean; + + @Column({ type: 'boolean', default: true }) + in_app_enabled: boolean; + + @Column({ type: 'boolean', default: false }) + sms_enabled: boolean; + + @Column({ type: 'jsonb', nullable: true }) + category_preferences: Record | null; + + @Column({ type: 'boolean', default: true }) + marketing_emails: boolean; + + @Column({ type: 'boolean', default: true }) + product_updates: boolean; + + @Column({ type: 'boolean', default: true }) + security_alerts: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/notifications/gateways/index.ts b/src/modules/notifications/gateways/index.ts new file mode 100644 index 0000000..3563190 --- /dev/null +++ b/src/modules/notifications/gateways/index.ts @@ -0,0 +1 @@ +export * from './notifications.gateway'; diff --git a/src/modules/notifications/gateways/notifications.gateway.ts b/src/modules/notifications/gateways/notifications.gateway.ts new file mode 100644 index 0000000..401f9de --- /dev/null +++ b/src/modules/notifications/gateways/notifications.gateway.ts @@ -0,0 +1,280 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + MessageBody, + ConnectedSocket, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Logger, UseGuards } from '@nestjs/common'; +import { Notification } from '../entities'; + +interface AuthenticatedSocket extends Socket { + userId?: string; + tenantId?: string; +} + +@WebSocketGateway({ + namespace: '/notifications', + cors: { + origin: process.env.FRONTEND_URL || '*', + credentials: true, + }, +}) +export class NotificationsGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(NotificationsGateway.name); + + // Map: "tenantId:userId" -> Set of socket IDs + private userSockets = new Map>(); + + // Map: socket ID -> user key + private socketUsers = new Map(); + + handleConnection(client: AuthenticatedSocket) { + try { + const userId = this.extractUserId(client); + const tenantId = this.extractTenantId(client); + + if (!userId || !tenantId) { + this.logger.warn(`Client ${client.id} connected without auth, disconnecting`); + client.disconnect(); + return; + } + + client.userId = userId; + client.tenantId = tenantId; + + const userKey = `${tenantId}:${userId}`; + + // Add to user sockets map + if (!this.userSockets.has(userKey)) { + this.userSockets.set(userKey, new Set()); + } + this.userSockets.get(userKey)!.add(client.id); + + // Add to socket users map + this.socketUsers.set(client.id, userKey); + + // Join rooms + client.join(`tenant:${tenantId}`); + client.join(`user:${userId}`); + + this.logger.debug( + `User ${userId} connected from tenant ${tenantId} (socket: ${client.id})`, + ); + + // Send connection confirmation + client.emit('connected', { + socketId: client.id, + userId, + tenantId, + }); + } catch (error) { + this.logger.error(`Connection error: ${error.message}`); + client.disconnect(); + } + } + + handleDisconnect(client: AuthenticatedSocket) { + const userKey = this.socketUsers.get(client.id); + + if (userKey) { + const sockets = this.userSockets.get(userKey); + if (sockets) { + sockets.delete(client.id); + if (sockets.size === 0) { + this.userSockets.delete(userKey); + } + } + this.socketUsers.delete(client.id); + } + + this.logger.debug(`Client ${client.id} disconnected`); + } + + /** + * Emit notification to a specific user + */ + async emitToUser( + tenantId: string, + userId: string, + notification: Partial, + ): Promise { + const userKey = `${tenantId}:${userId}`; + const socketIds = this.userSockets.get(userKey); + + if (!socketIds || socketIds.size === 0) { + this.logger.debug(`No active sockets for user ${userId}`); + return 0; + } + + for (const socketId of socketIds) { + this.server.to(socketId).emit('notification:created', notification); + } + + this.logger.debug( + `Emitted notification to user ${userId} (${socketIds.size} sockets)`, + ); + + return socketIds.size; + } + + /** + * Emit to all users in a tenant + */ + async emitToTenant( + tenantId: string, + event: string, + data: any, + ): Promise { + this.server.to(`tenant:${tenantId}`).emit(event, data); + this.logger.debug(`Emitted ${event} to tenant ${tenantId}`); + } + + /** + * Emit notification read event to sync across devices + */ + @SubscribeMessage('notification:read') + handleMarkAsRead( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() payload: { notificationId: string }, + ): void { + if (!client.userId || !client.tenantId) { + return; + } + + const userKey = `${client.tenantId}:${client.userId}`; + const socketIds = this.userSockets.get(userKey); + + if (socketIds) { + // Broadcast to all other sockets of the same user + for (const socketId of socketIds) { + if (socketId !== client.id) { + this.server.to(socketId).emit('notification:read', payload); + } + } + } + + this.logger.debug( + `User ${client.userId} marked notification ${payload.notificationId} as read`, + ); + } + + /** + * Handle mark all as read + */ + @SubscribeMessage('notification:read-all') + handleMarkAllAsRead(@ConnectedSocket() client: AuthenticatedSocket): void { + if (!client.userId || !client.tenantId) { + return; + } + + const userKey = `${client.tenantId}:${client.userId}`; + const socketIds = this.userSockets.get(userKey); + + if (socketIds) { + for (const socketId of socketIds) { + if (socketId !== client.id) { + this.server.to(socketId).emit('notification:read-all', {}); + } + } + } + + this.logger.debug(`User ${client.userId} marked all notifications as read`); + } + + /** + * Handle client requesting unread count + */ + @SubscribeMessage('notification:get-unread-count') + handleGetUnreadCount( + @ConnectedSocket() client: AuthenticatedSocket, + ): { event: string } { + // The actual count will be fetched by the service and emitted back + return { event: 'notification:unread-count-requested' }; + } + + /** + * Emit unread count update to user + */ + async emitUnreadCount( + tenantId: string, + userId: string, + count: number, + ): Promise { + const userKey = `${tenantId}:${userId}`; + const socketIds = this.userSockets.get(userKey); + + if (socketIds) { + for (const socketId of socketIds) { + this.server.to(socketId).emit('notification:unread-count', { count }); + } + } + } + + /** + * Emit notification deleted event + */ + async emitNotificationDeleted( + tenantId: string, + userId: string, + notificationId: string, + ): Promise { + const userKey = `${tenantId}:${userId}`; + const socketIds = this.userSockets.get(userKey); + + if (socketIds) { + for (const socketId of socketIds) { + this.server + .to(socketId) + .emit('notification:deleted', { notificationId }); + } + } + } + + /** + * Get connected users count + */ + getConnectedUsersCount(): number { + return this.userSockets.size; + } + + /** + * Get total socket connections + */ + getTotalConnections(): number { + return this.socketUsers.size; + } + + /** + * Check if user is online + */ + isUserOnline(tenantId: string, userId: string): boolean { + const userKey = `${tenantId}:${userId}`; + const sockets = this.userSockets.get(userKey); + return sockets !== undefined && sockets.size > 0; + } + + private extractUserId(client: Socket): string | null { + return ( + (client.handshake.auth?.userId as string) || + (client.handshake.query?.userId as string) || + null + ); + } + + private extractTenantId(client: Socket): string | null { + return ( + (client.handshake.auth?.tenantId as string) || + (client.handshake.query?.tenantId as string) || + null + ); + } +} diff --git a/src/modules/notifications/index.ts b/src/modules/notifications/index.ts new file mode 100644 index 0000000..7329605 --- /dev/null +++ b/src/modules/notifications/index.ts @@ -0,0 +1,7 @@ +export * from './notifications.module'; +export * from './notifications.controller'; +export * from './services'; +export * from './entities'; +export * from './dto'; +export * from './gateways'; +export * from './controllers'; diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..605e5ba --- /dev/null +++ b/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,155 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { NotificationsService } from './services/notifications.service'; +import { + CreateNotificationDto, + SendTemplateNotificationDto, + UpdatePreferencesDto, +} from './dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PermissionsGuard, RequirePermissions } from '../rbac/guards/permissions.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; + +@ApiTags('notifications') +@Controller('notifications') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + // ==================== User Notifications ==================== + + @Get() + @ApiOperation({ summary: 'Get my notifications' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'unreadOnly', required: false, type: Boolean }) + async getMyNotifications( + @CurrentUser() user: RequestUser, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('unreadOnly') unreadOnly?: boolean, + ) { + return this.notificationsService.findAllForUser(user.id, user.tenant_id, { + page: page || 1, + limit: limit || 20, + unreadOnly: unreadOnly || false, + }); + } + + @Get('unread-count') + @ApiOperation({ summary: 'Get unread notifications count' }) + async getUnreadCount(@CurrentUser() user: RequestUser) { + const count = await this.notificationsService.getUnreadCount( + user.id, + user.tenant_id, + ); + return { count }; + } + + @Patch(':id/read') + @ApiOperation({ summary: 'Mark notification as read' }) + async markAsRead( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + return this.notificationsService.markAsRead(id, user.id, user.tenant_id); + } + + @Post('read-all') + @ApiOperation({ summary: 'Mark all notifications as read' }) + async markAllAsRead(@CurrentUser() user: RequestUser) { + const count = await this.notificationsService.markAllAsRead( + user.id, + user.tenant_id, + ); + return { message: `${count} notificaciones marcadas como leídas` }; + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete notification' }) + async delete( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + await this.notificationsService.delete(id, user.id, user.tenant_id); + return { message: 'Notificación eliminada' }; + } + + // ==================== Preferences ==================== + + @Get('preferences') + @ApiOperation({ summary: 'Get my notification preferences' }) + async getPreferences(@CurrentUser() user: RequestUser) { + return this.notificationsService.getPreferences(user.id, user.tenant_id); + } + + @Patch('preferences') + @ApiOperation({ summary: 'Update my notification preferences' }) + async updatePreferences( + @CurrentUser() user: RequestUser, + @Body() dto: UpdatePreferencesDto, + ) { + return this.notificationsService.updatePreferences( + user.id, + user.tenant_id, + dto, + ); + } + + // ==================== Admin Operations ==================== + + @Post() + @UseGuards(PermissionsGuard) + @RequirePermissions('notifications:manage') + @ApiOperation({ summary: 'Send notification to user (admin)' }) + async sendNotification( + @Body() dto: CreateNotificationDto, + @CurrentUser() user: RequestUser, + ) { + return this.notificationsService.create(dto, user.tenant_id); + } + + @Post('template') + @UseGuards(PermissionsGuard) + @RequirePermissions('notifications:manage') + @ApiOperation({ summary: 'Send notification from template (admin)' }) + async sendFromTemplate( + @Body() dto: SendTemplateNotificationDto, + @CurrentUser() user: RequestUser, + ) { + return this.notificationsService.sendFromTemplate(dto, user.tenant_id); + } + + @Get('templates') + @UseGuards(PermissionsGuard) + @RequirePermissions('notifications:manage') + @ApiOperation({ summary: 'List notification templates (admin)' }) + async getTemplates() { + return this.notificationsService.findAllTemplates(); + } + + @Get('templates/:code') + @UseGuards(PermissionsGuard) + @RequirePermissions('notifications:manage') + @ApiOperation({ summary: 'Get notification template by code (admin)' }) + async getTemplate(@Param('code') code: string) { + return this.notificationsService.findTemplateByCode(code); + } +} diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..2f0f384 --- /dev/null +++ b/src/modules/notifications/notifications.module.ts @@ -0,0 +1,56 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { NotificationsController } from './notifications.controller'; +import { DevicesController } from './controllers'; +import { + NotificationsService, + DevicesService, + PushNotificationService, + NotificationQueueService, +} from './services'; +import { + Notification, + NotificationTemplate, + UserNotificationPreference, + UserDevice, + NotificationQueue, + NotificationLog, +} from './entities'; +import { NotificationsGateway } from './gateways'; +import { RbacModule } from '../rbac/rbac.module'; +import { EmailModule } from '../email'; +import { WhatsAppModule } from '../whatsapp/whatsapp.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Notification, + NotificationTemplate, + UserNotificationPreference, + UserDevice, + NotificationQueue, + NotificationLog, + ]), + ConfigModule, + RbacModule, + EmailModule, + WhatsAppModule, + ], + controllers: [NotificationsController, DevicesController], + providers: [ + NotificationsService, + DevicesService, + PushNotificationService, + NotificationQueueService, + NotificationsGateway, + ], + exports: [ + NotificationsService, + DevicesService, + PushNotificationService, + NotificationQueueService, + NotificationsGateway, + ], +}) +export class NotificationsModule {} diff --git a/src/modules/notifications/services/devices.service.ts b/src/modules/notifications/services/devices.service.ts new file mode 100644 index 0000000..ef8ffd9 --- /dev/null +++ b/src/modules/notifications/services/devices.service.ts @@ -0,0 +1,187 @@ +import { + Injectable, + Logger, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserDevice } from '../entities'; +import { RegisterDeviceDto, UpdateDeviceDto } from '../dto'; + +@Injectable() +export class DevicesService { + private readonly logger = new Logger(DevicesService.name); + + constructor( + @InjectRepository(UserDevice) + private readonly deviceRepository: Repository, + ) {} + + async findByUser(userId: string, tenantId: string): Promise { + return this.deviceRepository.find({ + where: { user_id: userId, tenant_id: tenantId }, + order: { last_used_at: 'DESC' }, + }); + } + + async findActiveByUser(userId: string, tenantId: string): Promise { + return this.deviceRepository.find({ + where: { user_id: userId, tenant_id: tenantId, is_active: true }, + order: { last_used_at: 'DESC' }, + }); + } + + async findById( + deviceId: string, + userId: string, + tenantId: string, + ): Promise { + const device = await this.deviceRepository.findOne({ + where: { id: deviceId, user_id: userId, tenant_id: tenantId }, + }); + + if (!device) { + throw new NotFoundException('Dispositivo no encontrado'); + } + + return device; + } + + async register( + userId: string, + tenantId: string, + dto: RegisterDeviceDto, + ): Promise { + // Check if device already exists + const existing = await this.deviceRepository.findOne({ + where: { + user_id: userId, + device_token: dto.deviceToken, + }, + }); + + if (existing) { + // Update existing device + existing.is_active = true; + existing.last_used_at = new Date(); + existing.device_name = dto.deviceName || existing.device_name; + existing.browser = dto.browser || existing.browser; + existing.browser_version = dto.browserVersion || existing.browser_version; + existing.os = dto.os || existing.os; + existing.os_version = dto.osVersion || existing.os_version; + + this.logger.log(`Device ${existing.id} reactivated for user ${userId}`); + return this.deviceRepository.save(existing); + } + + // Create new device + const device = this.deviceRepository.create({ + user_id: userId, + tenant_id: tenantId, + device_token: dto.deviceToken, + device_type: dto.deviceType || 'web', + device_name: dto.deviceName, + browser: dto.browser, + browser_version: dto.browserVersion, + os: dto.os, + os_version: dto.osVersion, + is_active: true, + last_used_at: new Date(), + }); + + const saved = await this.deviceRepository.save(device); + this.logger.log(`Device ${saved.id} registered for user ${userId}`); + + return saved; + } + + async update( + deviceId: string, + userId: string, + tenantId: string, + dto: UpdateDeviceDto, + ): Promise { + const device = await this.findById(deviceId, userId, tenantId); + + if (dto.deviceName !== undefined) { + device.device_name = dto.deviceName; + } + + if (dto.isActive !== undefined) { + device.is_active = dto.isActive; + } + + return this.deviceRepository.save(device); + } + + async unregister( + deviceId: string, + userId: string, + tenantId: string, + ): Promise { + const device = await this.findById(deviceId, userId, tenantId); + + // Soft delete - mark as inactive + device.is_active = false; + await this.deviceRepository.save(device); + + this.logger.log(`Device ${deviceId} unregistered for user ${userId}`); + } + + async delete( + deviceId: string, + userId: string, + tenantId: string, + ): Promise { + const result = await this.deviceRepository.delete({ + id: deviceId, + user_id: userId, + tenant_id: tenantId, + }); + + if (result.affected === 0) { + throw new NotFoundException('Dispositivo no encontrado'); + } + + this.logger.log(`Device ${deviceId} deleted for user ${userId}`); + } + + async markAsUsed(deviceId: string): Promise { + await this.deviceRepository.update(deviceId, { + last_used_at: new Date(), + }); + } + + async markAsInactive(deviceId: string): Promise { + await this.deviceRepository.update(deviceId, { + is_active: false, + }); + + this.logger.warn(`Device ${deviceId} marked as inactive (subscription expired)`); + } + + async countActiveDevices(userId: string, tenantId: string): Promise { + return this.deviceRepository.count({ + where: { user_id: userId, tenant_id: tenantId, is_active: true }, + }); + } + + async cleanupInactiveDevices(daysInactive: number = 90): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysInactive); + + const result = await this.deviceRepository + .createQueryBuilder() + .delete() + .where('is_active = :inactive', { inactive: false }) + .andWhere('last_used_at < :cutoff', { cutoff: cutoffDate }) + .execute(); + + if (result.affected && result.affected > 0) { + this.logger.log(`Cleaned up ${result.affected} inactive devices`); + } + + return result.affected || 0; + } +} diff --git a/src/modules/notifications/services/index.ts b/src/modules/notifications/services/index.ts new file mode 100644 index 0000000..2a5aebe --- /dev/null +++ b/src/modules/notifications/services/index.ts @@ -0,0 +1,4 @@ +export * from './notifications.service'; +export * from './devices.service'; +export * from './push-notification.service'; +export * from './notification-queue.service'; diff --git a/src/modules/notifications/services/notification-queue.service.ts b/src/modules/notifications/services/notification-queue.service.ts new file mode 100644 index 0000000..438bd7e --- /dev/null +++ b/src/modules/notifications/services/notification-queue.service.ts @@ -0,0 +1,322 @@ +import { + Injectable, + Logger, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual, In } from 'typeorm'; +import { + NotificationQueue, + NotificationLog, + Notification, + QueueStatus, + NotificationChannel, +} from '../entities'; + +export interface QueueItem { + id: string; + notification_id: string; + channel: NotificationChannel; + priority_value: number; + attempts: number; + notification: Notification; +} + +export interface QueueStats { + queued: number; + processing: number; + sent: number; + failed: number; + retrying: number; +} + +@Injectable() +export class NotificationQueueService { + private readonly logger = new Logger(NotificationQueueService.name); + + constructor( + @InjectRepository(NotificationQueue) + private readonly queueRepository: Repository, + @InjectRepository(NotificationLog) + private readonly logRepository: Repository, + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + ) {} + + async enqueue( + notificationId: string, + channel: NotificationChannel, + priority: 'low' | 'normal' | 'high' | 'urgent' = 'normal', + scheduledFor?: Date, + ): Promise { + const priorityValue = this.getPriorityValue(priority); + + const queueItem = this.queueRepository.create({ + notification_id: notificationId, + channel, + priority_value: priorityValue, + scheduled_for: scheduledFor || new Date(), + status: 'queued', + attempts: 0, + max_attempts: 3, + }); + + const saved = await this.queueRepository.save(queueItem); + this.logger.debug( + `Enqueued notification ${notificationId} for channel ${channel}`, + ); + + return saved; + } + + async enqueueBatch( + notificationId: string, + channels: NotificationChannel[], + priority: 'low' | 'normal' | 'high' | 'urgent' = 'normal', + ): Promise { + const priorityValue = this.getPriorityValue(priority); + const now = new Date(); + + const items = channels.map((channel) => + this.queueRepository.create({ + notification_id: notificationId, + channel, + priority_value: priorityValue, + scheduled_for: now, + status: 'queued', + attempts: 0, + max_attempts: 3, + }), + ); + + const saved = await this.queueRepository.save(items); + this.logger.debug( + `Enqueued notification ${notificationId} for ${channels.length} channels`, + ); + + return saved; + } + + async getPendingItems( + limit: number = 100, + channel?: NotificationChannel, + ): Promise { + const now = new Date(); + + const queryBuilder = this.queueRepository + .createQueryBuilder('q') + .leftJoinAndSelect('q.notification', 'n') + .where('q.status IN (:...statuses)', { + statuses: ['queued', 'retrying'], + }) + .andWhere('(q.scheduled_for IS NULL OR q.scheduled_for <= :now)', { now }) + .andWhere('(q.next_retry_at IS NULL OR q.next_retry_at <= :now)', { now }) + .orderBy('q.priority_value', 'DESC') + .addOrderBy('q.created_at', 'ASC') + .take(limit); + + if (channel) { + queryBuilder.andWhere('q.channel = :channel', { channel }); + } + + return queryBuilder.getMany() as Promise; + } + + async markAsProcessing(queueId: string): Promise { + await this.queueRepository.update(queueId, { + status: 'processing', + last_attempt_at: new Date(), + }); + } + + async markAsSent( + queueId: string, + provider?: string, + providerMessageId?: string, + providerResponse?: Record, + ): Promise { + const queueItem = await this.queueRepository.findOne({ + where: { id: queueId }, + }); + + if (!queueItem) { + return; + } + + // Update queue item + await this.queueRepository.update(queueId, { + status: 'sent', + completed_at: new Date(), + attempts: queueItem.attempts + 1, + }); + + // Update notification status + await this.notificationRepository.update(queueItem.notification_id, { + delivery_status: 'sent', + sent_at: new Date(), + }); + + // Create log + await this.logRepository.save({ + notification_id: queueItem.notification_id, + queue_id: queueId, + channel: queueItem.channel, + status: 'sent', + provider, + provider_message_id: providerMessageId, + provider_response: providerResponse, + delivered_at: new Date(), + }); + + this.logger.debug(`Queue item ${queueId} marked as sent`); + } + + async markAsFailed( + queueId: string, + errorMessage: string, + provider?: string, + ): Promise { + const queueItem = await this.queueRepository.findOne({ + where: { id: queueId }, + }); + + if (!queueItem) { + return; + } + + const newAttempts = queueItem.attempts + 1; + const shouldRetry = newAttempts < queueItem.max_attempts; + + if (shouldRetry) { + // Schedule retry with exponential backoff + const retryDelay = Math.pow(2, queueItem.attempts) * 60 * 1000; // 1, 2, 4 minutes + const nextRetryAt = new Date(Date.now() + retryDelay); + + await this.queueRepository.update(queueId, { + status: 'retrying', + attempts: newAttempts, + error_message: errorMessage, + error_count: queueItem.error_count + 1, + next_retry_at: nextRetryAt, + }); + + this.logger.debug( + `Queue item ${queueId} scheduled for retry at ${nextRetryAt.toISOString()}`, + ); + } else { + // Final failure + await this.queueRepository.update(queueId, { + status: 'failed', + attempts: newAttempts, + error_message: errorMessage, + error_count: queueItem.error_count + 1, + completed_at: new Date(), + }); + + // Update notification status + await this.notificationRepository.update(queueItem.notification_id, { + delivery_status: 'failed', + }); + + this.logger.warn( + `Queue item ${queueId} failed permanently after ${newAttempts} attempts`, + ); + } + + // Create log + await this.logRepository.save({ + notification_id: queueItem.notification_id, + queue_id: queueId, + channel: queueItem.channel, + status: 'failed', + provider, + error_message: errorMessage, + }); + } + + async getStats(): Promise { + const stats = await this.queueRepository + .createQueryBuilder('q') + .select('q.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('q.status') + .getRawMany(); + + const result: QueueStats = { + queued: 0, + processing: 0, + sent: 0, + failed: 0, + retrying: 0, + }; + + for (const row of stats) { + result[row.status as keyof QueueStats] = parseInt(row.count, 10); + } + + return result; + } + + async getStatsByChannel(): Promise< + Array<{ channel: string; status: string; count: number }> + > { + return this.queueRepository + .createQueryBuilder('q') + .select('q.channel', 'channel') + .addSelect('q.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('q.channel') + .addGroupBy('q.status') + .getRawMany(); + } + + async cleanupOldItems(daysToKeep: number = 30): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const result = await this.queueRepository + .createQueryBuilder() + .delete() + .where('status IN (:...statuses)', { statuses: ['sent', 'failed'] }) + .andWhere('completed_at < :cutoff', { cutoff: cutoffDate }) + .execute(); + + if (result.affected && result.affected > 0) { + this.logger.log(`Cleaned up ${result.affected} old queue items`); + } + + return result.affected || 0; + } + + async cancelPending(notificationId: string): Promise { + const result = await this.queueRepository.update( + { + notification_id: notificationId, + status: In(['queued', 'retrying']), + }, + { + status: 'failed', + error_message: 'Cancelled', + completed_at: new Date(), + }, + ); + + return result.affected || 0; + } + + private getPriorityValue( + priority: 'low' | 'normal' | 'high' | 'urgent', + ): number { + switch (priority) { + case 'urgent': + return 10; + case 'high': + return 5; + case 'normal': + return 0; + case 'low': + return -5; + default: + return 0; + } + } +} diff --git a/src/modules/notifications/services/notifications.service.ts b/src/modules/notifications/services/notifications.service.ts new file mode 100644 index 0000000..4d273c1 --- /dev/null +++ b/src/modules/notifications/services/notifications.service.ts @@ -0,0 +1,372 @@ +import { Injectable, NotFoundException, Logger, Inject, forwardRef, Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { + Notification, + NotificationTemplate, + UserNotificationPreference, +} from '../entities'; +import { + CreateNotificationDto, + SendTemplateNotificationDto, + UpdatePreferencesDto, +} from '../dto'; +import { EmailService } from '@modules/email'; +import { WhatsAppService } from '@modules/whatsapp/services/whatsapp.service'; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + @InjectRepository(NotificationTemplate) + private readonly templateRepository: Repository, + @InjectRepository(UserNotificationPreference) + private readonly preferenceRepository: Repository, + private readonly emailService: EmailService, + @Optional() private readonly whatsappService?: WhatsAppService, + ) {} + + // ==================== Notifications ==================== + + async create( + dto: CreateNotificationDto, + tenantId: string, + ): Promise { + const notification = this.notificationRepository.create({ + user_id: dto.userId, + tenant_id: tenantId, + type: dto.type || 'info', + channel: dto.channel || 'in_app', + title: dto.title, + message: dto.message, + data: dto.data || null, + action_url: dto.actionUrl || null, + delivery_status: 'pending', + }); + + const saved = await this.notificationRepository.save(notification); + + // If in_app, mark as delivered immediately + if (saved.channel === 'in_app') { + saved.delivery_status = 'delivered'; + saved.sent_at = new Date(); + await this.notificationRepository.save(saved); + } + + // For email, send via EmailService + if (saved.channel === 'email' && dto.email) { + try { + const result = await this.emailService.sendEmail({ + to: { email: dto.email, name: dto.userName }, + subject: saved.title, + html: this.formatEmailHtml(saved.title, saved.message, saved.action_url), + text: saved.message, + metadata: { notificationId: saved.id }, + }); + + if (result.success) { + saved.delivery_status = 'delivered'; + saved.sent_at = new Date(); + this.logger.log(`Email sent to ${dto.email}: ${result.messageId}`); + } else { + saved.delivery_status = 'failed'; + this.logger.error(`Failed to send email to ${dto.email}: ${result.error}`); + } + await this.notificationRepository.save(saved); + } catch (error) { + this.logger.error(`Email delivery error: ${error.message}`); + saved.delivery_status = 'failed'; + await this.notificationRepository.save(saved); + } + } + + // For WhatsApp, send via WhatsAppService + if (saved.channel === 'whatsapp' && dto.phoneNumber && this.whatsappService) { + try { + const result = await this.whatsappService.sendTextMessage(tenantId, { + to: dto.phoneNumber, + text: `${saved.title}\n\n${saved.message}`, + contactName: dto.userName, + metadata: { notificationId: saved.id }, + }); + + if (result.success) { + saved.delivery_status = 'delivered'; + saved.sent_at = new Date(); + this.logger.log(`WhatsApp sent to ${dto.phoneNumber}: ${result.messageId}`); + } else { + saved.delivery_status = 'failed'; + this.logger.error(`Failed to send WhatsApp to ${dto.phoneNumber}: ${result.error}`); + } + await this.notificationRepository.save(saved); + } catch (error) { + this.logger.error(`WhatsApp delivery error: ${error.message}`); + saved.delivery_status = 'failed'; + await this.notificationRepository.save(saved); + } + } + + // TODO: For push/sms, implement respective services + + return saved; + } + + async sendFromTemplate( + dto: SendTemplateNotificationDto, + tenantId: string, + ): Promise { + const template = await this.templateRepository.findOne({ + where: { code: dto.templateCode, is_active: true }, + }); + + if (!template) { + throw new NotFoundException(`Template '${dto.templateCode}' no encontrado`); + } + + // Replace variables in template + let message = template.body; + let subject = template.subject || ''; + + if (dto.variables) { + for (const [key, value] of Object.entries(dto.variables)) { + const regex = new RegExp(`{{${key}}}`, 'g'); + message = message.replace(regex, String(value)); + subject = subject.replace(regex, String(value)); + } + } + + return this.create( + { + userId: dto.userId, + channel: template.channel as any, + title: subject || template.name, + message, + data: { templateCode: dto.templateCode, variables: dto.variables }, + }, + tenantId, + ); + } + + async findAllForUser( + userId: string, + tenantId: string, + options: { page?: number; limit?: number; unreadOnly?: boolean } = {}, + ): Promise<{ data: Notification[]; total: number; unread: number }> { + const { page = 1, limit = 20, unreadOnly = false } = options; + + const whereCondition: any = { + user_id: userId, + tenant_id: tenantId, + channel: 'in_app', // Only in-app notifications for listing + }; + + if (unreadOnly) { + whereCondition.is_read = false; + } + + const [data, total] = await this.notificationRepository.findAndCount({ + where: whereCondition, + order: { created_at: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + const unread = await this.notificationRepository.count({ + where: { + user_id: userId, + tenant_id: tenantId, + channel: 'in_app', + is_read: false, + }, + }); + + return { data, total, unread }; + } + + async markAsRead( + notificationId: string, + userId: string, + tenantId: string, + ): Promise { + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId, user_id: userId, tenant_id: tenantId }, + }); + + if (!notification) { + throw new NotFoundException('Notificación no encontrada'); + } + + notification.is_read = true; + notification.read_at = new Date(); + + return this.notificationRepository.save(notification); + } + + async markAllAsRead(userId: string, tenantId: string): Promise { + const result = await this.notificationRepository.update( + { + user_id: userId, + tenant_id: tenantId, + is_read: false, + }, + { + is_read: true, + read_at: new Date(), + }, + ); + + return result.affected || 0; + } + + async delete( + notificationId: string, + userId: string, + tenantId: string, + ): Promise { + const result = await this.notificationRepository.delete({ + id: notificationId, + user_id: userId, + tenant_id: tenantId, + }); + + if (result.affected === 0) { + throw new NotFoundException('Notificación no encontrada'); + } + } + + async getUnreadCount(userId: string, tenantId: string): Promise { + return this.notificationRepository.count({ + where: { + user_id: userId, + tenant_id: tenantId, + channel: 'in_app', + is_read: false, + }, + }); + } + + // ==================== Templates ==================== + + async findAllTemplates(): Promise { + return this.templateRepository.find({ + where: { is_active: true }, + order: { category: 'ASC', code: 'ASC' }, + }); + } + + async findTemplateByCode(code: string): Promise { + const template = await this.templateRepository.findOne({ + where: { code, is_active: true }, + }); + + if (!template) { + throw new NotFoundException(`Template '${code}' no encontrado`); + } + + return template; + } + + // ==================== Preferences ==================== + + async getPreferences( + userId: string, + tenantId: string, + ): Promise { + let preferences = await this.preferenceRepository.findOne({ + where: { user_id: userId, tenant_id: tenantId }, + }); + + // Create default preferences if not exist + if (!preferences) { + preferences = this.preferenceRepository.create({ + user_id: userId, + tenant_id: tenantId, + email_enabled: true, + push_enabled: true, + in_app_enabled: true, + sms_enabled: false, + marketing_emails: true, + product_updates: true, + security_alerts: true, + }); + await this.preferenceRepository.save(preferences); + } + + return preferences; + } + + async updatePreferences( + userId: string, + tenantId: string, + dto: UpdatePreferencesDto, + ): Promise { + const preferences = await this.getPreferences(userId, tenantId); + + if (dto.emailEnabled !== undefined) preferences.email_enabled = dto.emailEnabled; + if (dto.pushEnabled !== undefined) preferences.push_enabled = dto.pushEnabled; + if (dto.inAppEnabled !== undefined) preferences.in_app_enabled = dto.inAppEnabled; + if (dto.smsEnabled !== undefined) preferences.sms_enabled = dto.smsEnabled; + if (dto.marketingEmails !== undefined) preferences.marketing_emails = dto.marketingEmails; + if (dto.productUpdates !== undefined) preferences.product_updates = dto.productUpdates; + if (dto.securityAlerts !== undefined) preferences.security_alerts = dto.securityAlerts; + if (dto.categoryPreferences !== undefined) { + preferences.category_preferences = dto.categoryPreferences; + } + + return this.preferenceRepository.save(preferences); + } + + // ==================== Email Formatting ==================== + + private formatEmailHtml( + title: string, + message: string, + actionUrl?: string | null, + ): string { + const actionButton = actionUrl + ? `

+ + Ver más + +

` + : ''; + + return ` + + + + + + + +
+

${title}

+

${message}

+ ${actionButton} +
+

+ Este es un mensaje automático de Template SaaS +

+ + + `; + } + + // ==================== Cleanup ==================== + + async cleanupOldNotifications(daysToKeep: number = 30): Promise { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); + + const result = await this.notificationRepository.delete({ + created_at: LessThan(cutoffDate), + is_read: true, + }); + + return result.affected || 0; + } +} diff --git a/src/modules/notifications/services/push-notification.service.ts b/src/modules/notifications/services/push-notification.service.ts new file mode 100644 index 0000000..cfb0789 --- /dev/null +++ b/src/modules/notifications/services/push-notification.service.ts @@ -0,0 +1,253 @@ +import { + Injectable, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as webpush from 'web-push'; +import { UserDevice, NotificationLog } from '../entities'; + +export interface PushPayload { + title: string; + body: string; + icon?: string; + badge?: string; + url?: string; + data?: Record; + actions?: Array<{ action: string; title: string }>; +} + +export interface SendResult { + deviceId: string; + success: boolean; + error?: string; + statusCode?: number; +} + +@Injectable() +export class PushNotificationService implements OnModuleInit { + private readonly logger = new Logger(PushNotificationService.name); + private isConfigured = false; + + constructor( + private readonly configService: ConfigService, + @InjectRepository(UserDevice) + private readonly deviceRepository: Repository, + @InjectRepository(NotificationLog) + private readonly logRepository: Repository, + ) {} + + onModuleInit() { + const vapidPublicKey = this.configService.get('VAPID_PUBLIC_KEY'); + const vapidPrivateKey = this.configService.get('VAPID_PRIVATE_KEY'); + const vapidSubject = this.configService.get( + 'VAPID_SUBJECT', + 'mailto:admin@example.com', + ); + + if (vapidPublicKey && vapidPrivateKey) { + try { + webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey); + this.isConfigured = true; + this.logger.log('Web Push configured with VAPID keys'); + } catch (error) { + this.logger.error(`Failed to configure Web Push: ${error.message}`); + } + } else { + this.logger.warn( + 'VAPID keys not configured. Push notifications will be disabled.', + ); + } + } + + isEnabled(): boolean { + return this.isConfigured; + } + + getVapidPublicKey(): string | null { + if (!this.isConfigured) { + return null; + } + return this.configService.get('VAPID_PUBLIC_KEY') || null; + } + + async sendToUser( + userId: string, + tenantId: string, + payload: PushPayload, + notificationId?: string, + ): Promise { + if (!this.isConfigured) { + this.logger.warn('Push notifications not configured, skipping send'); + return []; + } + + const devices = await this.deviceRepository.find({ + where: { user_id: userId, tenant_id: tenantId, is_active: true }, + }); + + if (devices.length === 0) { + this.logger.debug(`No active devices found for user ${userId}`); + return []; + } + + const results: SendResult[] = []; + const pushPayload = JSON.stringify({ + title: payload.title, + body: payload.body, + icon: payload.icon || '/icon-192.png', + badge: payload.badge || '/badge-72.png', + url: payload.url || '/', + data: payload.data, + actions: payload.actions || [ + { action: 'view', title: 'Ver' }, + { action: 'dismiss', title: 'Descartar' }, + ], + }); + + for (const device of devices) { + const result = await this.sendToDevice( + device, + pushPayload, + notificationId, + ); + results.push(result); + } + + const successCount = results.filter((r) => r.success).length; + this.logger.log( + `Push sent to user ${userId}: ${successCount}/${results.length} successful`, + ); + + return results; + } + + async sendToDevice( + device: UserDevice, + payload: string, + notificationId?: string, + ): Promise { + try { + const subscription = JSON.parse(device.device_token); + + await webpush.sendNotification(subscription, payload); + + // Update last used + device.last_used_at = new Date(); + await this.deviceRepository.save(device); + + // Log success + if (notificationId) { + await this.logRepository.save({ + notification_id: notificationId, + channel: 'push', + status: 'sent', + provider: 'web-push', + device_id: device.id, + delivered_at: new Date(), + }); + } + + return { + deviceId: device.id, + success: true, + }; + } catch (error) { + const statusCode = error.statusCode; + + // Handle expired subscriptions + if (statusCode === 410 || statusCode === 404) { + this.logger.warn( + `Device ${device.id} subscription expired (${statusCode}), marking inactive`, + ); + device.is_active = false; + await this.deviceRepository.save(device); + } + + // Log failure + if (notificationId) { + await this.logRepository.save({ + notification_id: notificationId, + channel: 'push', + status: 'failed', + provider: 'web-push', + device_id: device.id, + error_code: statusCode?.toString(), + error_message: error.message, + }); + } + + return { + deviceId: device.id, + success: false, + error: error.message, + statusCode, + }; + } + } + + async sendBroadcast( + tenantId: string, + payload: PushPayload, + ): Promise<{ total: number; successful: number; failed: number }> { + if (!this.isConfigured) { + this.logger.warn('Push notifications not configured, skipping broadcast'); + return { total: 0, successful: 0, failed: 0 }; + } + + const devices = await this.deviceRepository.find({ + where: { tenant_id: tenantId, is_active: true }, + }); + + const pushPayload = JSON.stringify({ + title: payload.title, + body: payload.body, + icon: payload.icon || '/icon-192.png', + badge: payload.badge || '/badge-72.png', + url: payload.url || '/', + data: payload.data, + }); + + let successful = 0; + let failed = 0; + + for (const device of devices) { + const result = await this.sendToDevice(device, pushPayload); + if (result.success) { + successful++; + } else { + failed++; + } + } + + this.logger.log( + `Broadcast to tenant ${tenantId}: ${successful}/${devices.length} successful`, + ); + + return { + total: devices.length, + successful, + failed, + }; + } + + validateSubscription(subscriptionJson: string): boolean { + try { + const subscription = JSON.parse(subscriptionJson); + + if (!subscription.endpoint) { + return false; + } + + if (!subscription.keys?.p256dh || !subscription.keys?.auth) { + return false; + } + + return true; + } catch { + return false; + } + } +} diff --git a/src/modules/onboarding/__tests__/onboarding.controller.spec.ts b/src/modules/onboarding/__tests__/onboarding.controller.spec.ts new file mode 100644 index 0000000..c3ce259 --- /dev/null +++ b/src/modules/onboarding/__tests__/onboarding.controller.spec.ts @@ -0,0 +1,608 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContext, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { OnboardingController } from '../onboarding.controller'; +import { OnboardingService } from '../onboarding.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RequestUser } from '../../auth/strategies/jwt.strategy'; +import { OnboardingStatusDto, CompleteOnboardingResponseDto } from '../dto'; + +describe('OnboardingController', () => { + let controller: OnboardingController; + let service: jest.Mocked; + + // Mock user data + const mockRequestUser: RequestUser = { + id: '550e8400-e29b-41d4-a716-446655440000', + email: 'test@example.com', + tenant_id: '550e8400-e29b-41d4-a716-446655440001', + }; + + // Mock onboarding status response + const mockOnboardingStatus: OnboardingStatusDto = { + step: 3, + completed: false, + data: { + company: { + name: 'Test Company', + slug: 'test-company', + logo_url: null, + settings: null, + }, + team: { + invitesSent: 2, + membersJoined: 1, + }, + plan: { + selected: false, + planId: null, + }, + }, + }; + + // Mock complete onboarding response + const mockCompleteResponse: CompleteOnboardingResponseDto = { + success: true, + redirectUrl: '/dashboard', + }; + + beforeEach(async () => { + const mockOnboardingService = { + getStatus: jest.fn(), + completeOnboarding: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [OnboardingController], + providers: [ + { provide: OnboardingService, useValue: mockOnboardingService }, + Reflector, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate: jest.fn().mockReturnValue(true), + }) + .compile(); + + controller = module.get(OnboardingController); + service = module.get(OnboardingService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== Controller Instantiation ==================== + + describe('controller setup', () => { + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + }); + + // ==================== getStatus Tests ==================== + + describe('getStatus', () => { + it('should return onboarding status for authenticated user', async () => { + service.getStatus.mockResolvedValue(mockOnboardingStatus); + + const result = await controller.getStatus(mockRequestUser); + + expect(result).toEqual(mockOnboardingStatus); + expect(service.getStatus).toHaveBeenCalledWith(mockRequestUser.tenant_id); + expect(service.getStatus).toHaveBeenCalledTimes(1); + }); + + it('should return completed status when onboarding is done', async () => { + const completedStatus: OnboardingStatusDto = { + ...mockOnboardingStatus, + step: 4, + completed: true, + data: { + ...mockOnboardingStatus.data, + plan: { + selected: true, + planId: 'plan-123', + }, + }, + }; + service.getStatus.mockResolvedValue(completedStatus); + + const result = await controller.getStatus(mockRequestUser); + + expect(result.completed).toBe(true); + expect(result.step).toBe(4); + expect(result.data.plan.selected).toBe(true); + }); + + it('should return step 1 when no company info is set', async () => { + const step1Status: OnboardingStatusDto = { + step: 1, + completed: false, + data: { + company: null, + team: { + invitesSent: 0, + membersJoined: 0, + }, + plan: { + selected: false, + planId: null, + }, + }, + }; + service.getStatus.mockResolvedValue(step1Status); + + const result = await controller.getStatus(mockRequestUser); + + expect(result.step).toBe(1); + expect(result.data.company).toBeNull(); + }); + + it('should throw BadRequestException when tenant not found', async () => { + service.getStatus.mockRejectedValue(new BadRequestException('Tenant not found')); + + await expect(controller.getStatus(mockRequestUser)).rejects.toThrow( + BadRequestException, + ); + await expect(controller.getStatus(mockRequestUser)).rejects.toThrow( + 'Tenant not found', + ); + }); + + it('should pass tenant_id from user context to service', async () => { + service.getStatus.mockResolvedValue(mockOnboardingStatus); + const differentUser: RequestUser = { + ...mockRequestUser, + tenant_id: 'different-tenant-123', + }; + + await controller.getStatus(differentUser); + + expect(service.getStatus).toHaveBeenCalledWith('different-tenant-123'); + }); + + it('should return correct team data', async () => { + const statusWithTeam: OnboardingStatusDto = { + ...mockOnboardingStatus, + data: { + ...mockOnboardingStatus.data, + team: { + invitesSent: 5, + membersJoined: 3, + }, + }, + }; + service.getStatus.mockResolvedValue(statusWithTeam); + + const result = await controller.getStatus(mockRequestUser); + + expect(result.data.team.invitesSent).toBe(5); + expect(result.data.team.membersJoined).toBe(3); + }); + }); + + // ==================== completeOnboarding Tests ==================== + + describe('completeOnboarding', () => { + it('should complete onboarding successfully', async () => { + service.completeOnboarding.mockResolvedValue(mockCompleteResponse); + + const result = await controller.completeOnboarding(mockRequestUser); + + expect(result).toEqual(mockCompleteResponse); + expect(result.success).toBe(true); + expect(result.redirectUrl).toBe('/dashboard'); + expect(service.completeOnboarding).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockRequestUser.id, + ); + }); + + it('should pass both tenant_id and user_id to service', async () => { + service.completeOnboarding.mockResolvedValue(mockCompleteResponse); + + await controller.completeOnboarding(mockRequestUser); + + expect(service.completeOnboarding).toHaveBeenCalledWith( + '550e8400-e29b-41d4-a716-446655440001', + '550e8400-e29b-41d4-a716-446655440000', + ); + }); + + it('should return success even if tenant is already active', async () => { + const alreadyActiveResponse: CompleteOnboardingResponseDto = { + success: true, + redirectUrl: '/dashboard', + }; + service.completeOnboarding.mockResolvedValue(alreadyActiveResponse); + + const result = await controller.completeOnboarding(mockRequestUser); + + expect(result.success).toBe(true); + }); + + it('should throw BadRequestException when tenant not found', async () => { + service.completeOnboarding.mockRejectedValue( + new BadRequestException('Tenant not found'), + ); + + await expect(controller.completeOnboarding(mockRequestUser)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when user not found', async () => { + service.completeOnboarding.mockRejectedValue( + new BadRequestException('User not found'), + ); + + await expect(controller.completeOnboarding(mockRequestUser)).rejects.toThrow( + 'User not found', + ); + }); + + it('should throw BadRequestException for invalid tenant state', async () => { + service.completeOnboarding.mockRejectedValue( + new BadRequestException('Tenant is not in a valid state for onboarding completion'), + ); + + await expect(controller.completeOnboarding(mockRequestUser)).rejects.toThrow( + BadRequestException, + ); + await expect(controller.completeOnboarding(mockRequestUser)).rejects.toThrow( + 'Tenant is not in a valid state for onboarding completion', + ); + }); + + it('should call service with correct arguments for different users', async () => { + service.completeOnboarding.mockResolvedValue(mockCompleteResponse); + const differentUser: RequestUser = { + id: 'user-abc-123', + email: 'different@example.com', + tenant_id: 'tenant-xyz-789', + }; + + await controller.completeOnboarding(differentUser); + + expect(service.completeOnboarding).toHaveBeenCalledWith( + 'tenant-xyz-789', + 'user-abc-123', + ); + }); + }); + + // ==================== Guards Testing ==================== + + describe('JwtAuthGuard', () => { + let moduleWithGuard: TestingModule; + let controllerWithGuard: OnboardingController; + let mockJwtGuard: jest.Mocked; + + beforeEach(async () => { + mockJwtGuard = { + canActivate: jest.fn(), + } as any; + + moduleWithGuard = await Test.createTestingModule({ + controllers: [OnboardingController], + providers: [ + { + provide: OnboardingService, + useValue: { + getStatus: jest.fn().mockResolvedValue(mockOnboardingStatus), + completeOnboarding: jest.fn().mockResolvedValue(mockCompleteResponse), + }, + }, + Reflector, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue(mockJwtGuard) + .compile(); + + controllerWithGuard = moduleWithGuard.get(OnboardingController); + }); + + it('should apply JwtAuthGuard to getStatus endpoint', async () => { + const guards = Reflect.getMetadata('__guards__', OnboardingController); + expect(guards).toBeDefined(); + expect(guards.length).toBeGreaterThan(0); + + // Verify JwtAuthGuard is in the list of guards + const hasJwtGuard = guards.some( + (guard: any) => guard === JwtAuthGuard || guard.name === 'JwtAuthGuard', + ); + expect(hasJwtGuard).toBe(true); + }); + + it('should apply JwtAuthGuard to completeOnboarding endpoint', async () => { + // The guard is applied at controller level, so checking controller metadata + const guards = Reflect.getMetadata('__guards__', OnboardingController); + expect(guards).toBeDefined(); + expect(guards.some((g: any) => g === JwtAuthGuard)).toBe(true); + }); + + it('should reject request when guard returns false', async () => { + mockJwtGuard.canActivate.mockReturnValue(false); + + const mockExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({}), + }), + } as ExecutionContext; + + const canActivate = mockJwtGuard.canActivate(mockExecutionContext); + expect(canActivate).toBe(false); + }); + + it('should allow request when guard returns true', async () => { + mockJwtGuard.canActivate.mockReturnValue(true); + + const mockExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { authorization: 'Bearer valid-token' }, + }), + }), + } as ExecutionContext; + + const canActivate = mockJwtGuard.canActivate(mockExecutionContext); + expect(canActivate).toBe(true); + }); + }); + + // ==================== HTTP Response Codes ==================== + + describe('HTTP response codes', () => { + it('should return 200 for successful getStatus', async () => { + service.getStatus.mockResolvedValue(mockOnboardingStatus); + + const result = await controller.getStatus(mockRequestUser); + + // The @Get decorator defaults to 200 OK + expect(result).toBeDefined(); + }); + + it('should return 200 for successful completeOnboarding', async () => { + service.completeOnboarding.mockResolvedValue(mockCompleteResponse); + + const result = await controller.completeOnboarding(mockRequestUser); + + // @HttpCode(HttpStatus.OK) is applied to this endpoint + expect(result).toBeDefined(); + expect(result.success).toBe(true); + }); + }); + + // ==================== Edge Cases ==================== + + describe('edge cases', () => { + it('should handle empty tenant_id gracefully', async () => { + const userWithEmptyTenant: RequestUser = { + ...mockRequestUser, + tenant_id: '', + }; + service.getStatus.mockRejectedValue(new BadRequestException('Tenant not found')); + + await expect(controller.getStatus(userWithEmptyTenant)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should handle null company data in status', async () => { + const statusWithNullCompany: OnboardingStatusDto = { + step: 1, + completed: false, + data: { + company: null, + team: { invitesSent: 0, membersJoined: 0 }, + plan: { selected: false, planId: null }, + }, + }; + service.getStatus.mockResolvedValue(statusWithNullCompany); + + const result = await controller.getStatus(mockRequestUser); + + expect(result.data.company).toBeNull(); + }); + + it('should handle service timeout', async () => { + service.getStatus.mockRejectedValue(new Error('Request timeout')); + + await expect(controller.getStatus(mockRequestUser)).rejects.toThrow( + 'Request timeout', + ); + }); + + it('should handle unexpected service errors', async () => { + service.completeOnboarding.mockRejectedValue(new Error('Internal server error')); + + await expect(controller.completeOnboarding(mockRequestUser)).rejects.toThrow( + 'Internal server error', + ); + }); + }); + + // ==================== Onboarding Flow Integration ==================== + + describe('onboarding flow', () => { + it('should support complete onboarding flow from step 1 to completion', async () => { + // Step 1: Initial status + const step1Status: OnboardingStatusDto = { + step: 1, + completed: false, + data: { + company: null, + team: { invitesSent: 0, membersJoined: 0 }, + plan: { selected: false, planId: null }, + }, + }; + + // Step 3: Company done, needs plan + const step3Status: OnboardingStatusDto = { + step: 3, + completed: false, + data: { + company: { name: 'Test', slug: 'test', logo_url: null, settings: null }, + team: { invitesSent: 2, membersJoined: 1 }, + plan: { selected: false, planId: null }, + }, + }; + + // Step 4: Ready for completion + const step4Status: OnboardingStatusDto = { + step: 4, + completed: false, + data: { + company: { name: 'Test', slug: 'test', logo_url: null, settings: null }, + team: { invitesSent: 2, membersJoined: 1 }, + plan: { selected: true, planId: 'plan-123' }, + }, + }; + + // Simulate flow + service.getStatus + .mockResolvedValueOnce(step1Status) + .mockResolvedValueOnce(step3Status) + .mockResolvedValueOnce(step4Status); + + const result1 = await controller.getStatus(mockRequestUser); + expect(result1.step).toBe(1); + + const result2 = await controller.getStatus(mockRequestUser); + expect(result2.step).toBe(3); + + const result3 = await controller.getStatus(mockRequestUser); + expect(result3.step).toBe(4); + + // Complete onboarding + service.completeOnboarding.mockResolvedValue(mockCompleteResponse); + const completeResult = await controller.completeOnboarding(mockRequestUser); + expect(completeResult.success).toBe(true); + }); + + it('should handle status check after completion', async () => { + const completedStatus: OnboardingStatusDto = { + step: 4, + completed: true, + data: { + company: { name: 'Test', slug: 'test', logo_url: null, settings: null }, + team: { invitesSent: 3, membersJoined: 2 }, + plan: { selected: true, planId: 'plan-pro' }, + }, + }; + service.getStatus.mockResolvedValue(completedStatus); + + const result = await controller.getStatus(mockRequestUser); + + expect(result.completed).toBe(true); + expect(result.step).toBe(4); + }); + }); + + // ==================== Tenant Setup During Onboarding ==================== + + describe('tenant setup during onboarding', () => { + it('should track company setup progress', async () => { + const statusWithCompany: OnboardingStatusDto = { + step: 3, + completed: false, + data: { + company: { + name: 'My Company Inc.', + slug: 'my-company-inc', + logo_url: 'https://example.com/logo.png', + settings: { timezone: 'UTC', currency: 'USD' }, + }, + team: { invitesSent: 0, membersJoined: 0 }, + plan: { selected: false, planId: null }, + }, + }; + service.getStatus.mockResolvedValue(statusWithCompany); + + const result = await controller.getStatus(mockRequestUser); + + expect(result.data.company).not.toBeNull(); + expect(result.data.company?.name).toBe('My Company Inc.'); + expect(result.data.company?.slug).toBe('my-company-inc'); + expect(result.data.company?.logo_url).toBe('https://example.com/logo.png'); + expect(result.data.company?.settings).toEqual({ timezone: 'UTC', currency: 'USD' }); + }); + + it('should track team invitations', async () => { + const statusWithTeam: OnboardingStatusDto = { + step: 3, + completed: false, + data: { + company: { name: 'Test', slug: 'test', logo_url: null, settings: null }, + team: { invitesSent: 10, membersJoined: 5 }, + plan: { selected: false, planId: null }, + }, + }; + service.getStatus.mockResolvedValue(statusWithTeam); + + const result = await controller.getStatus(mockRequestUser); + + expect(result.data.team.invitesSent).toBe(10); + expect(result.data.team.membersJoined).toBe(5); + }); + + it('should track plan selection', async () => { + const statusWithPlan: OnboardingStatusDto = { + step: 4, + completed: false, + data: { + company: { name: 'Test', slug: 'test', logo_url: null, settings: null }, + team: { invitesSent: 2, membersJoined: 1 }, + plan: { selected: true, planId: 'plan-enterprise' }, + }, + }; + service.getStatus.mockResolvedValue(statusWithPlan); + + const result = await controller.getStatus(mockRequestUser); + + expect(result.data.plan.selected).toBe(true); + expect(result.data.plan.planId).toBe('plan-enterprise'); + }); + }); + + // ==================== Error Propagation ==================== + + describe('error propagation', () => { + it('should propagate BadRequestException from service', async () => { + const error = new BadRequestException('Custom validation error'); + service.getStatus.mockRejectedValue(error); + + await expect(controller.getStatus(mockRequestUser)).rejects.toThrow( + BadRequestException, + ); + await expect(controller.getStatus(mockRequestUser)).rejects.toThrow( + 'Custom validation error', + ); + }); + + it('should propagate generic errors from service', async () => { + const error = new Error('Database connection failed'); + service.completeOnboarding.mockRejectedValue(error); + + await expect(controller.completeOnboarding(mockRequestUser)).rejects.toThrow( + 'Database connection failed', + ); + }); + + it('should handle multiple consecutive errors', async () => { + service.getStatus + .mockRejectedValueOnce(new BadRequestException('First error')) + .mockRejectedValueOnce(new BadRequestException('Second error')) + .mockResolvedValueOnce(mockOnboardingStatus); + + await expect(controller.getStatus(mockRequestUser)).rejects.toThrow('First error'); + await expect(controller.getStatus(mockRequestUser)).rejects.toThrow('Second error'); + + const result = await controller.getStatus(mockRequestUser); + expect(result).toEqual(mockOnboardingStatus); + }); + }); +}); diff --git a/src/modules/onboarding/__tests__/onboarding.service.spec.ts b/src/modules/onboarding/__tests__/onboarding.service.spec.ts new file mode 100644 index 0000000..2f8f178 --- /dev/null +++ b/src/modules/onboarding/__tests__/onboarding.service.spec.ts @@ -0,0 +1,329 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { OnboardingService } from '../onboarding.service'; +import { Tenant } from '../../tenants/entities/tenant.entity'; +import { User } from '../../auth/entities/user.entity'; +import { Token } from '../../auth/entities/token.entity'; +import { Subscription, SubscriptionStatus } from '../../billing/entities/subscription.entity'; +import { EmailService } from '../../email/services/email.service'; +import { AuditService } from '../../audit/services/audit.service'; + +describe('OnboardingService', () => { + let service: OnboardingService; + let tenantRepo: jest.Mocked>; + let userRepo: jest.Mocked>; + let tokenRepo: jest.Mocked>; + let subscriptionRepo: jest.Mocked>; + let emailService: jest.Mocked; + let auditService: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + const mockPlanId = '550e8400-e29b-41d4-a716-446655440003'; + + const mockTenant: Partial = { + id: mockTenantId, + name: 'Test Company', + slug: 'test-company', + status: 'pending', + logo_url: null, + settings: null, + plan_id: null, + }; + + const mockUser: Partial = { + id: mockUserId, + tenant_id: mockTenantId, + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + status: 'active', + }; + + const mockSubscription: Partial = { + id: 'sub-001', + tenant_id: mockTenantId, + plan_id: mockPlanId, + status: SubscriptionStatus.ACTIVE, + }; + + beforeEach(async () => { + const mockTenantRepo = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const mockUserRepo = { + findOne: jest.fn(), + count: jest.fn(), + }; + + const mockTokenRepo = { + count: jest.fn(), + }; + + const mockSubscriptionRepo = { + findOne: jest.fn(), + }; + + const mockEmailService = { + sendTemplateEmail: jest.fn(), + }; + + const mockAuditService = { + createAuditLog: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OnboardingService, + { provide: getRepositoryToken(Tenant), useValue: mockTenantRepo }, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: getRepositoryToken(Token), useValue: mockTokenRepo }, + { provide: getRepositoryToken(Subscription), useValue: mockSubscriptionRepo }, + { provide: EmailService, useValue: mockEmailService }, + { provide: AuditService, useValue: mockAuditService }, + ], + }).compile(); + + service = module.get(OnboardingService); + tenantRepo = module.get(getRepositoryToken(Tenant)); + userRepo = module.get(getRepositoryToken(User)); + tokenRepo = module.get(getRepositoryToken(Token)); + subscriptionRepo = module.get(getRepositoryToken(Subscription)); + emailService = module.get(EmailService); + auditService = module.get(AuditService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== getStatus Tests ==================== + + describe('getStatus', () => { + it('should return onboarding status for tenant with all data', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + tokenRepo.count.mockResolvedValue(3); + userRepo.count.mockResolvedValue(2); + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await service.getStatus(mockTenantId); + + expect(result).toBeDefined(); + expect(result.completed).toBe(false); + expect(result.data.company).toBeDefined(); + expect(result.data.company?.name).toBe('Test Company'); + expect(result.data.company?.slug).toBe('test-company'); + expect(result.data.team.invitesSent).toBe(3); + expect(result.data.team.membersJoined).toBe(1); // 2 - 1 (owner) + expect(result.data.plan.selected).toBe(true); + expect(result.data.plan.planId).toBe(mockPlanId); + }); + + it('should return step 1 when company info is missing', async () => { + const tenantWithoutInfo = { ...mockTenant, name: '', slug: '' }; + tenantRepo.findOne.mockResolvedValue(tenantWithoutInfo as Tenant); + tokenRepo.count.mockResolvedValue(0); + userRepo.count.mockResolvedValue(1); + subscriptionRepo.findOne.mockResolvedValue(null); + + const result = await service.getStatus(mockTenantId); + + expect(result.step).toBe(1); + expect(result.data.company).toBeNull(); + }); + + it('should return step 3 when plan is not selected', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + tokenRepo.count.mockResolvedValue(0); + userRepo.count.mockResolvedValue(1); + subscriptionRepo.findOne.mockResolvedValue(null); + + const result = await service.getStatus(mockTenantId); + + expect(result.step).toBe(3); + expect(result.data.plan.selected).toBe(false); + }); + + it('should return step 4 when ready for completion', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + tokenRepo.count.mockResolvedValue(2); + userRepo.count.mockResolvedValue(3); + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await service.getStatus(mockTenantId); + + expect(result.step).toBe(4); + }); + + it('should return completed=true when tenant is active', async () => { + const activeTenant = { ...mockTenant, status: 'active' }; + tenantRepo.findOne.mockResolvedValue(activeTenant as Tenant); + tokenRepo.count.mockResolvedValue(0); + userRepo.count.mockResolvedValue(1); + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await service.getStatus(mockTenantId); + + expect(result.completed).toBe(true); + expect(result.step).toBe(4); + }); + + it('should throw BadRequestException when tenant not found', async () => { + tenantRepo.findOne.mockResolvedValue(null); + + await expect(service.getStatus(mockTenantId)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // ==================== completeOnboarding Tests ==================== + + describe('completeOnboarding', () => { + it('should complete onboarding successfully', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + userRepo.findOne.mockResolvedValue(mockUser as User); + tenantRepo.save.mockResolvedValue({ ...mockTenant, status: 'active' } as Tenant); + auditService.createAuditLog.mockResolvedValue({} as any); + emailService.sendTemplateEmail.mockResolvedValue({ success: true } as any); + + const result = await service.completeOnboarding(mockTenantId, mockUserId); + + expect(result.success).toBe(true); + expect(result.redirectUrl).toBe('/dashboard'); + expect(tenantRepo.save).toHaveBeenCalled(); + expect(auditService.createAuditLog).toHaveBeenCalledWith( + expect.objectContaining({ + tenant_id: mockTenantId, + user_id: mockUserId, + action: 'update', + entity_type: 'tenant', + }), + ); + expect(emailService.sendTemplateEmail).toHaveBeenCalledWith( + expect.objectContaining({ + templateKey: 'welcome', + }), + ); + }); + + it('should return success if tenant already active', async () => { + const activeTenant = { ...mockTenant, status: 'active' }; + tenantRepo.findOne.mockResolvedValue(activeTenant as Tenant); + + const result = await service.completeOnboarding(mockTenantId, mockUserId); + + expect(result.success).toBe(true); + expect(result.redirectUrl).toBe('/dashboard'); + expect(tenantRepo.save).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when tenant not found', async () => { + tenantRepo.findOne.mockResolvedValue(null); + + await expect( + service.completeOnboarding(mockTenantId, mockUserId), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when user not found', async () => { + // Ensure tenant is in pending state so we don't return early + const pendingTenant = { ...mockTenant, status: 'pending' }; + tenantRepo.findOne.mockResolvedValue(pendingTenant as Tenant); + userRepo.findOne.mockResolvedValue(null); + + await expect( + service.completeOnboarding(mockTenantId, mockUserId), + ).rejects.toThrow(BadRequestException); + }); + + it('should complete onboarding even if email fails', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + userRepo.findOne.mockResolvedValue(mockUser as User); + tenantRepo.save.mockResolvedValue({ ...mockTenant, status: 'active' } as Tenant); + auditService.createAuditLog.mockResolvedValue({} as any); + emailService.sendTemplateEmail.mockRejectedValue(new Error('Email failed')); + + const result = await service.completeOnboarding(mockTenantId, mockUserId); + + expect(result.success).toBe(true); + expect(result.redirectUrl).toBe('/dashboard'); + }); + + it('should update tenant status from pending to active', async () => { + // Ensure tenant is in pending state so we process the status update + const pendingTenant = { ...mockTenant, status: 'pending' }; + tenantRepo.findOne.mockResolvedValue(pendingTenant as Tenant); + userRepo.findOne.mockResolvedValue(mockUser as User); + tenantRepo.save.mockImplementation(async (tenant) => tenant as Tenant); + auditService.createAuditLog.mockResolvedValue({} as any); + emailService.sendTemplateEmail.mockResolvedValue({ success: true } as any); + + await service.completeOnboarding(mockTenantId, mockUserId); + + expect(tenantRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'active', + }), + ); + }); + }); + + // ==================== Team Data Tests ==================== + + describe('team data calculation', () => { + it('should return 0 members joined when only owner exists', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + tokenRepo.count.mockResolvedValue(0); + userRepo.count.mockResolvedValue(1); // Only owner + subscriptionRepo.findOne.mockResolvedValue(null); + + const result = await service.getStatus(mockTenantId); + + expect(result.data.team.membersJoined).toBe(0); + }); + + it('should count invitations correctly', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + tokenRepo.count.mockResolvedValue(5); + userRepo.count.mockResolvedValue(1); + subscriptionRepo.findOne.mockResolvedValue(null); + + const result = await service.getStatus(mockTenantId); + + expect(result.data.team.invitesSent).toBe(5); + }); + }); + + // ==================== Plan Data Tests ==================== + + describe('plan data calculation', () => { + it('should return selected=false when no subscription', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + tokenRepo.count.mockResolvedValue(0); + userRepo.count.mockResolvedValue(1); + subscriptionRepo.findOne.mockResolvedValue(null); + + const result = await service.getStatus(mockTenantId); + + expect(result.data.plan.selected).toBe(false); + expect(result.data.plan.planId).toBeNull(); + }); + + it('should return selected=true with planId when subscription exists', async () => { + tenantRepo.findOne.mockResolvedValue(mockTenant as Tenant); + tokenRepo.count.mockResolvedValue(0); + userRepo.count.mockResolvedValue(1); + subscriptionRepo.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await service.getStatus(mockTenantId); + + expect(result.data.plan.selected).toBe(true); + expect(result.data.plan.planId).toBe(mockPlanId); + }); + }); +}); diff --git a/src/modules/onboarding/dto/complete-onboarding.dto.ts b/src/modules/onboarding/dto/complete-onboarding.dto.ts new file mode 100644 index 0000000..ca98c88 --- /dev/null +++ b/src/modules/onboarding/dto/complete-onboarding.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CompleteOnboardingResponseDto { + @ApiProperty({ description: 'Whether the operation was successful' }) + success: boolean; + + @ApiProperty({ description: 'URL to redirect to after onboarding' }) + redirectUrl: string; +} diff --git a/src/modules/onboarding/dto/index.ts b/src/modules/onboarding/dto/index.ts new file mode 100644 index 0000000..e87847a --- /dev/null +++ b/src/modules/onboarding/dto/index.ts @@ -0,0 +1,2 @@ +export * from './onboarding-status.dto'; +export * from './complete-onboarding.dto'; diff --git a/src/modules/onboarding/dto/onboarding-status.dto.ts b/src/modules/onboarding/dto/onboarding-status.dto.ts new file mode 100644 index 0000000..76fcc5e --- /dev/null +++ b/src/modules/onboarding/dto/onboarding-status.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CompanyDataDto { + @ApiProperty({ description: 'Company name' }) + name: string; + + @ApiProperty({ description: 'Company slug' }) + slug: string; + + @ApiPropertyOptional({ description: 'Company logo URL' }) + logo_url: string | null; + + @ApiPropertyOptional({ description: 'Company settings' }) + settings: Record | null; +} + +export class TeamDataDto { + @ApiProperty({ description: 'Number of invitations sent' }) + invitesSent: number; + + @ApiProperty({ description: 'Number of members who have joined' }) + membersJoined: number; +} + +export class PlanDataDto { + @ApiProperty({ description: 'Whether a plan has been selected' }) + selected: boolean; + + @ApiPropertyOptional({ description: 'Selected plan ID' }) + planId: string | null; +} + +export class OnboardingDataDto { + @ApiPropertyOptional({ description: 'Company information', type: CompanyDataDto }) + company: CompanyDataDto | null; + + @ApiProperty({ description: 'Team information', type: TeamDataDto }) + team: TeamDataDto; + + @ApiProperty({ description: 'Plan information', type: PlanDataDto }) + plan: PlanDataDto; +} + +export class OnboardingStatusDto { + @ApiProperty({ description: 'Current onboarding step (1-4)', minimum: 1, maximum: 4 }) + step: number; + + @ApiProperty({ description: 'Whether onboarding is completed' }) + completed: boolean; + + @ApiProperty({ description: 'Onboarding data', type: OnboardingDataDto }) + data: OnboardingDataDto; +} diff --git a/src/modules/onboarding/index.ts b/src/modules/onboarding/index.ts new file mode 100644 index 0000000..2699d55 --- /dev/null +++ b/src/modules/onboarding/index.ts @@ -0,0 +1,4 @@ +export * from './onboarding.module'; +export * from './onboarding.controller'; +export * from './onboarding.service'; +export * from './dto'; diff --git a/src/modules/onboarding/onboarding.controller.ts b/src/modules/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..7f9ce65 --- /dev/null +++ b/src/modules/onboarding/onboarding.controller.ts @@ -0,0 +1,55 @@ +import { + Controller, + Get, + Post, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { OnboardingService } from './onboarding.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; +import { OnboardingStatusDto, CompleteOnboardingResponseDto } from './dto'; + +@ApiTags('onboarding') +@Controller('onboarding') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class OnboardingController { + constructor(private readonly onboardingService: OnboardingService) {} + + @Get('status') + @ApiOperation({ summary: 'Get onboarding status for current tenant' }) + @ApiResponse({ + status: 200, + description: 'Current onboarding status', + type: OnboardingStatusDto, + }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async getStatus(@CurrentUser() user: RequestUser): Promise { + return this.onboardingService.getStatus(user.tenant_id); + } + + @Post('complete') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Complete the onboarding process' }) + @ApiResponse({ + status: 200, + description: 'Onboarding completed successfully', + type: CompleteOnboardingResponseDto, + }) + @ApiResponse({ status: 400, description: 'Bad request - Invalid tenant state' }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + async completeOnboarding( + @CurrentUser() user: RequestUser, + ): Promise { + return this.onboardingService.completeOnboarding(user.tenant_id, user.id); + } +} diff --git a/src/modules/onboarding/onboarding.module.ts b/src/modules/onboarding/onboarding.module.ts new file mode 100644 index 0000000..4e4a625 --- /dev/null +++ b/src/modules/onboarding/onboarding.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { OnboardingController } from './onboarding.controller'; +import { OnboardingService } from './onboarding.service'; +import { Tenant } from '../tenants/entities/tenant.entity'; +import { User } from '../auth/entities/user.entity'; +import { Token } from '../auth/entities/token.entity'; +import { Subscription } from '../billing/entities/subscription.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Tenant, User, Token, Subscription]), + ], + controllers: [OnboardingController], + providers: [OnboardingService], + exports: [OnboardingService], +}) +export class OnboardingModule {} diff --git a/src/modules/onboarding/onboarding.service.ts b/src/modules/onboarding/onboarding.service.ts new file mode 100644 index 0000000..6430d33 --- /dev/null +++ b/src/modules/onboarding/onboarding.service.ts @@ -0,0 +1,263 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Tenant } from '../tenants/entities/tenant.entity'; +import { User } from '../auth/entities/user.entity'; +import { Token } from '../auth/entities/token.entity'; +import { Subscription } from '../billing/entities/subscription.entity'; +import { EmailService } from '../email/services/email.service'; +import { AuditService, CreateAuditLogParams } from '../audit/services/audit.service'; +import { AuditAction } from '../audit/entities/audit-log.entity'; +import { + OnboardingStatusDto, + CompanyDataDto, + TeamDataDto, + PlanDataDto, + CompleteOnboardingResponseDto, +} from './dto'; + +@Injectable() +export class OnboardingService { + private readonly logger = new Logger(OnboardingService.name); + + constructor( + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Token) + private readonly tokenRepository: Repository, + @InjectRepository(Subscription) + private readonly subscriptionRepository: Repository, + private readonly emailService: EmailService, + private readonly auditService: AuditService, + ) {} + + /** + * Get the current onboarding status for a tenant + */ + async getStatus(tenantId: string): Promise { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId }, + }); + + if (!tenant) { + throw new BadRequestException('Tenant not found'); + } + + // Get team data + const teamData = await this.getTeamData(tenantId); + + // Get plan data + const planData = await this.getPlanData(tenantId); + + // Get company data + const companyData = this.getCompanyData(tenant); + + // Calculate current step + const step = this.calculateStep(companyData, teamData, planData, tenant); + + // Check if completed + const completed = tenant.status === 'active'; + + return { + step, + completed, + data: { + company: companyData, + team: teamData, + plan: planData, + }, + }; + } + + /** + * Complete the onboarding process + */ + async completeOnboarding( + tenantId: string, + userId: string, + ): Promise { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId }, + }); + + if (!tenant) { + throw new BadRequestException('Tenant not found'); + } + + // Verify tenant is in pending/trial state + if (tenant.status === 'active') { + return { + success: true, + redirectUrl: '/dashboard', + }; + } + + if (tenant.status !== 'pending' && tenant.status !== 'suspended' && tenant.status !== 'cancelled') { + throw new BadRequestException('Tenant is not in a valid state for onboarding completion'); + } + + // Get the user for sending welcome email + const user = await this.userRepository.findOne({ + where: { id: userId, tenant_id: tenantId }, + }); + + if (!user) { + throw new BadRequestException('User not found'); + } + + // Store old values for audit + const oldStatus = tenant.status; + + // Update tenant status to active + tenant.status = 'active'; + await this.tenantRepository.save(tenant); + + // Log to audit + await this.auditService.createAuditLog({ + tenant_id: tenantId, + user_id: userId, + action: AuditAction.UPDATE, + entity_type: 'tenant', + entity_id: tenantId, + old_values: { status: oldStatus }, + new_values: { status: 'active' }, + description: 'Onboarding completed', + }); + + // Send welcome email + await this.sendWelcomeEmail(user, tenant); + + this.logger.log(`Onboarding completed for tenant ${tenantId}`); + + return { + success: true, + redirectUrl: '/dashboard', + }; + } + + /** + * Get company data from tenant + */ + private getCompanyData(tenant: Tenant): CompanyDataDto | null { + if (!tenant.name || !tenant.slug) { + return null; + } + + return { + name: tenant.name, + slug: tenant.slug, + logo_url: tenant.logo_url, + settings: tenant.settings, + }; + } + + /** + * Get team data (invitations sent and members joined) + */ + private async getTeamData(tenantId: string): Promise { + // Count invitations sent (tokens with type 'invitation') + const invitesSent = await this.tokenRepository.count({ + where: { + tenant_id: tenantId, + token_type: 'invitation', + }, + }); + + // Count members joined (users in the tenant excluding the owner) + const membersJoined = await this.userRepository.count({ + where: { + tenant_id: tenantId, + status: 'active', + }, + }); + + // Subtract 1 for the owner if there's at least one member + const actualMembersJoined = membersJoined > 1 ? membersJoined - 1 : 0; + + return { + invitesSent, + membersJoined: actualMembersJoined, + }; + } + + /** + * Get plan data (subscription status) + */ + private async getPlanData(tenantId: string): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + }); + + return { + selected: !!subscription, + planId: subscription?.plan_id || null, + }; + } + + /** + * Calculate the current onboarding step + * Step 1: Company info + * Step 2: Team invitations + * Step 3: Plan selection + * Step 4: Complete + */ + private calculateStep( + companyData: CompanyDataDto | null, + teamData: TeamDataDto, + planData: PlanDataDto, + tenant: Tenant, + ): number { + // If onboarding is already complete, return step 4 + if (tenant.status === 'active') { + return 4; + } + + // Step 1: Company info + if (!companyData) { + return 1; + } + + // Step 2: Team invitations (optional, but track if done) + // For step calculation, if company is done, move to step 2 + // The user can skip this step + + // Step 3: Plan selection + if (!planData.selected) { + // If company info is filled, we're at step 2 or 3 + // Check if we should be at step 2 (invite) or step 3 (plan) + // For simplicity, if company is done but no plan, return step 3 + return 3; + } + + // All prerequisites done, ready for completion + return 4; + } + + /** + * Send welcome email to user + */ + private async sendWelcomeEmail(user: User, tenant: Tenant): Promise { + try { + await this.emailService.sendTemplateEmail({ + to: { + email: user.email, + name: user.first_name || undefined, + }, + templateKey: 'welcome', + variables: { + userName: user.first_name || user.email, + appName: 'Template SaaS', + tenantName: tenant.name, + }, + }); + + this.logger.log(`Welcome email sent to ${user.email}`); + } catch (error) { + // Log error but don't fail the onboarding completion + this.logger.error(`Failed to send welcome email to ${user.email}`, error.stack); + } + } +} diff --git a/src/modules/rbac/__tests__/rbac.controller.spec.ts b/src/modules/rbac/__tests__/rbac.controller.spec.ts new file mode 100644 index 0000000..42fa9e0 --- /dev/null +++ b/src/modules/rbac/__tests__/rbac.controller.spec.ts @@ -0,0 +1,249 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { RbacController } from '../rbac.controller'; +import { RbacService } from '../services/rbac.service'; + +describe('RbacController', () => { + let controller: RbacController; + let service: jest.Mocked; + + const mockRequestUser = { + id: 'user-123', + sub: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + role: 'admin', + }; + + const mockRole = { + id: 'role-123', + tenant_id: 'tenant-123', + name: 'Admin', + description: 'Administrator role', + is_system: false, + created_at: new Date('2026-01-01'), + }; + + const mockPermission = { + id: 'perm-123', + key: 'users:read', + name: 'Read Users', + description: 'Can read users', + category: 'users', + }; + + const mockUserRole = { + id: 'ur-123', + user_id: 'user-123', + role_id: 'role-123', + tenant_id: 'tenant-123', + role: mockRole, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RbacController], + providers: [ + { + provide: RbacService, + useValue: { + findAllRoles: jest.fn(), + getRoleWithPermissions: jest.fn(), + createRole: jest.fn(), + updateRole: jest.fn(), + deleteRole: jest.fn(), + findAllPermissions: jest.fn(), + findPermissionsByCategory: jest.fn(), + getUserRoles: jest.fn(), + getUserPermissions: jest.fn(), + assignRoleToUser: jest.fn(), + removeRoleFromUser: jest.fn(), + userHasPermission: jest.fn().mockResolvedValue(true), + userHasAnyPermission: jest.fn().mockResolvedValue(true), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(RbacController); + service = module.get(RbacService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // Roles Tests + describe('findAllRoles', () => { + it('should return all roles for tenant', async () => { + service.findAllRoles.mockResolvedValue([mockRole] as any); + + const result = await controller.findAllRoles(mockRequestUser); + + expect(result).toEqual([mockRole]); + expect(service.findAllRoles).toHaveBeenCalledWith('tenant-123'); + }); + }); + + describe('findRoleById', () => { + it('should return role with permissions', async () => { + const roleWithPerms = { ...mockRole, permissions: [mockPermission] }; + service.getRoleWithPermissions.mockResolvedValue(roleWithPerms as any); + + const result = await controller.findRoleById('role-123', mockRequestUser); + + expect(result).toEqual(roleWithPerms); + expect(service.getRoleWithPermissions).toHaveBeenCalledWith('role-123', 'tenant-123'); + }); + }); + + describe('createRole', () => { + it('should create a role', async () => { + const dto = { name: 'New Role', description: 'A new role' }; + service.createRole.mockResolvedValue(mockRole as any); + + const result = await controller.createRole(dto as any, mockRequestUser); + + expect(result).toEqual(mockRole); + expect(service.createRole).toHaveBeenCalledWith(dto, 'tenant-123'); + }); + }); + + describe('updateRole', () => { + it('should update a role', async () => { + const dto = { name: 'Updated Role' }; + const updated = { ...mockRole, name: 'Updated Role' }; + service.updateRole.mockResolvedValue(updated as any); + + const result = await controller.updateRole('role-123', dto as any, mockRequestUser); + + expect(result.name).toBe('Updated Role'); + expect(service.updateRole).toHaveBeenCalledWith('role-123', dto, 'tenant-123'); + }); + }); + + describe('deleteRole', () => { + it('should delete a role', async () => { + service.deleteRole.mockResolvedValue(undefined); + + const result = await controller.deleteRole('role-123', mockRequestUser); + + expect(result.message).toBe('Role eliminado correctamente'); + expect(service.deleteRole).toHaveBeenCalledWith('role-123', 'tenant-123'); + }); + }); + + // Permissions Tests + describe('findAllPermissions', () => { + it('should return all permissions', async () => { + service.findAllPermissions.mockResolvedValue([mockPermission] as any); + + const result = await controller.findAllPermissions(); + + expect(result).toEqual([mockPermission]); + expect(service.findAllPermissions).toHaveBeenCalled(); + }); + }); + + describe('findPermissionsByCategory', () => { + it('should return permissions by category', async () => { + service.findPermissionsByCategory.mockResolvedValue([mockPermission] as any); + + const result = await controller.findPermissionsByCategory('users'); + + expect(result).toEqual([mockPermission]); + expect(service.findPermissionsByCategory).toHaveBeenCalledWith('users'); + }); + }); + + // User Roles Tests + describe('getUserRoles', () => { + it('should return user roles', async () => { + service.getUserRoles.mockResolvedValue([mockUserRole] as any); + + const result = await controller.getUserRoles('user-456', mockRequestUser); + + expect(result).toEqual([mockUserRole]); + expect(service.getUserRoles).toHaveBeenCalledWith('user-456', 'tenant-123'); + }); + }); + + describe('getUserPermissions', () => { + it('should return user permissions', async () => { + const permissions = [mockPermission, { ...mockPermission, id: 'perm-456', key: 'users:write' }]; + service.getUserPermissions.mockResolvedValue(permissions as any); + + const result = await controller.getUserPermissions('user-456', mockRequestUser); + + expect(result).toEqual(permissions); + expect(service.getUserPermissions).toHaveBeenCalledWith('user-456', 'tenant-123'); + }); + }); + + describe('assignRoleToUser', () => { + it('should assign role to user', async () => { + const dto = { user_id: 'user-456', role_id: 'role-123' }; + service.assignRoleToUser.mockResolvedValue(mockUserRole as any); + + const result = await controller.assignRoleToUser(dto as any, mockRequestUser); + + expect(result).toEqual(mockUserRole); + expect(service.assignRoleToUser).toHaveBeenCalledWith(dto, 'tenant-123', 'user-123'); + }); + }); + + describe('removeRoleFromUser', () => { + it('should remove role from user', async () => { + service.removeRoleFromUser.mockResolvedValue(undefined); + + const result = await controller.removeRoleFromUser('user-456', 'role-123', mockRequestUser); + + expect(result.message).toBe('Role removido correctamente'); + expect(service.removeRoleFromUser).toHaveBeenCalledWith('user-456', 'role-123', 'tenant-123'); + }); + }); + + // Permission Check Tests + describe('checkPermission', () => { + it('should return true when user has permission', async () => { + service.userHasPermission.mockResolvedValue(true); + + const result = await controller.checkPermission('users:read', mockRequestUser); + + expect(result).toEqual({ hasPermission: true }); + expect(service.userHasPermission).toHaveBeenCalledWith('user-123', 'tenant-123', 'users:read'); + }); + + it('should return false when user lacks permission', async () => { + service.userHasPermission.mockResolvedValue(false); + + const result = await controller.checkPermission('admin:delete', mockRequestUser); + + expect(result).toEqual({ hasPermission: false }); + }); + }); + + describe('getMyRoles', () => { + it('should return current user roles', async () => { + service.getUserRoles.mockResolvedValue([mockUserRole] as any); + + const result = await controller.getMyRoles(mockRequestUser); + + expect(result).toEqual([mockUserRole]); + expect(service.getUserRoles).toHaveBeenCalledWith('user-123', 'tenant-123'); + }); + }); + + describe('getMyPermissions', () => { + it('should return current user permissions', async () => { + const permissions = [mockPermission, { ...mockPermission, id: 'perm-456', key: 'roles:read' }]; + service.getUserPermissions.mockResolvedValue(permissions as any); + + const result = await controller.getMyPermissions(mockRequestUser); + + expect(result).toEqual(permissions); + expect(service.getUserPermissions).toHaveBeenCalledWith('user-123', 'tenant-123'); + }); + }); +}); diff --git a/src/modules/rbac/__tests__/rbac.service.spec.ts b/src/modules/rbac/__tests__/rbac.service.spec.ts new file mode 100644 index 0000000..279c4c8 --- /dev/null +++ b/src/modules/rbac/__tests__/rbac.service.spec.ts @@ -0,0 +1,424 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + NotFoundException, + ConflictException, + ForbiddenException, +} from '@nestjs/common'; +import { RbacService } from '../services/rbac.service'; +import { Role, Permission, UserRole, RolePermission } from '../entities'; + +describe('RbacService', () => { + let service: RbacService; + let roleRepository: jest.Mocked>; + let permissionRepository: jest.Mocked>; + let userRoleRepository: jest.Mocked>; + let rolePermissionRepository: jest.Mocked>; + + const mockRole: Role = { + id: 'role-123', + tenant_id: 'tenant-123', + name: 'Admin', + code: 'admin', + description: 'Administrator role', + is_system: false, + is_active: true, + metadata: null, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + const mockSystemRole: Role = { + ...mockRole, + id: 'role-system', + code: 'owner', + name: 'Owner', + is_system: true, + }; + + const mockPermission: Permission = { + id: 'perm-123', + code: 'users:read', + name: 'Read Users', + description: 'Can read user data', + category: 'users', + is_sensitive: false, + requires_owner: false, + created_at: new Date('2026-01-01'), + }; + + const mockUserRole: UserRole = { + id: 'ur-123', + user_id: 'user-123', + role_id: 'role-123', + tenant_id: 'tenant-123', + assigned_by: 'admin-123', + created_at: new Date('2026-01-01'), + }; + + const mockRolePermission: RolePermission = { + id: 'rp-123', + role_id: 'role-123', + permission_id: 'perm-123', + created_at: new Date('2026-01-01'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RbacService, + { + provide: getRepositoryToken(Role), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Permission), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: getRepositoryToken(UserRole), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, + }, + { + provide: getRepositoryToken(RolePermission), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(RbacService); + roleRepository = module.get(getRepositoryToken(Role)); + permissionRepository = module.get(getRepositoryToken(Permission)); + userRoleRepository = module.get(getRepositoryToken(UserRole)); + rolePermissionRepository = module.get(getRepositoryToken(RolePermission)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createRole', () => { + const createDto = { + name: 'Manager', + code: 'manager', + description: 'Manager role', + permissions: ['users:read'], + }; + + it('should create a new role', async () => { + const createdRole = { ...mockRole, ...createDto, id: 'new-role-123' } as Role; + // First call: check if role exists (returns null) + // Second call: find role for setRolePermissions (returns the created role) + roleRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(createdRole); + roleRepository.create.mockReturnValue(createdRole); + roleRepository.save.mockResolvedValue(createdRole); + permissionRepository.find.mockResolvedValue([mockPermission]); + rolePermissionRepository.delete.mockResolvedValue({ affected: 0, raw: {} }); + rolePermissionRepository.save.mockResolvedValue([mockRolePermission] as any); + + const result = await service.createRole(createDto, 'tenant-123'); + + expect(result.name).toBe('Manager'); + expect(roleRepository.create).toHaveBeenCalled(); + expect(roleRepository.save).toHaveBeenCalled(); + }); + + it('should throw ConflictException if role code exists', async () => { + roleRepository.findOne.mockResolvedValue(mockRole); + + await expect(service.createRole(createDto, 'tenant-123')).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('updateRole', () => { + const updateDto = { name: 'Updated Admin', description: 'Updated description' }; + + it('should update a role', async () => { + const roleCopy = { ...mockRole }; + roleRepository.findOne.mockResolvedValue(roleCopy as Role); + roleRepository.save.mockResolvedValue({ ...roleCopy, ...updateDto } as Role); + + const result = await service.updateRole('role-123', updateDto, 'tenant-123'); + + expect(result.name).toBe('Updated Admin'); + expect(roleRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException if role not found', async () => { + roleRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateRole('non-existent', updateDto, 'tenant-123'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw ForbiddenException for system role', async () => { + roleRepository.findOne.mockResolvedValue(mockSystemRole); + + await expect( + service.updateRole('role-system', updateDto, 'tenant-123'), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('deleteRole', () => { + it('should delete a role', async () => { + roleRepository.findOne.mockResolvedValue(mockRole); + userRoleRepository.count.mockResolvedValue(0); + rolePermissionRepository.delete.mockResolvedValue({ affected: 1, raw: {} }); + roleRepository.delete.mockResolvedValue({ affected: 1, raw: {} }); + + await service.deleteRole('role-123', 'tenant-123'); + + expect(roleRepository.delete).toHaveBeenCalledWith({ id: 'role-123' }); + }); + + it('should throw NotFoundException if role not found', async () => { + roleRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteRole('non-existent', 'tenant-123')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw ForbiddenException for system role', async () => { + roleRepository.findOne.mockResolvedValue(mockSystemRole); + + await expect(service.deleteRole('role-system', 'tenant-123')).rejects.toThrow( + ForbiddenException, + ); + }); + + it('should throw ConflictException if role is assigned to users', async () => { + roleRepository.findOne.mockResolvedValue(mockRole); + userRoleRepository.count.mockResolvedValue(5); + + await expect(service.deleteRole('role-123', 'tenant-123')).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('findAllRoles', () => { + it('should return all active roles for tenant', async () => { + roleRepository.find.mockResolvedValue([mockRole]); + + const result = await service.findAllRoles('tenant-123'); + + expect(result).toEqual([mockRole]); + expect(roleRepository.find).toHaveBeenCalledWith({ + where: { tenant_id: 'tenant-123', is_active: true }, + order: { is_system: 'DESC', name: 'ASC' }, + }); + }); + }); + + describe('findRoleById', () => { + it('should return a role by id', async () => { + roleRepository.findOne.mockResolvedValue(mockRole); + + const result = await service.findRoleById('role-123', 'tenant-123'); + + expect(result).toEqual(mockRole); + }); + + it('should throw NotFoundException if role not found', async () => { + roleRepository.findOne.mockResolvedValue(null); + + await expect( + service.findRoleById('non-existent', 'tenant-123'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('findAllPermissions', () => { + it('should return all permissions', async () => { + permissionRepository.find.mockResolvedValue([mockPermission]); + + const result = await service.findAllPermissions(); + + expect(result).toEqual([mockPermission]); + expect(permissionRepository.find).toHaveBeenCalledWith({ + order: { category: 'ASC', code: 'ASC' }, + }); + }); + }); + + describe('assignRoleToUser', () => { + const assignDto = { userId: 'user-123', roleId: 'role-123' }; + + it('should assign a role to a user', async () => { + roleRepository.findOne.mockResolvedValue(mockRole); + userRoleRepository.findOne.mockResolvedValue(null); + userRoleRepository.create.mockReturnValue(mockUserRole as UserRole); + userRoleRepository.save.mockResolvedValue(mockUserRole); + + const result = await service.assignRoleToUser( + assignDto, + 'tenant-123', + 'admin-123', + ); + + expect(result).toEqual(mockUserRole); + expect(userRoleRepository.save).toHaveBeenCalled(); + }); + + it('should throw ConflictException if role already assigned', async () => { + roleRepository.findOne.mockResolvedValue(mockRole); + userRoleRepository.findOne.mockResolvedValue(mockUserRole); + + await expect( + service.assignRoleToUser(assignDto, 'tenant-123', 'admin-123'), + ).rejects.toThrow(ConflictException); + }); + + it('should throw NotFoundException if role not found', async () => { + roleRepository.findOne.mockResolvedValue(null); + + await expect( + service.assignRoleToUser(assignDto, 'tenant-123', 'admin-123'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('removeRoleFromUser', () => { + it('should remove a role from a user', async () => { + userRoleRepository.delete.mockResolvedValue({ affected: 1, raw: {} }); + + await service.removeRoleFromUser('user-123', 'role-123', 'tenant-123'); + + expect(userRoleRepository.delete).toHaveBeenCalledWith({ + user_id: 'user-123', + role_id: 'role-123', + tenant_id: 'tenant-123', + }); + }); + + it('should throw NotFoundException if assignment not found', async () => { + userRoleRepository.delete.mockResolvedValue({ affected: 0, raw: {} }); + + await expect( + service.removeRoleFromUser('user-123', 'role-123', 'tenant-123'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getUserRoles', () => { + it('should return all roles for a user', async () => { + userRoleRepository.find.mockResolvedValue([mockUserRole]); + roleRepository.find.mockResolvedValue([mockRole]); + + const result = await service.getUserRoles('user-123', 'tenant-123'); + + expect(result).toEqual([mockRole]); + }); + + it('should return empty array if user has no roles', async () => { + userRoleRepository.find.mockResolvedValue([]); + + const result = await service.getUserRoles('user-123', 'tenant-123'); + + expect(result).toEqual([]); + }); + }); + + describe('getUserPermissions', () => { + it('should return all permissions for a user', async () => { + userRoleRepository.find.mockResolvedValue([mockUserRole]); + roleRepository.find.mockResolvedValue([mockRole]); + rolePermissionRepository.find.mockResolvedValue([mockRolePermission]); + permissionRepository.find.mockResolvedValue([mockPermission]); + + const result = await service.getUserPermissions('user-123', 'tenant-123'); + + expect(result).toEqual([mockPermission]); + }); + + it('should return empty array if user has no roles', async () => { + userRoleRepository.find.mockResolvedValue([]); + + const result = await service.getUserPermissions('user-123', 'tenant-123'); + + expect(result).toEqual([]); + }); + }); + + describe('userHasPermission', () => { + it('should return true if user has permission', async () => { + userRoleRepository.find.mockResolvedValue([mockUserRole]); + roleRepository.find.mockResolvedValue([mockRole]); + rolePermissionRepository.find.mockResolvedValue([mockRolePermission]); + permissionRepository.find.mockResolvedValue([mockPermission]); + + const result = await service.userHasPermission( + 'user-123', + 'tenant-123', + 'users:read', + ); + + expect(result).toBe(true); + }); + + it('should return false if user lacks permission', async () => { + userRoleRepository.find.mockResolvedValue([mockUserRole]); + roleRepository.find.mockResolvedValue([mockRole]); + rolePermissionRepository.find.mockResolvedValue([mockRolePermission]); + permissionRepository.find.mockResolvedValue([mockPermission]); + + const result = await service.userHasPermission( + 'user-123', + 'tenant-123', + 'users:delete', + ); + + expect(result).toBe(false); + }); + }); + + describe('userHasRole', () => { + it('should return true if user has role', async () => { + userRoleRepository.find.mockResolvedValue([mockUserRole]); + roleRepository.find.mockResolvedValue([mockRole]); + + const result = await service.userHasRole('user-123', 'tenant-123', 'admin'); + + expect(result).toBe(true); + }); + + it('should return false if user lacks role', async () => { + userRoleRepository.find.mockResolvedValue([]); + + const result = await service.userHasRole('user-123', 'tenant-123', 'admin'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/modules/rbac/dto/create-role.dto.ts b/src/modules/rbac/dto/create-role.dto.ts new file mode 100644 index 0000000..75673a1 --- /dev/null +++ b/src/modules/rbac/dto/create-role.dto.ts @@ -0,0 +1,55 @@ +import { IsString, IsNotEmpty, IsOptional, IsArray, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateRoleDto { + @ApiProperty({ example: 'Manager' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ example: 'manager' }) + @IsString() + @IsNotEmpty() + code: string; + + @ApiPropertyOptional({ example: 'Can manage team members' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ type: [String], example: ['users:read', 'users:write'] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; +} + +export class UpdateRoleDto { + @ApiPropertyOptional({ example: 'Manager' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ example: 'Can manage team members' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; +} + +export class AssignRoleDto { + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' }) + @IsUUID() + @IsNotEmpty() + userId: string; + + @ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440001' }) + @IsUUID() + @IsNotEmpty() + roleId: string; +} diff --git a/src/modules/rbac/dto/index.ts b/src/modules/rbac/dto/index.ts new file mode 100644 index 0000000..e5d67c9 --- /dev/null +++ b/src/modules/rbac/dto/index.ts @@ -0,0 +1 @@ +export * from './create-role.dto'; diff --git a/src/modules/rbac/entities/index.ts b/src/modules/rbac/entities/index.ts new file mode 100644 index 0000000..0be79c1 --- /dev/null +++ b/src/modules/rbac/entities/index.ts @@ -0,0 +1,4 @@ +export * from './role.entity'; +export * from './permission.entity'; +export * from './user-role.entity'; +export * from './role-permission.entity'; diff --git a/src/modules/rbac/entities/permission.entity.ts b/src/modules/rbac/entities/permission.entity.ts new file mode 100644 index 0000000..fd37e79 --- /dev/null +++ b/src/modules/rbac/entities/permission.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'users', name: 'permissions' }) +export class Permission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + @Index({ unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 50 }) + category: string; + + @Column({ type: 'boolean', default: false }) + is_sensitive: boolean; + + @Column({ type: 'boolean', default: false }) + requires_owner: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/rbac/entities/role-permission.entity.ts b/src/modules/rbac/entities/role-permission.entity.ts new file mode 100644 index 0000000..d8924ba --- /dev/null +++ b/src/modules/rbac/entities/role-permission.entity.ts @@ -0,0 +1,25 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'users', name: 'role_permissions' }) +@Index(['role_id', 'permission_id'], { unique: true }) +export class RolePermission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + role_id: string; + + @Column({ type: 'uuid' }) + @Index() + permission_id: string; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/rbac/entities/role.entity.ts b/src/modules/rbac/entities/role.entity.ts new file mode 100644 index 0000000..4026c32 --- /dev/null +++ b/src/modules/rbac/entities/role.entity.ts @@ -0,0 +1,49 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToMany, + JoinTable, +} from 'typeorm'; +import { Permission } from './permission.entity'; + +@Entity({ schema: 'users', name: 'roles' }) +export class Role { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'varchar', length: 50 }) + name: string; + + @Column({ type: 'varchar', length: 50 }) + @Index() + code: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'boolean', default: false }) + is_system: boolean; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + // Relations will be handled via service queries for now + // to avoid complex eager loading issues +} diff --git a/src/modules/rbac/entities/user-role.entity.ts b/src/modules/rbac/entities/user-role.entity.ts new file mode 100644 index 0000000..60f4251 --- /dev/null +++ b/src/modules/rbac/entities/user-role.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'users', name: 'user_roles' }) +@Index(['user_id', 'role_id'], { unique: true }) +export class UserRole { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + user_id: string; + + @Column({ type: 'uuid' }) + @Index() + role_id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'uuid', nullable: true }) + assigned_by: string | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; +} diff --git a/src/modules/rbac/guards/index.ts b/src/modules/rbac/guards/index.ts new file mode 100644 index 0000000..55c681b --- /dev/null +++ b/src/modules/rbac/guards/index.ts @@ -0,0 +1 @@ +export * from './permissions.guard'; diff --git a/src/modules/rbac/guards/permissions.guard.ts b/src/modules/rbac/guards/permissions.guard.ts new file mode 100644 index 0000000..ab3640b --- /dev/null +++ b/src/modules/rbac/guards/permissions.guard.ts @@ -0,0 +1,122 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RbacService } from '../services/rbac.service'; + +export const PERMISSIONS_KEY = 'permissions'; +export const ROLES_KEY = 'roles'; + +export const RequirePermissions = (...permissions: string[]) => + (target: any, key?: string, descriptor?: PropertyDescriptor) => { + Reflect.defineMetadata(PERMISSIONS_KEY, permissions, descriptor?.value || target); + return descriptor || target; + }; + +export const RequireRoles = (...roles: string[]) => + (target: any, key?: string, descriptor?: PropertyDescriptor) => { + Reflect.defineMetadata(ROLES_KEY, roles, descriptor?.value || target); + return descriptor || target; + }; + +@Injectable() +export class PermissionsGuard implements CanActivate { + constructor( + private reflector: Reflector, + private rbacService: RbacService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + // Get required permissions from decorator + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + // Get required roles from decorator + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + // If no requirements, allow access + if (!requiredPermissions?.length && !requiredRoles?.length) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) { + throw new ForbiddenException('Usuario no autenticado'); + } + + const { id: userId, tenant_id: tenantId } = user; + + // Check roles if required + if (requiredRoles?.length) { + for (const role of requiredRoles) { + const hasRole = await this.rbacService.userHasRole(userId, tenantId, role); + if (hasRole) { + return true; // User has at least one required role + } + } + } + + // Check permissions if required + if (requiredPermissions?.length) { + const hasPermission = await this.rbacService.userHasAnyPermission( + userId, + tenantId, + requiredPermissions, + ); + + if (hasPermission) { + return true; + } + } + + throw new ForbiddenException('No tiene permisos suficientes para esta acción'); + } +} + +@Injectable() +export class AllPermissionsGuard implements CanActivate { + constructor( + private reflector: Reflector, + private rbacService: RbacService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const requiredPermissions = this.reflector.getAllAndOverride( + PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredPermissions?.length) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) { + throw new ForbiddenException('Usuario no autenticado'); + } + + const hasAll = await this.rbacService.userHasAllPermissions( + user.id, + user.tenant_id, + requiredPermissions, + ); + + if (!hasAll) { + throw new ForbiddenException('No tiene todos los permisos requeridos'); + } + + return true; + } +} diff --git a/src/modules/rbac/index.ts b/src/modules/rbac/index.ts new file mode 100644 index 0000000..44fa164 --- /dev/null +++ b/src/modules/rbac/index.ts @@ -0,0 +1,6 @@ +export * from './rbac.module'; +export * from './rbac.controller'; +export * from './services'; +export * from './entities'; +export * from './dto'; +export * from './guards'; diff --git a/src/modules/rbac/rbac.controller.ts b/src/modules/rbac/rbac.controller.ts new file mode 100644 index 0000000..714b8fb --- /dev/null +++ b/src/modules/rbac/rbac.controller.ts @@ -0,0 +1,171 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, + Query, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; +import { RbacService } from './services/rbac.service'; +import { CreateRoleDto, UpdateRoleDto, AssignRoleDto } from './dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PermissionsGuard, RequirePermissions } from './guards/permissions.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; + +@ApiTags('rbac') +@Controller('rbac') +@UseGuards(JwtAuthGuard, PermissionsGuard) +@ApiBearerAuth() +export class RbacController { + constructor(private readonly rbacService: RbacService) {} + + // ==================== Roles ==================== + + @Get('roles') + @RequirePermissions('roles:read') + @ApiOperation({ summary: 'List all roles' }) + async findAllRoles(@CurrentUser() user: RequestUser) { + return this.rbacService.findAllRoles(user.tenant_id); + } + + @Get('roles/:id') + @RequirePermissions('roles:read') + @ApiOperation({ summary: 'Get role by ID with permissions' }) + async findRoleById( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + return this.rbacService.getRoleWithPermissions(id, user.tenant_id); + } + + @Post('roles') + @RequirePermissions('roles:write') + @ApiOperation({ summary: 'Create new role' }) + @ApiResponse({ status: 201, description: 'Role created' }) + async createRole( + @Body() dto: CreateRoleDto, + @CurrentUser() user: RequestUser, + ) { + return this.rbacService.createRole(dto, user.tenant_id); + } + + @Patch('roles/:id') + @RequirePermissions('roles:write') + @ApiOperation({ summary: 'Update role' }) + async updateRole( + @Param('id') id: string, + @Body() dto: UpdateRoleDto, + @CurrentUser() user: RequestUser, + ) { + return this.rbacService.updateRole(id, dto, user.tenant_id); + } + + @Delete('roles/:id') + @RequirePermissions('roles:delete') + @ApiOperation({ summary: 'Delete role' }) + async deleteRole( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + await this.rbacService.deleteRole(id, user.tenant_id); + return { message: 'Role eliminado correctamente' }; + } + + // ==================== Permissions ==================== + + @Get('permissions') + @RequirePermissions('roles:read') + @ApiOperation({ summary: 'List all permissions' }) + async findAllPermissions() { + return this.rbacService.findAllPermissions(); + } + + @Get('permissions/category/:category') + @RequirePermissions('roles:read') + @ApiOperation({ summary: 'List permissions by category' }) + async findPermissionsByCategory(@Param('category') category: string) { + return this.rbacService.findPermissionsByCategory(category); + } + + // ==================== User Roles ==================== + + @Get('users/:userId/roles') + @RequirePermissions('users:read', 'roles:read') + @ApiOperation({ summary: 'Get user roles' }) + async getUserRoles( + @Param('userId') userId: string, + @CurrentUser() user: RequestUser, + ) { + return this.rbacService.getUserRoles(userId, user.tenant_id); + } + + @Get('users/:userId/permissions') + @RequirePermissions('users:read', 'roles:read') + @ApiOperation({ summary: 'Get user permissions' }) + async getUserPermissions( + @Param('userId') userId: string, + @CurrentUser() user: RequestUser, + ) { + return this.rbacService.getUserPermissions(userId, user.tenant_id); + } + + @Post('users/assign-role') + @RequirePermissions('roles:assign') + @ApiOperation({ summary: 'Assign role to user' }) + async assignRoleToUser( + @Body() dto: AssignRoleDto, + @CurrentUser() user: RequestUser, + ) { + return this.rbacService.assignRoleToUser(dto, user.tenant_id, user.id); + } + + @Delete('users/:userId/roles/:roleId') + @RequirePermissions('roles:assign') + @ApiOperation({ summary: 'Remove role from user' }) + async removeRoleFromUser( + @Param('userId') userId: string, + @Param('roleId') roleId: string, + @CurrentUser() user: RequestUser, + ) { + await this.rbacService.removeRoleFromUser(userId, roleId, user.tenant_id); + return { message: 'Role removido correctamente' }; + } + + // ==================== Permission Check ==================== + + @Get('check') + @ApiOperation({ summary: 'Check if current user has permission' }) + async checkPermission( + @Query('permission') permission: string, + @CurrentUser() user: RequestUser, + ) { + const hasPermission = await this.rbacService.userHasPermission( + user.id, + user.tenant_id, + permission, + ); + return { hasPermission }; + } + + @Get('me/roles') + @ApiOperation({ summary: 'Get current user roles' }) + async getMyRoles(@CurrentUser() user: RequestUser) { + return this.rbacService.getUserRoles(user.id, user.tenant_id); + } + + @Get('me/permissions') + @ApiOperation({ summary: 'Get current user permissions' }) + async getMyPermissions(@CurrentUser() user: RequestUser) { + return this.rbacService.getUserPermissions(user.id, user.tenant_id); + } +} diff --git a/src/modules/rbac/rbac.module.ts b/src/modules/rbac/rbac.module.ts new file mode 100644 index 0000000..623af32 --- /dev/null +++ b/src/modules/rbac/rbac.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RbacController } from './rbac.controller'; +import { RbacService } from './services/rbac.service'; +import { PermissionsGuard, AllPermissionsGuard } from './guards/permissions.guard'; +import { Role, Permission, UserRole, RolePermission } from './entities'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Role, Permission, UserRole, RolePermission]), + ], + controllers: [RbacController], + providers: [RbacService, PermissionsGuard, AllPermissionsGuard], + exports: [RbacService, PermissionsGuard, AllPermissionsGuard], +}) +export class RbacModule {} diff --git a/src/modules/rbac/services/index.ts b/src/modules/rbac/services/index.ts new file mode 100644 index 0000000..9e9b3fa --- /dev/null +++ b/src/modules/rbac/services/index.ts @@ -0,0 +1 @@ +export * from './rbac.service'; diff --git a/src/modules/rbac/services/rbac.service.ts b/src/modules/rbac/services/rbac.service.ts new file mode 100644 index 0000000..a23075b --- /dev/null +++ b/src/modules/rbac/services/rbac.service.ts @@ -0,0 +1,334 @@ +import { + Injectable, + NotFoundException, + ConflictException, + ForbiddenException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Role, Permission, UserRole, RolePermission } from '../entities'; +import { CreateRoleDto, UpdateRoleDto, AssignRoleDto } from '../dto'; + +@Injectable() +export class RbacService { + constructor( + @InjectRepository(Role) + private readonly roleRepository: Repository, + @InjectRepository(Permission) + private readonly permissionRepository: Repository, + @InjectRepository(UserRole) + private readonly userRoleRepository: Repository, + @InjectRepository(RolePermission) + private readonly rolePermissionRepository: Repository, + ) {} + + // ==================== Roles ==================== + + async createRole(dto: CreateRoleDto, tenantId: string): Promise { + // Check if code already exists for tenant + const existing = await this.roleRepository.findOne({ + where: { code: dto.code, tenant_id: tenantId }, + }); + + if (existing) { + throw new ConflictException(`Role con código '${dto.code}' ya existe`); + } + + const role = this.roleRepository.create({ + tenant_id: tenantId, + name: dto.name, + code: dto.code, + description: dto.description || null, + is_system: false, + is_active: true, + }); + + await this.roleRepository.save(role); + + // Assign permissions if provided + if (dto.permissions?.length) { + await this.setRolePermissions(role.id, dto.permissions, tenantId); + } + + return role; + } + + async updateRole( + roleId: string, + dto: UpdateRoleDto, + tenantId: string, + ): Promise { + const role = await this.roleRepository.findOne({ + where: { id: roleId, tenant_id: tenantId }, + }); + + if (!role) { + throw new NotFoundException('Role no encontrado'); + } + + if (role.is_system) { + throw new ForbiddenException('No se puede modificar un role de sistema'); + } + + if (dto.name) role.name = dto.name; + if (dto.description !== undefined) role.description = dto.description; + + await this.roleRepository.save(role); + + // Update permissions if provided + if (dto.permissions) { + await this.setRolePermissions(roleId, dto.permissions, tenantId); + } + + return role; + } + + async deleteRole(roleId: string, tenantId: string): Promise { + const role = await this.roleRepository.findOne({ + where: { id: roleId, tenant_id: tenantId }, + }); + + if (!role) { + throw new NotFoundException('Role no encontrado'); + } + + if (role.is_system) { + throw new ForbiddenException('No se puede eliminar un role de sistema'); + } + + // Check if role is assigned to users + const usersWithRole = await this.userRoleRepository.count({ + where: { role_id: roleId }, + }); + + if (usersWithRole > 0) { + throw new ConflictException( + `Role está asignado a ${usersWithRole} usuario(s). Desasigne primero.`, + ); + } + + // Delete role permissions + await this.rolePermissionRepository.delete({ role_id: roleId }); + + // Delete role + await this.roleRepository.delete({ id: roleId }); + } + + async findAllRoles(tenantId: string): Promise { + return this.roleRepository.find({ + where: { tenant_id: tenantId, is_active: true }, + order: { is_system: 'DESC', name: 'ASC' }, + }); + } + + async findRoleById(roleId: string, tenantId: string): Promise { + const role = await this.roleRepository.findOne({ + where: { id: roleId, tenant_id: tenantId }, + }); + + if (!role) { + throw new NotFoundException('Role no encontrado'); + } + + return role; + } + + async getRoleWithPermissions( + roleId: string, + tenantId: string, + ): Promise<{ role: Role; permissions: Permission[] }> { + const role = await this.findRoleById(roleId, tenantId); + const permissions = await this.getRolePermissions(roleId); + + return { role, permissions }; + } + + // ==================== Permissions ==================== + + async findAllPermissions(): Promise { + return this.permissionRepository.find({ + order: { category: 'ASC', code: 'ASC' }, + }); + } + + async findPermissionsByCategory(category: string): Promise { + return this.permissionRepository.find({ + where: { category }, + order: { code: 'ASC' }, + }); + } + + async getRolePermissions(roleId: string): Promise { + const rolePermissions = await this.rolePermissionRepository.find({ + where: { role_id: roleId }, + }); + + if (!rolePermissions.length) { + return []; + } + + const permissionIds = rolePermissions.map((rp) => rp.permission_id); + + return this.permissionRepository.find({ + where: { id: In(permissionIds) }, + order: { category: 'ASC', code: 'ASC' }, + }); + } + + async setRolePermissions( + roleId: string, + permissionCodes: string[], + tenantId: string, + ): Promise { + // Verify role exists and belongs to tenant + const role = await this.findRoleById(roleId, tenantId); + + if (role.is_system) { + throw new ForbiddenException('No se pueden modificar permisos de un role de sistema'); + } + + // Get permission IDs from codes + const permissions = await this.permissionRepository.find({ + where: { code: In(permissionCodes) }, + }); + + // Delete existing role permissions + await this.rolePermissionRepository.delete({ role_id: roleId }); + + // Create new role permissions + if (permissions.length) { + const rolePermissions = permissions.map((p) => ({ + role_id: roleId, + permission_id: p.id, + })); + + await this.rolePermissionRepository.save(rolePermissions); + } + } + + // ==================== User Roles ==================== + + async assignRoleToUser( + dto: AssignRoleDto, + tenantId: string, + assignedBy: string, + ): Promise { + // Verify role exists + await this.findRoleById(dto.roleId, tenantId); + + // Check if already assigned + const existing = await this.userRoleRepository.findOne({ + where: { + user_id: dto.userId, + role_id: dto.roleId, + tenant_id: tenantId, + }, + }); + + if (existing) { + throw new ConflictException('Usuario ya tiene este role asignado'); + } + + const userRole = this.userRoleRepository.create({ + user_id: dto.userId, + role_id: dto.roleId, + tenant_id: tenantId, + assigned_by: assignedBy, + }); + + return this.userRoleRepository.save(userRole); + } + + async removeRoleFromUser( + userId: string, + roleId: string, + tenantId: string, + ): Promise { + const result = await this.userRoleRepository.delete({ + user_id: userId, + role_id: roleId, + tenant_id: tenantId, + }); + + if (result.affected === 0) { + throw new NotFoundException('Asignación de role no encontrada'); + } + } + + async getUserRoles(userId: string, tenantId: string): Promise { + const userRoles = await this.userRoleRepository.find({ + where: { user_id: userId, tenant_id: tenantId }, + }); + + if (!userRoles.length) { + return []; + } + + const roleIds = userRoles.map((ur) => ur.role_id); + + return this.roleRepository.find({ + where: { id: In(roleIds), is_active: true }, + }); + } + + async getUserPermissions(userId: string, tenantId: string): Promise { + const roles = await this.getUserRoles(userId, tenantId); + + if (!roles.length) { + return []; + } + + const roleIds = roles.map((r) => r.id); + + const rolePermissions = await this.rolePermissionRepository.find({ + where: { role_id: In(roleIds) }, + }); + + if (!rolePermissions.length) { + return []; + } + + const permissionIds = [...new Set(rolePermissions.map((rp) => rp.permission_id))]; + + return this.permissionRepository.find({ + where: { id: In(permissionIds) }, + }); + } + + async userHasPermission( + userId: string, + tenantId: string, + permissionCode: string, + ): Promise { + const permissions = await this.getUserPermissions(userId, tenantId); + return permissions.some((p) => p.code === permissionCode); + } + + async userHasAnyPermission( + userId: string, + tenantId: string, + permissionCodes: string[], + ): Promise { + const permissions = await this.getUserPermissions(userId, tenantId); + return permissions.some((p) => permissionCodes.includes(p.code)); + } + + async userHasAllPermissions( + userId: string, + tenantId: string, + permissionCodes: string[], + ): Promise { + const permissions = await this.getUserPermissions(userId, tenantId); + const userPermissionCodes = permissions.map((p) => p.code); + return permissionCodes.every((code) => userPermissionCodes.includes(code)); + } + + async userHasRole( + userId: string, + tenantId: string, + roleCode: string, + ): Promise { + const roles = await this.getUserRoles(userId, tenantId); + return roles.some((r) => r.code === roleCode); + } +} diff --git a/src/modules/reports/__tests__/excel.service.spec.ts b/src/modules/reports/__tests__/excel.service.spec.ts new file mode 100644 index 0000000..fe76136 --- /dev/null +++ b/src/modules/reports/__tests__/excel.service.spec.ts @@ -0,0 +1,180 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExcelService } from '../services/excel.service'; + +describe('ExcelService', () => { + let service: ExcelService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ExcelService], + }).compile(); + + service = module.get(ExcelService); + }); + + describe('generateUserReport', () => { + it('should generate an Excel buffer for users', async () => { + const mockUsers = [ + { + id: 'user-001', + email: 'john@example.com', + first_name: 'John', + last_name: 'Doe', + phone: '+1234567890', + status: 'active', + email_verified: true, + last_login_at: new Date('2024-01-15'), + created_at: new Date('2024-01-10'), + }, + { + id: 'user-002', + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Smith', + phone: '+0987654321', + status: 'pending', + email_verified: false, + last_login_at: null, + created_at: new Date('2024-02-20'), + }, + ]; + + const result = await service.generateUserReport(mockUsers as any); + + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle empty user list', async () => { + const result = await service.generateUserReport([]); + + expect(result).toBeInstanceOf(Buffer); + }); + + it('should handle users with null values', async () => { + const mockUsers = [ + { + id: 'user-001', + email: 'test@example.com', + first_name: null, + last_name: null, + phone: null, + status: 'active', + email_verified: false, + last_login_at: null, + created_at: new Date(), + }, + ]; + + const result = await service.generateUserReport(mockUsers as any); + + expect(result).toBeInstanceOf(Buffer); + }); + }); + + describe('generateBillingReport', () => { + it('should generate an Excel buffer for subscriptions', async () => { + const mockSubscriptions = [ + { + id: 'sub-001', + tenant_id: 'tenant-001', + plan_id: 'plan-001', + status: 'active', + payment_provider: 'stripe', + current_period_start: new Date('2024-01-01'), + current_period_end: new Date('2024-02-01'), + trial_end: null, + cancelled_at: null, + created_at: new Date('2024-01-01'), + }, + ]; + + const result = await service.generateBillingReport(mockSubscriptions as any); + + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle empty subscription list', async () => { + const result = await service.generateBillingReport([]); + + expect(result).toBeInstanceOf(Buffer); + }); + + it('should handle subscriptions with null payment provider', async () => { + const mockSubscriptions = [ + { + id: 'sub-001', + tenant_id: 'tenant-001', + plan_id: 'plan-001', + status: 'active', + payment_provider: null, + current_period_start: new Date(), + current_period_end: new Date(), + trial_end: new Date(), + cancelled_at: new Date(), + created_at: new Date(), + }, + ]; + + const result = await service.generateBillingReport(mockSubscriptions as any); + + expect(result).toBeInstanceOf(Buffer); + }); + }); + + describe('generateAuditReport', () => { + it('should generate an Excel buffer for audit logs', async () => { + const mockLogs = [ + { + id: 'log-001', + action: 'user.login', + entity_type: 'user', + entity_id: 'user-001', + user_id: 'user-001', + ip_address: '192.168.1.1', + endpoint: '/api/auth/login', + http_method: 'POST', + response_status: 200, + duration_ms: 150, + description: 'User logged in', + created_at: new Date('2024-01-15T10:30:00Z'), + }, + ]; + + const result = await service.generateAuditReport(mockLogs as any); + + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle empty audit log list', async () => { + const result = await service.generateAuditReport([]); + + expect(result).toBeInstanceOf(Buffer); + }); + + it('should handle audit logs with null values', async () => { + const mockLogs = [ + { + id: 'log-001', + action: 'system.startup', + entity_type: 'system', + entity_id: null, + user_id: null, + ip_address: null, + endpoint: null, + http_method: null, + response_status: null, + duration_ms: null, + description: null, + created_at: new Date(), + }, + ]; + + const result = await service.generateAuditReport(mockLogs as any); + + expect(result).toBeInstanceOf(Buffer); + }); + }); +}); diff --git a/src/modules/reports/__tests__/pdf.service.spec.ts b/src/modules/reports/__tests__/pdf.service.spec.ts new file mode 100644 index 0000000..1d2010c --- /dev/null +++ b/src/modules/reports/__tests__/pdf.service.spec.ts @@ -0,0 +1,168 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PdfService } from '../services/pdf.service'; + +describe('PdfService', () => { + let service: PdfService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PdfService], + }).compile(); + + service = module.get(PdfService); + }); + + describe('generateUserReport', () => { + it('should generate a PDF buffer for users', async () => { + const mockUsers = [ + { + id: 'user-001', + email: 'john@example.com', + first_name: 'John', + last_name: 'Doe', + status: 'active', + created_at: new Date('2024-01-15'), + }, + { + id: 'user-002', + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Smith', + status: 'pending', + created_at: new Date('2024-02-20'), + }, + ]; + + const result = await service.generateUserReport(mockUsers as any); + + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle empty user list', async () => { + const result = await service.generateUserReport([]); + + expect(result).toBeInstanceOf(Buffer); + }); + + it('should handle users with missing names', async () => { + const mockUsers = [ + { + id: 'user-001', + email: 'test@example.com', + first_name: null, + last_name: null, + status: 'active', + created_at: new Date(), + }, + ]; + + const result = await service.generateUserReport(mockUsers as any); + + expect(result).toBeInstanceOf(Buffer); + }); + + it('should handle users with missing created_at', async () => { + const mockUsers = [ + { + id: 'user-001', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + status: 'active', + created_at: null, + }, + ]; + + const result = await service.generateUserReport(mockUsers as any); + + expect(result).toBeInstanceOf(Buffer); + }); + }); + + describe('generateBillingReport', () => { + it('should generate a PDF buffer for subscriptions', async () => { + const mockSubscriptions = [ + { + id: 'sub-001', + tenant_id: 'tenant-001', + plan_id: 'plan-001', + status: 'active', + current_period_start: new Date('2024-01-01'), + current_period_end: new Date('2024-02-01'), + }, + ]; + + const result = await service.generateBillingReport(mockSubscriptions as any); + + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle empty subscription list', async () => { + const result = await service.generateBillingReport([]); + + expect(result).toBeInstanceOf(Buffer); + }); + + it('should handle subscriptions with null dates', async () => { + const mockSubscriptions = [ + { + id: 'sub-001', + tenant_id: 'tenant-001', + plan_id: 'plan-001', + status: 'cancelled', + current_period_start: null, + current_period_end: null, + }, + ]; + + const result = await service.generateBillingReport(mockSubscriptions as any); + + expect(result).toBeInstanceOf(Buffer); + }); + }); + + describe('generateAuditReport', () => { + it('should generate a PDF buffer for audit logs', async () => { + const mockLogs = [ + { + id: 'log-001', + action: 'user.login', + entity_type: 'user', + entity_id: 'user-001', + user_id: 'user-001', + created_at: new Date('2024-01-15T10:30:00Z'), + }, + ]; + + const result = await service.generateAuditReport(mockLogs as any); + + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle empty audit log list', async () => { + const result = await service.generateAuditReport([]); + + expect(result).toBeInstanceOf(Buffer); + }); + + it('should handle audit logs with null values', async () => { + const mockLogs = [ + { + id: 'log-001', + action: 'system.startup', + entity_type: 'system', + entity_id: null, + user_id: null, + created_at: null, + }, + ]; + + const result = await service.generateAuditReport(mockLogs as any); + + expect(result).toBeInstanceOf(Buffer); + }); + }); +}); diff --git a/src/modules/reports/__tests__/reports.controller.spec.ts b/src/modules/reports/__tests__/reports.controller.spec.ts new file mode 100644 index 0000000..77420dd --- /dev/null +++ b/src/modules/reports/__tests__/reports.controller.spec.ts @@ -0,0 +1,1006 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, StreamableFile } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Response } from 'express'; +import { ReportsController } from '../reports.controller'; +import { ReportsService, ReportResult } from '../services/reports.service'; +import { ReportQueryDto, ReportFormat } from '../dto/report-query.dto'; +import { RequestUser } from '../../auth/strategies/jwt.strategy'; + +describe('ReportsController', () => { + let controller: ReportsController; + let service: jest.Mocked; + + // Mock data + const mockTenantId = 'tenant-123'; + const mockRequestUser: RequestUser = { + id: 'user-123', + tenant_id: mockTenantId, + email: 'test@example.com', + }; + + const mockReportQuery: ReportQueryDto = { + dateFrom: '2026-01-01T00:00:00.000Z', + dateTo: '2026-01-31T23:59:59.999Z', + page: 1, + limit: 100, + }; + + const mockPdfResult: ReportResult = { + buffer: Buffer.from('mock pdf content'), + contentType: 'application/pdf', + filename: 'users-report-2026-01-13.pdf', + }; + + const mockExcelResult: ReportResult = { + buffer: Buffer.from('mock excel content'), + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + filename: 'users-report-2026-01-13.xlsx', + }; + + const mockCsvResult: ReportResult = { + buffer: Buffer.from('id,email,name\n1,test@test.com,Test User'), + contentType: 'text/csv', + filename: 'users-report-2026-01-13.csv', + }; + + // Mock Response object + const createMockResponse = (): Partial => ({ + set: jest.fn().mockReturnThis(), + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ReportsController], + providers: [ + { + provide: ReportsService, + useValue: { + getUsersReport: jest.fn(), + getBillingReport: jest.fn(), + getAuditReport: jest.fn(), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(ReportsController); + service = module.get(ReportsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('ReportsController - Setup', () => { + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + }); + + // ==================== USERS REPORT TESTS ==================== + + describe('getUsersReport', () => { + describe('PDF Format', () => { + it('should generate users report in PDF format', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getUsersReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getUsersReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + mockReportQuery, + ); + expect(mockRes.set).toHaveBeenCalledWith({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${mockPdfResult.filename}"`, + 'Content-Length': mockPdfResult.buffer.length, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }); + }); + + it('should handle PDF format case-insensitively', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'PDF', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(service.getUsersReport).toHaveBeenCalled(); + }); + }); + + describe('Excel Format', () => { + it('should generate users report in Excel format', async () => { + service.getUsersReport.mockResolvedValue(mockExcelResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getUsersReport( + 'excel', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getUsersReport).toHaveBeenCalledWith( + mockTenantId, + 'excel', + mockReportQuery, + ); + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + ); + }); + }); + + describe('CSV Format', () => { + it('should generate users report in CSV format', async () => { + service.getUsersReport.mockResolvedValue(mockCsvResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getUsersReport( + 'csv', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getUsersReport).toHaveBeenCalledWith( + mockTenantId, + 'csv', + mockReportQuery, + ); + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'text/csv', + }), + ); + }); + }); + + describe('Tenant ID Handling', () => { + it('should use tenant_id from decorator if available', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'pdf', + mockReportQuery, + 'decorator-tenant-id', + mockRequestUser, + mockRes, + ); + + expect(service.getUsersReport).toHaveBeenCalledWith( + 'decorator-tenant-id', + 'pdf', + mockReportQuery, + ); + }); + + it('should fallback to user tenant_id if decorator tenant is undefined', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'pdf', + mockReportQuery, + undefined as any, + mockRequestUser, + mockRes, + ); + + expect(service.getUsersReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + mockReportQuery, + ); + }); + + it('should throw BadRequestException if no tenant ID available', async () => { + const mockRes = createMockResponse() as Response; + const userWithoutTenant = { ...mockRequestUser, tenant_id: undefined } as unknown as RequestUser; + + await expect( + controller.getUsersReport( + 'pdf', + mockReportQuery, + undefined as any, + userWithoutTenant, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('Invalid Format', () => { + it('should throw BadRequestException for invalid format', async () => { + const mockRes = createMockResponse() as Response; + + await expect( + controller.getUsersReport( + 'invalid-format', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException with descriptive message for invalid format', async () => { + const mockRes = createMockResponse() as Response; + + try { + await controller.getUsersReport( + 'xml', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + fail('Expected BadRequestException to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException); + expect((error as BadRequestException).message).toContain('Invalid format'); + expect((error as BadRequestException).message).toContain('pdf'); + expect((error as BadRequestException).message).toContain('excel'); + expect((error as BadRequestException).message).toContain('csv'); + } + }); + }); + + describe('Query Parameters', () => { + it('should pass query parameters to service', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + const customQuery: ReportQueryDto = { + dateFrom: '2026-01-15T00:00:00.000Z', + dateTo: '2026-01-20T23:59:59.999Z', + page: 2, + limit: 50, + }; + + await controller.getUsersReport( + 'pdf', + customQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(service.getUsersReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + customQuery, + ); + }); + + it('should handle empty query parameters', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + const emptyQuery: ReportQueryDto = {}; + + await controller.getUsersReport( + 'pdf', + emptyQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(service.getUsersReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + emptyQuery, + ); + }); + }); + }); + + // ==================== BILLING REPORT TESTS ==================== + + describe('getBillingReport', () => { + const mockBillingPdfResult: ReportResult = { + buffer: Buffer.from('mock billing pdf content'), + contentType: 'application/pdf', + filename: 'billing-report-2026-01-13.pdf', + }; + + const mockBillingExcelResult: ReportResult = { + buffer: Buffer.from('mock billing excel content'), + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + filename: 'billing-report-2026-01-13.xlsx', + }; + + const mockBillingCsvResult: ReportResult = { + buffer: Buffer.from('id,tenant_id,plan_id,status\n1,t1,p1,active'), + contentType: 'text/csv', + filename: 'billing-report-2026-01-13.csv', + }; + + describe('PDF Format', () => { + it('should generate billing report in PDF format', async () => { + service.getBillingReport.mockResolvedValue(mockBillingPdfResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getBillingReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getBillingReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + mockReportQuery, + ); + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'application/pdf', + }), + ); + }); + }); + + describe('Excel Format', () => { + it('should generate billing report in Excel format', async () => { + service.getBillingReport.mockResolvedValue(mockBillingExcelResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getBillingReport( + 'excel', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getBillingReport).toHaveBeenCalledWith( + mockTenantId, + 'excel', + mockReportQuery, + ); + }); + }); + + describe('CSV Format', () => { + it('should generate billing report in CSV format', async () => { + service.getBillingReport.mockResolvedValue(mockBillingCsvResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getBillingReport( + 'csv', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getBillingReport).toHaveBeenCalledWith( + mockTenantId, + 'csv', + mockReportQuery, + ); + }); + }); + + describe('Tenant ID Handling', () => { + it('should throw BadRequestException if no tenant ID available', async () => { + const mockRes = createMockResponse() as Response; + const userWithoutTenant = { ...mockRequestUser, tenant_id: undefined } as unknown as RequestUser; + + await expect( + controller.getBillingReport( + 'pdf', + mockReportQuery, + undefined as any, + userWithoutTenant, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should use effectiveTenantId from user if decorator tenant is empty', async () => { + service.getBillingReport.mockResolvedValue(mockBillingPdfResult); + const mockRes = createMockResponse() as Response; + + await controller.getBillingReport( + 'pdf', + mockReportQuery, + '', + mockRequestUser, + mockRes, + ); + + expect(service.getBillingReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + mockReportQuery, + ); + }); + }); + + describe('Invalid Format', () => { + it('should throw BadRequestException for invalid format', async () => { + const mockRes = createMockResponse() as Response; + + await expect( + controller.getBillingReport( + 'json', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + }); + }); + }); + + // ==================== AUDIT REPORT TESTS ==================== + + describe('getAuditReport', () => { + const mockAuditPdfResult: ReportResult = { + buffer: Buffer.from('mock audit pdf content'), + contentType: 'application/pdf', + filename: 'audit-report-2026-01-13.pdf', + }; + + const mockAuditExcelResult: ReportResult = { + buffer: Buffer.from('mock audit excel content'), + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + filename: 'audit-report-2026-01-13.xlsx', + }; + + const mockAuditCsvResult: ReportResult = { + buffer: Buffer.from('id,action,entity_type,entity_id\n1,CREATE,User,u1'), + contentType: 'text/csv', + filename: 'audit-report-2026-01-13.csv', + }; + + describe('PDF Format', () => { + it('should generate audit report in PDF format', async () => { + service.getAuditReport.mockResolvedValue(mockAuditPdfResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getAuditReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getAuditReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + mockReportQuery, + ); + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'application/pdf', + }), + ); + }); + }); + + describe('Excel Format', () => { + it('should generate audit report in Excel format', async () => { + service.getAuditReport.mockResolvedValue(mockAuditExcelResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getAuditReport( + 'excel', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getAuditReport).toHaveBeenCalledWith( + mockTenantId, + 'excel', + mockReportQuery, + ); + }); + }); + + describe('CSV Format', () => { + it('should generate audit report in CSV format', async () => { + service.getAuditReport.mockResolvedValue(mockAuditCsvResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getAuditReport( + 'csv', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service.getAuditReport).toHaveBeenCalledWith( + mockTenantId, + 'csv', + mockReportQuery, + ); + }); + }); + + describe('Tenant ID Handling', () => { + it('should throw BadRequestException if no tenant ID available', async () => { + const mockRes = createMockResponse() as Response; + const userWithoutTenant = { ...mockRequestUser, tenant_id: undefined } as unknown as RequestUser; + + await expect( + controller.getAuditReport( + 'pdf', + mockReportQuery, + undefined as any, + userWithoutTenant, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should fallback to user tenant_id when decorator returns falsy value', async () => { + service.getAuditReport.mockResolvedValue(mockAuditPdfResult); + const mockRes = createMockResponse() as Response; + + await controller.getAuditReport( + 'pdf', + mockReportQuery, + null as any, + mockRequestUser, + mockRes, + ); + + expect(service.getAuditReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + mockReportQuery, + ); + }); + }); + + describe('Invalid Format', () => { + it('should throw BadRequestException for invalid format', async () => { + const mockRes = createMockResponse() as Response; + + await expect( + controller.getAuditReport( + 'html', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + }); + }); + }); + + // ==================== RESPONSE FORMAT TESTS ==================== + + describe('Response Format', () => { + it('should set correct headers for PDF response', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(mockRes.set).toHaveBeenCalledWith({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${mockPdfResult.filename}"`, + 'Content-Length': mockPdfResult.buffer.length, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }); + }); + + it('should set correct headers for Excel response', async () => { + service.getUsersReport.mockResolvedValue(mockExcelResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'excel', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${mockExcelResult.filename}"`, + }), + ); + }); + + it('should set correct headers for CSV response', async () => { + service.getUsersReport.mockResolvedValue(mockCsvResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'csv', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="${mockCsvResult.filename}"`, + }), + ); + }); + + it('should return StreamableFile with correct buffer', async () => { + const testBuffer = Buffer.from('test content for streaming'); + const testResult: ReportResult = { + buffer: testBuffer, + contentType: 'application/pdf', + filename: 'test.pdf', + }; + service.getUsersReport.mockResolvedValue(testResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getUsersReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + }); + + it('should include cache control headers to prevent caching', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }), + ); + }); + }); + + // ==================== ERROR HANDLING TESTS ==================== + + describe('Error Handling', () => { + it('should propagate service errors for getUsersReport', async () => { + const error = new Error('Database connection failed'); + service.getUsersReport.mockRejectedValue(error); + const mockRes = createMockResponse() as Response; + + await expect( + controller.getUsersReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ), + ).rejects.toThrow('Database connection failed'); + }); + + it('should propagate service errors for getBillingReport', async () => { + const error = new Error('Subscription data not found'); + service.getBillingReport.mockRejectedValue(error); + const mockRes = createMockResponse() as Response; + + await expect( + controller.getBillingReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ), + ).rejects.toThrow('Subscription data not found'); + }); + + it('should propagate service errors for getAuditReport', async () => { + const error = new Error('Audit log query timeout'); + service.getAuditReport.mockRejectedValue(error); + const mockRes = createMockResponse() as Response; + + await expect( + controller.getAuditReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ), + ).rejects.toThrow('Audit log query timeout'); + }); + + it('should validate format before calling service', async () => { + const mockRes = createMockResponse() as Response; + + await expect( + controller.getUsersReport( + 'invalid', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + + // Service should not be called when format validation fails + expect(service.getUsersReport).not.toHaveBeenCalled(); + }); + + it('should validate tenant ID before calling service', async () => { + const mockRes = createMockResponse() as Response; + const userWithoutTenant = { ...mockRequestUser, tenant_id: undefined } as unknown as RequestUser; + + await expect( + controller.getUsersReport( + 'pdf', + mockReportQuery, + undefined as any, + userWithoutTenant, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + + // Service should not be called when tenant validation fails + expect(service.getUsersReport).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException with specific message for missing tenant', async () => { + const mockRes = createMockResponse() as Response; + const userWithoutTenant = { ...mockRequestUser, tenant_id: undefined } as unknown as RequestUser; + + try { + await controller.getUsersReport( + 'pdf', + mockReportQuery, + undefined as any, + userWithoutTenant, + mockRes, + ); + fail('Expected BadRequestException to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(BadRequestException); + expect((error as BadRequestException).message).toBe('Tenant ID is required'); + } + }); + }); + + // ==================== EDGE CASES TESTS ==================== + + describe('Edge Cases', () => { + it('should handle empty date range', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + const queryWithoutDates: ReportQueryDto = { + page: 1, + limit: 100, + }; + + await controller.getUsersReport( + 'pdf', + queryWithoutDates, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(service.getUsersReport).toHaveBeenCalledWith( + mockTenantId, + 'pdf', + queryWithoutDates, + ); + }); + + it('should handle large buffer sizes', async () => { + const largeBuffer = Buffer.alloc(10 * 1024 * 1024); // 10MB + const largeResult: ReportResult = { + buffer: largeBuffer, + contentType: 'application/pdf', + filename: 'large-report.pdf', + }; + service.getUsersReport.mockResolvedValue(largeResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getUsersReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Length': largeBuffer.length, + }), + ); + }); + + it('should handle empty buffer', async () => { + const emptyResult: ReportResult = { + buffer: Buffer.alloc(0), + contentType: 'application/pdf', + filename: 'empty-report.pdf', + }; + service.getUsersReport.mockResolvedValue(emptyResult); + const mockRes = createMockResponse() as Response; + + const result = await controller.getUsersReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Length': 0, + }), + ); + }); + + it('should handle special characters in filename', async () => { + const specialResult: ReportResult = { + buffer: Buffer.from('content'), + contentType: 'application/pdf', + filename: 'report-with-special-chars_2026-01-13.pdf', + }; + service.getUsersReport.mockResolvedValue(specialResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'pdf', + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(mockRes.set).toHaveBeenCalledWith( + expect.objectContaining({ + 'Content-Disposition': `attachment; filename="${specialResult.filename}"`, + }), + ); + }); + + it('should handle user with null tenant_id', async () => { + const mockRes = createMockResponse() as Response; + const userWithNullTenant = { ...mockRequestUser, tenant_id: null } as unknown as RequestUser; + + await expect( + controller.getUsersReport( + 'pdf', + mockReportQuery, + undefined as any, + userWithNullTenant, + mockRes, + ), + ).rejects.toThrow(BadRequestException); + }); + + it('should use first non-falsy tenant ID (decorator priority)', async () => { + service.getUsersReport.mockResolvedValue(mockPdfResult); + const mockRes = createMockResponse() as Response; + + await controller.getUsersReport( + 'pdf', + mockReportQuery, + 'from-decorator', + { ...mockRequestUser, tenant_id: 'from-user' }, + mockRes, + ); + + expect(service.getUsersReport).toHaveBeenCalledWith( + 'from-decorator', + 'pdf', + mockReportQuery, + ); + }); + }); + + // ==================== ALL FORMATS MATRIX TESTS ==================== + + describe('All Formats Matrix', () => { + const formats = ['pdf', 'excel', 'csv']; + const reports = [ + { name: 'users', method: 'getUsersReport', serviceFn: 'getUsersReport' }, + { name: 'billing', method: 'getBillingReport', serviceFn: 'getBillingReport' }, + { name: 'audit', method: 'getAuditReport', serviceFn: 'getAuditReport' }, + ]; + + reports.forEach(({ name, method, serviceFn }) => { + formats.forEach((format) => { + it(`should generate ${name} report in ${format} format`, async () => { + const mockResult: ReportResult = { + buffer: Buffer.from(`mock ${name} ${format} content`), + contentType: format === 'pdf' ? 'application/pdf' : + format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : + 'text/csv', + filename: `${name}-report-2026-01-13.${format === 'excel' ? 'xlsx' : format}`, + }; + + (service[serviceFn as keyof typeof service] as jest.Mock).mockResolvedValue(mockResult); + const mockRes = createMockResponse() as Response; + + const result = await (controller as any)[method]( + format, + mockReportQuery, + mockTenantId, + mockRequestUser, + mockRes, + ); + + expect(result).toBeInstanceOf(StreamableFile); + expect(service[serviceFn as keyof typeof service]).toHaveBeenCalledWith( + mockTenantId, + format, + mockReportQuery, + ); + }); + }); + }); + }); +}); diff --git a/src/modules/reports/__tests__/reports.service.spec.ts b/src/modules/reports/__tests__/reports.service.spec.ts new file mode 100644 index 0000000..50e53ba --- /dev/null +++ b/src/modules/reports/__tests__/reports.service.spec.ts @@ -0,0 +1,356 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { ReportsService, ReportResult } from '../services/reports.service'; +import { PdfService } from '../services/pdf.service'; +import { ExcelService } from '../services/excel.service'; +import { User } from '../../auth/entities/user.entity'; +import { Subscription } from '../../billing/entities/subscription.entity'; +import { AuditLog } from '../../audit/entities/audit-log.entity'; +import { ReportFormat } from '../dto/report-query.dto'; + +describe('ReportsService', () => { + let service: ReportsService; + let userRepo: jest.Mocked>; + let subscriptionRepo: jest.Mocked>; + let auditLogRepo: jest.Mocked>; + let pdfService: jest.Mocked; + let excelService: jest.Mocked; + + const mockTenantId = 'tenant-001'; + + const mockUsers = [ + { + id: 'user-001', + email: 'john@example.com', + first_name: 'John', + last_name: 'Doe', + phone: '+1234567890', + status: 'active', + email_verified: true, + last_login_at: new Date(), + created_at: new Date(), + }, + { + id: 'user-002', + email: 'jane@example.com', + first_name: 'Jane', + last_name: 'Smith', + phone: '+0987654321', + status: 'active', + email_verified: true, + last_login_at: new Date(), + created_at: new Date(), + }, + ]; + + const mockSubscriptions = [ + { + id: 'sub-001', + tenant_id: mockTenantId, + plan_id: 'plan-001', + status: 'active', + payment_provider: 'stripe', + current_period_start: new Date(), + current_period_end: new Date(), + trial_end: null, + cancelled_at: null, + created_at: new Date(), + plan: { name: 'Pro Plan' }, + }, + ]; + + const mockAuditLogs = [ + { + id: 'log-001', + action: 'user.login', + entity_type: 'user', + entity_id: 'user-001', + user_id: 'user-001', + ip_address: '192.168.1.1', + endpoint: '/api/auth/login', + http_method: 'POST', + response_status: 200, + duration_ms: 150, + description: 'User logged in', + created_at: new Date(), + }, + ]; + + beforeEach(async () => { + const mockUserRepo = { + find: jest.fn(), + }; + + const mockSubscriptionRepo = { + find: jest.fn(), + }; + + const mockAuditLogRepo = { + find: jest.fn(), + }; + + const mockPdfService = { + generateUserReport: jest.fn(), + generateBillingReport: jest.fn(), + generateAuditReport: jest.fn(), + }; + + const mockExcelService = { + generateUserReport: jest.fn(), + generateBillingReport: jest.fn(), + generateAuditReport: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReportsService, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + { provide: getRepositoryToken(Subscription), useValue: mockSubscriptionRepo }, + { provide: getRepositoryToken(AuditLog), useValue: mockAuditLogRepo }, + { provide: PdfService, useValue: mockPdfService }, + { provide: ExcelService, useValue: mockExcelService }, + ], + }).compile(); + + service = module.get(ReportsService); + userRepo = module.get(getRepositoryToken(User)); + subscriptionRepo = module.get(getRepositoryToken(Subscription)); + auditLogRepo = module.get(getRepositoryToken(AuditLog)); + pdfService = module.get(PdfService); + excelService = module.get(ExcelService); + }); + + describe('getUsersReport', () => { + it('should generate CSV users report', async () => { + userRepo.find.mockResolvedValue(mockUsers as any); + + const result = await service.getUsersReport(mockTenantId, ReportFormat.CSV); + + expect(userRepo.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + order: { created_at: 'DESC' }, + take: 10000, + skip: 0, + }); + expect(result.contentType).toBe('text/csv'); + expect(result.filename).toContain('.csv'); + expect(result.buffer).toBeInstanceOf(Buffer); + }); + + it('should generate PDF users report', async () => { + userRepo.find.mockResolvedValue(mockUsers as any); + pdfService.generateUserReport.mockResolvedValue(Buffer.from('PDF content')); + + const result = await service.getUsersReport(mockTenantId, ReportFormat.PDF); + + expect(pdfService.generateUserReport).toHaveBeenCalledWith(mockUsers); + expect(result.contentType).toBe('application/pdf'); + expect(result.filename).toContain('.pdf'); + }); + + it('should generate Excel users report', async () => { + userRepo.find.mockResolvedValue(mockUsers as any); + excelService.generateUserReport.mockResolvedValue(Buffer.from('Excel content')); + + const result = await service.getUsersReport(mockTenantId, ReportFormat.EXCEL); + + expect(excelService.generateUserReport).toHaveBeenCalledWith(mockUsers); + expect(result.contentType).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + expect(result.filename).toContain('.xlsx'); + }); + + it('should apply date filters', async () => { + userRepo.find.mockResolvedValue([]); + const dateFrom = '2024-01-01'; + const dateTo = '2024-12-31'; + + await service.getUsersReport(mockTenantId, ReportFormat.CSV, { dateFrom, dateTo }); + + expect(userRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + tenant_id: mockTenantId, + created_at: expect.any(Object), + }), + }), + ); + }); + + it('should apply only dateFrom filter', async () => { + userRepo.find.mockResolvedValue([]); + const dateFrom = '2024-01-01'; + + await service.getUsersReport(mockTenantId, 'csv', { dateFrom }); + + expect(userRepo.find).toHaveBeenCalled(); + }); + + it('should apply only dateTo filter', async () => { + userRepo.find.mockResolvedValue([]); + const dateTo = '2024-12-31'; + + await service.getUsersReport(mockTenantId, 'csv', { dateTo }); + + expect(userRepo.find).toHaveBeenCalled(); + }); + + it('should apply pagination', async () => { + userRepo.find.mockResolvedValue([]); + + await service.getUsersReport(mockTenantId, 'csv', { page: 2, limit: 50 }); + + expect(userRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + take: 50, + skip: 50, + }), + ); + }); + }); + + describe('getBillingReport', () => { + it('should generate CSV billing report', async () => { + subscriptionRepo.find.mockResolvedValue(mockSubscriptions as any); + + const result = await service.getBillingReport(mockTenantId, 'csv'); + + expect(subscriptionRepo.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + relations: ['plan'], + order: { created_at: 'DESC' }, + take: 10000, + skip: 0, + }); + expect(result.contentType).toBe('text/csv'); + }); + + it('should generate PDF billing report', async () => { + subscriptionRepo.find.mockResolvedValue(mockSubscriptions as any); + pdfService.generateBillingReport.mockResolvedValue(Buffer.from('PDF')); + + const result = await service.getBillingReport(mockTenantId, ReportFormat.PDF); + + expect(pdfService.generateBillingReport).toHaveBeenCalledWith(mockSubscriptions); + expect(result.contentType).toBe('application/pdf'); + }); + + it('should generate Excel billing report', async () => { + subscriptionRepo.find.mockResolvedValue(mockSubscriptions as any); + excelService.generateBillingReport.mockResolvedValue(Buffer.from('Excel')); + + const result = await service.getBillingReport(mockTenantId, 'excel'); + + expect(excelService.generateBillingReport).toHaveBeenCalledWith(mockSubscriptions); + }); + }); + + describe('getAuditReport', () => { + it('should generate CSV audit report', async () => { + auditLogRepo.find.mockResolvedValue(mockAuditLogs as any); + + const result = await service.getAuditReport(mockTenantId, 'csv'); + + expect(auditLogRepo.find).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + order: { created_at: 'DESC' }, + take: 10000, + skip: 0, + }); + expect(result.contentType).toBe('text/csv'); + }); + + it('should generate PDF audit report', async () => { + auditLogRepo.find.mockResolvedValue(mockAuditLogs as any); + pdfService.generateAuditReport.mockResolvedValue(Buffer.from('PDF')); + + const result = await service.getAuditReport(mockTenantId, 'pdf'); + + expect(pdfService.generateAuditReport).toHaveBeenCalledWith(mockAuditLogs); + }); + + it('should generate Excel audit report', async () => { + auditLogRepo.find.mockResolvedValue(mockAuditLogs as any); + excelService.generateAuditReport.mockResolvedValue(Buffer.from('Excel')); + + const result = await service.getAuditReport(mockTenantId, ReportFormat.EXCEL); + + expect(excelService.generateAuditReport).toHaveBeenCalledWith(mockAuditLogs); + }); + }); + + describe('CSV generation', () => { + it('should handle null and undefined values', async () => { + const usersWithNulls = [ + { id: 'user-001', email: 'test@test.com', phone: null, created_at: new Date() }, + ]; + userRepo.find.mockResolvedValue(usersWithNulls as any); + + const result = await service.getUsersReport(mockTenantId, 'csv'); + + const csvContent = result.buffer.toString(); + expect(csvContent).toContain('user-001'); + }); + + it('should escape commas and quotes in values', async () => { + const usersWithSpecialChars = [ + { id: 'user-001', email: 'test@test.com', first_name: 'John, Jr.', created_at: new Date() }, + ]; + userRepo.find.mockResolvedValue(usersWithSpecialChars as any); + + const result = await service.getUsersReport(mockTenantId, 'csv'); + + const csvContent = result.buffer.toString(); + expect(csvContent).toContain('"John, Jr."'); + }); + + it('should handle dates', async () => { + const date = new Date('2024-01-15T10:00:00Z'); + const usersWithDates = [ + { id: 'user-001', email: 'test@test.com', created_at: date }, + ]; + userRepo.find.mockResolvedValue(usersWithDates as any); + + const result = await service.getUsersReport(mockTenantId, 'csv'); + + const csvContent = result.buffer.toString(); + expect(csvContent).toContain('2024-01-15'); + }); + + it('should handle objects by stringifying', async () => { + const usersWithObjects = [ + { id: 'user-001', email: 'test@test.com', metadata: { key: 'value' }, created_at: new Date() }, + ]; + userRepo.find.mockResolvedValue(usersWithObjects as any); + + // Note: metadata is not in the columns list, so it won't be included + const result = await service.getUsersReport(mockTenantId, 'csv'); + + expect(result.buffer).toBeInstanceOf(Buffer); + }); + }); + + describe('filename generation', () => { + it('should include report type and date in filename', async () => { + userRepo.find.mockResolvedValue([]); + + const result = await service.getUsersReport(mockTenantId, 'csv'); + + expect(result.filename).toMatch(/^users-report-\d{4}-\d{2}-\d{2}\.csv$/); + }); + + it('should include correct extension for each format', async () => { + userRepo.find.mockResolvedValue([]); + pdfService.generateUserReport.mockResolvedValue(Buffer.from('PDF')); + excelService.generateUserReport.mockResolvedValue(Buffer.from('Excel')); + + const csvResult = await service.getUsersReport(mockTenantId, 'csv'); + const pdfResult = await service.getUsersReport(mockTenantId, 'pdf'); + const excelResult = await service.getUsersReport(mockTenantId, 'excel'); + + expect(csvResult.filename).toMatch(/\.csv$/); + expect(pdfResult.filename).toMatch(/\.pdf$/); + expect(excelResult.filename).toMatch(/\.xlsx$/); + }); + }); +}); diff --git a/src/modules/reports/dto/index.ts b/src/modules/reports/dto/index.ts new file mode 100644 index 0000000..601111a --- /dev/null +++ b/src/modules/reports/dto/index.ts @@ -0,0 +1 @@ +export * from './report-query.dto'; diff --git a/src/modules/reports/dto/report-query.dto.ts b/src/modules/reports/dto/report-query.dto.ts new file mode 100644 index 0000000..0a87953 --- /dev/null +++ b/src/modules/reports/dto/report-query.dto.ts @@ -0,0 +1,54 @@ +import { + IsOptional, + IsDateString, + IsNumber, + Min, + Max, + IsEnum, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export enum ReportFormat { + PDF = 'pdf', + EXCEL = 'excel', + CSV = 'csv', +} + +export class ReportQueryDto { + @ApiPropertyOptional({ description: 'Start date filter (ISO 8601)' }) + @IsOptional() + @IsDateString() + dateFrom?: string; + + @ApiPropertyOptional({ description: 'End date filter (ISO 8601)' }) + @IsOptional() + @IsDateString() + dateTo?: string; + + @ApiPropertyOptional({ description: 'Page number', default: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', default: 100 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(10000) + limit?: number = 100; +} + +export class ReportFormatDto { + @ApiPropertyOptional({ + description: 'Report format', + enum: ReportFormat, + default: ReportFormat.PDF + }) + @IsOptional() + @IsEnum(ReportFormat) + format?: ReportFormat = ReportFormat.PDF; +} diff --git a/src/modules/reports/index.ts b/src/modules/reports/index.ts new file mode 100644 index 0000000..22e5e38 --- /dev/null +++ b/src/modules/reports/index.ts @@ -0,0 +1,4 @@ +export * from './reports.module'; +export * from './reports.controller'; +export * from './services'; +export * from './dto'; diff --git a/src/modules/reports/reports.controller.ts b/src/modules/reports/reports.controller.ts new file mode 100644 index 0000000..b9527da --- /dev/null +++ b/src/modules/reports/reports.controller.ts @@ -0,0 +1,180 @@ +import { + Controller, + Get, + Param, + Query, + Res, + UseGuards, + BadRequestException, + StreamableFile, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiParam, + ApiQuery, +} from '@nestjs/swagger'; +import { Response } from 'express'; +import { ReportsService } from './services/reports.service'; +import { ReportQueryDto, ReportFormat } from './dto/report-query.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../auth/decorators/tenant.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; + +@ApiTags('Reports') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('reports') +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + // ==================== USERS REPORT ==================== + + @Get('users/:format') + @ApiOperation({ summary: 'Export users report' }) + @ApiParam({ + name: 'format', + description: 'Report format', + enum: ['pdf', 'excel', 'csv'], + }) + @ApiQuery({ name: 'dateFrom', required: false, description: 'Start date filter (ISO 8601)' }) + @ApiQuery({ name: 'dateTo', required: false, description: 'End date filter (ISO 8601)' }) + @ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page' }) + @ApiResponse({ status: 200, description: 'Users report file' }) + @ApiResponse({ status: 400, description: 'Invalid format' }) + async getUsersReport( + @Param('format') format: string, + @Query() query: ReportQueryDto, + @CurrentTenant() tenantId: string, + @CurrentUser() user: RequestUser, + @Res({ passthrough: true }) res: Response, + ): Promise { + this.validateFormat(format); + const effectiveTenantId = tenantId || user?.tenant_id; + + if (!effectiveTenantId) { + throw new BadRequestException('Tenant ID is required'); + } + + const result = await this.reportsService.getUsersReport( + effectiveTenantId, + format as ReportFormat, + query, + ); + + return this.sendReportResponse(res, result); + } + + // ==================== BILLING REPORT ==================== + + @Get('billing/:format') + @ApiOperation({ summary: 'Export billing report' }) + @ApiParam({ + name: 'format', + description: 'Report format', + enum: ['pdf', 'excel', 'csv'], + }) + @ApiQuery({ name: 'dateFrom', required: false, description: 'Start date filter (ISO 8601)' }) + @ApiQuery({ name: 'dateTo', required: false, description: 'End date filter (ISO 8601)' }) + @ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page' }) + @ApiResponse({ status: 200, description: 'Billing report file' }) + @ApiResponse({ status: 400, description: 'Invalid format' }) + async getBillingReport( + @Param('format') format: string, + @Query() query: ReportQueryDto, + @CurrentTenant() tenantId: string, + @CurrentUser() user: RequestUser, + @Res({ passthrough: true }) res: Response, + ): Promise { + this.validateFormat(format); + const effectiveTenantId = tenantId || user?.tenant_id; + + if (!effectiveTenantId) { + throw new BadRequestException('Tenant ID is required'); + } + + const result = await this.reportsService.getBillingReport( + effectiveTenantId, + format as ReportFormat, + query, + ); + + return this.sendReportResponse(res, result); + } + + // ==================== AUDIT REPORT ==================== + + @Get('audit/:format') + @ApiOperation({ summary: 'Export audit logs report' }) + @ApiParam({ + name: 'format', + description: 'Report format', + enum: ['pdf', 'excel', 'csv'], + }) + @ApiQuery({ name: 'dateFrom', required: false, description: 'Start date filter (ISO 8601)' }) + @ApiQuery({ name: 'dateTo', required: false, description: 'End date filter (ISO 8601)' }) + @ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page' }) + @ApiResponse({ status: 200, description: 'Audit report file' }) + @ApiResponse({ status: 400, description: 'Invalid format' }) + async getAuditReport( + @Param('format') format: string, + @Query() query: ReportQueryDto, + @CurrentTenant() tenantId: string, + @CurrentUser() user: RequestUser, + @Res({ passthrough: true }) res: Response, + ): Promise { + this.validateFormat(format); + const effectiveTenantId = tenantId || user?.tenant_id; + + if (!effectiveTenantId) { + throw new BadRequestException('Tenant ID is required'); + } + + const result = await this.reportsService.getAuditReport( + effectiveTenantId, + format as ReportFormat, + query, + ); + + return this.sendReportResponse(res, result); + } + + // ==================== HELPER METHODS ==================== + + /** + * Validate report format + */ + private validateFormat(format: string): void { + const validFormats = ['pdf', 'excel', 'csv']; + if (!validFormats.includes(format.toLowerCase())) { + throw new BadRequestException( + `Invalid format: ${format}. Valid formats are: ${validFormats.join(', ')}`, + ); + } + } + + /** + * Send report response with proper headers + */ + private sendReportResponse( + res: Response, + result: { buffer: Buffer; contentType: string; filename: string }, + ): StreamableFile { + res.set({ + 'Content-Type': result.contentType, + 'Content-Disposition': `attachment; filename="${result.filename}"`, + 'Content-Length': result.buffer.length, + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + }); + + return new StreamableFile(result.buffer); + } +} diff --git a/src/modules/reports/reports.module.ts b/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..c722754 --- /dev/null +++ b/src/modules/reports/reports.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ReportsController } from './reports.controller'; +import { ReportsService, PdfService, ExcelService } from './services'; +import { User } from '../auth/entities/user.entity'; +import { Subscription } from '../billing/entities/subscription.entity'; +import { AuditLog } from '../audit/entities/audit-log.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Subscription, AuditLog]), + ], + controllers: [ReportsController], + providers: [ReportsService, PdfService, ExcelService], + exports: [ReportsService], +}) +export class ReportsModule {} diff --git a/src/modules/reports/services/excel.service.ts b/src/modules/reports/services/excel.service.ts new file mode 100644 index 0000000..b7a95e4 --- /dev/null +++ b/src/modules/reports/services/excel.service.ts @@ -0,0 +1,272 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { User } from '../../auth/entities/user.entity'; +import { Subscription } from '../../billing/entities/subscription.entity'; +import { AuditLog } from '../../audit/entities/audit-log.entity'; + +@Injectable() +export class ExcelService { + private readonly logger = new Logger(ExcelService.name); + + /** + * Generate an Excel report for users + */ + async generateUserReport(users: User[]): Promise { + const columns = [ + { header: 'Email', key: 'email', width: 35 }, + { header: 'First Name', key: 'first_name', width: 20 }, + { header: 'Last Name', key: 'last_name', width: 20 }, + { header: 'Phone', key: 'phone', width: 15 }, + { header: 'Status', key: 'status', width: 15 }, + { header: 'Email Verified', key: 'email_verified', width: 15 }, + { header: 'Last Login', key: 'last_login_at', width: 20 }, + { header: 'Created At', key: 'created_at', width: 20 }, + ]; + + const data = users.map(user => ({ + email: user.email, + first_name: user.first_name || '', + last_name: user.last_name || '', + phone: user.phone || '', + status: user.status, + email_verified: user.email_verified ? 'Yes' : 'No', + last_login_at: user.last_login_at ? new Date(user.last_login_at).toLocaleString() : 'Never', + created_at: user.created_at ? new Date(user.created_at).toLocaleString() : '', + })); + + return this.generateExcel('Users', columns, data); + } + + /** + * Generate an Excel report for billing/subscriptions + */ + async generateBillingReport(subscriptions: Subscription[]): Promise { + const columns = [ + { header: 'Subscription ID', key: 'id', width: 40 }, + { header: 'Tenant ID', key: 'tenant_id', width: 40 }, + { header: 'Plan ID', key: 'plan_id', width: 40 }, + { header: 'Status', key: 'status', width: 15 }, + { header: 'Payment Provider', key: 'payment_provider', width: 20 }, + { header: 'Period Start', key: 'period_start', width: 20 }, + { header: 'Period End', key: 'period_end', width: 20 }, + { header: 'Trial End', key: 'trial_end', width: 20 }, + { header: 'Cancelled At', key: 'cancelled_at', width: 20 }, + { header: 'Created At', key: 'created_at', width: 20 }, + ]; + + const data = subscriptions.map(sub => ({ + id: sub.id, + tenant_id: sub.tenant_id, + plan_id: sub.plan_id, + status: sub.status, + payment_provider: sub.payment_provider || 'N/A', + period_start: sub.current_period_start ? new Date(sub.current_period_start).toLocaleString() : '', + period_end: sub.current_period_end ? new Date(sub.current_period_end).toLocaleString() : '', + trial_end: sub.trial_end ? new Date(sub.trial_end).toLocaleString() : '', + cancelled_at: sub.cancelled_at ? new Date(sub.cancelled_at).toLocaleString() : '', + created_at: sub.created_at ? new Date(sub.created_at).toLocaleString() : '', + })); + + return this.generateExcel('Billing', columns, data); + } + + /** + * Generate an Excel report for audit logs + */ + async generateAuditReport(logs: AuditLog[]): Promise { + const columns = [ + { header: 'ID', key: 'id', width: 40 }, + { header: 'Action', key: 'action', width: 15 }, + { header: 'Entity Type', key: 'entity_type', width: 20 }, + { header: 'Entity ID', key: 'entity_id', width: 40 }, + { header: 'User ID', key: 'user_id', width: 40 }, + { header: 'IP Address', key: 'ip_address', width: 15 }, + { header: 'Endpoint', key: 'endpoint', width: 30 }, + { header: 'HTTP Method', key: 'http_method', width: 12 }, + { header: 'Response Status', key: 'response_status', width: 15 }, + { header: 'Duration (ms)', key: 'duration_ms', width: 15 }, + { header: 'Description', key: 'description', width: 50 }, + { header: 'Created At', key: 'created_at', width: 20 }, + ]; + + const data = logs.map(log => ({ + id: log.id, + action: log.action, + entity_type: log.entity_type, + entity_id: log.entity_id || '', + user_id: log.user_id || '', + ip_address: log.ip_address || '', + endpoint: log.endpoint || '', + http_method: log.http_method || '', + response_status: log.response_status?.toString() || '', + duration_ms: log.duration_ms?.toString() || '', + description: log.description || '', + created_at: log.created_at ? new Date(log.created_at).toLocaleString() : '', + })); + + return this.generateExcel('Audit Logs', columns, data); + } + + /** + * Generic Excel generation method + */ + private async generateExcel( + sheetName: string, + columns: Array<{ header: string; key: string; width: number }>, + data: Array>, + ): Promise { + // Try to use ExcelJS if available, otherwise use mock implementation + try { + const ExcelJS = await this.tryLoadExcelJS(); + + if (ExcelJS) { + return this.generateWithExcelJS(ExcelJS, sheetName, columns, data); + } + } catch (error) { + this.logger.warn('ExcelJS not available, using mock implementation'); + } + + // Fallback: Generate a simple CSV-like format + return this.generateMockExcel(sheetName, columns, data); + } + + /** + * Try to load ExcelJS dynamically + */ + private async tryLoadExcelJS(): Promise { + try { + + const exceljs = require('exceljs'); + return exceljs; + } catch { + return null; + } + } + + /** + * Generate Excel using ExcelJS + */ + private async generateWithExcelJS( + ExcelJS: any, + sheetName: string, + columns: Array<{ header: string; key: string; width: number }>, + data: Array>, + ): Promise { + const workbook = new ExcelJS.Workbook(); + + // Set workbook properties + workbook.creator = 'Template SaaS'; + workbook.lastModifiedBy = 'Template SaaS'; + workbook.created = new Date(); + workbook.modified = new Date(); + + // Add worksheet + const worksheet = workbook.addWorksheet(sheetName); + + // Set columns + worksheet.columns = columns; + + // Style the header row + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // Add data rows + for (const row of data) { + worksheet.addRow(row); + } + + // Add alternating row colors + worksheet.eachRow((row: any, rowNumber: number) => { + if (rowNumber > 1 && rowNumber % 2 === 0) { + row.eachCell((cell: any) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF2F2F2' }, + }; + }); + } + }); + + // Generate buffer + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } + + /** + * Generate a mock Excel file when ExcelJS is not available + * This creates a simple XML-based spreadsheet + */ + private generateMockExcel( + sheetName: string, + columns: Array<{ header: string; key: string; width: number }>, + data: Array>, + ): Buffer { + // Create a simple XML spreadsheet format (SpreadsheetML) + const lines: string[] = []; + + lines.push(''); + lines.push(''); + lines.push(''); + lines.push(' '); + lines.push(' Template SaaS'); + lines.push(` ${new Date().toISOString()}`); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(' '); + lines.push(` `); + lines.push(' '); + + // Column widths + for (const col of columns) { + lines.push(` `); + } + + // Header row + lines.push(' '); + for (const col of columns) { + lines.push(` ${this.escapeXml(col.header)}`); + } + lines.push(' '); + + // Data rows + for (const row of data) { + lines.push(' '); + for (const col of columns) { + const value = row[col.key] ?? ''; + const type = typeof value === 'number' ? 'Number' : 'String'; + lines.push(` ${this.escapeXml(String(value))}`); + } + lines.push(' '); + } + + lines.push('
'); + lines.push('
'); + lines.push('
'); + + return Buffer.from(lines.join('\n'), 'utf-8'); + } + + /** + * Escape XML special characters + */ + private escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} diff --git a/src/modules/reports/services/index.ts b/src/modules/reports/services/index.ts new file mode 100644 index 0000000..d0fddea --- /dev/null +++ b/src/modules/reports/services/index.ts @@ -0,0 +1,3 @@ +export * from './pdf.service'; +export * from './excel.service'; +export * from './reports.service'; diff --git a/src/modules/reports/services/pdf.service.ts b/src/modules/reports/services/pdf.service.ts new file mode 100644 index 0000000..c8d0769 --- /dev/null +++ b/src/modules/reports/services/pdf.service.ts @@ -0,0 +1,283 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { User } from '../../auth/entities/user.entity'; +import { Subscription } from '../../billing/entities/subscription.entity'; +import { AuditLog } from '../../audit/entities/audit-log.entity'; + +@Injectable() +export class PdfService { + private readonly logger = new Logger(PdfService.name); + + /** + * Generate a PDF report for users + */ + async generateUserReport(users: User[]): Promise { + return this.generatePdf('Users Report', users, [ + { header: 'Email', key: 'email', width: 200 }, + { header: 'Name', key: 'fullName', width: 150 }, + { header: 'Status', key: 'status', width: 80 }, + { header: 'Created At', key: 'created_at', width: 100 }, + ], (user: User) => ({ + email: user.email, + fullName: `${user.first_name || ''} ${user.last_name || ''}`.trim() || 'N/A', + status: user.status, + created_at: user.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A', + })); + } + + /** + * Generate a PDF report for billing/subscriptions + */ + async generateBillingReport(subscriptions: Subscription[]): Promise { + return this.generatePdf('Billing Report', subscriptions, [ + { header: 'Tenant ID', key: 'tenant_id', width: 120 }, + { header: 'Plan ID', key: 'plan_id', width: 120 }, + { header: 'Status', key: 'status', width: 80 }, + { header: 'Period Start', key: 'period_start', width: 100 }, + { header: 'Period End', key: 'period_end', width: 100 }, + ], (sub: Subscription) => ({ + tenant_id: this.truncateString(sub.tenant_id, 15), + plan_id: this.truncateString(sub.plan_id, 15), + status: sub.status, + period_start: sub.current_period_start ? new Date(sub.current_period_start).toLocaleDateString() : 'N/A', + period_end: sub.current_period_end ? new Date(sub.current_period_end).toLocaleDateString() : 'N/A', + })); + } + + /** + * Generate a PDF report for audit logs + */ + async generateAuditReport(logs: AuditLog[]): Promise { + return this.generatePdf('Audit Report', logs, [ + { header: 'Action', key: 'action', width: 80 }, + { header: 'Entity Type', key: 'entity_type', width: 100 }, + { header: 'Entity ID', key: 'entity_id', width: 100 }, + { header: 'User ID', key: 'user_id', width: 100 }, + { header: 'Created At', key: 'created_at', width: 120 }, + ], (log: AuditLog) => ({ + action: log.action, + entity_type: log.entity_type, + entity_id: this.truncateString(log.entity_id || 'N/A', 15), + user_id: this.truncateString(log.user_id || 'N/A', 15), + created_at: log.created_at ? new Date(log.created_at).toLocaleString() : 'N/A', + })); + } + + /** + * Generic PDF generation method + */ + private async generatePdf( + title: string, + data: T[], + columns: Array<{ header: string; key: string; width: number }>, + rowMapper: (item: T) => Record, + ): Promise { + // Try to use PDFKit if available, otherwise use mock implementation + try { + // Dynamic import for pdfkit + const PDFDocumentModule = await this.tryLoadPdfKit(); + + if (PDFDocumentModule) { + return this.generateWithPdfKit(PDFDocumentModule, title, data, columns, rowMapper); + } + } catch (error) { + this.logger.warn('PDFKit not available, using mock implementation'); + } + + // Fallback: Generate a simple text-based PDF representation + return this.generateMockPdf(title, data, columns, rowMapper); + } + + /** + * Try to load PDFKit dynamically + */ + private async tryLoadPdfKit(): Promise { + try { + + const pdfkit = require('pdfkit'); + return pdfkit; + } catch { + return null; + } + } + + /** + * Generate PDF using PDFKit + */ + private generateWithPdfKit( + PDFDocument: any, + title: string, + data: T[], + columns: Array<{ header: string; key: string; width: number }>, + rowMapper: (item: T) => Record, + ): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + const doc = new PDFDocument({ + margin: 50, + size: 'A4', + }); + + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + + // Header + this.addHeader(doc, title); + + // Table header + this.addTableHeader(doc, columns); + + // Table rows + let yPosition = doc.y + 10; + const pageHeight = doc.page.height - 100; + + for (const item of data) { + if (yPosition > pageHeight) { + doc.addPage(); + this.addTableHeader(doc, columns); + yPosition = doc.y + 10; + } + + const rowData = rowMapper(item); + let xPosition = 50; + + doc.fontSize(9).font('Helvetica').fillColor('black'); + + for (const column of columns) { + const value = rowData[column.key] || ''; + doc.text(value, xPosition, yPosition, { + width: column.width, + align: 'left', + }); + xPosition += column.width + 10; + } + + yPosition += 20; + } + + // Footer + this.addFooter(doc); + + doc.end(); + }); + } + + /** + * Add PDF header + */ + private addHeader(doc: any, title: string): void { + doc.fontSize(20).font('Helvetica-Bold').fillColor('#333333').text(title, { + align: 'center', + }); + + doc.moveDown(); + doc.fontSize(10).font('Helvetica').fillColor('#666666') + .text(`Generated: ${new Date().toLocaleString()}`, { align: 'center' }); + + doc.moveDown(2); + + // Draw a line under the header + doc.moveTo(50, doc.y) + .lineTo(doc.page.width - 50, doc.y) + .stroke(); + + doc.moveDown(); + } + + /** + * Add table header row + */ + private addTableHeader( + doc: any, + columns: Array<{ header: string; key: string; width: number }>, + ): void { + let xPosition = 50; + const yPosition = doc.y; + + // Background for header + doc.rect(45, yPosition - 5, doc.page.width - 90, 25).fill('#f0f0f0'); + + doc.fontSize(10).font('Helvetica-Bold').fillColor('#333333'); + + for (const column of columns) { + doc.text(column.header, xPosition, yPosition, { + width: column.width, + align: 'left', + }); + xPosition += column.width + 10; + } + + doc.y = yPosition + 25; + } + + /** + * Add PDF footer + */ + private addFooter(doc: any): void { + const bottomMargin = doc.page.height - 50; + + doc.fontSize(8) + .font('Helvetica') + .fillColor('#999999') + .text( + 'This report was automatically generated by Template SaaS.', + 50, + bottomMargin, + { align: 'center' } + ); + } + + /** + * Generate a mock PDF when PDFKit is not available + * This creates a simple text buffer that represents the PDF content + */ + private generateMockPdf( + title: string, + data: T[], + columns: Array<{ header: string; key: string; width: number }>, + rowMapper: (item: T) => Record, + ): Buffer { + const lines: string[] = []; + + // PDF header (minimal PDF structure) + lines.push('%PDF-1.4'); + lines.push('%Mock PDF Document'); + lines.push(''); + + // Title + lines.push(`Title: ${title}`); + lines.push(`Generated: ${new Date().toLocaleString()}`); + lines.push(''); + lines.push('='.repeat(80)); + + // Column headers + const headerLine = columns.map(c => c.header.padEnd(Math.min(c.width / 5, 20))).join(' | '); + lines.push(headerLine); + lines.push('-'.repeat(80)); + + // Data rows + for (const item of data) { + const rowData = rowMapper(item); + const rowLine = columns.map(c => + (rowData[c.key] || '').toString().padEnd(Math.min(c.width / 5, 20)).substring(0, Math.min(c.width / 5, 20)) + ).join(' | '); + lines.push(rowLine); + } + + lines.push(''); + lines.push('='.repeat(80)); + lines.push(`Total records: ${data.length}`); + lines.push(''); + lines.push('%%EOF'); + + return Buffer.from(lines.join('\n'), 'utf-8'); + } + + /** + * Truncate string to specified length + */ + private truncateString(str: string, maxLength: number): string { + if (!str) return ''; + if (str.length <= maxLength) return str; + return str.substring(0, maxLength - 3) + '...'; + } +} diff --git a/src/modules/reports/services/reports.service.ts b/src/modules/reports/services/reports.service.ts new file mode 100644 index 0000000..fa91c74 --- /dev/null +++ b/src/modules/reports/services/reports.service.ts @@ -0,0 +1,263 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; +import { Subscription } from '../../billing/entities/subscription.entity'; +import { AuditLog } from '../../audit/entities/audit-log.entity'; +import { PdfService } from './pdf.service'; +import { ExcelService } from './excel.service'; +import { ReportFormat, ReportQueryDto } from '../dto/report-query.dto'; + +export interface ReportResult { + buffer: Buffer; + contentType: string; + filename: string; +} + +@Injectable() +export class ReportsService { + private readonly logger = new Logger(ReportsService.name); + + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Subscription) + private readonly subscriptionRepository: Repository, + @InjectRepository(AuditLog) + private readonly auditLogRepository: Repository, + private readonly pdfService: PdfService, + private readonly excelService: ExcelService, + ) {} + + /** + * Generate users report in the specified format + */ + async getUsersReport( + tenantId: string, + format: ReportFormat | string, + query?: ReportQueryDto, + ): Promise { + this.logger.log(`Generating users report for tenant ${tenantId} in ${format} format`); + + // Build query conditions + const whereConditions: any = { tenant_id: tenantId }; + this.addDateFilters(whereConditions, 'created_at', query); + + // Fetch users for this tenant + const users = await this.userRepository.find({ + where: whereConditions, + order: { created_at: 'DESC' }, + take: query?.limit || 10000, + skip: ((query?.page || 1) - 1) * (query?.limit || 10000), + }); + + return this.generateReport('users', users, format, async (data) => { + switch (format) { + case ReportFormat.PDF: + case 'pdf': + return this.pdfService.generateUserReport(data); + case ReportFormat.EXCEL: + case 'excel': + return this.excelService.generateUserReport(data); + case ReportFormat.CSV: + case 'csv': + default: + return this.generateCsvReport(data, [ + 'id', 'email', 'first_name', 'last_name', 'phone', + 'status', 'email_verified', 'last_login_at', 'created_at' + ]); + } + }); + } + + /** + * Generate billing report in the specified format + */ + async getBillingReport( + tenantId: string, + format: ReportFormat | string, + query?: ReportQueryDto, + ): Promise { + this.logger.log(`Generating billing report for tenant ${tenantId} in ${format} format`); + + // Build query conditions + const whereConditions: any = { tenant_id: tenantId }; + this.addDateFilters(whereConditions, 'created_at', query); + + // Fetch subscriptions for this tenant + const subscriptions = await this.subscriptionRepository.find({ + where: whereConditions, + relations: ['plan'], + order: { created_at: 'DESC' }, + take: query?.limit || 10000, + skip: ((query?.page || 1) - 1) * (query?.limit || 10000), + }); + + return this.generateReport('billing', subscriptions, format, async (data) => { + switch (format) { + case ReportFormat.PDF: + case 'pdf': + return this.pdfService.generateBillingReport(data); + case ReportFormat.EXCEL: + case 'excel': + return this.excelService.generateBillingReport(data); + case ReportFormat.CSV: + case 'csv': + default: + return this.generateCsvReport(data, [ + 'id', 'tenant_id', 'plan_id', 'status', 'payment_provider', + 'current_period_start', 'current_period_end', 'trial_end', + 'cancelled_at', 'created_at' + ]); + } + }); + } + + /** + * Generate audit report in the specified format + */ + async getAuditReport( + tenantId: string, + format: ReportFormat | string, + query?: ReportQueryDto, + ): Promise { + this.logger.log(`Generating audit report for tenant ${tenantId} in ${format} format`); + + // Build query conditions + const whereConditions: any = { tenant_id: tenantId }; + this.addDateFilters(whereConditions, 'created_at', query); + + // Fetch audit logs for this tenant + const logs = await this.auditLogRepository.find({ + where: whereConditions, + order: { created_at: 'DESC' }, + take: query?.limit || 10000, + skip: ((query?.page || 1) - 1) * (query?.limit || 10000), + }); + + return this.generateReport('audit', logs, format, async (data) => { + switch (format) { + case ReportFormat.PDF: + case 'pdf': + return this.pdfService.generateAuditReport(data); + case ReportFormat.EXCEL: + case 'excel': + return this.excelService.generateAuditReport(data); + case ReportFormat.CSV: + case 'csv': + default: + return this.generateCsvReport(data, [ + 'id', 'action', 'entity_type', 'entity_id', 'user_id', + 'ip_address', 'endpoint', 'http_method', 'response_status', + 'duration_ms', 'description', 'created_at' + ]); + } + }); + } + + /** + * Add date filters to query conditions + */ + private addDateFilters( + conditions: any, + dateField: string, + query?: ReportQueryDto, + ): void { + if (query?.dateFrom && query?.dateTo) { + conditions[dateField] = Between( + new Date(query.dateFrom), + new Date(query.dateTo), + ); + } else if (query?.dateFrom) { + conditions[dateField] = MoreThanOrEqual(new Date(query.dateFrom)); + } else if (query?.dateTo) { + conditions[dateField] = LessThanOrEqual(new Date(query.dateTo)); + } + } + + /** + * Generate report with proper metadata + */ + private async generateReport( + reportType: string, + data: T[], + format: ReportFormat | string, + generator: (data: T[]) => Promise, + ): Promise { + const buffer = await generator(data); + const timestamp = new Date().toISOString().split('T')[0]; + const filename = `${reportType}-report-${timestamp}`; + + return { + buffer, + ...this.getContentTypeAndExtension(format, filename), + }; + } + + /** + * Get content type and filename based on format + */ + private getContentTypeAndExtension( + format: ReportFormat | string, + baseFilename: string, + ): { contentType: string; filename: string } { + switch (format) { + case ReportFormat.PDF: + case 'pdf': + return { + contentType: 'application/pdf', + filename: `${baseFilename}.pdf`, + }; + case ReportFormat.EXCEL: + case 'excel': + return { + contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + filename: `${baseFilename}.xlsx`, + }; + case ReportFormat.CSV: + case 'csv': + default: + return { + contentType: 'text/csv', + filename: `${baseFilename}.csv`, + }; + } + } + + /** + * Generate CSV report as fallback + */ + private generateCsvReport(data: T[], columns: string[]): Buffer { + const lines: string[] = []; + + // Header row + lines.push(columns.join(',')); + + // Data rows + for (const item of data) { + const row = columns.map(col => { + const value = (item as any)[col]; + if (value === null || value === undefined) { + return ''; + } + // Handle dates + if (value instanceof Date) { + return value.toISOString(); + } + // Handle objects + if (typeof value === 'object') { + return `"${JSON.stringify(value).replace(/"/g, '""')}"`; + } + // Handle strings with special characters + const strValue = String(value); + if (strValue.includes(',') || strValue.includes('"') || strValue.includes('\n')) { + return `"${strValue.replace(/"/g, '""')}"`; + } + return strValue; + }); + lines.push(row.join(',')); + } + + return Buffer.from(lines.join('\n'), 'utf-8'); + } +} diff --git a/src/modules/storage/__tests__/s3.provider.spec.ts b/src/modules/storage/__tests__/s3.provider.spec.ts new file mode 100644 index 0000000..32c8778 --- /dev/null +++ b/src/modules/storage/__tests__/s3.provider.spec.ts @@ -0,0 +1,535 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { S3Provider, PresignedUrlOptions } from '../providers/s3.provider'; +import { StorageProvider } from '../entities/file.entity'; + +// Mock AWS SDK +jest.mock('@aws-sdk/client-s3', () => { + const mockSend = jest.fn(); + return { + S3Client: jest.fn(() => ({ + send: mockSend, + })), + PutObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + GetObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + DeleteObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + HeadObjectCommand: jest.fn().mockImplementation((input) => ({ input })), + ListObjectsV2Command: jest.fn().mockImplementation((input) => ({ input })), + }; +}); + +jest.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: jest.fn().mockResolvedValue('https://s3.amazonaws.com/presigned-url'), +})); + +import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; + +describe('S3Provider', () => { + let provider: S3Provider; + let configService: jest.Mocked; + let mockS3Client: { send: jest.Mock }; + + const mockConfig = { + STORAGE_PROVIDER: 's3', + STORAGE_BUCKET: 'test-bucket', + AWS_REGION: 'us-east-1', + AWS_ACCESS_KEY_ID: 'test-access-key', + AWS_SECRET_ACCESS_KEY: 'test-secret-key', + STORAGE_ENDPOINT: undefined, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: any) => { + return mockConfig[key as keyof typeof mockConfig] ?? defaultValue; + }), + }, + }, + ], + }).compile(); + + provider = module.get(S3Provider); + configService = module.get(ConfigService); + mockS3Client = (S3Client as jest.Mock).mock.results[0]?.value; + }); + + describe('onModuleInit', () => { + it('should configure storage when credentials are provided', () => { + provider.onModuleInit(); + + expect(provider.isConfigured()).toBe(true); + expect(provider.getBucket()).toBe('test-bucket'); + expect(provider.getProvider()).toBe(StorageProvider.S3); + }); + + it('should not configure storage when bucket is missing', async () => { + const moduleWithoutBucket = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: any) => { + if (key === 'STORAGE_BUCKET') return undefined; + return mockConfig[key as keyof typeof mockConfig] ?? defaultValue; + }), + }, + }, + ], + }).compile(); + + const providerWithoutBucket = moduleWithoutBucket.get(S3Provider); + providerWithoutBucket.onModuleInit(); + + expect(providerWithoutBucket.isConfigured()).toBe(false); + }); + + it('should not configure storage when access key is missing', async () => { + const moduleWithoutAccessKey = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: any) => { + if (key === 'AWS_ACCESS_KEY_ID') return undefined; + return mockConfig[key as keyof typeof mockConfig] ?? defaultValue; + }), + }, + }, + ], + }).compile(); + + const providerWithoutAccessKey = moduleWithoutAccessKey.get(S3Provider); + providerWithoutAccessKey.onModuleInit(); + + expect(providerWithoutAccessKey.isConfigured()).toBe(false); + }); + + it('should configure with custom endpoint for MinIO', async () => { + const moduleWithEndpoint = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: any) => { + if (key === 'STORAGE_ENDPOINT') return 'http://localhost:9000'; + if (key === 'STORAGE_PROVIDER') return 'minio'; + return mockConfig[key as keyof typeof mockConfig] ?? defaultValue; + }), + }, + }, + ], + }).compile(); + + const providerWithEndpoint = moduleWithEndpoint.get(S3Provider); + providerWithEndpoint.onModuleInit(); + + expect(providerWithEndpoint.isConfigured()).toBe(true); + expect(providerWithEndpoint.getProvider()).toBe('minio'); + }); + + it('should configure with R2 provider', async () => { + const moduleWithR2 = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: any) => { + if (key === 'STORAGE_PROVIDER') return 'r2'; + if (key === 'STORAGE_ENDPOINT') return 'https://account.r2.cloudflarestorage.com'; + return mockConfig[key as keyof typeof mockConfig] ?? defaultValue; + }), + }, + }, + ], + }).compile(); + + const providerWithR2 = moduleWithR2.get(S3Provider); + providerWithR2.onModuleInit(); + + expect(providerWithR2.isConfigured()).toBe(true); + expect(providerWithR2.getProvider()).toBe('r2'); + }); + }); + + describe('getUploadUrl', () => { + beforeEach(() => { + provider.onModuleInit(); + mockS3Client = (S3Client as jest.Mock).mock.results[0]?.value; + }); + + it('should return presigned upload URL', async () => { + const options: PresignedUrlOptions = { + bucket: 'test-bucket', + key: 'tenant-123/files/upload-001/test.jpg', + contentType: 'image/jpeg', + contentLength: 1024000, + }; + + const result = await provider.getUploadUrl(options); + + expect(result).toHaveProperty('url'); + expect(result).toHaveProperty('expiresAt'); + expect(result.url).toBe('https://s3.amazonaws.com/presigned-url'); + expect(getSignedUrl).toHaveBeenCalled(); + expect(PutObjectCommand).toHaveBeenCalledWith(expect.objectContaining({ + Bucket: 'test-bucket', + Key: 'tenant-123/files/upload-001/test.jpg', + ContentType: 'image/jpeg', + ContentLength: 1024000, + })); + }); + + it('should use default expiration of 15 minutes', async () => { + const options: PresignedUrlOptions = { + bucket: 'test-bucket', + key: 'tenant-123/files/upload-001/test.jpg', + contentType: 'image/jpeg', + contentLength: 1024000, + }; + + const result = await provider.getUploadUrl(options); + + // Check that expiresAt is approximately 15 minutes from now + const expectedExpiresAt = new Date(Date.now() + 900 * 1000); + const timeDiff = Math.abs(result.expiresAt.getTime() - expectedExpiresAt.getTime()); + expect(timeDiff).toBeLessThan(1000); // Within 1 second + }); + + it('should use custom expiration time', async () => { + const options: PresignedUrlOptions = { + bucket: 'test-bucket', + key: 'tenant-123/files/upload-001/test.jpg', + contentType: 'image/jpeg', + contentLength: 1024000, + expiresIn: 3600, // 1 hour + }; + + const result = await provider.getUploadUrl(options); + + const expectedExpiresAt = new Date(Date.now() + 3600 * 1000); + const timeDiff = Math.abs(result.expiresAt.getTime() - expectedExpiresAt.getTime()); + expect(timeDiff).toBeLessThan(1000); + }); + + it('should include metadata in upload URL', async () => { + const options: PresignedUrlOptions = { + bucket: 'test-bucket', + key: 'tenant-123/files/upload-001/test.jpg', + contentType: 'image/jpeg', + contentLength: 1024000, + metadata: { + 'tenant-id': 'tenant-123', + 'upload-id': 'upload-001', + }, + }; + + await provider.getUploadUrl(options); + + expect(PutObjectCommand).toHaveBeenCalledWith(expect.objectContaining({ + Metadata: { + 'tenant-id': 'tenant-123', + 'upload-id': 'upload-001', + }, + })); + }); + + it('should throw error when not configured', async () => { + const unconfiguredModule = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue(undefined), + }, + }, + ], + }).compile(); + + const unconfiguredProvider = unconfiguredModule.get(S3Provider); + unconfiguredProvider.onModuleInit(); + + const options: PresignedUrlOptions = { + bucket: 'test-bucket', + key: 'test-key', + contentType: 'image/jpeg', + contentLength: 1024000, + }; + + await expect(unconfiguredProvider.getUploadUrl(options)).rejects.toThrow('Storage not configured'); + }); + }); + + describe('getDownloadUrl', () => { + beforeEach(() => { + provider.onModuleInit(); + }); + + it('should return presigned download URL', async () => { + const result = await provider.getDownloadUrl('tenant-123/files/file-001/test.jpg'); + + expect(result).toHaveProperty('url'); + expect(result).toHaveProperty('expiresAt'); + expect(result.url).toBe('https://s3.amazonaws.com/presigned-url'); + expect(getSignedUrl).toHaveBeenCalled(); + expect(GetObjectCommand).toHaveBeenCalledWith(expect.objectContaining({ + Bucket: 'test-bucket', + Key: 'tenant-123/files/file-001/test.jpg', + })); + }); + + it('should use default expiration of 1 hour', async () => { + const result = await provider.getDownloadUrl('test-key'); + + const expectedExpiresAt = new Date(Date.now() + 3600 * 1000); + const timeDiff = Math.abs(result.expiresAt.getTime() - expectedExpiresAt.getTime()); + expect(timeDiff).toBeLessThan(1000); + }); + + it('should use custom expiration time', async () => { + const result = await provider.getDownloadUrl('test-key', 7200); + + const expectedExpiresAt = new Date(Date.now() + 7200 * 1000); + const timeDiff = Math.abs(result.expiresAt.getTime() - expectedExpiresAt.getTime()); + expect(timeDiff).toBeLessThan(1000); + }); + + it('should throw error when not configured', async () => { + const unconfiguredModule = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue(undefined), + }, + }, + ], + }).compile(); + + const unconfiguredProvider = unconfiguredModule.get(S3Provider); + unconfiguredProvider.onModuleInit(); + + await expect(unconfiguredProvider.getDownloadUrl('test-key')).rejects.toThrow('Storage not configured'); + }); + }); + + describe('deleteObject', () => { + beforeEach(() => { + provider.onModuleInit(); + mockS3Client = (S3Client as jest.Mock).mock.results[0]?.value; + mockS3Client.send = jest.fn().mockResolvedValue({}); + }); + + it('should delete object from S3', async () => { + await provider.deleteObject('tenant-123/files/file-001/test.jpg'); + + expect(mockS3Client.send).toHaveBeenCalled(); + expect(DeleteObjectCommand).toHaveBeenCalledWith(expect.objectContaining({ + Bucket: 'test-bucket', + Key: 'tenant-123/files/file-001/test.jpg', + })); + }); + + it('should throw error when not configured', async () => { + const unconfiguredModule = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue(undefined), + }, + }, + ], + }).compile(); + + const unconfiguredProvider = unconfiguredModule.get(S3Provider); + unconfiguredProvider.onModuleInit(); + + await expect(unconfiguredProvider.deleteObject('test-key')).rejects.toThrow('Storage not configured'); + }); + + it('should handle S3 errors', async () => { + mockS3Client.send.mockRejectedValue(new Error('S3 error')); + + await expect(provider.deleteObject('test-key')).rejects.toThrow('S3 error'); + }); + }); + + describe('headObject', () => { + beforeEach(() => { + provider.onModuleInit(); + mockS3Client = (S3Client as jest.Mock).mock.results[0]?.value; + }); + + it('should return object metadata', async () => { + mockS3Client.send = jest.fn().mockResolvedValue({ + ContentLength: 1024000, + ContentType: 'image/jpeg', + }); + + const result = await provider.headObject('tenant-123/files/file-001/test.jpg'); + + expect(result).toEqual({ + contentLength: 1024000, + contentType: 'image/jpeg', + }); + expect(HeadObjectCommand).toHaveBeenCalledWith(expect.objectContaining({ + Bucket: 'test-bucket', + Key: 'tenant-123/files/file-001/test.jpg', + })); + }); + + it('should return null when object not found', async () => { + const notFoundError = new Error('Not Found'); + (notFoundError as any).name = 'NotFound'; + mockS3Client.send = jest.fn().mockRejectedValue(notFoundError); + + const result = await provider.headObject('non-existent-key'); + + expect(result).toBeNull(); + }); + + it('should throw error for other S3 errors', async () => { + mockS3Client.send = jest.fn().mockRejectedValue(new Error('Access Denied')); + + await expect(provider.headObject('test-key')).rejects.toThrow('Access Denied'); + }); + + it('should return default content type when not provided', async () => { + mockS3Client.send = jest.fn().mockResolvedValue({ + ContentLength: 1024, + ContentType: undefined, + }); + + const result = await provider.headObject('test-key'); + + expect(result).toEqual({ + contentLength: 1024, + contentType: 'application/octet-stream', + }); + }); + + it('should return 0 for content length when not provided', async () => { + mockS3Client.send = jest.fn().mockResolvedValue({ + ContentLength: undefined, + ContentType: 'text/plain', + }); + + const result = await provider.headObject('test-key'); + + expect(result).toEqual({ + contentLength: 0, + contentType: 'text/plain', + }); + }); + + it('should throw error when not configured', async () => { + const unconfiguredModule = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue(undefined), + }, + }, + ], + }).compile(); + + const unconfiguredProvider = unconfiguredModule.get(S3Provider); + unconfiguredProvider.onModuleInit(); + + await expect(unconfiguredProvider.headObject('test-key')).rejects.toThrow('Storage not configured'); + }); + }); + + describe('generatePath', () => { + beforeEach(() => { + provider.onModuleInit(); + }); + + it('should generate correct path', () => { + const path = provider.generatePath('tenant-123', 'files', 'upload-001', 'test.jpg'); + + expect(path).toBe('tenant-123/files/upload-001/test.jpg'); + }); + + it('should sanitize filename with special characters', () => { + const path = provider.generatePath('tenant-123', 'images', 'upload-001', 'my file (1).jpg'); + + expect(path).toBe('tenant-123/images/upload-001/my_file__1_.jpg'); + }); + + it('should preserve dots and hyphens in filename', () => { + const path = provider.generatePath('tenant-123', 'documents', 'upload-001', 'my-file.v2.pdf'); + + expect(path).toBe('tenant-123/documents/upload-001/my-file.v2.pdf'); + }); + + it('should handle unicode characters', () => { + const path = provider.generatePath('tenant-123', 'files', 'upload-001', 'archivo_espanol.pdf'); + + expect(path).toBe('tenant-123/files/upload-001/archivo_espanol.pdf'); + }); + + it('should handle multiple spaces', () => { + const path = provider.generatePath('tenant-123', 'files', 'upload-001', 'file name.txt'); + + expect(path).toBe('tenant-123/files/upload-001/file___name.txt'); + }); + }); + + describe('isConfigured', () => { + it('should return true when configured', () => { + provider.onModuleInit(); + expect(provider.isConfigured()).toBe(true); + }); + + it('should return false when not configured', async () => { + const unconfiguredModule = await Test.createTestingModule({ + providers: [ + S3Provider, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue(undefined), + }, + }, + ], + }).compile(); + + const unconfiguredProvider = unconfiguredModule.get(S3Provider); + unconfiguredProvider.onModuleInit(); + + expect(unconfiguredProvider.isConfigured()).toBe(false); + }); + }); + + describe('getBucket', () => { + it('should return configured bucket', () => { + provider.onModuleInit(); + expect(provider.getBucket()).toBe('test-bucket'); + }); + }); + + describe('getProvider', () => { + it('should return configured provider type', () => { + provider.onModuleInit(); + expect(provider.getProvider()).toBe(StorageProvider.S3); + }); + }); +}); diff --git a/src/modules/storage/__tests__/storage.controller.spec.ts b/src/modules/storage/__tests__/storage.controller.spec.ts new file mode 100644 index 0000000..2edeea5 --- /dev/null +++ b/src/modules/storage/__tests__/storage.controller.spec.ts @@ -0,0 +1,409 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { StorageController } from '../storage.controller'; +import { StorageService } from '../services/storage.service'; + +describe('StorageController', () => { + let controller: StorageController; + let service: jest.Mocked; + + const mockRequestUser = { + id: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + role: 'admin', + }; + + const mockFile = { + id: 'file-123', + tenant_id: 'tenant-123', + uploaded_by: 'user-123', + filename: 'test.pdf', + original_filename: 'test.pdf', + mime_type: 'application/pdf', + size: 1024, + storage_key: 's3://bucket/tenant-123/file-123.pdf', + category: 'documents', + status: 'active', + created_at: new Date('2026-01-01'), + }; + + const mockPresignedUrlResponse = { + uploadUrl: 'https://s3.amazonaws.com/bucket/presigned', + fileId: 'file-123', + key: 'tenant-123/file-123.pdf', + expiresIn: 3600, + }; + + const mockDownloadUrlResponse = { + downloadUrl: 'https://s3.amazonaws.com/bucket/download-presigned', + filename: 'test.pdf', + expiresIn: 3600, + }; + + const mockUsage = { + tenantId: 'tenant-123', + totalFiles: 10, + totalSize: 102400, + usedStorage: 102400, + limitStorage: 1073741824, + percentUsed: 0.01, + byCategory: { + documents: { count: 5, size: 51200 }, + images: { count: 5, size: 51200 }, + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [StorageController], + providers: [ + { + provide: StorageService, + useValue: { + getUploadUrl: jest.fn(), + confirmUpload: jest.fn(), + listFiles: jest.fn(), + getFile: jest.fn(), + getDownloadUrl: jest.fn(), + updateFile: jest.fn(), + deleteFile: jest.fn(), + getUsage: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(StorageController); + service = module.get(StorageService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getUploadUrl', () => { + it('should return presigned upload URL', async () => { + const dto = { filename: 'test.pdf', mimeType: 'application/pdf', size: 1024 }; + service.getUploadUrl.mockResolvedValue(mockPresignedUrlResponse as any); + + const result = await controller.getUploadUrl(mockRequestUser, dto as any); + + expect(result).toEqual(mockPresignedUrlResponse); + expect(service.getUploadUrl).toHaveBeenCalledWith('tenant-123', 'user-123', dto); + }); + }); + + describe('confirmUpload', () => { + it('should confirm upload', async () => { + const dto = { fileId: 'file-123' }; + service.confirmUpload.mockResolvedValue(mockFile as any); + + const result = await controller.confirmUpload(mockRequestUser, dto as any); + + expect(result).toEqual(mockFile); + expect(service.confirmUpload).toHaveBeenCalledWith('tenant-123', 'user-123', dto); + }); + }); + + describe('listFiles', () => { + it('should return paginated files', async () => { + const filesResult = { data: [mockFile], total: 1, page: 1, limit: 20 }; + service.listFiles.mockResolvedValue(filesResult as any); + + const result = await controller.listFiles(mockRequestUser, { page: 1, limit: 20 } as any); + + expect(result).toEqual(filesResult); + expect(service.listFiles).toHaveBeenCalledWith('tenant-123', { page: 1, limit: 20 }); + }); + + it('should filter by category', async () => { + const filesResult = { data: [mockFile], total: 1, page: 1, limit: 20 }; + service.listFiles.mockResolvedValue(filesResult as any); + + const dto = { page: 1, limit: 20, category: 'documents' }; + const result = await controller.listFiles(mockRequestUser, dto as any); + + expect(result).toEqual(filesResult); + expect(service.listFiles).toHaveBeenCalledWith('tenant-123', dto); + }); + }); + + describe('getFile', () => { + it('should return file by id', async () => { + service.getFile.mockResolvedValue(mockFile as any); + + const result = await controller.getFile(mockRequestUser, 'file-123'); + + expect(result).toEqual(mockFile); + expect(service.getFile).toHaveBeenCalledWith('tenant-123', 'file-123'); + }); + }); + + describe('getDownloadUrl', () => { + it('should return presigned download URL', async () => { + service.getDownloadUrl.mockResolvedValue(mockDownloadUrlResponse as any); + + const result = await controller.getDownloadUrl(mockRequestUser, 'file-123'); + + expect(result).toEqual(mockDownloadUrlResponse); + expect(service.getDownloadUrl).toHaveBeenCalledWith('tenant-123', 'file-123'); + }); + }); + + describe('updateFile', () => { + it('should update file metadata', async () => { + const dto = { filename: 'renamed.pdf', category: 'archives' }; + const updatedFile = { ...mockFile, filename: 'renamed.pdf', category: 'archives' }; + service.updateFile.mockResolvedValue(updatedFile as any); + + const result = await controller.updateFile(mockRequestUser, 'file-123', dto as any); + + expect(result.filename).toBe('renamed.pdf'); + expect(service.updateFile).toHaveBeenCalledWith('tenant-123', 'file-123', dto); + }); + }); + + describe('deleteFile', () => { + it('should delete file', async () => { + service.deleteFile.mockResolvedValue(undefined); + + await controller.deleteFile(mockRequestUser, 'file-123'); + + expect(service.deleteFile).toHaveBeenCalledWith('tenant-123', 'file-123'); + }); + }); + + describe('getUsage', () => { + it('should return storage usage', async () => { + service.getUsage.mockResolvedValue(mockUsage as any); + + const result = await controller.getUsage(mockRequestUser); + + expect(result).toEqual(mockUsage); + expect(service.getUsage).toHaveBeenCalledWith('tenant-123'); + }); + + it('should return usage with quota limits', async () => { + const usageWithLimits = { + ...mockUsage, + usagePercent: 50.5, + maxBytes: 1073741824, + }; + service.getUsage.mockResolvedValue(usageWithLimits as any); + + const result = await controller.getUsage(mockRequestUser); + + expect(result).toHaveProperty('usagePercent'); + expect(result).toHaveProperty('maxBytes'); + }); + }); + + // ==================== Additional Upload URL Tests ==================== + + describe('getUploadUrl - additional cases', () => { + it('should pass folder parameter to service', async () => { + const dto = { filename: 'test.pdf', mimeType: 'application/pdf', size: 1024, folder: 'documents' }; + service.getUploadUrl.mockResolvedValue(mockPresignedUrlResponse as any); + + await controller.getUploadUrl(mockRequestUser, dto as any); + + expect(service.getUploadUrl).toHaveBeenCalledWith('tenant-123', 'user-123', dto); + }); + + it('should handle large file upload request', async () => { + const dto = { filename: 'large-video.mp4', mimeType: 'video/mp4', size: 104857600 }; // 100 MB + service.getUploadUrl.mockResolvedValue(mockPresignedUrlResponse as any); + + const result = await controller.getUploadUrl(mockRequestUser, dto as any); + + expect(result).toEqual(mockPresignedUrlResponse); + }); + + it('should use tenant_id from authenticated user', async () => { + const differentTenantUser = { ...mockRequestUser, tenant_id: 'tenant-456' }; + const dto = { filename: 'test.pdf', mimeType: 'application/pdf', size: 1024 }; + service.getUploadUrl.mockResolvedValue(mockPresignedUrlResponse as any); + + await controller.getUploadUrl(differentTenantUser, dto as any); + + expect(service.getUploadUrl).toHaveBeenCalledWith('tenant-456', 'user-123', dto); + }); + }); + + // ==================== Additional Confirm Upload Tests ==================== + + describe('confirmUpload - additional cases', () => { + it('should pass metadata to service', async () => { + const dto = { fileId: 'file-123', metadata: { description: 'Test file', tags: ['test'] } }; + service.confirmUpload.mockResolvedValue(mockFile as any); + + await controller.confirmUpload(mockRequestUser, dto as any); + + expect(service.confirmUpload).toHaveBeenCalledWith('tenant-123', 'user-123', dto); + }); + + it('should return created file details', async () => { + const dto = { fileId: 'file-123' }; + const createdFile = { ...mockFile, id: 'new-file-id' }; + service.confirmUpload.mockResolvedValue(createdFile as any); + + const result = await controller.confirmUpload(mockRequestUser, dto as any); + + expect(result.id).toBe('new-file-id'); + }); + }); + + // ==================== Additional List Files Tests ==================== + + describe('listFiles - additional cases', () => { + it('should pass search parameter', async () => { + const dto = { page: 1, limit: 20, search: 'report' }; + const filesResult = { data: [mockFile], total: 1, page: 1, limit: 20 }; + service.listFiles.mockResolvedValue(filesResult as any); + + await controller.listFiles(mockRequestUser, dto as any); + + expect(service.listFiles).toHaveBeenCalledWith('tenant-123', dto); + }); + + it('should pass mimeType filter', async () => { + const dto = { page: 1, limit: 20, mimeType: 'image' }; + const filesResult = { data: [], total: 0, page: 1, limit: 20 }; + service.listFiles.mockResolvedValue(filesResult as any); + + await controller.listFiles(mockRequestUser, dto as any); + + expect(service.listFiles).toHaveBeenCalledWith('tenant-123', dto); + }); + + it('should pass folder filter', async () => { + const dto = { page: 1, limit: 20, folder: 'images' }; + const filesResult = { data: [mockFile], total: 1, page: 1, limit: 20 }; + service.listFiles.mockResolvedValue(filesResult as any); + + await controller.listFiles(mockRequestUser, dto as any); + + expect(service.listFiles).toHaveBeenCalledWith('tenant-123', dto); + }); + + it('should return paginated result with totalPages', async () => { + const filesResult = { + data: [mockFile], + total: 50, + page: 1, + limit: 10, + totalPages: 5 + }; + service.listFiles.mockResolvedValue(filesResult as any); + + const result = await controller.listFiles(mockRequestUser, { page: 1, limit: 10 } as any); + + expect(result.totalPages).toBe(5); + }); + }); + + // ==================== Additional Get File Tests ==================== + + describe('getFile - additional cases', () => { + it('should return file with all metadata', async () => { + const fileWithMetadata = { + ...mockFile, + metadata: { description: 'Test', tags: ['a', 'b'] }, + thumbnails: { small: 'url1', medium: 'url2' }, + }; + service.getFile.mockResolvedValue(fileWithMetadata as any); + + const result = await controller.getFile(mockRequestUser, 'file-123'); + + expect(result.metadata).toEqual({ description: 'Test', tags: ['a', 'b'] }); + }); + + it('should isolate files by tenant', async () => { + const differentTenantUser = { ...mockRequestUser, tenant_id: 'tenant-456' }; + service.getFile.mockResolvedValue(mockFile as any); + + await controller.getFile(differentTenantUser, 'file-123'); + + expect(service.getFile).toHaveBeenCalledWith('tenant-456', 'file-123'); + }); + }); + + // ==================== Additional Download URL Tests ==================== + + describe('getDownloadUrl - additional cases', () => { + it('should return URL with expiration time', async () => { + const downloadResponse = { + ...mockDownloadUrlResponse, + expiresAt: new Date('2026-01-01T12:00:00Z'), + }; + service.getDownloadUrl.mockResolvedValue(downloadResponse as any); + + const result = await controller.getDownloadUrl(mockRequestUser, 'file-123'); + + expect(result).toHaveProperty('expiresAt'); + }); + + it('should pass correct file ID to service', async () => { + service.getDownloadUrl.mockResolvedValue(mockDownloadUrlResponse as any); + + await controller.getDownloadUrl(mockRequestUser, 'specific-file-id'); + + expect(service.getDownloadUrl).toHaveBeenCalledWith('tenant-123', 'specific-file-id'); + }); + }); + + // ==================== Additional Update File Tests ==================== + + describe('updateFile - additional cases', () => { + it('should update file visibility', async () => { + const dto = { visibility: 'public' }; + const updatedFile = { ...mockFile, visibility: 'public' }; + service.updateFile.mockResolvedValue(updatedFile as any); + + const result = await controller.updateFile(mockRequestUser, 'file-123', dto as any); + + expect(result.visibility).toBe('public'); + }); + + it('should update file folder', async () => { + const dto = { folder: 'archives' }; + const updatedFile = { ...mockFile, folder: 'archives' }; + service.updateFile.mockResolvedValue(updatedFile as any); + + const result = await controller.updateFile(mockRequestUser, 'file-123', dto as any); + + expect(result.folder).toBe('archives'); + }); + + it('should update metadata', async () => { + const dto = { metadata: { description: 'Updated description' } }; + const updatedFile = { ...mockFile, metadata: { description: 'Updated description' } }; + service.updateFile.mockResolvedValue(updatedFile as any); + + const result = await controller.updateFile(mockRequestUser, 'file-123', dto as any); + + expect(result.metadata).toEqual({ description: 'Updated description' }); + }); + }); + + // ==================== Additional Delete File Tests ==================== + + describe('deleteFile - additional cases', () => { + it('should call service with correct tenant and file ID', async () => { + service.deleteFile.mockResolvedValue(undefined); + + await controller.deleteFile(mockRequestUser, 'file-to-delete'); + + expect(service.deleteFile).toHaveBeenCalledWith('tenant-123', 'file-to-delete'); + }); + + it('should isolate deletion by tenant', async () => { + const differentTenantUser = { ...mockRequestUser, tenant_id: 'tenant-456' }; + service.deleteFile.mockResolvedValue(undefined); + + await controller.deleteFile(differentTenantUser, 'file-123'); + + expect(service.deleteFile).toHaveBeenCalledWith('tenant-456', 'file-123'); + }); + }); +}); diff --git a/src/modules/storage/__tests__/storage.service.spec.ts b/src/modules/storage/__tests__/storage.service.spec.ts new file mode 100644 index 0000000..7c0b245 --- /dev/null +++ b/src/modules/storage/__tests__/storage.service.spec.ts @@ -0,0 +1,936 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { StorageService } from '../services/storage.service'; +import { FileEntity, FileStatus, FileVisibility, StorageProvider } from '../entities/file.entity'; +import { PendingUploadEntity } from '../entities/pending-upload.entity'; +import { StorageUsageEntity } from '../entities/storage-usage.entity'; +import { S3Provider } from '../providers/s3.provider'; + +describe('StorageService', () => { + let service: StorageService; + let fileRepo: jest.Mocked>; + let pendingUploadRepo: jest.Mocked>; + let usageRepo: jest.Mocked>; + let s3Provider: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockFile: Partial = { + id: 'file-001', + tenant_id: mockTenantId, + uploaded_by: mockUserId, + filename: 'test_image.jpg', + original_name: 'test image.jpg', + mime_type: 'image/jpeg', + size_bytes: 1024000, + bucket: 'template-saas', + path: `${mockTenantId}/files/file-001/test_image.jpg`, + provider: StorageProvider.S3, + folder: 'files', + status: FileStatus.READY, + visibility: FileVisibility.PRIVATE, + created_at: new Date(), + }; + + const mockPendingUpload: Partial = { + id: 'upload-001', + tenant_id: mockTenantId, + user_id: mockUserId, + filename: 'test_image.jpg', + original_name: 'test image.jpg', + mime_type: 'image/jpeg', + size_bytes: 1024000, + folder: 'files', + bucket: 'template-saas', + path: `${mockTenantId}/files/upload-001/test_image.jpg`, + provider: StorageProvider.S3, + status: 'pending', + expires_at: new Date(Date.now() + 3600000), + }; + + const mockUsage: Partial = { + id: 'usage-001', + tenant_id: mockTenantId, + total_files: 10, + total_bytes: 10485760, + max_bytes: 1073741824, + max_file_size: 52428800, + }; + + beforeEach(async () => { + const mockFileRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + findAndCount: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockPendingUploadRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + }; + + const mockUsageRepo = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + const mockS3Provider = { + isConfigured: jest.fn(), + getBucket: jest.fn(), + getProvider: jest.fn(), + generatePath: jest.fn(), + getUploadUrl: jest.fn(), + getDownloadUrl: jest.fn(), + headObject: jest.fn(), + deleteObject: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StorageService, + { provide: getRepositoryToken(FileEntity), useValue: mockFileRepo }, + { provide: getRepositoryToken(PendingUploadEntity), useValue: mockPendingUploadRepo }, + { provide: getRepositoryToken(StorageUsageEntity), useValue: mockUsageRepo }, + { provide: S3Provider, useValue: mockS3Provider }, + ], + }).compile(); + + service = module.get(StorageService); + fileRepo = module.get(getRepositoryToken(FileEntity)); + pendingUploadRepo = module.get(getRepositoryToken(PendingUploadEntity)); + usageRepo = module.get(getRepositoryToken(StorageUsageEntity)); + s3Provider = module.get(S3Provider); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== Upload URL Tests ==================== + + describe('getUploadUrl', () => { + const uploadDto = { + filename: 'test image.jpg', + mimeType: 'image/jpeg', + sizeBytes: 1024000, + }; + + it('should return presigned upload URL', async () => { + s3Provider.isConfigured.mockReturnValue(true); + s3Provider.getBucket.mockReturnValue('template-saas'); + s3Provider.getProvider.mockReturnValue(StorageProvider.S3); + s3Provider.generatePath.mockReturnValue(`${mockTenantId}/files/xxx/test_image.jpg`); + s3Provider.getUploadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/presigned-url', + expiresAt: new Date(Date.now() + 3600000), + }); + usageRepo.findOne.mockResolvedValue(null); + pendingUploadRepo.create.mockReturnValue(mockPendingUpload as PendingUploadEntity); + pendingUploadRepo.save.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + + const result = await service.getUploadUrl(mockTenantId, mockUserId, uploadDto); + + expect(result).toHaveProperty('uploadId'); + expect(result).toHaveProperty('url'); + expect(result).toHaveProperty('expiresAt'); + expect(s3Provider.getUploadUrl).toHaveBeenCalled(); + }); + + it('should throw when storage not configured', async () => { + s3Provider.isConfigured.mockReturnValue(false); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, uploadDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw for disallowed MIME type', async () => { + s3Provider.isConfigured.mockReturnValue(true); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, { + ...uploadDto, + mimeType: 'application/x-executable', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw for blocked file extension', async () => { + s3Provider.isConfigured.mockReturnValue(true); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, { + ...uploadDto, + filename: 'malicious.exe', + mimeType: 'image/jpeg', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw when file exceeds max size', async () => { + s3Provider.isConfigured.mockReturnValue(true); + usageRepo.findOne.mockResolvedValue({ + ...mockUsage, + max_file_size: 1000, + } as StorageUsageEntity); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, uploadDto), + ).rejects.toThrow(ForbiddenException); + }); + + it('should throw when storage limit exceeded', async () => { + s3Provider.isConfigured.mockReturnValue(true); + usageRepo.findOne.mockResolvedValue({ + ...mockUsage, + total_bytes: 1073741800, + max_bytes: 1073741824, + } as StorageUsageEntity); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, { + ...uploadDto, + sizeBytes: 100000000, + }), + ).rejects.toThrow(ForbiddenException); + }); + }); + + // ==================== Confirm Upload Tests ==================== + + describe('confirmUpload', () => { + it('should confirm upload successfully', async () => { + pendingUploadRepo.findOne.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + s3Provider.headObject.mockResolvedValue({ contentLength: 1024000, contentType: 'image/png' }); + fileRepo.create.mockReturnValue(mockFile as FileEntity); + fileRepo.save.mockResolvedValue(mockFile as FileEntity); + pendingUploadRepo.update.mockResolvedValue({ affected: 1 } as any); + + const result = await service.confirmUpload(mockTenantId, mockUserId, { + uploadId: 'upload-001', + }); + + expect(result).toHaveProperty('id'); + expect(result).toHaveProperty('filename'); + expect(fileRepo.save).toHaveBeenCalled(); + }); + + it('should throw when upload not found', async () => { + pendingUploadRepo.findOne.mockResolvedValue(null); + + await expect( + service.confirmUpload(mockTenantId, mockUserId, { uploadId: 'invalid' }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw when upload expired', async () => { + pendingUploadRepo.findOne.mockResolvedValue({ + ...mockPendingUpload, + expires_at: new Date(Date.now() - 3600000), + } as PendingUploadEntity); + pendingUploadRepo.update.mockResolvedValue({ affected: 1 } as any); + + await expect( + service.confirmUpload(mockTenantId, mockUserId, { uploadId: 'upload-001' }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw when file not found in S3', async () => { + pendingUploadRepo.findOne.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + s3Provider.headObject.mockResolvedValue(null); + pendingUploadRepo.update.mockResolvedValue({ affected: 1 } as any); + + await expect( + service.confirmUpload(mockTenantId, mockUserId, { uploadId: 'upload-001' }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ==================== List Files Tests ==================== + + describe('listFiles', () => { + it('should return paginated files', async () => { + fileRepo.findAndCount.mockResolvedValue([[mockFile as FileEntity], 1]); + + const result = await service.listFiles(mockTenantId, { page: 1, limit: 20 }); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.totalPages).toBe(1); + }); + + it('should filter by folder', async () => { + fileRepo.findAndCount.mockResolvedValue([[mockFile as FileEntity], 1]); + + await service.listFiles(mockTenantId, { folder: 'images' }); + + expect(fileRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ folder: 'images' }), + }), + ); + }); + + it('should use default pagination', async () => { + fileRepo.findAndCount.mockResolvedValue([[], 0]); + + const result = await service.listFiles(mockTenantId, {}); + + expect(result.page).toBe(1); + expect(result.limit).toBe(20); + }); + }); + + // ==================== Get File Tests ==================== + + describe('getFile', () => { + it('should return file by id', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + + const result = await service.getFile(mockTenantId, 'file-001'); + + expect(result).toHaveProperty('id', 'file-001'); + expect(result).toHaveProperty('filename'); + }); + + it('should throw when file not found', async () => { + fileRepo.findOne.mockResolvedValue(null); + + await expect(service.getFile(mockTenantId, 'invalid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ==================== Download URL Tests ==================== + + describe('getDownloadUrl', () => { + it('should return presigned download URL', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + s3Provider.getDownloadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/download-url', + expiresAt: new Date(Date.now() + 3600000), + }); + + const result = await service.getDownloadUrl(mockTenantId, 'file-001'); + + expect(result).toHaveProperty('url'); + expect(result).toHaveProperty('expiresAt'); + }); + + it('should throw when file not found', async () => { + fileRepo.findOne.mockResolvedValue(null); + + await expect( + service.getDownloadUrl(mockTenantId, 'invalid'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Update File Tests ==================== + + describe('updateFile', () => { + it('should update file folder', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + fileRepo.save.mockResolvedValue({ ...mockFile, folder: 'images' } as FileEntity); + + const result = await service.updateFile(mockTenantId, 'file-001', { + folder: 'images', + }); + + expect(result.folder).toBe('images'); + }); + + it('should update file visibility', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + fileRepo.save.mockResolvedValue({ + ...mockFile, + visibility: FileVisibility.PUBLIC, + } as FileEntity); + + const result = await service.updateFile(mockTenantId, 'file-001', { + visibility: FileVisibility.PUBLIC, + }); + + expect(result.visibility).toBe(FileVisibility.PUBLIC); + }); + + it('should throw when file not found', async () => { + fileRepo.findOne.mockResolvedValue(null); + + await expect( + service.updateFile(mockTenantId, 'invalid', { folder: 'new' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Delete File Tests ==================== + + describe('deleteFile', () => { + it('should soft delete file', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + fileRepo.save.mockResolvedValue({ + ...mockFile, + status: FileStatus.DELETED, + deleted_at: new Date(), + } as FileEntity); + s3Provider.deleteObject.mockResolvedValue(undefined); + + await service.deleteFile(mockTenantId, 'file-001'); + + expect(fileRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: FileStatus.DELETED, + }), + ); + }); + + it('should handle S3 deletion failure gracefully', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + fileRepo.save.mockResolvedValue({ + ...mockFile, + status: FileStatus.DELETED, + } as FileEntity); + s3Provider.deleteObject.mockRejectedValue(new Error('S3 error')); + + // Should not throw + await expect(service.deleteFile(mockTenantId, 'file-001')).resolves.not.toThrow(); + }); + + it('should throw when file not found', async () => { + fileRepo.findOne.mockResolvedValue(null); + + await expect(service.deleteFile(mockTenantId, 'invalid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ==================== Usage Tests ==================== + + describe('getUsage', () => { + it('should return storage usage', async () => { + usageRepo.findOne.mockResolvedValue(mockUsage as StorageUsageEntity); + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { folder: 'files', count: '5' }, + { folder: 'images', count: '5' }, + ]), + }; + fileRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getUsage(mockTenantId); + + expect(result).toHaveProperty('totalFiles', 10); + expect(result).toHaveProperty('totalBytes'); + expect(result).toHaveProperty('filesByFolder'); + }); + + it('should create default usage if not exists', async () => { + usageRepo.findOne.mockResolvedValue(null); + usageRepo.create.mockReturnValue({ + tenant_id: mockTenantId, + total_files: 0, + total_bytes: 0, + } as StorageUsageEntity); + usageRepo.save.mockResolvedValue({ + tenant_id: mockTenantId, + total_files: 0, + total_bytes: 0, + } as StorageUsageEntity); + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + fileRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getUsage(mockTenantId); + + expect(result.totalFiles).toBe(0); + expect(usageRepo.create).toHaveBeenCalled(); + }); + + it('should calculate usage percentage correctly', async () => { + usageRepo.findOne.mockResolvedValue({ + ...mockUsage, + total_bytes: 536870912, // 512 MB + max_bytes: 1073741824, // 1 GB + } as StorageUsageEntity); + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + fileRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getUsage(mockTenantId); + + expect(result.usagePercent).toBe(50); + }); + + it('should return zero usage percent when no max_bytes set', async () => { + usageRepo.findOne.mockResolvedValue({ + ...mockUsage, + max_bytes: null, + } as StorageUsageEntity); + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + fileRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getUsage(mockTenantId); + + expect(result.usagePercent).toBe(0); + expect(result.maxBytes).toBeNull(); + }); + + it('should aggregate files by folder correctly', async () => { + usageRepo.findOne.mockResolvedValue(mockUsage as StorageUsageEntity); + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([ + { folder: 'documents', count: '10' }, + { folder: 'images', count: '25' }, + { folder: 'videos', count: '5' }, + ]), + }; + fileRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + + const result = await service.getUsage(mockTenantId); + + expect(result.filesByFolder).toEqual({ + documents: 10, + images: 25, + videos: 5, + }); + }); + }); + + // ==================== Additional Upload URL Tests ==================== + + describe('getUploadUrl - additional cases', () => { + const baseUploadDto = { + filename: 'test.jpg', + mimeType: 'image/jpeg', + sizeBytes: 1024000, + }; + + it('should accept PNG images', async () => { + s3Provider.isConfigured.mockReturnValue(true); + s3Provider.getBucket.mockReturnValue('template-saas'); + s3Provider.getProvider.mockReturnValue(StorageProvider.S3); + s3Provider.generatePath.mockReturnValue(`${mockTenantId}/files/xxx/test.png`); + s3Provider.getUploadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/presigned-url', + expiresAt: new Date(Date.now() + 3600000), + }); + usageRepo.findOne.mockResolvedValue(null); + pendingUploadRepo.create.mockReturnValue(mockPendingUpload as PendingUploadEntity); + pendingUploadRepo.save.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + + const result = await service.getUploadUrl(mockTenantId, mockUserId, { + ...baseUploadDto, + filename: 'test.png', + mimeType: 'image/png', + }); + + expect(result).toHaveProperty('url'); + }); + + it('should accept PDF documents', async () => { + s3Provider.isConfigured.mockReturnValue(true); + s3Provider.getBucket.mockReturnValue('template-saas'); + s3Provider.getProvider.mockReturnValue(StorageProvider.S3); + s3Provider.generatePath.mockReturnValue(`${mockTenantId}/files/xxx/document.pdf`); + s3Provider.getUploadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/presigned-url', + expiresAt: new Date(Date.now() + 3600000), + }); + usageRepo.findOne.mockResolvedValue(null); + pendingUploadRepo.create.mockReturnValue(mockPendingUpload as PendingUploadEntity); + pendingUploadRepo.save.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + + const result = await service.getUploadUrl(mockTenantId, mockUserId, { + filename: 'document.pdf', + mimeType: 'application/pdf', + sizeBytes: 2048000, + }); + + expect(result).toHaveProperty('url'); + }); + + it('should accept CSV files', async () => { + s3Provider.isConfigured.mockReturnValue(true); + s3Provider.getBucket.mockReturnValue('template-saas'); + s3Provider.getProvider.mockReturnValue(StorageProvider.S3); + s3Provider.generatePath.mockReturnValue(`${mockTenantId}/files/xxx/data.csv`); + s3Provider.getUploadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/presigned-url', + expiresAt: new Date(Date.now() + 3600000), + }); + usageRepo.findOne.mockResolvedValue(null); + pendingUploadRepo.create.mockReturnValue(mockPendingUpload as PendingUploadEntity); + pendingUploadRepo.save.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + + const result = await service.getUploadUrl(mockTenantId, mockUserId, { + filename: 'data.csv', + mimeType: 'text/csv', + sizeBytes: 512000, + }); + + expect(result).toHaveProperty('url'); + }); + + it('should reject .bat files', async () => { + s3Provider.isConfigured.mockReturnValue(true); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, { + ...baseUploadDto, + filename: 'script.bat', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject .sh files', async () => { + s3Provider.isConfigured.mockReturnValue(true); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, { + ...baseUploadDto, + filename: 'script.sh', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject .php files', async () => { + s3Provider.isConfigured.mockReturnValue(true); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, { + ...baseUploadDto, + filename: 'backdoor.php', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should reject .js files', async () => { + s3Provider.isConfigured.mockReturnValue(true); + + await expect( + service.getUploadUrl(mockTenantId, mockUserId, { + ...baseUploadDto, + filename: 'malicious.js', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should use custom folder when provided', async () => { + s3Provider.isConfigured.mockReturnValue(true); + s3Provider.getBucket.mockReturnValue('template-saas'); + s3Provider.getProvider.mockReturnValue(StorageProvider.S3); + s3Provider.generatePath.mockReturnValue(`${mockTenantId}/images/xxx/test.jpg`); + s3Provider.getUploadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/presigned-url', + expiresAt: new Date(Date.now() + 3600000), + }); + usageRepo.findOne.mockResolvedValue(null); + pendingUploadRepo.create.mockReturnValue({ + ...mockPendingUpload, + folder: 'images', + } as PendingUploadEntity); + pendingUploadRepo.save.mockResolvedValue({ + ...mockPendingUpload, + folder: 'images', + } as PendingUploadEntity); + + await service.getUploadUrl(mockTenantId, mockUserId, { + ...baseUploadDto, + folder: 'images', + }); + + expect(s3Provider.generatePath).toHaveBeenCalledWith( + mockTenantId, + 'images', + expect.any(String), + baseUploadDto.filename, + ); + }); + + it('should allow upload when tenant has no usage record', async () => { + s3Provider.isConfigured.mockReturnValue(true); + s3Provider.getBucket.mockReturnValue('template-saas'); + s3Provider.getProvider.mockReturnValue(StorageProvider.S3); + s3Provider.generatePath.mockReturnValue(`${mockTenantId}/files/xxx/test.jpg`); + s3Provider.getUploadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/presigned-url', + expiresAt: new Date(Date.now() + 3600000), + }); + usageRepo.findOne.mockResolvedValue(null); + pendingUploadRepo.create.mockReturnValue(mockPendingUpload as PendingUploadEntity); + pendingUploadRepo.save.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + + const result = await service.getUploadUrl(mockTenantId, mockUserId, baseUploadDto); + + expect(result).toHaveProperty('url'); + }); + + it('should include maxSize in response', async () => { + s3Provider.isConfigured.mockReturnValue(true); + s3Provider.getBucket.mockReturnValue('template-saas'); + s3Provider.getProvider.mockReturnValue(StorageProvider.S3); + s3Provider.generatePath.mockReturnValue(`${mockTenantId}/files/xxx/test.jpg`); + s3Provider.getUploadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/presigned-url', + expiresAt: new Date(Date.now() + 3600000), + }); + usageRepo.findOne.mockResolvedValue(null); + pendingUploadRepo.create.mockReturnValue(mockPendingUpload as PendingUploadEntity); + pendingUploadRepo.save.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + + const result = await service.getUploadUrl(mockTenantId, mockUserId, baseUploadDto); + + expect(result.maxSize).toBe(baseUploadDto.sizeBytes); + }); + }); + + // ==================== Additional Confirm Upload Tests ==================== + + describe('confirmUpload - additional cases', () => { + it('should include metadata in file when provided', async () => { + pendingUploadRepo.findOne.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + s3Provider.headObject.mockResolvedValue({ contentLength: 1024000, contentType: 'image/jpeg' }); + fileRepo.create.mockReturnValue({ + ...mockFile, + metadata: { description: 'Test file', tags: ['test'] }, + } as FileEntity); + fileRepo.save.mockResolvedValue({ + ...mockFile, + metadata: { description: 'Test file', tags: ['test'] }, + } as FileEntity); + pendingUploadRepo.update.mockResolvedValue({ affected: 1 } as any); + + const result = await service.confirmUpload(mockTenantId, mockUserId, { + uploadId: 'upload-001', + metadata: { description: 'Test file', tags: ['test'] }, + }); + + expect(result.metadata).toEqual({ description: 'Test file', tags: ['test'] }); + }); + + it('should mark pending upload as failed when file not in S3', async () => { + pendingUploadRepo.findOne.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + s3Provider.headObject.mockResolvedValue(null); + pendingUploadRepo.update.mockResolvedValue({ affected: 1 } as any); + + await expect( + service.confirmUpload(mockTenantId, mockUserId, { uploadId: 'upload-001' }), + ).rejects.toThrow(BadRequestException); + + expect(pendingUploadRepo.update).toHaveBeenCalledWith( + mockPendingUpload.id, + { status: 'failed' }, + ); + }); + + it('should use actual file size from S3', async () => { + const actualSize = 2048000; + pendingUploadRepo.findOne.mockResolvedValue(mockPendingUpload as PendingUploadEntity); + s3Provider.headObject.mockResolvedValue({ contentLength: actualSize, contentType: 'image/jpeg' }); + fileRepo.create.mockReturnValue({ + ...mockFile, + size_bytes: actualSize, + } as FileEntity); + fileRepo.save.mockResolvedValue({ + ...mockFile, + size_bytes: actualSize, + } as FileEntity); + pendingUploadRepo.update.mockResolvedValue({ affected: 1 } as any); + + const result = await service.confirmUpload(mockTenantId, mockUserId, { + uploadId: 'upload-001', + }); + + expect(result.sizeBytes).toBe(actualSize); + }); + }); + + // ==================== Additional List Files Tests ==================== + + describe('listFiles - additional cases', () => { + it('should filter by MIME type prefix', async () => { + fileRepo.findAndCount.mockResolvedValue([[mockFile as FileEntity], 1]); + + await service.listFiles(mockTenantId, { mimeType: 'image' }); + + expect(fileRepo.findAndCount).toHaveBeenCalled(); + }); + + it('should search by original name', async () => { + fileRepo.findAndCount.mockResolvedValue([[mockFile as FileEntity], 1]); + + await service.listFiles(mockTenantId, { search: 'test' }); + + expect(fileRepo.findAndCount).toHaveBeenCalled(); + }); + + it('should calculate total pages correctly', async () => { + const files = Array(25).fill(mockFile as FileEntity); + fileRepo.findAndCount.mockResolvedValue([files.slice(0, 10), 25]); + + const result = await service.listFiles(mockTenantId, { page: 1, limit: 10 }); + + expect(result.totalPages).toBe(3); + }); + + it('should handle empty results', async () => { + fileRepo.findAndCount.mockResolvedValue([[], 0]); + + const result = await service.listFiles(mockTenantId, {}); + + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + expect(result.totalPages).toBe(0); + }); + + it('should apply pagination offset correctly', async () => { + fileRepo.findAndCount.mockResolvedValue([[mockFile as FileEntity], 50]); + + await service.listFiles(mockTenantId, { page: 3, limit: 10 }); + + expect(fileRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 20, + take: 10, + }), + ); + }); + }); + + // ==================== Additional Delete File Tests ==================== + + describe('deleteFile - additional cases', () => { + it('should set deleted_at timestamp', async () => { + const savedFile = { ...mockFile }; + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + fileRepo.save.mockImplementation((file) => { + Object.assign(savedFile, file); + return Promise.resolve(savedFile as FileEntity); + }); + s3Provider.deleteObject.mockResolvedValue(undefined); + + await service.deleteFile(mockTenantId, 'file-001'); + + expect(savedFile.deleted_at).toBeInstanceOf(Date); + }); + + it('should call S3 deleteObject with correct path', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + fileRepo.save.mockResolvedValue({ + ...mockFile, + status: FileStatus.DELETED, + deleted_at: new Date(), + } as FileEntity); + s3Provider.deleteObject.mockResolvedValue(undefined); + + await service.deleteFile(mockTenantId, 'file-001'); + + expect(s3Provider.deleteObject).toHaveBeenCalledWith(mockFile.path); + }); + }); + + // ==================== Additional Download URL Tests ==================== + + describe('getDownloadUrl - additional cases', () => { + it('should call S3 provider with file path', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + s3Provider.getDownloadUrl.mockResolvedValue({ + url: 'https://s3.amazonaws.com/download-url', + expiresAt: new Date(Date.now() + 3600000), + }); + + await service.getDownloadUrl(mockTenantId, 'file-001'); + + expect(s3Provider.getDownloadUrl).toHaveBeenCalledWith(mockFile.path); + }); + + it('should not return download URL for deleted files', async () => { + fileRepo.findOne.mockResolvedValue(null); // File is filtered out by query + + await expect( + service.getDownloadUrl(mockTenantId, 'deleted-file'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Additional Update File Tests ==================== + + describe('updateFile - additional cases', () => { + it('should merge metadata with existing metadata', async () => { + const existingFile = { + ...mockFile, + metadata: { existing: 'value' }, + }; + fileRepo.findOne.mockResolvedValue(existingFile as FileEntity); + fileRepo.save.mockImplementation((file) => Promise.resolve(file as FileEntity)); + + const result = await service.updateFile(mockTenantId, 'file-001', { + metadata: { new: 'data' }, + }); + + expect(result.metadata).toEqual({ existing: 'value', new: 'data' }); + }); + + it('should not modify file when no update fields provided', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + fileRepo.save.mockResolvedValue(mockFile as FileEntity); + + const result = await service.updateFile(mockTenantId, 'file-001', {}); + + expect(result.folder).toBe(mockFile.folder); + expect(result.visibility).toBe(mockFile.visibility); + }); + + it('should update visibility to tenant level', async () => { + fileRepo.findOne.mockResolvedValue(mockFile as FileEntity); + fileRepo.save.mockImplementation((file) => Promise.resolve(file as FileEntity)); + + const result = await service.updateFile(mockTenantId, 'file-001', { + visibility: FileVisibility.TENANT, + }); + + expect(result.visibility).toBe(FileVisibility.TENANT); + }); + }); +}); diff --git a/src/modules/storage/dto/index.ts b/src/modules/storage/dto/index.ts new file mode 100644 index 0000000..099fe10 --- /dev/null +++ b/src/modules/storage/dto/index.ts @@ -0,0 +1 @@ +export * from './storage.dto'; diff --git a/src/modules/storage/dto/storage.dto.ts b/src/modules/storage/dto/storage.dto.ts new file mode 100644 index 0000000..19f8bdc --- /dev/null +++ b/src/modules/storage/dto/storage.dto.ts @@ -0,0 +1,123 @@ +import { IsString, IsNumber, IsOptional, IsEnum, IsUUID, Min, Max } from 'class-validator'; +import { FileVisibility } from '../entities/file.entity'; + +// ==================== Request DTOs ==================== + +export class GetUploadUrlDto { + @IsString() + filename: string; + + @IsString() + mimeType: string; + + @IsNumber() + @Min(1) + @Max(524288000) // 500 MB max + sizeBytes: number; + + @IsOptional() + @IsString() + folder?: string; + + @IsOptional() + @IsEnum(FileVisibility) + visibility?: FileVisibility; +} + +export class ConfirmUploadDto { + @IsUUID() + uploadId: string; + + @IsOptional() + metadata?: Record; +} + +export class ListFilesDto { + @IsOptional() + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; + + @IsOptional() + @IsString() + folder?: string; + + @IsOptional() + @IsString() + mimeType?: string; + + @IsOptional() + @IsString() + search?: string; +} + +export class UpdateFileDto { + @IsOptional() + @IsString() + folder?: string; + + @IsOptional() + @IsEnum(FileVisibility) + visibility?: FileVisibility; + + @IsOptional() + metadata?: Record; +} + +// ==================== Response DTOs ==================== + +export class PresignedUrlResponseDto { + uploadId: string; + url: string; + fields?: Record; + expiresAt: Date; + maxSize: number; +} + +export class FileResponseDto { + id: string; + filename: string; + originalName: string; + mimeType: string; + sizeBytes: number; + folder: string; + visibility: string; + thumbnails: Record; + metadata: Record; + createdAt: Date; + updatedAt: Date; +} + +export class FileListResponseDto { + data: FileResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export class DownloadUrlResponseDto { + url: string; + expiresAt: Date; +} + +export class StorageUsageResponseDto { + totalFiles: number; + totalBytes: number; + maxBytes: number | null; + maxFileSize: number | null; + usagePercent: number; + filesByFolder: Record; +} + +export class StorageStatsResponseDto { + usage: StorageUsageResponseDto; + recentFiles: FileResponseDto[]; + filesByType: Record; +} diff --git a/src/modules/storage/entities/file.entity.ts b/src/modules/storage/entities/file.entity.ts new file mode 100644 index 0000000..18c78a1 --- /dev/null +++ b/src/modules/storage/entities/file.entity.ts @@ -0,0 +1,107 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; + +export enum FileStatus { + UPLOADING = 'uploading', + PROCESSING = 'processing', + READY = 'ready', + FAILED = 'failed', + DELETED = 'deleted', +} + +export enum FileVisibility { + PRIVATE = 'private', + TENANT = 'tenant', + PUBLIC = 'public', +} + +export enum StorageProvider { + S3 = 's3', + R2 = 'r2', + MINIO = 'minio', + GCS = 'gcs', +} + +@Entity({ schema: 'storage', name: 'files' }) +@Index(['tenant_id', 'folder']) +@Index(['tenant_id', 'deleted_at']) +export class FileEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + @Index() + tenant_id: string; + + @Column('uuid') + @Index() + uploaded_by: string; + + @Column({ length: 255 }) + filename: string; + + @Column({ length: 500 }) + original_name: string; + + @Column({ length: 100 }) + mime_type: string; + + @Column('bigint') + size_bytes: number; + + @Column({ length: 100 }) + bucket: string; + + @Column({ length: 1000 }) + path: string; + + @Column({ + type: 'enum', + enum: StorageProvider, + enumName: 'storage_provider', + default: StorageProvider.S3, + }) + provider: StorageProvider; + + @Column({ + type: 'enum', + enum: FileStatus, + enumName: 'file_status', + default: FileStatus.READY, + }) + status: FileStatus; + + @Column({ + type: 'enum', + enum: FileVisibility, + enumName: 'visibility', + default: FileVisibility.PRIVATE, + }) + visibility: FileVisibility; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ type: 'jsonb', default: {} }) + thumbnails: Record; + + @Column({ length: 100, default: 'files' }) + folder: string; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updated_at: Date; + + @Column({ type: 'timestamptz', nullable: true }) + deleted_at: Date | null; +} diff --git a/src/modules/storage/entities/index.ts b/src/modules/storage/entities/index.ts new file mode 100644 index 0000000..c9bf3f3 --- /dev/null +++ b/src/modules/storage/entities/index.ts @@ -0,0 +1,3 @@ +export * from './file.entity'; +export * from './pending-upload.entity'; +export * from './storage-usage.entity'; diff --git a/src/modules/storage/entities/pending-upload.entity.ts b/src/modules/storage/entities/pending-upload.entity.ts new file mode 100644 index 0000000..6247005 --- /dev/null +++ b/src/modules/storage/entities/pending-upload.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; +import { StorageProvider } from './file.entity'; + +export enum PendingUploadStatus { + PENDING = 'pending', + COMPLETED = 'completed', + EXPIRED = 'expired', + FAILED = 'failed', +} + +@Entity({ schema: 'storage', name: 'pending_uploads' }) +@Index(['tenant_id']) +@Index(['user_id']) +@Index(['status']) +export class PendingUploadEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenant_id: string; + + @Column('uuid') + user_id: string; + + @Column({ length: 255 }) + filename: string; + + @Column({ length: 500 }) + original_name: string; + + @Column({ length: 100 }) + mime_type: string; + + @Column('bigint') + size_bytes: number; + + @Column({ length: 100, default: 'files' }) + folder: string; + + @Column({ length: 100 }) + bucket: string; + + @Column({ length: 1000 }) + path: string; + + @Column({ + type: 'enum', + enum: StorageProvider, + enumName: 'storage_provider', + default: StorageProvider.S3, + }) + provider: StorageProvider; + + @Column({ length: 20, default: 'pending' }) + status: string; + + @Column({ type: 'timestamptz' }) + expires_at: Date; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at: Date; + + @Column({ type: 'timestamptz', nullable: true }) + completed_at: Date | null; +} diff --git a/src/modules/storage/entities/storage-usage.entity.ts b/src/modules/storage/entities/storage-usage.entity.ts new file mode 100644 index 0000000..ea14010 --- /dev/null +++ b/src/modules/storage/entities/storage-usage.entity.ts @@ -0,0 +1,42 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'storage', name: 'usage' }) +@Index(['tenant_id'], { unique: true }) +export class StorageUsageEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenant_id: string; + + @Column({ type: 'int', default: 0 }) + total_files: number; + + @Column({ type: 'bigint', default: 0 }) + total_bytes: number; + + @Column({ type: 'jsonb', default: {} }) + files_by_type: Record; + + @Column({ type: 'jsonb', default: {} }) + bytes_by_type: Record; + + @Column({ type: 'bigint', nullable: true }) + max_bytes: number | null; + + @Column({ type: 'bigint', nullable: true }) + max_file_size: number | null; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updated_at: Date; +} diff --git a/src/modules/storage/index.ts b/src/modules/storage/index.ts new file mode 100644 index 0000000..3afed27 --- /dev/null +++ b/src/modules/storage/index.ts @@ -0,0 +1,6 @@ +export * from './storage.module'; +export * from './storage.controller'; +export * from './services'; +export * from './providers'; +export * from './entities'; +export * from './dto'; diff --git a/src/modules/storage/providers/index.ts b/src/modules/storage/providers/index.ts new file mode 100644 index 0000000..783fe2a --- /dev/null +++ b/src/modules/storage/providers/index.ts @@ -0,0 +1 @@ +export * from './s3.provider'; diff --git a/src/modules/storage/providers/s3.provider.ts b/src/modules/storage/providers/s3.provider.ts new file mode 100644 index 0000000..650ada4 --- /dev/null +++ b/src/modules/storage/providers/s3.provider.ts @@ -0,0 +1,163 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + HeadObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { StorageProvider } from '../entities/file.entity'; + +export interface PresignedUrlOptions { + bucket: string; + key: string; + contentType: string; + contentLength: number; + expiresIn?: number; + metadata?: Record; +} + +export interface PresignedUrlResult { + url: string; + expiresAt: Date; +} + +@Injectable() +export class S3Provider implements OnModuleInit { + private readonly logger = new Logger(S3Provider.name); + private client: S3Client; + private bucket: string; + private provider: StorageProvider; + private configured = false; + + constructor(private configService: ConfigService) {} + + onModuleInit() { + const storageProvider = this.configService.get('STORAGE_PROVIDER', 's3'); + const bucket = this.configService.get('STORAGE_BUCKET'); + const region = this.configService.get('AWS_REGION', 'us-east-1'); + const accessKeyId = this.configService.get('AWS_ACCESS_KEY_ID'); + const secretAccessKey = this.configService.get('AWS_SECRET_ACCESS_KEY'); + const endpoint = this.configService.get('STORAGE_ENDPOINT'); + + if (!bucket || !accessKeyId || !secretAccessKey) { + this.logger.warn('Storage not configured - missing STORAGE_BUCKET or AWS credentials'); + return; + } + + this.bucket = bucket; + this.provider = storageProvider as StorageProvider; + + const config: any = { + region, + credentials: { + accessKeyId, + secretAccessKey, + }, + }; + + // For MinIO or R2, use custom endpoint + if (endpoint) { + config.endpoint = endpoint; + config.forcePathStyle = true; // Required for MinIO + } + + this.client = new S3Client(config); + this.configured = true; + this.logger.log(`Storage configured: ${storageProvider} - ${bucket}`); + } + + isConfigured(): boolean { + return this.configured; + } + + getBucket(): string { + return this.bucket; + } + + getProvider(): StorageProvider { + return this.provider; + } + + async getUploadUrl(options: PresignedUrlOptions): Promise { + if (!this.configured) { + throw new Error('Storage not configured'); + } + + const expiresIn = options.expiresIn || 900; // 15 minutes default + + const command = new PutObjectCommand({ + Bucket: options.bucket || this.bucket, + Key: options.key, + ContentType: options.contentType, + ContentLength: options.contentLength, + Metadata: options.metadata, + }); + + const url = await getSignedUrl(this.client, command, { expiresIn }); + const expiresAt = new Date(Date.now() + expiresIn * 1000); + + return { url, expiresAt }; + } + + async getDownloadUrl(key: string, expiresIn = 3600): Promise { + if (!this.configured) { + throw new Error('Storage not configured'); + } + + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + const url = await getSignedUrl(this.client, command, { expiresIn }); + const expiresAt = new Date(Date.now() + expiresIn * 1000); + + return { url, expiresAt }; + } + + async deleteObject(key: string): Promise { + if (!this.configured) { + throw new Error('Storage not configured'); + } + + const command = new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + await this.client.send(command); + } + + async headObject(key: string): Promise<{ contentLength: number; contentType: string } | null> { + if (!this.configured) { + throw new Error('Storage not configured'); + } + + try { + const command = new HeadObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + const response = await this.client.send(command); + return { + contentLength: response.ContentLength || 0, + contentType: response.ContentType || 'application/octet-stream', + }; + } catch (error: any) { + if (error.name === 'NotFound') { + return null; + } + throw error; + } + } + + generatePath(tenantId: string, folder: string, uploadId: string, filename: string): string { + // Sanitize filename + const sanitized = filename.replace(/[^a-zA-Z0-9.-]/g, '_'); + return `${tenantId}/${folder}/${uploadId}/${sanitized}`; + } +} diff --git a/src/modules/storage/services/index.ts b/src/modules/storage/services/index.ts new file mode 100644 index 0000000..4047195 --- /dev/null +++ b/src/modules/storage/services/index.ts @@ -0,0 +1 @@ +export * from './storage.service'; diff --git a/src/modules/storage/services/storage.service.ts b/src/modules/storage/services/storage.service.ts new file mode 100644 index 0000000..c04de5b --- /dev/null +++ b/src/modules/storage/services/storage.service.ts @@ -0,0 +1,430 @@ +import { + Injectable, + Logger, + BadRequestException, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, IsNull, Like, ILike } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import { FileEntity, FileStatus, FileVisibility } from '../entities/file.entity'; +import { PendingUploadEntity } from '../entities/pending-upload.entity'; +import { StorageUsageEntity } from '../entities/storage-usage.entity'; +import { S3Provider } from '../providers/s3.provider'; +import { + GetUploadUrlDto, + ConfirmUploadDto, + ListFilesDto, + UpdateFileDto, + PresignedUrlResponseDto, + FileResponseDto, + FileListResponseDto, + DownloadUrlResponseDto, + StorageUsageResponseDto, +} from '../dto'; + +// Allowed MIME types +const ALLOWED_MIME_TYPES: Record = { + images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'], + documents: [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + spreadsheets: [ + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], + data: ['text/csv', 'application/json', 'text/plain'], +}; + +const BLOCKED_EXTENSIONS = ['.exe', '.bat', '.sh', '.php', '.js', '.cmd', '.com', '.scr']; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + + constructor( + @InjectRepository(FileEntity) + private readonly fileRepository: Repository, + @InjectRepository(PendingUploadEntity) + private readonly pendingUploadRepository: Repository, + @InjectRepository(StorageUsageEntity) + private readonly usageRepository: Repository, + private readonly s3Provider: S3Provider, + ) {} + + async getUploadUrl( + tenantId: string, + userId: string, + dto: GetUploadUrlDto, + ): Promise { + // Validate storage is configured + if (!this.s3Provider.isConfigured()) { + throw new BadRequestException('Storage not configured'); + } + + // Validate MIME type + if (!this.isAllowedMimeType(dto.mimeType)) { + throw new BadRequestException(`File type not allowed: ${dto.mimeType}`); + } + + // Validate extension + if (this.hasBlockedExtension(dto.filename)) { + throw new BadRequestException('File extension not allowed'); + } + + // Check storage limits + const canUpload = await this.canUpload(tenantId, dto.sizeBytes); + if (!canUpload.allowed) { + throw new ForbiddenException(canUpload.reason); + } + + // Generate upload ID and path + const uploadId = uuid(); + const folder = dto.folder || 'files'; + const path = this.s3Provider.generatePath(tenantId, folder, uploadId, dto.filename); + const bucket = this.s3Provider.getBucket(); + + // Get presigned URL + const presigned = await this.s3Provider.getUploadUrl({ + bucket, + key: path, + contentType: dto.mimeType, + contentLength: dto.sizeBytes, + metadata: { + 'tenant-id': tenantId, + 'upload-id': uploadId, + 'user-id': userId, + }, + }); + + // Save pending upload + const pendingUpload = this.pendingUploadRepository.create({ + id: uploadId, + tenant_id: tenantId, + user_id: userId, + filename: this.sanitizeFilename(dto.filename), + original_name: dto.filename, + mime_type: dto.mimeType, + size_bytes: dto.sizeBytes, + folder, + bucket, + path, + provider: this.s3Provider.getProvider(), + expires_at: presigned.expiresAt, + }); + + await this.pendingUploadRepository.save(pendingUpload); + + return { + uploadId, + url: presigned.url, + expiresAt: presigned.expiresAt, + maxSize: dto.sizeBytes, + }; + } + + async confirmUpload( + tenantId: string, + userId: string, + dto: ConfirmUploadDto, + ): Promise { + // Find pending upload + const pending = await this.pendingUploadRepository.findOne({ + where: { + id: dto.uploadId, + tenant_id: tenantId, + status: 'pending', + }, + }); + + if (!pending) { + throw new NotFoundException('Upload not found or expired'); + } + + if (pending.expires_at < new Date()) { + await this.pendingUploadRepository.update(pending.id, { status: 'expired' }); + throw new BadRequestException('Upload expired'); + } + + // Verify file exists in S3 + const headResult = await this.s3Provider.headObject(pending.path); + if (!headResult) { + await this.pendingUploadRepository.update(pending.id, { status: 'failed' }); + throw new BadRequestException('File not found in storage'); + } + + // Create file record + const file = this.fileRepository.create({ + tenant_id: tenantId, + uploaded_by: userId, + filename: pending.filename, + original_name: pending.original_name, + mime_type: pending.mime_type, + size_bytes: headResult.contentLength, + bucket: pending.bucket, + path: pending.path, + provider: pending.provider, + folder: pending.folder, + status: FileStatus.READY, + visibility: FileVisibility.PRIVATE, + metadata: dto.metadata || {}, + }); + + await this.fileRepository.save(file); + + // Mark pending as completed + await this.pendingUploadRepository.update(pending.id, { + status: 'completed', + completed_at: new Date(), + }); + + return this.toFileResponse(file); + } + + async listFiles(tenantId: string, dto: ListFilesDto): Promise { + const { page = 1, limit = 20, folder, mimeType, search } = dto; + const skip = (page - 1) * limit; + + const where: any = { + tenant_id: tenantId, + deleted_at: IsNull(), + status: FileStatus.READY, + }; + + if (folder) { + where.folder = folder; + } + + if (mimeType) { + where.mime_type = Like(`${mimeType}%`); + } + + if (search) { + where.original_name = ILike(`%${search}%`); + } + + const [files, total] = await this.fileRepository.findAndCount({ + where, + order: { created_at: 'DESC' }, + skip, + take: limit, + }); + + return { + data: files.map((f) => this.toFileResponse(f)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getFile(tenantId: string, fileId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { + id: fileId, + tenant_id: tenantId, + deleted_at: IsNull(), + }, + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + return this.toFileResponse(file); + } + + async getDownloadUrl(tenantId: string, fileId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { + id: fileId, + tenant_id: tenantId, + deleted_at: IsNull(), + status: FileStatus.READY, + }, + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + const result = await this.s3Provider.getDownloadUrl(file.path); + + return { + url: result.url, + expiresAt: result.expiresAt, + }; + } + + async updateFile( + tenantId: string, + fileId: string, + dto: UpdateFileDto, + ): Promise { + const file = await this.fileRepository.findOne({ + where: { + id: fileId, + tenant_id: tenantId, + deleted_at: IsNull(), + }, + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + if (dto.folder) file.folder = dto.folder; + if (dto.visibility) file.visibility = dto.visibility; + if (dto.metadata) file.metadata = { ...file.metadata, ...dto.metadata }; + + await this.fileRepository.save(file); + + return this.toFileResponse(file); + } + + async deleteFile(tenantId: string, fileId: string): Promise { + const file = await this.fileRepository.findOne({ + where: { + id: fileId, + tenant_id: tenantId, + deleted_at: IsNull(), + }, + }); + + if (!file) { + throw new NotFoundException('File not found'); + } + + // Soft delete + file.deleted_at = new Date(); + file.status = FileStatus.DELETED; + await this.fileRepository.save(file); + + // Delete from S3 (optional - could be done via background job) + try { + await this.s3Provider.deleteObject(file.path); + } catch (error) { + this.logger.warn(`Failed to delete file from S3: ${file.path}`, error); + } + } + + async getUsage(tenantId: string): Promise { + let usage = await this.usageRepository.findOne({ + where: { tenant_id: tenantId }, + }); + + if (!usage) { + // Create default usage record + usage = this.usageRepository.create({ + tenant_id: tenantId, + total_files: 0, + total_bytes: 0, + }); + await this.usageRepository.save(usage); + } + + // Get files by folder + const folderStats = await this.fileRepository + .createQueryBuilder('f') + .select('f.folder', 'folder') + .addSelect('COUNT(*)', 'count') + .where('f.tenant_id = :tenantId', { tenantId }) + .andWhere('f.deleted_at IS NULL') + .andWhere('f.status = :status', { status: FileStatus.READY }) + .groupBy('f.folder') + .getRawMany(); + + const filesByFolder: Record = {}; + for (const stat of folderStats) { + filesByFolder[stat.folder] = parseInt(stat.count, 10); + } + + const usagePercent = usage.max_bytes + ? Math.round((Number(usage.total_bytes) / Number(usage.max_bytes)) * 10000) / 100 + : 0; + + return { + totalFiles: usage.total_files, + totalBytes: Number(usage.total_bytes), + maxBytes: usage.max_bytes ? Number(usage.max_bytes) : null, + maxFileSize: usage.max_file_size ? Number(usage.max_file_size) : null, + usagePercent, + filesByFolder, + }; + } + + // ==================== Helper Methods ==================== + + private isAllowedMimeType(mimeType: string): boolean { + const allAllowed = Object.values(ALLOWED_MIME_TYPES).flat(); + return allAllowed.includes(mimeType); + } + + private hasBlockedExtension(filename: string): boolean { + const lower = filename.toLowerCase(); + return BLOCKED_EXTENSIONS.some((ext) => lower.endsWith(ext)); + } + + private sanitizeFilename(filename: string): string { + return filename.replace(/[^a-zA-Z0-9.-]/g, '_'); + } + + private async canUpload( + tenantId: string, + sizeBytes: number, + ): Promise<{ allowed: boolean; reason?: string }> { + const usage = await this.usageRepository.findOne({ + where: { tenant_id: tenantId }, + }); + + if (!usage) { + return { allowed: true }; + } + + if (usage.max_file_size && sizeBytes > Number(usage.max_file_size)) { + return { + allowed: false, + reason: `File size exceeds limit (max: ${this.formatBytes(Number(usage.max_file_size))})`, + }; + } + + if ( + usage.max_bytes && + Number(usage.total_bytes) + sizeBytes > Number(usage.max_bytes) + ) { + return { + allowed: false, + reason: `Storage limit exceeded (max: ${this.formatBytes(Number(usage.max_bytes))})`, + }; + } + + return { allowed: true }; + } + + private formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + } + + private toFileResponse(file: FileEntity): FileResponseDto { + return { + id: file.id, + filename: file.filename, + originalName: file.original_name, + mimeType: file.mime_type, + sizeBytes: Number(file.size_bytes), + folder: file.folder, + visibility: file.visibility, + thumbnails: file.thumbnails, + metadata: file.metadata, + createdAt: file.created_at, + updatedAt: file.updated_at, + }; + } +} diff --git a/src/modules/storage/storage.controller.ts b/src/modules/storage/storage.controller.ts new file mode 100644 index 0000000..d89a201 --- /dev/null +++ b/src/modules/storage/storage.controller.ts @@ -0,0 +1,127 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { StorageService } from './services/storage.service'; +import { + GetUploadUrlDto, + ConfirmUploadDto, + ListFilesDto, + UpdateFileDto, + PresignedUrlResponseDto, + FileResponseDto, + FileListResponseDto, + DownloadUrlResponseDto, + StorageUsageResponseDto, +} from './dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('storage') +@UseGuards(JwtAuthGuard) +export class StorageController { + constructor(private readonly storageService: StorageService) {} + + /** + * Get presigned URL for file upload + */ + @Post('upload-url') + async getUploadUrl( + @CurrentUser() user: RequestUser, + @Body() dto: GetUploadUrlDto, + ): Promise { + return this.storageService.getUploadUrl(user.tenant_id, user.id, dto); + } + + /** + * Confirm upload after file is uploaded to S3 + */ + @Post('confirm') + async confirmUpload( + @CurrentUser() user: RequestUser, + @Body() dto: ConfirmUploadDto, + ): Promise { + return this.storageService.confirmUpload(user.tenant_id, user.id, dto); + } + + /** + * List files with pagination and filtering + */ + @Get('files') + async listFiles( + @CurrentUser() user: RequestUser, + @Query() dto: ListFilesDto, + ): Promise { + return this.storageService.listFiles(user.tenant_id, dto); + } + + /** + * Get single file details + */ + @Get('files/:id') + async getFile( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) fileId: string, + ): Promise { + return this.storageService.getFile(user.tenant_id, fileId); + } + + /** + * Get presigned download URL + */ + @Get('files/:id/download') + async getDownloadUrl( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) fileId: string, + ): Promise { + return this.storageService.getDownloadUrl(user.tenant_id, fileId); + } + + /** + * Update file metadata + */ + @Patch('files/:id') + async updateFile( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) fileId: string, + @Body() dto: UpdateFileDto, + ): Promise { + return this.storageService.updateFile(user.tenant_id, fileId, dto); + } + + /** + * Delete file (soft delete) + */ + @Delete('files/:id') + async deleteFile( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) fileId: string, + ): Promise { + return this.storageService.deleteFile(user.tenant_id, fileId); + } + + /** + * Get storage usage statistics + */ + @Get('usage') + async getUsage( + @CurrentUser() user: RequestUser, + ): Promise { + return this.storageService.getUsage(user.tenant_id); + } +} diff --git a/src/modules/storage/storage.module.ts b/src/modules/storage/storage.module.ts new file mode 100644 index 0000000..616dba4 --- /dev/null +++ b/src/modules/storage/storage.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { StorageController } from './storage.controller'; +import { StorageService } from './services/storage.service'; +import { S3Provider } from './providers/s3.provider'; +import { FileEntity } from './entities/file.entity'; +import { PendingUploadEntity } from './entities/pending-upload.entity'; +import { StorageUsageEntity } from './entities/storage-usage.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + FileEntity, + PendingUploadEntity, + StorageUsageEntity, + ]), + ], + controllers: [StorageController], + providers: [StorageService, S3Provider], + exports: [StorageService, S3Provider], +}) +export class StorageModule {} diff --git a/src/modules/superadmin/__tests__/superadmin.controller.spec.ts b/src/modules/superadmin/__tests__/superadmin.controller.spec.ts new file mode 100644 index 0000000..21d1891 --- /dev/null +++ b/src/modules/superadmin/__tests__/superadmin.controller.spec.ts @@ -0,0 +1,851 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, BadRequestException, ConflictException, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { SuperadminController } from '../superadmin.controller'; +import { SuperadminService, TenantWithStats, PaginatedResult } from '../superadmin.service'; +import { + CreateTenantDto, + UpdateTenantDto, + UpdateTenantStatusDto, + ListTenantsQueryDto, +} from '../dto'; +import { Tenant } from '../../tenants/entities/tenant.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../../rbac/guards/permissions.guard'; +import { RbacService } from '../../rbac/services/rbac.service'; + +// Mock guard that always allows access +class MockGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + return true; + } +} + +describe('SuperadminController', () => { + let controller: SuperadminController; + let superadminService: jest.Mocked; + + // Mock data + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockPlanId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockTenant: TenantWithStats = { + id: mockTenantId, + name: 'Test Tenant', + slug: 'test-tenant', + domain: 'test.example.com', + logo_url: 'https://example.com/logo.png', + plan_id: mockPlanId, + status: 'active' as const, + settings: {}, + metadata: {}, + trial_ends_at: null, + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-01'), + userCount: 5, + subscription: null, + }; + + const mockUser = { + id: '550e8400-e29b-41d4-a716-446655440003', + tenant_id: mockTenantId, + email: 'user@example.com', + first_name: 'Test', + last_name: 'User', + status: 'active', + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-01'), + }; + + const mockDashboardStats = { + totalTenants: 100, + activeTenants: 80, + trialTenants: 15, + suspendedTenants: 5, + totalUsers: 500, + newTenantsThisMonth: 10, + }; + + const mockPaginatedTenants: PaginatedResult = { + data: [mockTenant], + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }; + + const mockPaginatedUsers: PaginatedResult = { + data: [mockUser], + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }; + + const mockTenantGrowth = [ + { month: 'Jan 2024', count: 5 }, + { month: 'Feb 2024', count: 8 }, + ]; + + const mockUserGrowth = [ + { month: 'Jan 2024', count: 20 }, + { month: 'Feb 2024', count: 35 }, + ]; + + const mockPlanDistribution = [ + { plan: 'Free', count: 30, percentage: 30 }, + { plan: 'Pro', count: 50, percentage: 50 }, + { plan: 'Enterprise', count: 20, percentage: 20 }, + ]; + + const mockStatusDistribution = [ + { status: 'Active', count: 80, percentage: 80 }, + { status: 'Pending', count: 15, percentage: 15 }, + { status: 'Suspended', count: 5, percentage: 5 }, + { status: 'Cancelled', count: 0, percentage: 0 }, + ]; + + const mockTopTenants = [ + { + id: mockTenantId, + name: 'Test Tenant', + slug: 'test-tenant', + userCount: 50, + status: 'active', + planName: 'Enterprise', + }, + ]; + + const mockMetricsSummary = { + tenantGrowth: mockTenantGrowth, + userGrowth: mockUserGrowth, + planDistribution: mockPlanDistribution, + statusDistribution: mockStatusDistribution, + topTenants: mockTopTenants, + }; + + beforeEach(async () => { + const mockSuperadminService = { + getDashboardStats: jest.fn(), + listTenants: jest.fn(), + getTenant: jest.fn(), + createTenant: jest.fn(), + updateTenant: jest.fn(), + updateTenantStatus: jest.fn(), + deleteTenant: jest.fn(), + getTenantUsers: jest.fn(), + getMetricsSummary: jest.fn(), + getTenantGrowth: jest.fn(), + getUserGrowth: jest.fn(), + getPlanDistribution: jest.fn(), + getStatusDistribution: jest.fn(), + getTopTenants: jest.fn(), + }; + + const mockRbacService = { + userHasRole: jest.fn().mockResolvedValue(true), + userHasAnyPermission: jest.fn().mockResolvedValue(true), + userHasAllPermissions: jest.fn().mockResolvedValue(true), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [SuperadminController], + providers: [ + { provide: SuperadminService, useValue: mockSuperadminService }, + { provide: RbacService, useValue: mockRbacService }, + Reflector, + ], + }) + .overrideGuard(JwtAuthGuard) + .useClass(MockGuard) + .overrideGuard(PermissionsGuard) + .useClass(MockGuard) + .compile(); + + controller = module.get(SuperadminController); + superadminService = module.get(SuperadminService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== Dashboard Tests ==================== + + describe('getDashboardStats', () => { + it('should return dashboard statistics', async () => { + superadminService.getDashboardStats.mockResolvedValue(mockDashboardStats); + + const result = await controller.getDashboardStats(); + + expect(result).toEqual(mockDashboardStats); + expect(superadminService.getDashboardStats).toHaveBeenCalledTimes(1); + }); + + it('should return correct structure with all stat fields', async () => { + superadminService.getDashboardStats.mockResolvedValue(mockDashboardStats); + + const result = await controller.getDashboardStats(); + + expect(result).toHaveProperty('totalTenants'); + expect(result).toHaveProperty('activeTenants'); + expect(result).toHaveProperty('trialTenants'); + expect(result).toHaveProperty('suspendedTenants'); + expect(result).toHaveProperty('totalUsers'); + expect(result).toHaveProperty('newTenantsThisMonth'); + }); + }); + + // ==================== Tenants Tests ==================== + + describe('listTenants', () => { + it('should return paginated list of tenants', async () => { + const query: ListTenantsQueryDto = { page: 1, limit: 10 }; + superadminService.listTenants.mockResolvedValue(mockPaginatedTenants); + + const result = await controller.listTenants(query); + + expect(result).toEqual(mockPaginatedTenants); + expect(superadminService.listTenants).toHaveBeenCalledWith(query); + }); + + it('should handle search parameter', async () => { + const query: ListTenantsQueryDto = { page: 1, limit: 10, search: 'test' }; + superadminService.listTenants.mockResolvedValue(mockPaginatedTenants); + + await controller.listTenants(query); + + expect(superadminService.listTenants).toHaveBeenCalledWith(query); + }); + + it('should handle status filter', async () => { + const query: ListTenantsQueryDto = { page: 1, limit: 10, status: 'active' }; + superadminService.listTenants.mockResolvedValue(mockPaginatedTenants); + + await controller.listTenants(query); + + expect(superadminService.listTenants).toHaveBeenCalledWith(query); + }); + + it('should handle sorting parameters', async () => { + const query: ListTenantsQueryDto = { + page: 1, + limit: 10, + sortBy: 'name', + sortOrder: 'ASC', + }; + superadminService.listTenants.mockResolvedValue(mockPaginatedTenants); + + await controller.listTenants(query); + + expect(superadminService.listTenants).toHaveBeenCalledWith(query); + }); + + it('should return empty data array when no tenants exist', async () => { + const emptyResult: PaginatedResult = { + data: [], + total: 0, + page: 1, + limit: 10, + totalPages: 0, + }; + superadminService.listTenants.mockResolvedValue(emptyResult); + + const result = await controller.listTenants({ page: 1, limit: 10 }); + + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + }); + }); + + describe('getTenant', () => { + it('should return tenant by ID with stats', async () => { + superadminService.getTenant.mockResolvedValue(mockTenant); + + const result = await controller.getTenant(mockTenantId); + + expect(result).toEqual(mockTenant); + expect(superadminService.getTenant).toHaveBeenCalledWith(mockTenantId); + }); + + it('should throw NotFoundException when tenant does not exist', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440999'; + superadminService.getTenant.mockRejectedValue( + new NotFoundException('Tenant not found'), + ); + + await expect(controller.getTenant(nonExistentId)).rejects.toThrow( + NotFoundException, + ); + expect(superadminService.getTenant).toHaveBeenCalledWith(nonExistentId); + }); + + it('should return tenant with userCount', async () => { + superadminService.getTenant.mockResolvedValue(mockTenant); + + const result = await controller.getTenant(mockTenantId); + + expect(result.userCount).toBeDefined(); + expect(typeof result.userCount).toBe('number'); + }); + }); + + describe('createTenant', () => { + it('should create a new tenant', async () => { + const createDto: CreateTenantDto = { + name: 'New Tenant', + slug: 'new-tenant', + domain: 'new.example.com', + }; + const createdTenant: Tenant = { + id: '550e8400-e29b-41d4-a716-446655440010', + name: createDto.name, + slug: createDto.slug, + domain: createDto.domain ?? null, + logo_url: null, + plan_id: null, + status: 'pending' as const, + settings: null, + metadata: null, + trial_ends_at: null, + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-01'), + }; + superadminService.createTenant.mockResolvedValue(createdTenant); + + const result = await controller.createTenant(createDto); + + expect(result).toEqual(createdTenant); + expect(superadminService.createTenant).toHaveBeenCalledWith(createDto); + }); + + it('should create tenant with optional fields', async () => { + const createDto: CreateTenantDto = { + name: 'New Tenant', + slug: 'new-tenant', + logo_url: 'https://example.com/logo.png', + plan_id: mockPlanId, + status: 'active', + }; + const createdTenant: Tenant = { + id: '550e8400-e29b-41d4-a716-446655440010', + name: createDto.name, + slug: createDto.slug, + domain: null, + logo_url: createDto.logo_url ?? null, + plan_id: createDto.plan_id ?? null, + status: 'active' as const, + settings: null, + metadata: null, + trial_ends_at: null, + created_at: new Date('2024-01-01'), + updated_at: new Date('2024-01-01'), + }; + superadminService.createTenant.mockResolvedValue(createdTenant); + + await controller.createTenant(createDto); + + expect(superadminService.createTenant).toHaveBeenCalledWith(createDto); + }); + + it('should throw ConflictException when slug already exists', async () => { + const createDto: CreateTenantDto = { + name: 'Duplicate Tenant', + slug: 'existing-slug', + }; + superadminService.createTenant.mockRejectedValue( + new ConflictException('A tenant with this slug already exists'), + ); + + await expect(controller.createTenant(createDto)).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('updateTenant', () => { + it('should update tenant details', async () => { + const updateDto: UpdateTenantDto = { + name: 'Updated Tenant Name', + domain: 'updated.example.com', + }; + const updatedTenant = { ...mockTenant, ...updateDto }; + superadminService.updateTenant.mockResolvedValue(updatedTenant); + + const result = await controller.updateTenant(mockTenantId, updateDto); + + expect(result).toEqual(updatedTenant); + expect(superadminService.updateTenant).toHaveBeenCalledWith( + mockTenantId, + updateDto, + ); + }); + + it('should throw NotFoundException when updating non-existent tenant', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440999'; + const updateDto: UpdateTenantDto = { name: 'Updated Name' }; + superadminService.updateTenant.mockRejectedValue( + new NotFoundException('Tenant not found'), + ); + + await expect( + controller.updateTenant(nonExistentId, updateDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should update tenant settings', async () => { + const updateDto: UpdateTenantDto = { + settings: { theme: 'dark', language: 'es' }, + }; + const updatedTenant: Tenant = { + ...mockTenant, + settings: updateDto.settings ?? null, + }; + superadminService.updateTenant.mockResolvedValue(updatedTenant); + + const result = await controller.updateTenant(mockTenantId, updateDto); + + expect(result.settings).toEqual(updateDto.settings); + }); + + it('should update tenant metadata', async () => { + const updateDto: UpdateTenantDto = { + metadata: { customField: 'value' }, + }; + const updatedTenant: Tenant = { + ...mockTenant, + metadata: updateDto.metadata ?? null, + }; + superadminService.updateTenant.mockResolvedValue(updatedTenant); + + const result = await controller.updateTenant(mockTenantId, updateDto); + + expect(result.metadata).toEqual(updateDto.metadata); + }); + }); + + describe('updateTenantStatus', () => { + it('should update tenant status to suspended', async () => { + const statusDto: UpdateTenantStatusDto = { + status: 'suspended', + reason: 'Payment overdue', + }; + const updatedTenant: Tenant = { ...mockTenant, status: 'suspended' as const }; + superadminService.updateTenantStatus.mockResolvedValue(updatedTenant); + + const result = await controller.updateTenantStatus(mockTenantId, statusDto); + + expect(result.status).toBe('suspended'); + expect(superadminService.updateTenantStatus).toHaveBeenCalledWith( + mockTenantId, + statusDto, + ); + }); + + it('should update tenant status to active', async () => { + const statusDto: UpdateTenantStatusDto = { status: 'active' }; + const updatedTenant: Tenant = { ...mockTenant, status: 'active' as const }; + superadminService.updateTenantStatus.mockResolvedValue(updatedTenant); + + const result = await controller.updateTenantStatus(mockTenantId, statusDto); + + expect(result.status).toBe('active'); + }); + + it('should throw NotFoundException when tenant not found', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440999'; + const statusDto: UpdateTenantStatusDto = { status: 'suspended' }; + superadminService.updateTenantStatus.mockRejectedValue( + new NotFoundException('Tenant not found'), + ); + + await expect( + controller.updateTenantStatus(nonExistentId, statusDto), + ).rejects.toThrow(NotFoundException); + }); + + it('should update status with reason', async () => { + const statusDto: UpdateTenantStatusDto = { + status: 'cancelled', + reason: 'Customer requested cancellation', + }; + const updatedTenant: Tenant = { + ...mockTenant, + status: 'cancelled' as const, + metadata: { + statusChangeReason: statusDto.reason, + statusChangedAt: expect.any(String), + }, + }; + superadminService.updateTenantStatus.mockResolvedValue(updatedTenant); + + await controller.updateTenantStatus(mockTenantId, statusDto); + + expect(superadminService.updateTenantStatus).toHaveBeenCalledWith( + mockTenantId, + statusDto, + ); + }); + }); + + describe('deleteTenant', () => { + it('should delete tenant successfully', async () => { + superadminService.deleteTenant.mockResolvedValue(undefined); + + await controller.deleteTenant(mockTenantId); + + expect(superadminService.deleteTenant).toHaveBeenCalledWith(mockTenantId); + }); + + it('should throw NotFoundException when tenant not found', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440999'; + superadminService.deleteTenant.mockRejectedValue( + new NotFoundException('Tenant not found'), + ); + + await expect(controller.deleteTenant(nonExistentId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when tenant has users', async () => { + superadminService.deleteTenant.mockRejectedValue( + new BadRequestException( + 'Cannot delete tenant with active users. Please remove all users first or suspend the tenant.', + ), + ); + + await expect(controller.deleteTenant(mockTenantId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should return no content (204) on successful deletion', async () => { + superadminService.deleteTenant.mockResolvedValue(undefined); + + const result = await controller.deleteTenant(mockTenantId); + + expect(result).toBeUndefined(); + }); + }); + + // ==================== Tenant Users Tests ==================== + + describe('getTenantUsers', () => { + it('should return paginated list of tenant users', async () => { + superadminService.getTenantUsers.mockResolvedValue(mockPaginatedUsers); + + const result = await controller.getTenantUsers(mockTenantId, 1, 10); + + expect(result).toEqual(mockPaginatedUsers); + expect(superadminService.getTenantUsers).toHaveBeenCalledWith( + mockTenantId, + 1, + 10, + ); + }); + + it('should use default pagination values', async () => { + superadminService.getTenantUsers.mockResolvedValue(mockPaginatedUsers); + + await controller.getTenantUsers(mockTenantId); + + expect(superadminService.getTenantUsers).toHaveBeenCalledWith( + mockTenantId, + 1, + 10, + ); + }); + + it('should throw NotFoundException when tenant not found', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440999'; + superadminService.getTenantUsers.mockRejectedValue( + new NotFoundException('Tenant not found'), + ); + + await expect( + controller.getTenantUsers(nonExistentId, 1, 10), + ).rejects.toThrow(NotFoundException); + }); + + it('should handle custom pagination parameters', async () => { + superadminService.getTenantUsers.mockResolvedValue({ + ...mockPaginatedUsers, + page: 2, + limit: 25, + }); + + await controller.getTenantUsers(mockTenantId, 2, 25); + + expect(superadminService.getTenantUsers).toHaveBeenCalledWith( + mockTenantId, + 2, + 25, + ); + }); + }); + + // ==================== Metrics Tests ==================== + + describe('getMetricsSummary', () => { + it('should return complete metrics summary', async () => { + superadminService.getMetricsSummary.mockResolvedValue(mockMetricsSummary); + + const result = await controller.getMetricsSummary(); + + expect(result).toEqual(mockMetricsSummary); + expect(superadminService.getMetricsSummary).toHaveBeenCalledTimes(1); + }); + + it('should include all metric categories', async () => { + superadminService.getMetricsSummary.mockResolvedValue(mockMetricsSummary); + + const result = await controller.getMetricsSummary(); + + expect(result).toHaveProperty('tenantGrowth'); + expect(result).toHaveProperty('userGrowth'); + expect(result).toHaveProperty('planDistribution'); + expect(result).toHaveProperty('statusDistribution'); + expect(result).toHaveProperty('topTenants'); + }); + }); + + describe('getTenantGrowth', () => { + it('should return tenant growth data with default months', async () => { + superadminService.getTenantGrowth.mockResolvedValue(mockTenantGrowth); + + const result = await controller.getTenantGrowth(); + + expect(result).toEqual(mockTenantGrowth); + expect(superadminService.getTenantGrowth).toHaveBeenCalledWith(12); + }); + + it('should return tenant growth data with custom months', async () => { + superadminService.getTenantGrowth.mockResolvedValue(mockTenantGrowth); + + await controller.getTenantGrowth(6); + + expect(superadminService.getTenantGrowth).toHaveBeenCalledWith(6); + }); + + it('should return array with month and count properties', async () => { + superadminService.getTenantGrowth.mockResolvedValue(mockTenantGrowth); + + const result = await controller.getTenantGrowth(); + + expect(Array.isArray(result)).toBe(true); + result.forEach((item) => { + expect(item).toHaveProperty('month'); + expect(item).toHaveProperty('count'); + }); + }); + }); + + describe('getUserGrowth', () => { + it('should return user growth data with default months', async () => { + superadminService.getUserGrowth.mockResolvedValue(mockUserGrowth); + + const result = await controller.getUserGrowth(); + + expect(result).toEqual(mockUserGrowth); + expect(superadminService.getUserGrowth).toHaveBeenCalledWith(12); + }); + + it('should return user growth data with custom months', async () => { + superadminService.getUserGrowth.mockResolvedValue(mockUserGrowth); + + await controller.getUserGrowth(24); + + expect(superadminService.getUserGrowth).toHaveBeenCalledWith(24); + }); + + it('should return array with month and count properties', async () => { + superadminService.getUserGrowth.mockResolvedValue(mockUserGrowth); + + const result = await controller.getUserGrowth(); + + expect(Array.isArray(result)).toBe(true); + result.forEach((item) => { + expect(item).toHaveProperty('month'); + expect(item).toHaveProperty('count'); + }); + }); + }); + + describe('getPlanDistribution', () => { + it('should return plan distribution data', async () => { + superadminService.getPlanDistribution.mockResolvedValue(mockPlanDistribution); + + const result = await controller.getPlanDistribution(); + + expect(result).toEqual(mockPlanDistribution); + expect(superadminService.getPlanDistribution).toHaveBeenCalledTimes(1); + }); + + it('should return array with plan, count, and percentage', async () => { + superadminService.getPlanDistribution.mockResolvedValue(mockPlanDistribution); + + const result = await controller.getPlanDistribution(); + + expect(Array.isArray(result)).toBe(true); + result.forEach((item) => { + expect(item).toHaveProperty('plan'); + expect(item).toHaveProperty('count'); + expect(item).toHaveProperty('percentage'); + expect(typeof item.percentage).toBe('number'); + }); + }); + + it('should return percentages that sum to 100', async () => { + superadminService.getPlanDistribution.mockResolvedValue(mockPlanDistribution); + + const result = await controller.getPlanDistribution(); + + const totalPercentage = result.reduce((sum, item) => sum + item.percentage, 0); + expect(totalPercentage).toBe(100); + }); + }); + + describe('getStatusDistribution', () => { + it('should return status distribution data', async () => { + superadminService.getStatusDistribution.mockResolvedValue(mockStatusDistribution); + + const result = await controller.getStatusDistribution(); + + expect(result).toEqual(mockStatusDistribution); + expect(superadminService.getStatusDistribution).toHaveBeenCalledTimes(1); + }); + + it('should return array with status, count, and percentage', async () => { + superadminService.getStatusDistribution.mockResolvedValue(mockStatusDistribution); + + const result = await controller.getStatusDistribution(); + + expect(Array.isArray(result)).toBe(true); + result.forEach((item) => { + expect(item).toHaveProperty('status'); + expect(item).toHaveProperty('count'); + expect(item).toHaveProperty('percentage'); + }); + }); + + it('should include all status types', async () => { + superadminService.getStatusDistribution.mockResolvedValue(mockStatusDistribution); + + const result = await controller.getStatusDistribution(); + + const statuses = result.map((item) => item.status.toLowerCase()); + expect(statuses).toContain('active'); + expect(statuses).toContain('pending'); + expect(statuses).toContain('suspended'); + }); + }); + + describe('getTopTenants', () => { + it('should return top tenants with default limit', async () => { + superadminService.getTopTenants.mockResolvedValue(mockTopTenants); + + const result = await controller.getTopTenants(); + + expect(result).toEqual(mockTopTenants); + expect(superadminService.getTopTenants).toHaveBeenCalledWith(10); + }); + + it('should return top tenants with custom limit', async () => { + superadminService.getTopTenants.mockResolvedValue(mockTopTenants); + + await controller.getTopTenants(5); + + expect(superadminService.getTopTenants).toHaveBeenCalledWith(5); + }); + + it('should return array with required tenant info', async () => { + superadminService.getTopTenants.mockResolvedValue(mockTopTenants); + + const result = await controller.getTopTenants(); + + expect(Array.isArray(result)).toBe(true); + result.forEach((item) => { + expect(item).toHaveProperty('id'); + expect(item).toHaveProperty('name'); + expect(item).toHaveProperty('slug'); + expect(item).toHaveProperty('userCount'); + expect(item).toHaveProperty('status'); + expect(item).toHaveProperty('planName'); + }); + }); + + it('should return tenants sorted by userCount descending', async () => { + const sortedTopTenants = [ + { ...mockTopTenants[0], userCount: 100 }, + { ...mockTopTenants[0], id: '2', userCount: 50 }, + { ...mockTopTenants[0], id: '3', userCount: 25 }, + ]; + superadminService.getTopTenants.mockResolvedValue(sortedTopTenants); + + const result = await controller.getTopTenants(); + + for (let i = 0; i < result.length - 1; i++) { + expect(result[i].userCount).toBeGreaterThanOrEqual(result[i + 1].userCount); + } + }); + }); + + // ==================== Guards and Decorators Tests ==================== + + describe('Controller Metadata', () => { + it('should have JwtAuthGuard applied', () => { + const guards = Reflect.getMetadata('__guards__', SuperadminController); + expect(guards).toBeDefined(); + }); + + it('should have controller path "superadmin"', () => { + const path = Reflect.getMetadata('path', SuperadminController); + expect(path).toBe('superadmin'); + }); + }); + + // ==================== Error Handling Tests ==================== + + describe('Error Handling', () => { + it('should propagate NotFoundException from service', async () => { + superadminService.getTenant.mockRejectedValue( + new NotFoundException('Tenant not found'), + ); + + await expect(controller.getTenant('non-existent-id')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should propagate ConflictException from service', async () => { + superadminService.createTenant.mockRejectedValue( + new ConflictException('Slug already exists'), + ); + + await expect( + controller.createTenant({ name: 'Test', slug: 'existing' }), + ).rejects.toThrow(ConflictException); + }); + + it('should propagate BadRequestException from service', async () => { + superadminService.deleteTenant.mockRejectedValue( + new BadRequestException('Cannot delete tenant with users'), + ); + + await expect(controller.deleteTenant(mockTenantId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should propagate generic errors from service', async () => { + superadminService.getDashboardStats.mockRejectedValue( + new Error('Database connection failed'), + ); + + await expect(controller.getDashboardStats()).rejects.toThrow( + 'Database connection failed', + ); + }); + }); +}); diff --git a/src/modules/superadmin/__tests__/superadmin.service.spec.ts b/src/modules/superadmin/__tests__/superadmin.service.spec.ts new file mode 100644 index 0000000..4bef02b --- /dev/null +++ b/src/modules/superadmin/__tests__/superadmin.service.spec.ts @@ -0,0 +1,391 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { SuperadminService } from '../superadmin.service'; +import { Tenant } from '../../tenants/entities/tenant.entity'; +import { User } from '../../auth/entities/user.entity'; +import { Subscription } from '../../billing/entities/subscription.entity'; + +describe('SuperadminService', () => { + let service: SuperadminService; + let tenantRepository: jest.Mocked>; + let userRepository: jest.Mocked>; + let subscriptionRepository: jest.Mocked>; + + const mockTenant: Tenant = { + id: 'tenant-123', + name: 'Test Company', + slug: 'test-company', + domain: 'test.example.com', + logo_url: 'https://example.com/logo.png', + status: 'active', + plan_id: 'plan-123', + trial_ends_at: new Date('2026-02-01'), + settings: { theme: 'dark' }, + metadata: {}, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + const mockUser: Partial = { + id: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + status: 'active', + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + const mockSubscription: Partial = { + id: 'sub-123', + tenant_id: 'tenant-123', + plan_id: 'plan-123', + status: 'active' as any, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(5), + getMany: jest.fn().mockResolvedValue([]), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SuperadminService, + { + provide: getRepositoryToken(Tenant), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + findAndCount: jest.fn(), + save: jest.fn(), + create: jest.fn(), + remove: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }, + }, + { + provide: getRepositoryToken(User), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + findAndCount: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }, + }, + { + provide: getRepositoryToken(Subscription), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + }, + }, + ], + }).compile(); + + service = module.get(SuperadminService); + tenantRepository = module.get(getRepositoryToken(Tenant)); + userRepository = module.get(getRepositoryToken(User)); + subscriptionRepository = module.get(getRepositoryToken(Subscription)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('listTenants', () => { + it('should return paginated tenants with stats', async () => { + tenantRepository.findAndCount.mockResolvedValue([[mockTenant], 1]); + userRepository.count.mockResolvedValue(5); + subscriptionRepository.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await service.listTenants({ page: 1, limit: 10 }); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + expect(result.limit).toBe(10); + expect(result.totalPages).toBe(1); + expect(result.data[0].userCount).toBe(5); + }); + + it('should handle search parameter', async () => { + tenantRepository.findAndCount.mockResolvedValue([[], 0]); + + await service.listTenants({ page: 1, limit: 10, search: 'test' }); + + expect(tenantRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + name: expect.anything(), + }), + }), + ); + }); + + it('should handle status filter', async () => { + tenantRepository.findAndCount.mockResolvedValue([[], 0]); + + await service.listTenants({ page: 1, limit: 10, status: 'active' }); + + expect(tenantRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'active', + }), + }), + ); + }); + }); + + describe('getTenant', () => { + it('should return tenant with stats', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + userRepository.count.mockResolvedValue(5); + subscriptionRepository.findOne.mockResolvedValue(mockSubscription as Subscription); + + const result = await service.getTenant('tenant-123'); + + expect(result.id).toBe('tenant-123'); + expect(result.userCount).toBe(5); + expect(result.subscription).toBeDefined(); + }); + + it('should throw NotFoundException if tenant not found', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + await expect(service.getTenant('non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('createTenant', () => { + const createDto = { + name: 'New Company', + slug: 'new-company', + domain: 'new.example.com', + status: 'trial', + }; + + it('should create a new tenant', async () => { + tenantRepository.findOne.mockResolvedValue(null); + tenantRepository.create.mockReturnValue({ ...mockTenant, ...createDto } as Tenant); + tenantRepository.save.mockResolvedValue({ ...mockTenant, ...createDto } as Tenant); + + const result = await service.createTenant(createDto); + + expect(result.name).toBe('New Company'); + expect(tenantRepository.create).toHaveBeenCalled(); + expect(tenantRepository.save).toHaveBeenCalled(); + }); + + it('should throw ConflictException if slug exists', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + + await expect(service.createTenant(createDto)).rejects.toThrow( + ConflictException, + ); + }); + }); + + describe('updateTenant', () => { + const updateDto = { name: 'Updated Company' }; + + it('should update a tenant', async () => { + const tenantCopy = { ...mockTenant }; + tenantRepository.findOne.mockResolvedValue(tenantCopy as Tenant); + tenantRepository.save.mockResolvedValue({ ...tenantCopy, ...updateDto } as Tenant); + + const result = await service.updateTenant('tenant-123', updateDto); + + expect(result.name).toBe('Updated Company'); + expect(tenantRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException if tenant not found', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateTenant('non-existent', updateDto), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('updateTenantStatus', () => { + it('should update tenant status', async () => { + const tenantCopy = { ...mockTenant }; + tenantRepository.findOne.mockResolvedValue(tenantCopy as Tenant); + tenantRepository.save.mockResolvedValue({ ...tenantCopy, status: 'suspended' } as Tenant); + + const result = await service.updateTenantStatus('tenant-123', { + status: 'suspended', + reason: 'Non-payment', + }); + + expect(tenantRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException if tenant not found', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateTenantStatus('non-existent', { status: 'suspended' }), + ).rejects.toThrow(NotFoundException); + }); + + it('should store reason in metadata', async () => { + const tenantCopy = { ...mockTenant, metadata: {} }; + tenantRepository.findOne.mockResolvedValue(tenantCopy as Tenant); + tenantRepository.save.mockImplementation(async (t) => t as Tenant); + + await service.updateTenantStatus('tenant-123', { + status: 'suspended', + reason: 'Policy violation', + }); + + const savedTenant = tenantRepository.save.mock.calls[0][0]; + expect(savedTenant.metadata?.statusChangeReason).toBe('Policy violation'); + }); + }); + + describe('deleteTenant', () => { + it('should delete a tenant without users', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + userRepository.count.mockResolvedValue(0); + tenantRepository.remove.mockResolvedValue(mockTenant); + + await service.deleteTenant('tenant-123'); + + expect(tenantRepository.remove).toHaveBeenCalledWith(mockTenant); + }); + + it('should throw NotFoundException if tenant not found', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteTenant('non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException if tenant has users', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + userRepository.count.mockResolvedValue(5); + + await expect(service.deleteTenant('tenant-123')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('getTenantUsers', () => { + it('should return paginated users for a tenant', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + userRepository.findAndCount.mockResolvedValue([[mockUser as User], 1]); + + const result = await service.getTenantUsers('tenant-123', 1, 10); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.page).toBe(1); + }); + + it('should throw NotFoundException if tenant not found', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + await expect(service.getTenantUsers('non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getDashboardStats', () => { + it('should return dashboard statistics', async () => { + tenantRepository.count + .mockResolvedValueOnce(100) // totalTenants + .mockResolvedValueOnce(80) // activeTenants + .mockResolvedValueOnce(15) // trialTenants + .mockResolvedValueOnce(5); // suspendedTenants + userRepository.count.mockResolvedValue(500); + mockQueryBuilder.getCount.mockResolvedValue(10); + + const result = await service.getDashboardStats(); + + expect(result.totalTenants).toBe(100); + expect(result.activeTenants).toBe(80); + expect(result.trialTenants).toBe(15); + expect(result.suspendedTenants).toBe(5); + expect(result.totalUsers).toBe(500); + expect(result.newTenantsThisMonth).toBe(10); + }); + }); + + describe('getStatusDistribution', () => { + it('should return status distribution', async () => { + tenantRepository.count + .mockResolvedValueOnce(100) // total + .mockResolvedValueOnce(60) // active + .mockResolvedValueOnce(20) // pending + .mockResolvedValueOnce(15) // suspended + .mockResolvedValueOnce(5); // cancelled + + const result = await service.getStatusDistribution(); + + expect(result).toHaveLength(4); + expect(result.find(s => s.status === 'Active')?.count).toBe(60); + }); + + it('should calculate percentages correctly', async () => { + tenantRepository.count + .mockResolvedValueOnce(100) // total + .mockResolvedValueOnce(50) // active + .mockResolvedValueOnce(30) // pending + .mockResolvedValueOnce(15) // suspended + .mockResolvedValueOnce(5); // cancelled + + const result = await service.getStatusDistribution(); + + expect(result.find(s => s.status === 'Active')?.percentage).toBe(50); + expect(result.find(s => s.status === 'Pending')?.percentage).toBe(30); + }); + }); + + describe('getTenantGrowth', () => { + it('should return growth data for specified months', async () => { + mockQueryBuilder.getCount.mockResolvedValue(10); + + const result = await service.getTenantGrowth(6); + + expect(result).toHaveLength(6); + expect(result[0]).toHaveProperty('month'); + expect(result[0]).toHaveProperty('count'); + }); + }); + + describe('getUserGrowth', () => { + it('should return user growth data', async () => { + mockQueryBuilder.getCount.mockResolvedValue(20); + + const result = await service.getUserGrowth(3); + + expect(result).toHaveLength(3); + expect(result[0]).toHaveProperty('month'); + expect(result[0]).toHaveProperty('count'); + }); + }); +}); diff --git a/src/modules/superadmin/dto/index.ts b/src/modules/superadmin/dto/index.ts new file mode 100644 index 0000000..a868404 --- /dev/null +++ b/src/modules/superadmin/dto/index.ts @@ -0,0 +1,108 @@ +import { IsString, IsOptional, IsEnum, IsNumber, Min, Max, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateTenantDto { + @ApiProperty({ description: 'Tenant name' }) + @IsString() + name: string; + + @ApiProperty({ description: 'Tenant slug (unique identifier)' }) + @IsString() + slug: string; + + @ApiPropertyOptional({ description: 'Custom domain' }) + @IsString() + @IsOptional() + domain?: string; + + @ApiPropertyOptional({ description: 'Logo URL' }) + @IsString() + @IsOptional() + logo_url?: string; + + @ApiPropertyOptional({ description: 'Plan ID' }) + @IsUUID() + @IsOptional() + plan_id?: string; + + @ApiPropertyOptional({ description: 'Initial status', enum: ['active', 'trial', 'suspended'] }) + @IsEnum(['active', 'trial', 'suspended']) + @IsOptional() + status?: string; +} + +export class UpdateTenantDto { + @ApiPropertyOptional({ description: 'Tenant name' }) + @IsString() + @IsOptional() + name?: string; + + @ApiPropertyOptional({ description: 'Custom domain' }) + @IsString() + @IsOptional() + domain?: string; + + @ApiPropertyOptional({ description: 'Logo URL' }) + @IsString() + @IsOptional() + logo_url?: string; + + @ApiPropertyOptional({ description: 'Plan ID' }) + @IsUUID() + @IsOptional() + plan_id?: string; + + @ApiPropertyOptional({ description: 'Tenant settings (JSON)' }) + @IsOptional() + settings?: Record; + + @ApiPropertyOptional({ description: 'Tenant metadata (JSON)' }) + @IsOptional() + metadata?: Record; +} + +export class UpdateTenantStatusDto { + @ApiProperty({ description: 'New status', enum: ['active', 'suspended', 'trial', 'canceled'] }) + @IsEnum(['active', 'suspended', 'trial', 'canceled']) + status: string; + + @ApiPropertyOptional({ description: 'Reason for status change' }) + @IsString() + @IsOptional() + reason?: string; +} + +export class ListTenantsQueryDto { + @ApiPropertyOptional({ description: 'Page number', default: 1 }) + @IsNumber() + @Min(1) + @IsOptional() + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', default: 10 }) + @IsNumber() + @Min(1) + @Max(100) + @IsOptional() + limit?: number = 10; + + @ApiPropertyOptional({ description: 'Search term (name or slug)' }) + @IsString() + @IsOptional() + search?: string; + + @ApiPropertyOptional({ description: 'Filter by status', enum: ['active', 'suspended', 'trial', 'canceled'] }) + @IsEnum(['active', 'suspended', 'trial', 'canceled']) + @IsOptional() + status?: string; + + @ApiPropertyOptional({ description: 'Sort by field', enum: ['name', 'created_at', 'status'] }) + @IsString() + @IsOptional() + sortBy?: string = 'created_at'; + + @ApiPropertyOptional({ description: 'Sort order', enum: ['ASC', 'DESC'] }) + @IsEnum(['ASC', 'DESC']) + @IsOptional() + sortOrder?: 'ASC' | 'DESC' = 'DESC'; +} diff --git a/src/modules/superadmin/index.ts b/src/modules/superadmin/index.ts new file mode 100644 index 0000000..b4e6913 --- /dev/null +++ b/src/modules/superadmin/index.ts @@ -0,0 +1,4 @@ +export * from './superadmin.module'; +export * from './superadmin.service'; +export * from './superadmin.controller'; +export * from './dto'; diff --git a/src/modules/superadmin/superadmin.controller.ts b/src/modules/superadmin/superadmin.controller.ts new file mode 100644 index 0000000..893671e --- /dev/null +++ b/src/modules/superadmin/superadmin.controller.ts @@ -0,0 +1,158 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, +} from '@nestjs/swagger'; +import { SuperadminService } from './superadmin.service'; +import { + CreateTenantDto, + UpdateTenantDto, + UpdateTenantStatusDto, + ListTenantsQueryDto, +} from './dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PermissionsGuard, RequireRoles } from '../rbac/guards/permissions.guard'; + +@ApiTags('superadmin') +@Controller('superadmin') +@UseGuards(JwtAuthGuard, PermissionsGuard) +@RequireRoles('superadmin') +@ApiBearerAuth() +export class SuperadminController { + constructor(private readonly superadminService: SuperadminService) {} + + // ==================== Dashboard ==================== + + @Get('dashboard/stats') + @ApiOperation({ summary: 'Get dashboard statistics' }) + @ApiResponse({ status: 200, description: 'Dashboard statistics' }) + async getDashboardStats() { + return this.superadminService.getDashboardStats(); + } + + // ==================== Tenants ==================== + + @Get('tenants') + @ApiOperation({ summary: 'List all tenants with pagination' }) + @ApiResponse({ status: 200, description: 'Paginated list of tenants' }) + async listTenants(@Query() query: ListTenantsQueryDto) { + return this.superadminService.listTenants(query); + } + + @Get('tenants/:id') + @ApiOperation({ summary: 'Get tenant by ID' }) + @ApiResponse({ status: 200, description: 'Tenant details with stats' }) + @ApiResponse({ status: 404, description: 'Tenant not found' }) + async getTenant(@Param('id') id: string) { + return this.superadminService.getTenant(id); + } + + @Post('tenants') + @ApiOperation({ summary: 'Create a new tenant' }) + @ApiResponse({ status: 201, description: 'Tenant created' }) + @ApiResponse({ status: 409, description: 'Slug already exists' }) + async createTenant(@Body() dto: CreateTenantDto) { + return this.superadminService.createTenant(dto); + } + + @Patch('tenants/:id') + @ApiOperation({ summary: 'Update tenant details' }) + @ApiResponse({ status: 200, description: 'Tenant updated' }) + @ApiResponse({ status: 404, description: 'Tenant not found' }) + async updateTenant(@Param('id') id: string, @Body() dto: UpdateTenantDto) { + return this.superadminService.updateTenant(id, dto); + } + + @Patch('tenants/:id/status') + @ApiOperation({ summary: 'Update tenant status (suspend/activate)' }) + @ApiResponse({ status: 200, description: 'Status updated' }) + @ApiResponse({ status: 404, description: 'Tenant not found' }) + async updateTenantStatus( + @Param('id') id: string, + @Body() dto: UpdateTenantStatusDto, + ) { + return this.superadminService.updateTenantStatus(id, dto); + } + + @Delete('tenants/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a tenant' }) + @ApiResponse({ status: 204, description: 'Tenant deleted' }) + @ApiResponse({ status: 400, description: 'Cannot delete tenant with users' }) + @ApiResponse({ status: 404, description: 'Tenant not found' }) + async deleteTenant(@Param('id') id: string) { + return this.superadminService.deleteTenant(id); + } + + // ==================== Tenant Users ==================== + + @Get('tenants/:id/users') + @ApiOperation({ summary: 'Get users for a specific tenant' }) + @ApiResponse({ status: 200, description: 'Paginated list of tenant users' }) + @ApiResponse({ status: 404, description: 'Tenant not found' }) + async getTenantUsers( + @Param('id') id: string, + @Query('page') page = 1, + @Query('limit') limit = 10, + ) { + return this.superadminService.getTenantUsers(id, page, limit); + } + + // ==================== Metrics ==================== + + @Get('metrics') + @ApiOperation({ summary: 'Get all metrics summary' }) + @ApiResponse({ status: 200, description: 'Complete metrics summary' }) + async getMetricsSummary() { + return this.superadminService.getMetricsSummary(); + } + + @Get('metrics/tenant-growth') + @ApiOperation({ summary: 'Get tenant growth over time' }) + @ApiResponse({ status: 200, description: 'Tenant growth by month' }) + async getTenantGrowth(@Query('months') months = 12) { + return this.superadminService.getTenantGrowth(months); + } + + @Get('metrics/user-growth') + @ApiOperation({ summary: 'Get user growth over time' }) + @ApiResponse({ status: 200, description: 'User growth by month' }) + async getUserGrowth(@Query('months') months = 12) { + return this.superadminService.getUserGrowth(months); + } + + @Get('metrics/plan-distribution') + @ApiOperation({ summary: 'Get plan distribution' }) + @ApiResponse({ status: 200, description: 'Distribution of plans' }) + async getPlanDistribution() { + return this.superadminService.getPlanDistribution(); + } + + @Get('metrics/status-distribution') + @ApiOperation({ summary: 'Get tenant status distribution' }) + @ApiResponse({ status: 200, description: 'Distribution of tenant statuses' }) + async getStatusDistribution() { + return this.superadminService.getStatusDistribution(); + } + + @Get('metrics/top-tenants') + @ApiOperation({ summary: 'Get top tenants by user count' }) + @ApiResponse({ status: 200, description: 'Top tenants list' }) + async getTopTenants(@Query('limit') limit = 10) { + return this.superadminService.getTopTenants(limit); + } +} diff --git a/src/modules/superadmin/superadmin.module.ts b/src/modules/superadmin/superadmin.module.ts new file mode 100644 index 0000000..098e874 --- /dev/null +++ b/src/modules/superadmin/superadmin.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SuperadminController } from './superadmin.controller'; +import { SuperadminService } from './superadmin.service'; +import { Tenant } from '../tenants/entities/tenant.entity'; +import { User } from '../auth/entities/user.entity'; +import { Subscription, Plan } from '../billing/entities'; +import { RbacModule } from '../rbac/rbac.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Tenant, User, Subscription, Plan]), + RbacModule, + ], + controllers: [SuperadminController], + providers: [SuperadminService], + exports: [SuperadminService], +}) +export class SuperadminModule {} diff --git a/src/modules/superadmin/superadmin.service.ts b/src/modules/superadmin/superadmin.service.ts new file mode 100644 index 0000000..a5b6e1d --- /dev/null +++ b/src/modules/superadmin/superadmin.service.ts @@ -0,0 +1,435 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, ILike, FindOptionsWhere } from 'typeorm'; +import { Tenant } from '../tenants/entities/tenant.entity'; + +type TenantStatus = 'pending' | 'active' | 'suspended' | 'cancelled'; +import { User } from '../auth/entities/user.entity'; +import { Subscription } from '../billing/entities/subscription.entity'; +import { + CreateTenantDto, + UpdateTenantDto, + UpdateTenantStatusDto, + ListTenantsQueryDto, +} from './dto'; + +export interface TenantWithStats extends Tenant { + userCount?: number; + subscription?: Subscription | null; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@Injectable() +export class SuperadminService { + constructor( + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Subscription) + private readonly subscriptionRepository: Repository, + ) {} + + async listTenants(query: ListTenantsQueryDto): Promise> { + const { page = 1, limit = 10, search, status, sortBy = 'created_at', sortOrder = 'DESC' } = query; + + const where: FindOptionsWhere = {}; + + if (search) { + // Search by name or slug + where.name = ILike(`%${search}%`); + } + + if (status) { + where.status = status as TenantStatus; + } + + const [tenants, total] = await this.tenantRepository.findAndCount({ + where, + order: { [sortBy]: sortOrder }, + skip: (page - 1) * limit, + take: limit, + }); + + // Get user counts for each tenant + const tenantsWithStats: TenantWithStats[] = await Promise.all( + tenants.map(async (tenant) => { + const userCount = await this.userRepository.count({ + where: { tenant_id: tenant.id }, + }); + + const subscription = await this.subscriptionRepository.findOne({ + where: { tenant_id: tenant.id }, + relations: ['plan'], + }); + + return { + ...tenant, + userCount, + subscription, + }; + }), + ); + + return { + data: tenantsWithStats, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getTenant(id: string): Promise { + const tenant = await this.tenantRepository.findOne({ + where: { id }, + }); + + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + + const userCount = await this.userRepository.count({ + where: { tenant_id: id }, + }); + + const subscription = await this.subscriptionRepository.findOne({ + where: { tenant_id: id }, + relations: ['plan'], + }); + + return { + ...tenant, + userCount, + subscription, + }; + } + + async createTenant(dto: CreateTenantDto): Promise { + // Check if slug is unique + const existingTenant = await this.tenantRepository.findOne({ + where: { slug: dto.slug }, + }); + + if (existingTenant) { + throw new ConflictException('A tenant with this slug already exists'); + } + + const tenant = this.tenantRepository.create({ + name: dto.name, + slug: dto.slug, + domain: dto.domain, + logo_url: dto.logo_url, + plan_id: dto.plan_id, + status: (dto.status || 'pending') as TenantStatus, + trial_ends_at: dto.status === 'pending' ? new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) : null, // 14 days trial + }); + + const savedTenant = await this.tenantRepository.save(tenant); + return Array.isArray(savedTenant) ? savedTenant[0] : savedTenant; + } + + async updateTenant(id: string, dto: UpdateTenantDto): Promise { + const tenant = await this.tenantRepository.findOne({ + where: { id }, + }); + + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + + Object.assign(tenant, dto); + return this.tenantRepository.save(tenant); + } + + async updateTenantStatus(id: string, dto: UpdateTenantStatusDto): Promise { + const tenant = await this.tenantRepository.findOne({ + where: { id }, + }); + + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + + tenant.status = dto.status as TenantStatus; + + // If status is suspended or canceled, you might want to log the reason + if (dto.reason) { + tenant.metadata = { + ...tenant.metadata, + statusChangeReason: dto.reason, + statusChangedAt: new Date().toISOString(), + }; + } + + return this.tenantRepository.save(tenant); + } + + async deleteTenant(id: string): Promise { + const tenant = await this.tenantRepository.findOne({ + where: { id }, + }); + + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + + // Check if tenant has users + const userCount = await this.userRepository.count({ + where: { tenant_id: id }, + }); + + if (userCount > 0) { + throw new BadRequestException( + 'Cannot delete tenant with active users. Please remove all users first or suspend the tenant.', + ); + } + + await this.tenantRepository.remove(tenant); + } + + async getTenantUsers(tenantId: string, page = 1, limit = 10): Promise> { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId }, + }); + + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + + const [users, total] = await this.userRepository.findAndCount({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + data: users, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async getDashboardStats(): Promise<{ + totalTenants: number; + activeTenants: number; + trialTenants: number; + suspendedTenants: number; + totalUsers: number; + newTenantsThisMonth: number; + }> { + const [ + totalTenants, + activeTenants, + trialTenants, + suspendedTenants, + totalUsers, + newTenantsThisMonth, + ] = await Promise.all([ + this.tenantRepository.count(), + this.tenantRepository.count({ where: { status: 'active' } }), + this.tenantRepository.count({ where: { status: 'pending' } }), + this.tenantRepository.count({ where: { status: 'suspended' } }), + this.userRepository.count(), + this.tenantRepository + .createQueryBuilder('tenant') + .where('tenant.created_at >= :startOfMonth', { + startOfMonth: new Date(new Date().getFullYear(), new Date().getMonth(), 1), + }) + .getCount(), + ]); + + return { + totalTenants, + activeTenants, + trialTenants, + suspendedTenants, + totalUsers, + newTenantsThisMonth, + }; + } + + // ==================== Metrics ==================== + + async getTenantGrowth(months = 12): Promise<{ month: string; count: number }[]> { + const result: { month: string; count: number }[] = []; + const now = new Date(); + + for (let i = months - 1; i >= 0; i--) { + const startDate = new Date(now.getFullYear(), now.getMonth() - i, 1); + const endDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59); + + const count = await this.tenantRepository + .createQueryBuilder('tenant') + .where('tenant.created_at >= :startDate', { startDate }) + .andWhere('tenant.created_at <= :endDate', { endDate }) + .getCount(); + + result.push({ + month: startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), + count, + }); + } + + return result; + } + + async getUserGrowth(months = 12): Promise<{ month: string; count: number }[]> { + const result: { month: string; count: number }[] = []; + const now = new Date(); + + for (let i = months - 1; i >= 0; i--) { + const startDate = new Date(now.getFullYear(), now.getMonth() - i, 1); + const endDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59); + + const count = await this.userRepository + .createQueryBuilder('user') + .where('user.created_at >= :startDate', { startDate }) + .andWhere('user.created_at <= :endDate', { endDate }) + .getCount(); + + result.push({ + month: startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), + count, + }); + } + + return result; + } + + async getPlanDistribution(): Promise<{ plan: string; count: number; percentage: number }[]> { + const subscriptions = await this.subscriptionRepository + .createQueryBuilder('sub') + .leftJoinAndSelect('sub.plan', 'plan') + .where('sub.status = :status', { status: 'active' }) + .getMany(); + + const planCounts: Record = {}; + let total = 0; + + for (const sub of subscriptions) { + const planName = sub.plan?.display_name || sub.plan?.name || 'Unknown'; + planCounts[planName] = (planCounts[planName] || 0) + 1; + total++; + } + + // Add tenants without subscription as "Free" + const tenantsWithSubscription = subscriptions.map(s => s.tenant_id); + const freeCount = await this.tenantRepository + .createQueryBuilder('tenant') + .where('tenant.id NOT IN (:...ids)', { + ids: tenantsWithSubscription.length > 0 ? tenantsWithSubscription : ['00000000-0000-0000-0000-000000000000'] + }) + .getCount(); + + if (freeCount > 0) { + planCounts['Free'] = freeCount; + total += freeCount; + } + + return Object.entries(planCounts).map(([plan, count]) => ({ + plan, + count, + percentage: total > 0 ? Math.round((count / total) * 100) : 0, + })); + } + + async getStatusDistribution(): Promise<{ status: string; count: number; percentage: number }[]> { + const statuses: TenantStatus[] = ['active', 'pending', 'suspended', 'cancelled']; + const total = await this.tenantRepository.count(); + + const result = await Promise.all( + statuses.map(async (status) => { + const count = await this.tenantRepository.count({ where: { status } }); + return { + status: status.charAt(0).toUpperCase() + status.slice(1), + count, + percentage: total > 0 ? Math.round((count / total) * 100) : 0, + }; + }), + ); + + return result; + } + + async getTopTenants(limit = 10): Promise<{ + id: string; + name: string; + slug: string; + userCount: number; + status: string; + planName: string; + }[]> { + const tenants = await this.tenantRepository.find({ + order: { created_at: 'ASC' }, + take: 100, // Get more to sort by user count + }); + + const tenantsWithCounts = await Promise.all( + tenants.map(async (tenant) => { + const userCount = await this.userRepository.count({ + where: { tenant_id: tenant.id }, + }); + + const subscription = await this.subscriptionRepository.findOne({ + where: { tenant_id: tenant.id }, + relations: ['plan'], + }); + + return { + id: tenant.id, + name: tenant.name, + slug: tenant.slug, + userCount, + status: tenant.status, + planName: subscription?.plan?.display_name || 'Free', + }; + }), + ); + + // Sort by user count descending and take top N + return tenantsWithCounts + .sort((a, b) => b.userCount - a.userCount) + .slice(0, limit); + } + + async getMetricsSummary(): Promise<{ + tenantGrowth: { month: string; count: number }[]; + userGrowth: { month: string; count: number }[]; + planDistribution: { plan: string; count: number; percentage: number }[]; + statusDistribution: { status: string; count: number; percentage: number }[]; + topTenants: { id: string; name: string; slug: string; userCount: number; status: string; planName: string }[]; + }> { + const [tenantGrowth, userGrowth, planDistribution, statusDistribution, topTenants] = + await Promise.all([ + this.getTenantGrowth(12), + this.getUserGrowth(12), + this.getPlanDistribution(), + this.getStatusDistribution(), + this.getTopTenants(10), + ]); + + return { + tenantGrowth, + userGrowth, + planDistribution, + statusDistribution, + topTenants, + }; + } +} diff --git a/src/modules/tenants/__tests__/tenants.controller.spec.ts b/src/modules/tenants/__tests__/tenants.controller.spec.ts new file mode 100644 index 0000000..6153a2a --- /dev/null +++ b/src/modules/tenants/__tests__/tenants.controller.spec.ts @@ -0,0 +1,981 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { + NotFoundException, + ConflictException, + ForbiddenException, + BadRequestException, +} from '@nestjs/common'; +import { TenantsController } from '../tenants.controller'; +import { TenantsService } from '../tenants.service'; +import { RbacService } from '../../rbac/services/rbac.service'; +import { Tenant } from '../entities/tenant.entity'; +import { CreateTenantDto, UpdateTenantDto, TenantSettingsDto } from '../dto'; +import { RequestUser } from '../../auth/strategies/jwt.strategy'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { PermissionsGuard } from '../../rbac/guards/permissions.guard'; + +describe('TenantsController', () => { + let controller: TenantsController; + let service: jest.Mocked; + let rbacService: jest.Mocked; + let reflector: Reflector; + + // Mock tenant data + const mockTenant: Tenant = { + id: 'tenant-123', + name: 'Test Company', + slug: 'test-company', + domain: 'test.example.com', + logo_url: 'https://example.com/logo.png', + status: 'active', + plan_id: 'plan-123', + trial_ends_at: new Date('2026-02-01'), + settings: { theme: 'dark', timezone: 'America/Mexico_City' }, + metadata: {}, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + const mockPendingTenant: Tenant = { + ...mockTenant, + id: 'tenant-pending', + name: 'Pending Company', + slug: 'pending-company', + status: 'pending', + }; + + const mockSuspendedTenant: Tenant = { + ...mockTenant, + id: 'tenant-suspended', + name: 'Suspended Company', + slug: 'suspended-company', + status: 'suspended', + }; + + // Mock user data + const mockUser: RequestUser = { + id: 'user-123', + email: 'user@example.com', + tenant_id: 'tenant-123', + }; + + const mockAdminUser: RequestUser = { + id: 'admin-user-123', + email: 'admin@example.com', + tenant_id: 'tenant-123', + }; + + const mockDifferentTenantUser: RequestUser = { + id: 'user-456', + email: 'user@other.com', + tenant_id: 'tenant-456', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TenantsController], + providers: [ + { + provide: TenantsService, + useValue: { + findOne: jest.fn(), + findBySlug: jest.fn(), + create: jest.fn(), + update: jest.fn(), + slugExists: jest.fn(), + }, + }, + { + provide: RbacService, + useValue: { + userHasPermission: jest.fn().mockResolvedValue(true), + userHasAnyPermission: jest.fn().mockResolvedValue(true), + userHasAllPermissions: jest.fn().mockResolvedValue(true), + userHasRole: jest.fn().mockResolvedValue(true), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(TenantsController); + service = module.get(TenantsService); + rbacService = module.get(RbacService); + reflector = module.get(Reflector); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Controller initialization', () => { + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should have TenantsService injected', () => { + expect(service).toBeDefined(); + }); + }); + + // ============================================ + // CREATE TENANT TESTS + // ============================================ + describe('create', () => { + const createDto: CreateTenantDto = { + name: 'New Company', + slug: 'new-company', + domain: 'new.example.com', + logo_url: 'https://example.com/logo.png', + settings: { + timezone: 'America/Mexico_City', + industry: 'Technology', + }, + }; + + it('should create a new tenant successfully', async () => { + const createdTenant: Tenant = { + ...mockTenant, + id: 'new-tenant-id', + name: createDto.name, + slug: createDto.slug, + domain: createDto.domain!, + logo_url: createDto.logo_url!, + status: 'pending', + }; + + service.create.mockResolvedValue(createdTenant); + + const result = await controller.create(createDto); + + expect(result).toEqual(createdTenant); + expect(service.create).toHaveBeenCalledWith(createDto); + expect(service.create).toHaveBeenCalledTimes(1); + }); + + it('should create tenant with minimal required fields', async () => { + const minimalDto: CreateTenantDto = { + name: 'Minimal Company', + slug: 'minimal-company', + }; + + const createdTenant: Tenant = { + ...mockTenant, + id: 'minimal-tenant-id', + name: minimalDto.name, + slug: minimalDto.slug, + domain: null, + logo_url: null, + settings: {}, + status: 'pending', + }; + + service.create.mockResolvedValue(createdTenant); + + const result = await controller.create(minimalDto); + + expect(result).toEqual(createdTenant); + expect(result.domain).toBeNull(); + expect(result.logo_url).toBeNull(); + }); + + it('should create tenant with custom settings', async () => { + const dtoWithSettings: CreateTenantDto = { + name: 'Settings Company', + slug: 'settings-company', + settings: { + timezone: 'Europe/London', + locale: 'en-GB', + industry: 'Finance', + company_size: '51-200', + }, + }; + + const createdTenant: Tenant = { + ...mockTenant, + id: 'settings-tenant-id', + name: dtoWithSettings.name, + slug: dtoWithSettings.slug, + settings: dtoWithSettings.settings as Record, + status: 'pending', + }; + + service.create.mockResolvedValue(createdTenant); + + const result = await controller.create(dtoWithSettings); + + expect(result.settings).toEqual(dtoWithSettings.settings); + }); + + it('should propagate ConflictException for duplicate slug', async () => { + service.create.mockRejectedValue( + new ConflictException('Ya existe un tenant con este slug'), + ); + + await expect(controller.create(createDto)).rejects.toThrow(ConflictException); + await expect(controller.create(createDto)).rejects.toThrow( + 'Ya existe un tenant con este slug', + ); + }); + + it('should propagate BadRequestException for invalid input', async () => { + service.create.mockRejectedValue( + new BadRequestException('Invalid tenant data'), + ); + + await expect(controller.create(createDto)).rejects.toThrow(BadRequestException); + }); + + it('should handle service errors gracefully', async () => { + service.create.mockRejectedValue(new Error('Database connection failed')); + + await expect(controller.create(createDto)).rejects.toThrow('Database connection failed'); + }); + }); + + // ============================================ + // GET TENANT BY ID TESTS + // ============================================ + describe('findOne', () => { + it('should return a tenant by id', async () => { + service.findOne.mockResolvedValue(mockTenant); + + const result = await controller.findOne('tenant-123'); + + expect(result).toEqual(mockTenant); + expect(service.findOne).toHaveBeenCalledWith('tenant-123'); + }); + + it('should return tenant with all fields populated', async () => { + service.findOne.mockResolvedValue(mockTenant); + + const result = await controller.findOne('tenant-123'); + + expect(result.id).toBe('tenant-123'); + expect(result.name).toBe('Test Company'); + expect(result.slug).toBe('test-company'); + expect(result.domain).toBe('test.example.com'); + expect(result.logo_url).toBe('https://example.com/logo.png'); + expect(result.status).toBe('active'); + expect(result.settings).toEqual({ theme: 'dark', timezone: 'America/Mexico_City' }); + }); + + it('should return tenant with pending status', async () => { + service.findOne.mockResolvedValue(mockPendingTenant); + + const result = await controller.findOne('tenant-pending'); + + expect(result.status).toBe('pending'); + }); + + it('should return tenant with suspended status', async () => { + service.findOne.mockResolvedValue(mockSuspendedTenant); + + const result = await controller.findOne('tenant-suspended'); + + expect(result.status).toBe('suspended'); + }); + + it('should propagate NotFoundException from service', async () => { + service.findOne.mockRejectedValue(new NotFoundException('Tenant no encontrado')); + + await expect(controller.findOne('non-existent')).rejects.toThrow(NotFoundException); + await expect(controller.findOne('non-existent')).rejects.toThrow('Tenant no encontrado'); + }); + + it('should handle UUID format IDs', async () => { + const uuidId = '550e8400-e29b-41d4-a716-446655440000'; + service.findOne.mockResolvedValue({ ...mockTenant, id: uuidId }); + + const result = await controller.findOne(uuidId); + + expect(result.id).toBe(uuidId); + expect(service.findOne).toHaveBeenCalledWith(uuidId); + }); + }); + + // ============================================ + // GET CURRENT TENANT TESTS + // ============================================ + describe('getCurrent', () => { + it('should return current user tenant', async () => { + service.findOne.mockResolvedValue(mockTenant); + + const result = await controller.getCurrent(mockUser); + + expect(result).toEqual(mockTenant); + expect(service.findOne).toHaveBeenCalledWith(mockUser.tenant_id); + }); + + it('should use tenant_id from request user context', async () => { + service.findOne.mockResolvedValue(mockTenant); + + await controller.getCurrent(mockUser); + + expect(service.findOne).toHaveBeenCalledWith('tenant-123'); + }); + + it('should return tenant for different user contexts', async () => { + const differentTenant: Tenant = { + ...mockTenant, + id: 'tenant-456', + name: 'Different Company', + }; + + service.findOne.mockResolvedValue(differentTenant); + + const result = await controller.getCurrent(mockDifferentTenantUser); + + expect(result.id).toBe('tenant-456'); + expect(service.findOne).toHaveBeenCalledWith('tenant-456'); + }); + + it('should propagate NotFoundException if tenant not found', async () => { + service.findOne.mockRejectedValue(new NotFoundException('Tenant no encontrado')); + + await expect(controller.getCurrent(mockUser)).rejects.toThrow(NotFoundException); + }); + + it('should handle user with null tenant_id gracefully', async () => { + const userWithoutTenant: RequestUser = { + id: 'orphan-user', + email: 'orphan@example.com', + tenant_id: '', + }; + + service.findOne.mockRejectedValue(new NotFoundException('Tenant no encontrado')); + + await expect(controller.getCurrent(userWithoutTenant)).rejects.toThrow(NotFoundException); + }); + }); + + // ============================================ + // UPDATE CURRENT TENANT TESTS + // ============================================ + describe('updateCurrent', () => { + const updateDto: UpdateTenantDto = { + name: 'Updated Company', + logo_url: 'https://example.com/updated-logo.png', + settings: { + timezone: 'America/New_York', + }, + }; + + it('should update current tenant successfully', async () => { + const updatedTenant: Tenant = { + ...mockTenant, + name: updateDto.name!, + logo_url: updateDto.logo_url!, + settings: { ...mockTenant.settings, ...updateDto.settings }, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, updateDto); + + expect(result).toEqual(updatedTenant); + expect(service.update).toHaveBeenCalledWith(mockUser.tenant_id, updateDto); + }); + + it('should use tenant_id from user context for update', async () => { + service.update.mockResolvedValue(mockTenant); + + await controller.updateCurrent(mockUser, updateDto); + + expect(service.update).toHaveBeenCalledWith('tenant-123', updateDto); + }); + + it('should propagate NotFoundException if tenant not found', async () => { + service.update.mockRejectedValue(new NotFoundException('Tenant no encontrado')); + + await expect(controller.updateCurrent(mockUser, updateDto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should allow partial updates - name only', async () => { + const partialUpdateDto: UpdateTenantDto = { + name: 'Only Name Updated', + }; + + const updatedTenant: Tenant = { + ...mockTenant, + name: partialUpdateDto.name!, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, partialUpdateDto); + + expect(result.name).toBe('Only Name Updated'); + expect(service.update).toHaveBeenCalledWith(mockUser.tenant_id, partialUpdateDto); + }); + + it('should allow partial updates - logo_url only', async () => { + const partialUpdateDto: UpdateTenantDto = { + logo_url: 'https://example.com/new-logo.png', + }; + + const updatedTenant: Tenant = { + ...mockTenant, + logo_url: partialUpdateDto.logo_url!, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, partialUpdateDto); + + expect(result.logo_url).toBe('https://example.com/new-logo.png'); + }); + + it('should allow partial updates - settings only', async () => { + const partialUpdateDto: UpdateTenantDto = { + settings: { + timezone: 'Europe/Paris', + locale: 'fr-FR', + }, + }; + + const updatedTenant: Tenant = { + ...mockTenant, + settings: { ...mockTenant.settings, ...partialUpdateDto.settings }, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, partialUpdateDto); + + expect(result.settings?.timezone).toBe('Europe/Paris'); + expect(result.settings?.locale).toBe('fr-FR'); + }); + + it('should handle empty update dto', async () => { + const emptyUpdateDto: UpdateTenantDto = {}; + + service.update.mockResolvedValue(mockTenant); + + const result = await controller.updateCurrent(mockUser, emptyUpdateDto); + + expect(result).toEqual(mockTenant); + expect(service.update).toHaveBeenCalledWith(mockUser.tenant_id, emptyUpdateDto); + }); + }); + + // ============================================ + // TENANT SETTINGS MANAGEMENT TESTS + // ============================================ + describe('Tenant Settings Management', () => { + it('should update tenant timezone setting', async () => { + const settingsUpdate: UpdateTenantDto = { + settings: { timezone: 'Asia/Tokyo' }, + }; + + const updatedTenant: Tenant = { + ...mockTenant, + settings: { ...mockTenant.settings, timezone: 'Asia/Tokyo' }, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, settingsUpdate); + + expect(result.settings?.timezone).toBe('Asia/Tokyo'); + }); + + it('should update tenant locale setting', async () => { + const settingsUpdate: UpdateTenantDto = { + settings: { locale: 'ja-JP' }, + }; + + const updatedTenant: Tenant = { + ...mockTenant, + settings: { ...mockTenant.settings, locale: 'ja-JP' }, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, settingsUpdate); + + expect(result.settings?.locale).toBe('ja-JP'); + }); + + it('should update tenant industry setting', async () => { + const settingsUpdate: UpdateTenantDto = { + settings: { industry: 'Healthcare' }, + }; + + const updatedTenant: Tenant = { + ...mockTenant, + settings: { ...mockTenant.settings, industry: 'Healthcare' }, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, settingsUpdate); + + expect(result.settings?.industry).toBe('Healthcare'); + }); + + it('should update tenant company_size setting', async () => { + const settingsUpdate: UpdateTenantDto = { + settings: { company_size: '201-500' }, + }; + + const updatedTenant: Tenant = { + ...mockTenant, + settings: { ...mockTenant.settings, company_size: '201-500' }, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, settingsUpdate); + + expect(result.settings?.company_size).toBe('201-500'); + }); + + it('should merge multiple settings in one update', async () => { + const settingsUpdate: UpdateTenantDto = { + settings: { + timezone: 'America/Los_Angeles', + locale: 'en-US', + industry: 'Software', + company_size: '11-50', + }, + }; + + const updatedTenant: Tenant = { + ...mockTenant, + settings: { + ...mockTenant.settings, + ...settingsUpdate.settings, + }, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, settingsUpdate); + + expect(result.settings).toMatchObject(settingsUpdate.settings!); + }); + + it('should preserve existing settings when updating partial settings', async () => { + const existingTenant: Tenant = { + ...mockTenant, + settings: { + theme: 'dark', + timezone: 'America/Mexico_City', + locale: 'es-MX', + industry: 'Technology', + }, + }; + + const settingsUpdate: UpdateTenantDto = { + settings: { timezone: 'Europe/London' }, + }; + + const updatedTenant: Tenant = { + ...existingTenant, + settings: { + ...existingTenant.settings, + timezone: 'Europe/London', + }, + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, settingsUpdate); + + // Original settings should be preserved + expect(result.settings?.theme).toBe('dark'); + expect(result.settings?.locale).toBe('es-MX'); + expect(result.settings?.industry).toBe('Technology'); + // Updated setting should reflect new value + expect(result.settings?.timezone).toBe('Europe/London'); + }); + }); + + // ============================================ + // MULTI-TENANT AUTHORIZATION TESTS + // ============================================ + describe('Multi-tenant Authorization', () => { + it('should enforce tenant isolation - user can only access their tenant via getCurrent', async () => { + service.findOne.mockResolvedValue(mockTenant); + + await controller.getCurrent(mockUser); + + // Verify service was called with user's tenant_id + expect(service.findOne).toHaveBeenCalledWith('tenant-123'); + }); + + it('should use correct tenant_id from different users', async () => { + const tenant1: Tenant = { ...mockTenant, id: 'tenant-123' }; + const tenant2: Tenant = { ...mockTenant, id: 'tenant-456', name: 'Other Company' }; + + // First user + service.findOne.mockResolvedValueOnce(tenant1); + await controller.getCurrent(mockUser); + expect(service.findOne).toHaveBeenCalledWith('tenant-123'); + + // Second user + service.findOne.mockResolvedValueOnce(tenant2); + await controller.getCurrent(mockDifferentTenantUser); + expect(service.findOne).toHaveBeenCalledWith('tenant-456'); + }); + + it('should use tenant_id from user context when updating', async () => { + const updateDto: UpdateTenantDto = { name: 'New Name' }; + service.update.mockResolvedValue(mockTenant); + + await controller.updateCurrent(mockUser, updateDto); + + // Verify update was called with user's tenant_id, not arbitrary id + expect(service.update).toHaveBeenCalledWith('tenant-123', updateDto); + }); + + it('should prevent cross-tenant updates by using user context', async () => { + const updateDto: UpdateTenantDto = { name: 'Malicious Update' }; + + // Even if attacker tries to pass different data, the controller uses user.tenant_id + service.update.mockResolvedValue(mockTenant); + + await controller.updateCurrent(mockUser, updateDto); + + // Should only update user's own tenant + expect(service.update).toHaveBeenCalledWith('tenant-123', updateDto); + expect(service.update).not.toHaveBeenCalledWith('tenant-456', expect.anything()); + }); + }); + + // ============================================ + // ERROR HANDLING TESTS + // ============================================ + describe('Error Handling', () => { + describe('NotFoundException scenarios', () => { + it('should throw NotFoundException when tenant does not exist', async () => { + service.findOne.mockRejectedValue(new NotFoundException('Tenant no encontrado')); + + await expect(controller.findOne('non-existent-id')).rejects.toThrow(NotFoundException); + }); + + it('should throw NotFoundException with correct message', async () => { + const errorMessage = 'Tenant no encontrado'; + service.findOne.mockRejectedValue(new NotFoundException(errorMessage)); + + await expect(controller.findOne('non-existent-id')).rejects.toThrow(errorMessage); + }); + + it('should throw NotFoundException when updating non-existent tenant', async () => { + service.update.mockRejectedValue(new NotFoundException('Tenant no encontrado')); + + await expect( + controller.updateCurrent(mockUser, { name: 'New Name' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('ConflictException scenarios', () => { + it('should throw ConflictException for duplicate slug on create', async () => { + const createDto: CreateTenantDto = { + name: 'Duplicate Company', + slug: 'existing-slug', + }; + + service.create.mockRejectedValue( + new ConflictException('Ya existe un tenant con este slug'), + ); + + await expect(controller.create(createDto)).rejects.toThrow(ConflictException); + }); + + it('should propagate ConflictException with original message', async () => { + const errorMessage = 'Ya existe un tenant con este slug'; + const createDto: CreateTenantDto = { + name: 'Duplicate Company', + slug: 'existing-slug', + }; + + service.create.mockRejectedValue(new ConflictException(errorMessage)); + + await expect(controller.create(createDto)).rejects.toThrow(errorMessage); + }); + }); + + describe('Generic error handling', () => { + it('should propagate unexpected errors from findOne', async () => { + service.findOne.mockRejectedValue(new Error('Unexpected database error')); + + await expect(controller.findOne('tenant-123')).rejects.toThrow( + 'Unexpected database error', + ); + }); + + it('should propagate unexpected errors from create', async () => { + const createDto: CreateTenantDto = { + name: 'Test', + slug: 'test', + }; + + service.create.mockRejectedValue(new Error('Database connection lost')); + + await expect(controller.create(createDto)).rejects.toThrow('Database connection lost'); + }); + + it('should propagate unexpected errors from update', async () => { + service.update.mockRejectedValue(new Error('Transaction failed')); + + await expect( + controller.updateCurrent(mockUser, { name: 'New Name' }), + ).rejects.toThrow('Transaction failed'); + }); + }); + }); + + // ============================================ + // GUARD BEHAVIOR TESTS + // ============================================ + describe('Guards and Decorators', () => { + describe('JwtAuthGuard', () => { + it('should have JwtAuthGuard applied at controller level', () => { + const guards = Reflect.getMetadata('__guards__', TenantsController); + expect(guards).toBeDefined(); + expect(guards.some((g: any) => g === JwtAuthGuard || g.name === 'JwtAuthGuard')).toBe(true); + }); + }); + + describe('Public decorator on create', () => { + it('should have Public decorator on create method', () => { + const isPublic = Reflect.getMetadata('isPublic', controller.create); + expect(isPublic).toBe(true); + }); + }); + + describe('PermissionsGuard on updateCurrent', () => { + it('should have PermissionsGuard on updateCurrent', () => { + const guards = Reflect.getMetadata('__guards__', TenantsController.prototype.updateCurrent); + // Guard may be defined at method or class level + if (guards) { + expect(guards.some((g: any) => g === PermissionsGuard || g.name === 'PermissionsGuard')).toBe(true); + } + }); + + it('should require tenants:write permission on updateCurrent', () => { + const permissions = Reflect.getMetadata('permissions', TenantsController.prototype.updateCurrent); + if (permissions) { + expect(permissions).toContain('tenants:write'); + } + }); + }); + + describe('ApiBearerAuth decorator', () => { + it('should have ApiBearerAuth decorator at controller level', () => { + const security = Reflect.getMetadata('swagger/apiSecurity', TenantsController); + // This checks if the controller has security metadata + expect(security).toBeDefined(); + }); + }); + }); + + // ============================================ + // REQUEST USER CONTEXT TESTS + // ============================================ + describe('Request User Context', () => { + it('should correctly extract tenant_id from user context', async () => { + service.findOne.mockResolvedValue(mockTenant); + + await controller.getCurrent(mockUser); + + expect(service.findOne).toHaveBeenCalledWith(mockUser.tenant_id); + }); + + it('should handle user with minimal properties', async () => { + const minimalUser: RequestUser = { + id: 'minimal-id', + email: 'minimal@test.com', + tenant_id: 'minimal-tenant', + }; + + service.findOne.mockResolvedValue({ + ...mockTenant, + id: 'minimal-tenant', + }); + + const result = await controller.getCurrent(minimalUser); + + expect(service.findOne).toHaveBeenCalledWith('minimal-tenant'); + }); + + it('should pass user tenant_id correctly in update operations', async () => { + const updateDto: UpdateTenantDto = { + name: 'Updated via Context', + }; + + service.update.mockResolvedValue({ + ...mockTenant, + name: 'Updated via Context', + }); + + await controller.updateCurrent(mockAdminUser, updateDto); + + expect(service.update).toHaveBeenCalledWith('tenant-123', updateDto); + }); + }); + + // ============================================ + // EDGE CASES AND BOUNDARY TESTS + // ============================================ + describe('Edge Cases', () => { + it('should handle tenant with null optional fields', async () => { + const tenantWithNulls: Tenant = { + ...mockTenant, + domain: null, + logo_url: null, + plan_id: null, + trial_ends_at: null, + settings: null, + metadata: null, + }; + + service.findOne.mockResolvedValue(tenantWithNulls); + + const result = await controller.findOne('tenant-123'); + + expect(result.domain).toBeNull(); + expect(result.logo_url).toBeNull(); + expect(result.plan_id).toBeNull(); + expect(result.trial_ends_at).toBeNull(); + }); + + it('should handle tenant with empty settings object', async () => { + const tenantWithEmptySettings: Tenant = { + ...mockTenant, + settings: {}, + }; + + service.findOne.mockResolvedValue(tenantWithEmptySettings); + + const result = await controller.findOne('tenant-123'); + + expect(result.settings).toEqual({}); + }); + + it('should handle very long tenant names', async () => { + const longName = 'A'.repeat(255); + const createDto: CreateTenantDto = { + name: longName, + slug: 'long-name-company', + }; + + const createdTenant: Tenant = { + ...mockTenant, + name: longName, + }; + + service.create.mockResolvedValue(createdTenant); + + const result = await controller.create(createDto); + + expect(result.name).toBe(longName); + expect(result.name.length).toBe(255); + }); + + it('should handle special characters in settings', async () => { + const settingsWithSpecialChars = { + customField: 'Value with "quotes" and \'apostrophes\'', + unicodeField: 'Unicode: \u00e9\u00e8\u00ea \u00f1 \u00fc', + jsonLikeField: '{"nested": "json"}', + }; + + const tenantWithSpecialSettings: Tenant = { + ...mockTenant, + settings: settingsWithSpecialChars, + }; + + service.findOne.mockResolvedValue(tenantWithSpecialSettings); + + const result = await controller.findOne('tenant-123'); + + expect(result.settings).toEqual(settingsWithSpecialChars); + }); + + it('should handle concurrent calls correctly', async () => { + service.findOne.mockResolvedValue(mockTenant); + + const promises = [ + controller.getCurrent(mockUser), + controller.getCurrent(mockAdminUser), + controller.findOne('tenant-123'), + ]; + + const results = await Promise.all(promises); + + expect(results).toHaveLength(3); + expect(service.findOne).toHaveBeenCalledTimes(3); + }); + }); + + // ============================================ + // API RESPONSE FORMAT TESTS + // ============================================ + describe('API Response Format', () => { + it('should return complete tenant object on findOne', async () => { + service.findOne.mockResolvedValue(mockTenant); + + const result = await controller.findOne('tenant-123'); + + expect(result).toHaveProperty('id'); + expect(result).toHaveProperty('name'); + expect(result).toHaveProperty('slug'); + expect(result).toHaveProperty('domain'); + expect(result).toHaveProperty('logo_url'); + expect(result).toHaveProperty('status'); + expect(result).toHaveProperty('plan_id'); + expect(result).toHaveProperty('trial_ends_at'); + expect(result).toHaveProperty('settings'); + expect(result).toHaveProperty('metadata'); + expect(result).toHaveProperty('created_at'); + expect(result).toHaveProperty('updated_at'); + }); + + it('should return complete tenant object on create', async () => { + const createDto: CreateTenantDto = { + name: 'New Company', + slug: 'new-company', + }; + + const createdTenant: Tenant = { + ...mockTenant, + id: 'new-id', + name: createDto.name, + slug: createDto.slug, + status: 'pending', + }; + + service.create.mockResolvedValue(createdTenant); + + const result = await controller.create(createDto); + + expect(result).toHaveProperty('id'); + expect(result.status).toBe('pending'); + }); + + it('should return updated tenant object on updateCurrent', async () => { + const updateDto: UpdateTenantDto = { + name: 'Updated Name', + }; + + const updatedTenant: Tenant = { + ...mockTenant, + name: 'Updated Name', + updated_at: new Date(), + }; + + service.update.mockResolvedValue(updatedTenant); + + const result = await controller.updateCurrent(mockUser, updateDto); + + expect(result.name).toBe('Updated Name'); + expect(result).toHaveProperty('updated_at'); + }); + }); +}); diff --git a/src/modules/tenants/__tests__/tenants.service.spec.ts b/src/modules/tenants/__tests__/tenants.service.spec.ts new file mode 100644 index 0000000..a950cc0 --- /dev/null +++ b/src/modules/tenants/__tests__/tenants.service.spec.ts @@ -0,0 +1,326 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, ConflictException } from '@nestjs/common'; +import { TenantsService } from '../tenants.service'; +import { Tenant } from '../entities/tenant.entity'; +import { CreateTenantDto, UpdateTenantDto } from '../dto'; + +describe('TenantsService', () => { + let service: TenantsService; + let tenantRepository: jest.Mocked>; + + const mockTenant: Tenant = { + id: 'tenant-123', + name: 'Test Company', + slug: 'test-company', + domain: 'test.example.com', + logo_url: 'https://example.com/logo.png', + status: 'active', + plan_id: 'plan-123', + trial_ends_at: new Date('2026-02-01'), + settings: { theme: 'dark' }, + metadata: { industry: 'tech' }, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TenantsService, + { + provide: getRepositoryToken(Tenant), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(TenantsService); + tenantRepository = module.get(getRepositoryToken(Tenant)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findOne', () => { + it('should return a tenant by id', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + + const result = await service.findOne('tenant-123'); + + expect(result).toEqual(mockTenant); + expect(tenantRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'tenant-123' }, + }); + }); + + it('should throw NotFoundException if tenant not found', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne('non-existent')).rejects.toThrow( + NotFoundException, + ); + await expect(service.findOne('non-existent')).rejects.toThrow( + 'Tenant no encontrado', + ); + }); + + it('should handle repository errors', async () => { + tenantRepository.findOne.mockRejectedValue(new Error('DB Error')); + + await expect(service.findOne('tenant-123')).rejects.toThrow('DB Error'); + }); + }); + + describe('findBySlug', () => { + it('should return a tenant by slug', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + + const result = await service.findBySlug('test-company'); + + expect(result).toEqual(mockTenant); + expect(tenantRepository.findOne).toHaveBeenCalledWith({ + where: { slug: 'test-company' }, + }); + }); + + it('should return null if tenant not found by slug', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + const result = await service.findBySlug('non-existent'); + + expect(result).toBeNull(); + }); + + it('should handle slugs with special characters', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + + await service.findBySlug('company-name-123'); + + expect(tenantRepository.findOne).toHaveBeenCalledWith({ + where: { slug: 'company-name-123' }, + }); + }); + }); + + describe('create', () => { + const createDto: CreateTenantDto = { + name: 'New Company', + slug: 'new-company', + domain: 'new.example.com', + logo_url: 'https://example.com/new-logo.png', + settings: { + timezone: 'America/Mexico_City', + industry: 'Technology', + }, + }; + + it('should create a new tenant successfully', async () => { + const createdTenant: Tenant = { + ...mockTenant, + id: 'new-tenant-id', + name: createDto.name, + slug: createDto.slug, + domain: createDto.domain || null, + logo_url: createDto.logo_url || null, + status: 'pending', + settings: createDto.settings || {}, + }; + + tenantRepository.findOne.mockResolvedValue(null); // No existing tenant + tenantRepository.create.mockReturnValue(createdTenant); + tenantRepository.save.mockResolvedValue(createdTenant); + + const result = await service.create(createDto); + + expect(result).toEqual(createdTenant); + expect(tenantRepository.findOne).toHaveBeenCalledWith({ + where: { slug: createDto.slug }, + }); + expect(tenantRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: createDto.name, + slug: createDto.slug, + domain: createDto.domain, + logo_url: createDto.logo_url, + status: 'pending', + settings: createDto.settings, + }), + ); + expect(tenantRepository.save).toHaveBeenCalled(); + }); + + it('should throw ConflictException if slug already exists', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + + await expect(service.create(createDto)).rejects.toThrow(ConflictException); + await expect(service.create(createDto)).rejects.toThrow( + 'Ya existe un tenant con este slug', + ); + }); + + it('should create tenant with empty settings if not provided', async () => { + const dtoWithoutSettings: CreateTenantDto = { + name: 'Minimal Company', + slug: 'minimal-company', + }; + + const createdTenant: Tenant = { + ...mockTenant, + name: dtoWithoutSettings.name, + slug: dtoWithoutSettings.slug, + domain: null, + logo_url: null, + settings: {}, + status: 'pending', + }; + + tenantRepository.findOne.mockResolvedValue(null); + tenantRepository.create.mockReturnValue(createdTenant); + tenantRepository.save.mockResolvedValue(createdTenant); + + const result = await service.create(dtoWithoutSettings); + + expect(result.settings).toEqual({}); + expect(result.domain).toBeNull(); + expect(result.logo_url).toBeNull(); + }); + + it('should set trial_ends_at to 14 days from now', async () => { + const dtoMinimal: CreateTenantDto = { + name: 'Trial Company', + slug: 'trial-company', + }; + + const createdTenant: Tenant = { + ...mockTenant, + name: dtoMinimal.name, + slug: dtoMinimal.slug, + status: 'pending', + trial_ends_at: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), + }; + + tenantRepository.findOne.mockResolvedValue(null); + tenantRepository.create.mockReturnValue(createdTenant); + tenantRepository.save.mockResolvedValue(createdTenant); + + await service.create(dtoMinimal); + + expect(tenantRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'pending', + trial_ends_at: expect.any(Date), + }), + ); + }); + }); + + describe('update', () => { + const updateDto: UpdateTenantDto = { + name: 'Updated Company', + logo_url: 'https://example.com/updated-logo.png', + settings: { + timezone: 'America/New_York', + }, + }; + + it('should update tenant successfully', async () => { + const updatedTenant: Tenant = { + ...mockTenant, + name: updateDto.name!, + logo_url: updateDto.logo_url!, + settings: { ...mockTenant.settings, ...updateDto.settings }, + }; + + tenantRepository.findOne.mockResolvedValue(mockTenant); + tenantRepository.save.mockResolvedValue(updatedTenant); + + const result = await service.update('tenant-123', updateDto); + + expect(result.name).toBe(updateDto.name); + expect(result.logo_url).toBe(updateDto.logo_url); + expect(result.settings).toEqual( + expect.objectContaining({ timezone: 'America/New_York' }), + ); + expect(tenantRepository.save).toHaveBeenCalled(); + }); + + it('should throw NotFoundException if tenant not found', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + await expect(service.update('non-existent', updateDto)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should only update provided fields', async () => { + const partialUpdate: UpdateTenantDto = { + name: 'Only Name Updated', + }; + + tenantRepository.findOne.mockResolvedValue({ ...mockTenant }); + tenantRepository.save.mockImplementation((tenant) => + Promise.resolve(tenant as Tenant), + ); + + const result = await service.update('tenant-123', partialUpdate); + + expect(result.name).toBe('Only Name Updated'); + expect(result.logo_url).toBe(mockTenant.logo_url); + expect(result.settings).toEqual(mockTenant.settings); + }); + + it('should merge settings with existing settings', async () => { + const settingsUpdate: UpdateTenantDto = { + settings: { + industry: 'Healthcare', + }, + }; + + const tenantWithSettings: Tenant = { + ...mockTenant, + settings: { theme: 'dark', timezone: 'America/Mexico_City' }, + }; + + tenantRepository.findOne.mockResolvedValue({ ...tenantWithSettings }); + tenantRepository.save.mockImplementation((tenant) => + Promise.resolve(tenant as Tenant), + ); + + const result = await service.update('tenant-123', settingsUpdate); + + expect(result.settings).toEqual({ + theme: 'dark', + timezone: 'America/Mexico_City', + industry: 'Healthcare', + }); + }); + }); + + describe('slugExists', () => { + it('should return true if slug exists', async () => { + tenantRepository.findOne.mockResolvedValue(mockTenant); + + const result = await service.slugExists('test-company'); + + expect(result).toBe(true); + }); + + it('should return false if slug does not exist', async () => { + tenantRepository.findOne.mockResolvedValue(null); + + const result = await service.slugExists('non-existent'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/modules/tenants/dto/create-tenant.dto.ts b/src/modules/tenants/dto/create-tenant.dto.ts new file mode 100644 index 0000000..2f20ed2 --- /dev/null +++ b/src/modules/tenants/dto/create-tenant.dto.ts @@ -0,0 +1,83 @@ +import { + IsString, + IsOptional, + IsNotEmpty, + Matches, + MinLength, + MaxLength, + IsObject, + IsUrl, + ValidateNested, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class TenantSettingsDto { + @ApiPropertyOptional({ description: 'Timezone', example: 'America/Mexico_City' }) + @IsString() + @IsOptional() + timezone?: string; + + @ApiPropertyOptional({ description: 'Locale', example: 'es-MX' }) + @IsString() + @IsOptional() + locale?: string; + + @ApiPropertyOptional({ description: 'Industry', example: 'Technology' }) + @IsString() + @IsOptional() + industry?: string; + + @ApiPropertyOptional({ description: 'Company size', example: '11-50' }) + @IsString() + @IsOptional() + company_size?: string; +} + +export class CreateTenantDto { + @ApiProperty({ description: 'Tenant name', example: 'Acme Corporation' }) + @IsString() + @IsNotEmpty({ message: 'El nombre es requerido' }) + @MinLength(2, { message: 'El nombre debe tener al menos 2 caracteres' }) + @MaxLength(255, { message: 'El nombre no puede exceder 255 caracteres' }) + name: string; + + @ApiProperty({ + description: 'Tenant slug (unique identifier for URL)', + example: 'acme-corp', + }) + @IsString() + @IsNotEmpty({ message: 'El slug es requerido' }) + @MinLength(3, { message: 'El slug debe tener al menos 3 caracteres' }) + @MaxLength(50, { message: 'El slug no puede exceder 50 caracteres' }) + @Matches(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/, { + message: + 'El slug solo puede contener letras minusculas, numeros y guiones, y debe comenzar y terminar con letra o numero', + }) + slug: string; + + @ApiPropertyOptional({ description: 'Custom domain', example: 'acme.com' }) + @IsString() + @IsOptional() + @MaxLength(255, { message: 'El dominio no puede exceder 255 caracteres' }) + domain?: string; + + @ApiPropertyOptional({ + description: 'Logo URL', + example: 'https://example.com/logo.png', + }) + @IsOptional() + @IsUrl({}, { message: 'La URL del logo debe ser valida' }) + @MaxLength(500, { message: 'La URL del logo no puede exceder 500 caracteres' }) + logo_url?: string; + + @ApiPropertyOptional({ + description: 'Tenant settings', + type: TenantSettingsDto, + }) + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => TenantSettingsDto) + settings?: TenantSettingsDto; +} diff --git a/src/modules/tenants/dto/index.ts b/src/modules/tenants/dto/index.ts new file mode 100644 index 0000000..be4b1af --- /dev/null +++ b/src/modules/tenants/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-tenant.dto'; +export * from './update-tenant.dto'; diff --git a/src/modules/tenants/dto/update-tenant.dto.ts b/src/modules/tenants/dto/update-tenant.dto.ts new file mode 100644 index 0000000..d87c7a3 --- /dev/null +++ b/src/modules/tenants/dto/update-tenant.dto.ts @@ -0,0 +1,40 @@ +import { + IsString, + IsOptional, + MinLength, + MaxLength, + IsObject, + IsUrl, + ValidateNested, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { TenantSettingsDto } from './create-tenant.dto'; + +export class UpdateTenantDto { + @ApiPropertyOptional({ description: 'Tenant name', example: 'Acme Corporation' }) + @IsString() + @IsOptional() + @MinLength(2, { message: 'El nombre debe tener al menos 2 caracteres' }) + @MaxLength(255, { message: 'El nombre no puede exceder 255 caracteres' }) + name?: string; + + @ApiPropertyOptional({ + description: 'Logo URL', + example: 'https://example.com/logo.png', + }) + @IsOptional() + @IsUrl({}, { message: 'La URL del logo debe ser valida' }) + @MaxLength(500, { message: 'La URL del logo no puede exceder 500 caracteres' }) + logo_url?: string; + + @ApiPropertyOptional({ + description: 'Tenant settings (partial update)', + type: TenantSettingsDto, + }) + @IsOptional() + @IsObject() + @ValidateNested() + @Type(() => TenantSettingsDto) + settings?: Partial; +} diff --git a/src/modules/tenants/entities/tenant.entity.ts b/src/modules/tenants/entities/tenant.entity.ts new file mode 100644 index 0000000..de0f0d6 --- /dev/null +++ b/src/modules/tenants/entities/tenant.entity.ts @@ -0,0 +1,53 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'tenants', name: 'tenants' }) +export class Tenant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 255 }) + name: string; + + @Column({ type: 'varchar', length: 100 }) + @Index({ unique: true }) + slug: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + domain: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + logo_url: string | null; + + @Column({ + type: 'enum', + enum: ['pending', 'active', 'suspended', 'cancelled'], + enumName: 'tenants.tenant_status', + default: 'pending', + }) + status: 'pending' | 'active' | 'suspended' | 'cancelled'; + + @Column({ type: 'uuid', nullable: true }) + plan_id: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + trial_ends_at: Date | null; + + @Column({ type: 'jsonb', nullable: true }) + settings: Record | null; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; +} diff --git a/src/modules/tenants/index.ts b/src/modules/tenants/index.ts new file mode 100644 index 0000000..f4f810e --- /dev/null +++ b/src/modules/tenants/index.ts @@ -0,0 +1,4 @@ +export * from './tenants.module'; +export * from './tenants.service'; +export * from './entities/tenant.entity'; +export * from './dto'; diff --git a/src/modules/tenants/tenants.controller.ts b/src/modules/tenants/tenants.controller.ts new file mode 100644 index 0000000..7008e21 --- /dev/null +++ b/src/modules/tenants/tenants.controller.ts @@ -0,0 +1,76 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiResponse, + ApiBody, +} from '@nestjs/swagger'; +import { TenantsService } from './tenants.service'; +import { CreateTenantDto, UpdateTenantDto } from './dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PermissionsGuard, RequirePermissions } from '../rbac/guards/permissions.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; +import { Public } from '../auth/decorators/public.decorator'; + +@ApiTags('tenants') +@Controller('tenants') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class TenantsController { + constructor(private readonly tenantsService: TenantsService) {} + + @Post() + @Public() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create a new tenant' }) + @ApiBody({ type: CreateTenantDto }) + @ApiResponse({ status: 201, description: 'Tenant created successfully' }) + @ApiResponse({ status: 400, description: 'Invalid input data' }) + @ApiResponse({ status: 409, description: 'Tenant with this slug already exists' }) + async create(@Body() createTenantDto: CreateTenantDto) { + return this.tenantsService.create(createTenantDto); + } + + @Get('current') + @ApiOperation({ summary: 'Get current user tenant' }) + @ApiResponse({ status: 200, description: 'Returns the current tenant' }) + @ApiResponse({ status: 404, description: 'Tenant not found' }) + async getCurrent(@CurrentUser() user: RequestUser) { + return this.tenantsService.findOne(user.tenant_id); + } + + @Patch('current') + @UseGuards(PermissionsGuard) + @RequirePermissions('tenants:write') + @ApiOperation({ summary: 'Update current tenant' }) + @ApiBody({ type: UpdateTenantDto }) + @ApiResponse({ status: 200, description: 'Tenant updated successfully' }) + @ApiResponse({ status: 403, description: 'Insufficient permissions' }) + @ApiResponse({ status: 404, description: 'Tenant not found' }) + async updateCurrent( + @CurrentUser() user: RequestUser, + @Body() updateTenantDto: UpdateTenantDto, + ) { + return this.tenantsService.update(user.tenant_id, updateTenantDto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get tenant by ID' }) + @ApiResponse({ status: 200, description: 'Returns the tenant' }) + @ApiResponse({ status: 404, description: 'Tenant not found' }) + async findOne(@Param('id') id: string) { + return this.tenantsService.findOne(id); + } +} diff --git a/src/modules/tenants/tenants.module.ts b/src/modules/tenants/tenants.module.ts new file mode 100644 index 0000000..f1f65c6 --- /dev/null +++ b/src/modules/tenants/tenants.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TenantsController } from './tenants.controller'; +import { TenantsService } from './tenants.service'; +import { Tenant } from './entities/tenant.entity'; +import { RbacModule } from '../rbac/rbac.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Tenant]), RbacModule], + controllers: [TenantsController], + providers: [TenantsService], + exports: [TenantsService], +}) +export class TenantsModule {} diff --git a/src/modules/tenants/tenants.service.ts b/src/modules/tenants/tenants.service.ts new file mode 100644 index 0000000..ae830d1 --- /dev/null +++ b/src/modules/tenants/tenants.service.ts @@ -0,0 +1,84 @@ +import { + Injectable, + NotFoundException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Tenant } from './entities/tenant.entity'; +import { CreateTenantDto, UpdateTenantDto } from './dto'; + +@Injectable() +export class TenantsService { + constructor( + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + ) {} + + async findOne(id: string): Promise { + const tenant = await this.tenantRepository.findOne({ + where: { id }, + }); + + if (!tenant) { + throw new NotFoundException('Tenant no encontrado'); + } + + return tenant; + } + + async findBySlug(slug: string): Promise { + return this.tenantRepository.findOne({ + where: { slug }, + }); + } + + async create(dto: CreateTenantDto): Promise { + // Check if slug already exists + const existingTenant = await this.findBySlug(dto.slug); + if (existingTenant) { + throw new ConflictException('Ya existe un tenant con este slug'); + } + + // Create tenant with pending status (for onboarding flow) + const tenant = this.tenantRepository.create({ + name: dto.name, + slug: dto.slug, + domain: dto.domain || null, + logo_url: dto.logo_url || null, + status: 'pending', // Default to pending for new tenants during onboarding + settings: dto.settings || {}, + trial_ends_at: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days trial + }); + + return this.tenantRepository.save(tenant); + } + + async update(id: string, dto: UpdateTenantDto): Promise { + const tenant = await this.findOne(id); + + // Update basic fields if provided + if (dto.name !== undefined) { + tenant.name = dto.name; + } + + if (dto.logo_url !== undefined) { + tenant.logo_url = dto.logo_url; + } + + // Merge settings if provided (partial update) + if (dto.settings !== undefined) { + tenant.settings = { + ...tenant.settings, + ...dto.settings, + }; + } + + return this.tenantRepository.save(tenant); + } + + async slugExists(slug: string): Promise { + const tenant = await this.findBySlug(slug); + return !!tenant; + } +} diff --git a/src/modules/users/__tests__/invitation.service.spec.ts b/src/modules/users/__tests__/invitation.service.spec.ts new file mode 100644 index 0000000..cb7d0af --- /dev/null +++ b/src/modules/users/__tests__/invitation.service.spec.ts @@ -0,0 +1,330 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { ConflictException, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InvitationService } from '../services/invitation.service'; +import { Invitation } from '../entities/invitation.entity'; +import { User } from '../../auth/entities/user.entity'; +import { Tenant } from '../../tenants/entities/tenant.entity'; +import { EmailService } from '../../email/services/email.service'; +import { InviteRole } from '../dto/invite-user.dto'; + +describe('InvitationService', () => { + let service: InvitationService; + let invitationRepository: jest.Mocked>; + let userRepository: jest.Mocked>; + let tenantRepository: jest.Mocked>; + let emailService: jest.Mocked; + let configService: jest.Mocked; + + const mockUser: Partial = { + id: 'user-123', + tenant_id: 'tenant-123', + email: 'inviter@example.com', + first_name: 'John', + last_name: 'Doe', + get fullName() { + return 'John Doe'; + }, + }; + + const mockTenant: Partial = { + id: 'tenant-123', + name: 'Test Organization', + slug: 'test-org', + }; + + const mockInvitation: Partial = { + id: 'inv-123', + tenant_id: 'tenant-123', + email: 'invited@example.com', + token: 'secure-token-123', + status: 'pending', + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + created_at: new Date(), + created_by: 'user-123', + metadata: { role: 'member' }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InvitationService, + { + provide: getRepositoryToken(Invitation), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(Tenant), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: EmailService, + useValue: { + sendTemplateEmail: jest.fn().mockResolvedValue({ success: true }), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string, defaultValue?: string) => { + const config: Record = { + APP_URL: 'http://localhost:3000', + APP_NAME: 'Test App', + }; + return config[key] || defaultValue; + }), + }, + }, + ], + }).compile(); + + service = module.get(InvitationService); + invitationRepository = module.get(getRepositoryToken(Invitation)); + userRepository = module.get(getRepositoryToken(User)); + tenantRepository = module.get(getRepositoryToken(Tenant)); + emailService = module.get(EmailService); + configService = module.get(ConfigService); + + // Setup mock query builder for expireOldInvitations + const mockQueryBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + }; + invitationRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('invite', () => { + const inviteDto = { + email: 'newuser@example.com', + role: InviteRole.MEMBER, + }; + + it('should create and send an invitation successfully', async () => { + const createdInvitation = { + ...mockInvitation, + email: inviteDto.email.toLowerCase(), + metadata: { role: inviteDto.role }, + }; + + userRepository.findOne + .mockResolvedValueOnce(null) // No existing user with email + .mockResolvedValueOnce(mockUser as User); // Get inviter + invitationRepository.findOne.mockResolvedValue(null); // No existing invitation + tenantRepository.findOne.mockResolvedValue(mockTenant as Tenant); + invitationRepository.create.mockReturnValue(createdInvitation as Invitation); + invitationRepository.save.mockResolvedValue(createdInvitation as Invitation); + + const result = await service.invite(inviteDto, 'user-123', 'tenant-123'); + + expect(result).toHaveProperty('id'); + expect(result).toHaveProperty('email', inviteDto.email.toLowerCase()); + expect(result).toHaveProperty('role', inviteDto.role); + expect(result).toHaveProperty('status', 'pending'); + expect(result).toHaveProperty('expires_at'); + expect(emailService.sendTemplateEmail).toHaveBeenCalledTimes(1); + }); + + it('should throw ConflictException if email already registered in tenant', async () => { + userRepository.findOne.mockResolvedValue(mockUser as User); + + await expect(service.invite(inviteDto, 'user-123', 'tenant-123')).rejects.toThrow( + ConflictException, + ); + expect(emailService.sendTemplateEmail).not.toHaveBeenCalled(); + }); + + it('should throw ConflictException if pending invitation exists', async () => { + userRepository.findOne.mockResolvedValue(null); + invitationRepository.findOne.mockResolvedValue(mockInvitation as Invitation); + + await expect(service.invite(inviteDto, 'user-123', 'tenant-123')).rejects.toThrow( + ConflictException, + ); + expect(emailService.sendTemplateEmail).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException if inviter not found', async () => { + userRepository.findOne + .mockResolvedValueOnce(null) // No existing user with email + .mockResolvedValueOnce(null); // Inviter not found + invitationRepository.findOne.mockResolvedValue(null); + tenantRepository.findOne.mockResolvedValue(mockTenant as Tenant); + + await expect(service.invite(inviteDto, 'user-123', 'tenant-123')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw NotFoundException if tenant not found', async () => { + userRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockUser as User); + invitationRepository.findOne.mockResolvedValue(null); + tenantRepository.findOne.mockResolvedValue(null); + + await expect(service.invite(inviteDto, 'user-123', 'tenant-123')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should normalize email to lowercase', async () => { + const uppercaseEmailDto = { + email: 'UPPERCASE@EXAMPLE.COM', + role: InviteRole.ADMIN, + }; + + userRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockUser as User); + invitationRepository.findOne.mockResolvedValue(null); + tenantRepository.findOne.mockResolvedValue(mockTenant as Tenant); + invitationRepository.create.mockReturnValue(mockInvitation as Invitation); + invitationRepository.save.mockResolvedValue(mockInvitation as Invitation); + + await service.invite(uppercaseEmailDto, 'user-123', 'tenant-123'); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'uppercase@example.com', tenant_id: 'tenant-123' }, + }); + }); + }); + + describe('findAllByTenant', () => { + it('should return all invitations for a tenant', async () => { + const invitations = [mockInvitation as Invitation]; + invitationRepository.find.mockResolvedValue(invitations); + + const result = await service.findAllByTenant('tenant-123'); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('id', 'inv-123'); + expect(result[0]).toHaveProperty('email', 'invited@example.com'); + }); + + it('should return empty array when no invitations exist', async () => { + invitationRepository.find.mockResolvedValue([]); + + const result = await service.findAllByTenant('tenant-123'); + + expect(result).toEqual([]); + }); + }); + + describe('resend', () => { + it('should resend invitation with new token and expiration', async () => { + const originalToken = mockInvitation.token; + invitationRepository.findOne.mockResolvedValue({ ...mockInvitation } as Invitation); + userRepository.findOne.mockResolvedValue(mockUser as User); + tenantRepository.findOne.mockResolvedValue(mockTenant as Tenant); + invitationRepository.save.mockImplementation(async (inv) => inv as Invitation); + + const result = await service.resend(originalToken!, 'user-123', 'tenant-123'); + + expect(result).toHaveProperty('id'); + expect(invitationRepository.save).toHaveBeenCalled(); + expect(emailService.sendTemplateEmail).toHaveBeenCalledTimes(1); + }); + + it('should throw NotFoundException if invitation not found', async () => { + invitationRepository.findOne.mockResolvedValue(null); + + await expect(service.resend('invalid-token', 'user-123', 'tenant-123')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException if invitation is not pending', async () => { + const acceptedInvitation = { ...mockInvitation, status: 'accepted' as const }; + invitationRepository.findOne.mockResolvedValue(acceptedInvitation as Invitation); + + await expect(service.resend('token', 'user-123', 'tenant-123')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('cancel', () => { + it('should cancel a pending invitation', async () => { + invitationRepository.findOne.mockResolvedValue(mockInvitation as Invitation); + invitationRepository.remove.mockResolvedValue(mockInvitation as Invitation); + + await service.cancel('inv-123', 'tenant-123'); + + expect(invitationRepository.remove).toHaveBeenCalledWith(mockInvitation); + }); + + it('should throw NotFoundException if invitation not found', async () => { + invitationRepository.findOne.mockResolvedValue(null); + + await expect(service.cancel('invalid-id', 'tenant-123')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException if invitation is not pending', async () => { + const acceptedInvitation = { ...mockInvitation, status: 'accepted' as const }; + invitationRepository.findOne.mockResolvedValue(acceptedInvitation as Invitation); + + await expect(service.cancel('inv-123', 'tenant-123')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('findByToken', () => { + it('should return invitation by token', async () => { + invitationRepository.findOne.mockResolvedValue(mockInvitation as Invitation); + + const result = await service.findByToken('secure-token-123'); + + expect(result).toEqual(mockInvitation); + }); + + it('should return null if invitation not found', async () => { + invitationRepository.findOne.mockResolvedValue(null); + + const result = await service.findByToken('invalid-token'); + + expect(result).toBeNull(); + }); + + it('should mark invitation as expired if past expiration date', async () => { + const expiredInvitation = { + ...mockInvitation, + expires_at: new Date(Date.now() - 1000), // Past date + status: 'pending' as const, + }; + invitationRepository.findOne.mockResolvedValue(expiredInvitation as Invitation); + invitationRepository.save.mockResolvedValue({ ...expiredInvitation, status: 'expired' } as Invitation); + + const result = await service.findByToken('token'); + + expect(result?.status).toBe('expired'); + expect(invitationRepository.save).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/users/__tests__/users.controller.spec.ts b/src/modules/users/__tests__/users.controller.spec.ts new file mode 100644 index 0000000..ef11775 --- /dev/null +++ b/src/modules/users/__tests__/users.controller.spec.ts @@ -0,0 +1,126 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { UsersController } from '../users.controller'; +import { UsersService } from '../users.service'; +import { InvitationService } from '../services/invitation.service'; + +describe('UsersController', () => { + let controller: UsersController; + let service: jest.Mocked; + let invitationService: jest.Mocked; + + const mockUser = { + id: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + status: 'active', + }; + + const mockRequestUser = { + id: 'user-123', + sub: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + role: 'admin', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: { + findAllByTenant: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + }, + }, + { + provide: InvitationService, + useValue: { + invite: jest.fn(), + findAllByTenant: jest.fn(), + resend: jest.fn(), + cancel: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(UsersController); + service = module.get(UsersService); + invitationService = module.get(InvitationService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return paginated users', async () => { + const result = { + data: [mockUser], + total: 1, + page: 1, + limit: 10, + }; + service.findAllByTenant.mockResolvedValue(result); + + const response = await controller.findAll(mockRequestUser, 1, 10); + + expect(response).toEqual(result); + expect(service.findAllByTenant).toHaveBeenCalledWith('tenant-123', 1, 10); + }); + + it('should use default pagination values', async () => { + service.findAllByTenant.mockResolvedValue({ data: [], total: 0, page: 1, limit: 10 }); + + await controller.findAll(mockRequestUser); + + expect(service.findAllByTenant).toHaveBeenCalledWith('tenant-123', 1, 10); + }); + }); + + describe('findOne', () => { + it('should return a user by id', async () => { + service.findOne.mockResolvedValue(mockUser); + + const result = await controller.findOne('user-123', mockRequestUser); + + expect(result).toEqual(mockUser); + expect(service.findOne).toHaveBeenCalledWith('user-123', 'tenant-123'); + }); + + it('should propagate NotFoundException', async () => { + service.findOne.mockRejectedValue(new NotFoundException('Usuario no encontrado')); + + await expect(controller.findOne('non-existent', mockRequestUser)).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('update', () => { + it('should update a user', async () => { + const updateDto = { first_name: 'Jane' }; + const updatedUser = { ...mockUser, first_name: 'Jane' }; + service.update.mockResolvedValue(updatedUser); + + const result = await controller.update('user-123', updateDto, mockRequestUser); + + expect(result).toEqual(updatedUser); + expect(service.update).toHaveBeenCalledWith('user-123', updateDto, 'tenant-123'); + }); + + it('should propagate NotFoundException', async () => { + service.update.mockRejectedValue(new NotFoundException('Usuario no encontrado')); + + await expect( + controller.update('non-existent', {}, mockRequestUser), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/modules/users/__tests__/users.service.spec.ts b/src/modules/users/__tests__/users.service.spec.ts new file mode 100644 index 0000000..e894908 --- /dev/null +++ b/src/modules/users/__tests__/users.service.spec.ts @@ -0,0 +1,232 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { UsersService } from '../users.service'; +import { User } from '../../auth/entities/user.entity'; + +describe('UsersService', () => { + let service: UsersService; + let userRepository: jest.Mocked>; + + const mockUser: User = { + id: 'user-123', + tenant_id: 'tenant-123', + email: 'test@example.com', + password_hash: 'hashed_password', + first_name: 'John', + last_name: 'Doe', + avatar_url: 'https://example.com/avatar.png', + phone: '+1234567890', + status: 'active', + email_verified: true, + email_verified_at: new Date('2026-01-01'), + mfa_enabled: false, + mfa_secret: null, + mfa_backup_codes: null, + mfa_enabled_at: null, + last_login_at: new Date('2026-01-07'), + last_login_ip: '192.168.1.1', + metadata: { preferences: { theme: 'dark' } }, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-07'), + get fullName() { + return [this.first_name, this.last_name].filter(Boolean).join(' '); + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + findAndCount: jest.fn(), + save: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(UsersService); + userRepository = module.get(getRepositoryToken(User)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAllByTenant', () => { + it('should return paginated users for a tenant', async () => { + const users = [mockUser]; + userRepository.findAndCount.mockResolvedValue([users, 1]); + + const result = await service.findAllByTenant('tenant-123', 1, 10); + + expect(result).toEqual({ + data: [expect.objectContaining({ id: 'user-123' })], + total: 1, + page: 1, + limit: 10, + }); + expect(userRepository.findAndCount).toHaveBeenCalledWith({ + where: { tenant_id: 'tenant-123' }, + skip: 0, + take: 10, + order: { created_at: 'DESC' }, + }); + }); + + it('should return sanitized users without password_hash', async () => { + userRepository.findAndCount.mockResolvedValue([[mockUser], 1]); + + const result = await service.findAllByTenant('tenant-123', 1, 10); + + expect(result.data[0]).not.toHaveProperty('password_hash'); + expect(result.data[0]).toHaveProperty('email'); + }); + + it('should handle pagination correctly', async () => { + userRepository.findAndCount.mockResolvedValue([[], 0]); + + await service.findAllByTenant('tenant-123', 2, 20); + + expect(userRepository.findAndCount).toHaveBeenCalledWith({ + where: { tenant_id: 'tenant-123' }, + skip: 20, // (2-1) * 20 + take: 20, + order: { created_at: 'DESC' }, + }); + }); + + it('should return empty array when no users found', async () => { + userRepository.findAndCount.mockResolvedValue([[], 0]); + + const result = await service.findAllByTenant('tenant-123', 1, 10); + + expect(result.data).toEqual([]); + expect(result.total).toBe(0); + }); + }); + + describe('findOne', () => { + it('should return a user by id and tenant', async () => { + userRepository.findOne.mockResolvedValue(mockUser); + + const result = await service.findOne('user-123', 'tenant-123'); + + expect(result).not.toHaveProperty('password_hash'); + expect(result).toHaveProperty('email', 'test@example.com'); + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-123', tenant_id: 'tenant-123' }, + }); + }); + + it('should throw NotFoundException if user not found', async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne('non-existent', 'tenant-123')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should not return user from different tenant', async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect( + service.findOne('user-123', 'different-tenant'), + ).rejects.toThrow(NotFoundException); + + expect(userRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'user-123', tenant_id: 'different-tenant' }, + }); + }); + }); + + describe('update', () => { + it('should update allowed user fields', async () => { + const userCopy = { ...mockUser }; + userRepository.findOne.mockResolvedValue(userCopy as User); + userRepository.save.mockResolvedValue(userCopy as User); + + const updateDto = { + first_name: 'Jane', + last_name: 'Smith', + }; + + const result = await service.update('user-123', updateDto, 'tenant-123'); + + expect(userRepository.save).toHaveBeenCalled(); + expect(result).not.toHaveProperty('password_hash'); + }); + + it('should throw NotFoundException if user not found', async () => { + userRepository.findOne.mockResolvedValue(null); + + await expect( + service.update('non-existent', { first_name: 'Test' }, 'tenant-123'), + ).rejects.toThrow(NotFoundException); + }); + + it('should only update allowed fields', async () => { + const userCopy = { ...mockUser }; + userRepository.findOne.mockResolvedValue(userCopy as User); + userRepository.save.mockResolvedValue(userCopy as User); + + const updateDto = { + first_name: 'Jane', + email: 'hacker@example.com', // Not allowed + password_hash: 'new_hash', // Not allowed + }; + + await service.update('user-123', updateDto, 'tenant-123'); + + const savedUser = userRepository.save.mock.calls[0][0]; + expect(savedUser.email).not.toBe('hacker@example.com'); + }); + + it('should update phone number', async () => { + const userCopy = { ...mockUser }; + userRepository.findOne.mockResolvedValue(userCopy as User); + userRepository.save.mockResolvedValue(userCopy as User); + + await service.update('user-123', { phone: '+9876543210' }, 'tenant-123'); + + const savedUser = userRepository.save.mock.calls[0][0]; + expect(savedUser.phone).toBe('+9876543210'); + }); + + it('should update avatar_url', async () => { + const userCopy = { ...mockUser }; + userRepository.findOne.mockResolvedValue(userCopy as User); + userRepository.save.mockResolvedValue(userCopy as User); + + await service.update( + 'user-123', + { avatar_url: 'https://new-avatar.com/img.png' }, + 'tenant-123', + ); + + const savedUser = userRepository.save.mock.calls[0][0]; + expect(savedUser.avatar_url).toBe('https://new-avatar.com/img.png'); + }); + + it('should update metadata', async () => { + const userCopy = { ...mockUser }; + userRepository.findOne.mockResolvedValue(userCopy as User); + userRepository.save.mockResolvedValue(userCopy as User); + + const newMetadata = { preferences: { language: 'es' } }; + await service.update('user-123', { metadata: newMetadata }, 'tenant-123'); + + const savedUser = userRepository.save.mock.calls[0][0]; + expect(savedUser.metadata).toEqual(newMetadata); + }); + }); +}); diff --git a/src/modules/users/dto/index.ts b/src/modules/users/dto/index.ts new file mode 100644 index 0000000..343abb8 --- /dev/null +++ b/src/modules/users/dto/index.ts @@ -0,0 +1 @@ +export * from './invite-user.dto'; diff --git a/src/modules/users/dto/invite-user.dto.ts b/src/modules/users/dto/invite-user.dto.ts new file mode 100644 index 0000000..b97fd8c --- /dev/null +++ b/src/modules/users/dto/invite-user.dto.ts @@ -0,0 +1,49 @@ +import { IsEmail, IsNotEmpty, IsString, IsEnum, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export enum InviteRole { + ADMIN = 'admin', + MEMBER = 'member', + VIEWER = 'viewer', +} + +export class InviteUserDto { + @ApiProperty({ example: 'user@example.com', description: 'Email address to invite' }) + @IsEmail({}, { message: 'Email inválido' }) + @IsNotEmpty({ message: 'Email es requerido' }) + email: string; + + @ApiProperty({ + enum: InviteRole, + example: 'member', + description: 'Role to assign to the invited user', + }) + @IsEnum(InviteRole, { message: 'Rol debe ser admin, member o viewer' }) + @IsNotEmpty({ message: 'Rol es requerido' }) + role: InviteRole; + + @ApiPropertyOptional({ description: 'Custom message to include in the invitation' }) + @IsOptional() + @IsString() + message?: string; +} + +export class InvitationResponseDto { + @ApiProperty({ example: 'uuid-here' }) + id: string; + + @ApiProperty({ example: 'user@example.com' }) + email: string; + + @ApiProperty({ example: 'member' }) + role: string; + + @ApiProperty({ example: 'pending' }) + status: string; + + @ApiProperty() + expires_at: Date; + + @ApiProperty() + created_at: Date; +} diff --git a/src/modules/users/entities/invitation.entity.ts b/src/modules/users/entities/invitation.entity.ts new file mode 100644 index 0000000..4eb57d9 --- /dev/null +++ b/src/modules/users/entities/invitation.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; + +export type InvitationStatus = 'pending' | 'accepted' | 'expired' | 'cancelled'; + +@Entity({ schema: 'users', name: 'invitations' }) +export class Invitation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + tenant_id: string; + + @Column({ type: 'varchar', length: 255 }) + @Index() + email: string; + + @Column({ type: 'varchar', length: 255 }) + @Index({ unique: true }) + token: string; + + @Column({ type: 'uuid', nullable: true }) + role_id: string | null; + + @Column({ + type: 'enum', + enum: ['pending', 'accepted', 'expired', 'cancelled'], + enumName: 'users.invitation_status', + default: 'pending', + }) + status: InvitationStatus; + + @Column({ type: 'timestamp with time zone' }) + expires_at: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + accepted_at: Date | null; + + @Column({ type: 'uuid', nullable: true }) + accepted_by_user_id: string | null; + + @Column({ type: 'text', nullable: true }) + message: string | null; + + @Column({ type: 'jsonb', nullable: true, default: {} }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @Column({ type: 'uuid' }) + created_by: string; + + // Relations + @ManyToOne(() => User) + @JoinColumn({ name: 'created_by' }) + inviter: User; + + @ManyToOne(() => User) + @JoinColumn({ name: 'accepted_by_user_id' }) + acceptedByUser: User | null; +} diff --git a/src/modules/users/index.ts b/src/modules/users/index.ts new file mode 100644 index 0000000..41f2528 --- /dev/null +++ b/src/modules/users/index.ts @@ -0,0 +1,5 @@ +export * from './users.module'; +export * from './users.service'; +export * from './services/invitation.service'; +export * from './entities/invitation.entity'; +export * from './dto/invite-user.dto'; diff --git a/src/modules/users/services/index.ts b/src/modules/users/services/index.ts new file mode 100644 index 0000000..459c19b --- /dev/null +++ b/src/modules/users/services/index.ts @@ -0,0 +1 @@ +export * from './invitation.service'; diff --git a/src/modules/users/services/invitation.service.ts b/src/modules/users/services/invitation.service.ts new file mode 100644 index 0000000..f007ded --- /dev/null +++ b/src/modules/users/services/invitation.service.ts @@ -0,0 +1,289 @@ +import { + Injectable, + Logger, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { Invitation, InvitationStatus } from '../entities/invitation.entity'; +import { User } from '../../auth/entities/user.entity'; +import { Tenant } from '../../tenants/entities/tenant.entity'; +import { EmailService } from '../../email/services/email.service'; +import { InviteUserDto, InvitationResponseDto } from '../dto/invite-user.dto'; + +@Injectable() +export class InvitationService { + private readonly logger = new Logger(InvitationService.name); + private readonly invitationExpirationDays = 7; + + constructor( + @InjectRepository(Invitation) + private readonly invitationRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Tenant) + private readonly tenantRepository: Repository, + private readonly emailService: EmailService, + private readonly configService: ConfigService, + ) {} + + /** + * Send an invitation to join the tenant + */ + async invite( + dto: InviteUserDto, + inviterId: string, + tenantId: string, + ): Promise { + const email = dto.email.toLowerCase().trim(); + + // Check if user already exists in this tenant + const existingUser = await this.userRepository.findOne({ + where: { email, tenant_id: tenantId }, + }); + + if (existingUser) { + throw new ConflictException('Este email ya está registrado en la organización'); + } + + // Check if there's already a pending invitation for this email + const existingInvitation = await this.invitationRepository.findOne({ + where: { + email, + tenant_id: tenantId, + status: 'pending' as InvitationStatus, + }, + }); + + if (existingInvitation) { + throw new ConflictException('Ya existe una invitación pendiente para este email'); + } + + // Get inviter and tenant info for the email + const [inviter, tenant] = await Promise.all([ + this.userRepository.findOne({ where: { id: inviterId } }), + this.tenantRepository.findOne({ where: { id: tenantId } }), + ]); + + if (!inviter) { + throw new NotFoundException('Usuario invitador no encontrado'); + } + + if (!tenant) { + throw new NotFoundException('Organización no encontrada'); + } + + // Generate secure token + const token = this.generateSecureToken(); + + // Calculate expiration date + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + this.invitationExpirationDays); + + // Create invitation record + const invitation = this.invitationRepository.create({ + tenant_id: tenantId, + email, + token, + status: 'pending' as InvitationStatus, + expires_at: expiresAt, + created_by: inviterId, + message: dto.message || null, + metadata: { role: dto.role }, + }); + + await this.invitationRepository.save(invitation); + + // Send invitation email + await this.sendInvitationEmail(invitation, inviter, tenant, dto.role); + + this.logger.log(`Invitation sent to ${email} for tenant ${tenant.name}`); + + return this.toResponseDto(invitation, dto.role); + } + + /** + * List all invitations for a tenant + */ + async findAllByTenant(tenantId: string): Promise { + // First, update any expired invitations + await this.expireOldInvitations(tenantId); + + const invitations = await this.invitationRepository.find({ + where: { tenant_id: tenantId }, + order: { created_at: 'DESC' }, + }); + + return invitations.map((inv) => this.toResponseDto(inv, inv.metadata?.role || 'member')); + } + + /** + * Resend an invitation email + */ + async resend(token: string, inviterId: string, tenantId: string): Promise { + const invitation = await this.invitationRepository.findOne({ + where: { token, tenant_id: tenantId }, + }); + + if (!invitation) { + throw new NotFoundException('Invitación no encontrada'); + } + + if (invitation.status !== 'pending') { + throw new BadRequestException('Solo se pueden reenviar invitaciones pendientes'); + } + + // Get inviter and tenant info + const [inviter, tenant] = await Promise.all([ + this.userRepository.findOne({ where: { id: inviterId } }), + this.tenantRepository.findOne({ where: { id: tenantId } }), + ]); + + if (!inviter || !tenant) { + throw new NotFoundException('Usuario o organización no encontrada'); + } + + // Generate new token + const newToken = this.generateSecureToken(); + + // Reset expiration + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + this.invitationExpirationDays); + + // Update invitation + invitation.token = newToken; + invitation.expires_at = expiresAt; + + await this.invitationRepository.save(invitation); + + // Resend email + const role = invitation.metadata?.role || 'member'; + await this.sendInvitationEmail(invitation, inviter, tenant, role); + + this.logger.log(`Invitation resent to ${invitation.email}`); + + return this.toResponseDto(invitation, role); + } + + /** + * Cancel a pending invitation + */ + async cancel(id: string, tenantId: string): Promise { + const invitation = await this.invitationRepository.findOne({ + where: { id, tenant_id: tenantId }, + }); + + if (!invitation) { + throw new NotFoundException('Invitación no encontrada'); + } + + if (invitation.status !== 'pending') { + throw new BadRequestException('Solo se pueden cancelar invitaciones pendientes'); + } + + // Hard delete the invitation + await this.invitationRepository.remove(invitation); + + this.logger.log(`Invitation ${id} cancelled for ${invitation.email}`); + } + + /** + * Find invitation by token (for accepting invitations) + */ + async findByToken(token: string): Promise { + const invitation = await this.invitationRepository.findOne({ + where: { token }, + }); + + if (!invitation) { + return null; + } + + // Check if expired + if (invitation.expires_at < new Date() && invitation.status === 'pending') { + invitation.status = 'expired'; + await this.invitationRepository.save(invitation); + } + + return invitation; + } + + /** + * Generate a cryptographically secure token + */ + private generateSecureToken(): string { + return crypto.randomBytes(32).toString('hex'); + } + + /** + * Send invitation email + */ + private async sendInvitationEmail( + invitation: Invitation, + inviter: User, + tenant: Tenant, + role: string, + ): Promise { + const appUrl = this.configService.get('APP_URL', 'http://localhost:3000'); + const inviteUrl = `${appUrl}/invite/${invitation.token}`; + + const expiresIn = `${this.invitationExpirationDays} días`; + + await this.emailService.sendTemplateEmail({ + to: { email: invitation.email }, + templateKey: 'invitation', + variables: { + inviterName: inviter.fullName || inviter.email, + tenantName: tenant.name, + role: this.translateRole(role), + inviteLink: inviteUrl, + expiresIn, + appName: this.configService.get('APP_NAME', 'Template SaaS'), + }, + }); + } + + /** + * Update expired invitations status + */ + private async expireOldInvitations(tenantId: string): Promise { + await this.invitationRepository + .createQueryBuilder() + .update(Invitation) + .set({ status: 'expired' as InvitationStatus }) + .where('tenant_id = :tenantId', { tenantId }) + .andWhere('status = :status', { status: 'pending' }) + .andWhere('expires_at < NOW()') + .execute(); + } + + /** + * Translate role for display + */ + private translateRole(role: string): string { + const translations: Record = { + admin: 'Administrador', + member: 'Miembro', + viewer: 'Visor', + }; + return translations[role] || role; + } + + /** + * Convert invitation to response DTO + */ + private toResponseDto(invitation: Invitation, role: string): InvitationResponseDto { + return { + id: invitation.id, + email: invitation.email, + role, + status: invitation.status, + expires_at: invitation.expires_at, + created_at: invitation.created_at, + }; + } +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts new file mode 100644 index 0000000..317b371 --- /dev/null +++ b/src/modules/users/users.controller.ts @@ -0,0 +1,107 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + UseGuards, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; +import { UsersService } from './users.service'; +import { InvitationService } from './services/invitation.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { RequestUser } from '../auth/strategies/jwt.strategy'; +import { InviteUserDto, InvitationResponseDto } from './dto/invite-user.dto'; + +@ApiTags('users') +@Controller('users') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class UsersController { + constructor( + private readonly usersService: UsersService, + private readonly invitationService: InvitationService, + ) {} + + @Get() + @ApiOperation({ summary: 'List users in tenant' }) + async findAll( + @CurrentUser() user: RequestUser, + @Query('page') page = 1, + @Query('limit') limit = 10, + ) { + return this.usersService.findAllByTenant(user.tenant_id, page, limit); + } + + @Get(':id') + @ApiOperation({ summary: 'Get user by ID' }) + async findOne( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ) { + return this.usersService.findOne(id, user.tenant_id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update user' }) + async update( + @Param('id') id: string, + @Body() updateDto: any, + @CurrentUser() user: RequestUser, + ) { + return this.usersService.update(id, updateDto, user.tenant_id); + } + + // ==================== Invitation Endpoints ==================== + + @Post('invite') + @ApiOperation({ summary: 'Send invitation to join tenant' }) + @ApiResponse({ status: 201, description: 'Invitation sent successfully', type: InvitationResponseDto }) + @ApiResponse({ status: 409, description: 'Email already registered or invitation pending' }) + async invite( + @Body() inviteDto: InviteUserDto, + @CurrentUser() user: RequestUser, + ): Promise { + return this.invitationService.invite(inviteDto, user.id, user.tenant_id); + } + + @Get('invitations') + @ApiOperation({ summary: 'List pending invitations for tenant' }) + @ApiResponse({ status: 200, description: 'List of invitations', type: [InvitationResponseDto] }) + async listInvitations( + @CurrentUser() user: RequestUser, + ): Promise { + return this.invitationService.findAllByTenant(user.tenant_id); + } + + @Post('invitations/:token/resend') + @ApiOperation({ summary: 'Resend invitation email' }) + @ApiResponse({ status: 200, description: 'Invitation resent successfully', type: InvitationResponseDto }) + @ApiResponse({ status: 404, description: 'Invitation not found' }) + @ApiResponse({ status: 400, description: 'Only pending invitations can be resent' }) + async resendInvitation( + @Param('token') token: string, + @CurrentUser() user: RequestUser, + ): Promise { + return this.invitationService.resend(token, user.id, user.tenant_id); + } + + @Delete('invitations/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Cancel pending invitation' }) + @ApiResponse({ status: 204, description: 'Invitation cancelled' }) + @ApiResponse({ status: 404, description: 'Invitation not found' }) + @ApiResponse({ status: 400, description: 'Only pending invitations can be cancelled' }) + async cancelInvitation( + @Param('id') id: string, + @CurrentUser() user: RequestUser, + ): Promise { + return this.invitationService.cancel(id, user.tenant_id); + } +} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts new file mode 100644 index 0000000..6f028e8 --- /dev/null +++ b/src/modules/users/users.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { InvitationService } from './services/invitation.service'; +import { User } from '../auth/entities/user.entity'; +import { Invitation } from './entities/invitation.entity'; +import { Tenant } from '../tenants/entities/tenant.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([User, Invitation, Tenant]), + ConfigModule, + ], + controllers: [UsersController], + providers: [UsersService, InvitationService], + exports: [UsersService, InvitationService], +}) +export class UsersModule {} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts new file mode 100644 index 0000000..e151c98 --- /dev/null +++ b/src/modules/users/users.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { User } from '../auth/entities/user.entity'; + +@Injectable() +export class UsersService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + ) {} + + async findAllByTenant( + tenantId: string, + page: number, + limit: number, + ): Promise<{ data: Partial[]; total: number; page: number; limit: number }> { + const [users, total] = await this.userRepository.findAndCount({ + where: { tenant_id: tenantId }, + skip: (page - 1) * limit, + take: limit, + order: { created_at: 'DESC' }, + }); + + return { + data: users.map(this.sanitizeUser), + total, + page, + limit, + }; + } + + async findOne(id: string, tenantId: string): Promise> { + const user = await this.userRepository.findOne({ + where: { id, tenant_id: tenantId }, + }); + + if (!user) { + throw new NotFoundException('Usuario no encontrado'); + } + + return this.sanitizeUser(user); + } + + async update(id: string, updateDto: any, tenantId: string): Promise> { + const user = await this.userRepository.findOne({ + where: { id, tenant_id: tenantId }, + }); + + if (!user) { + throw new NotFoundException('Usuario no encontrado'); + } + + // Update allowed fields + const allowedFields = ['first_name', 'last_name', 'phone', 'avatar_url', 'metadata']; + for (const field of allowedFields) { + if (updateDto[field] !== undefined) { + (user as any)[field] = updateDto[field]; + } + } + + await this.userRepository.save(user); + + return this.sanitizeUser(user); + } + + private sanitizeUser(user: User): Partial { + const { password_hash, ...sanitized } = user; + return sanitized; + } +} diff --git a/src/modules/webhooks/__tests__/webhook.processor.spec.ts b/src/modules/webhooks/__tests__/webhook.processor.spec.ts new file mode 100644 index 0000000..a8f7568 --- /dev/null +++ b/src/modules/webhooks/__tests__/webhook.processor.spec.ts @@ -0,0 +1,304 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Job } from 'bullmq'; +import { WebhookProcessor } from '../processors/webhook.processor'; +import { WebhookDeliveryEntity, DeliveryStatus } from '../entities'; + +// Mock fetch globally +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +describe('WebhookProcessor', () => { + let processor: WebhookProcessor; + let deliveryRepo: jest.Mocked>; + + const mockDelivery: Partial = { + id: 'delivery-001', + webhookId: 'webhook-001', + tenantId: 'tenant-001', + eventType: 'user.created', + payload: { type: 'user.created', data: { id: 'user-001' } }, + status: DeliveryStatus.PENDING, + attempt: 1, + maxAttempts: 5, + createdAt: new Date(), + }; + + const mockJobData = { + deliveryId: 'delivery-001', + webhookId: 'webhook-001', + url: 'https://example.com/webhook', + secret: 'whsec_testsecret123', + headers: { 'X-Custom': 'header' }, + eventType: 'user.created', + payload: { type: 'user.created', data: { id: 'user-001' } }, + }; + + const createMockJob = (data = mockJobData): Job => ({ + id: 'job-001', + data, + attemptsMade: 0, + name: 'webhook-delivery', + queueName: 'webhooks', + }) as unknown as Job; + + beforeEach(async () => { + jest.clearAllMocks(); + + const mockDeliveryRepo = { + findOne: jest.fn(), + save: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookProcessor, + { + provide: getRepositoryToken(WebhookDeliveryEntity), + useValue: mockDeliveryRepo, + }, + ], + }).compile(); + + processor = module.get(WebhookProcessor); + deliveryRepo = module.get(getRepositoryToken(WebhookDeliveryEntity)); + }); + + describe('process', () => { + it('should successfully deliver a webhook', async () => { + const delivery = { ...mockDelivery }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + const mockResponse = { + ok: true, + status: 200, + text: jest.fn().mockResolvedValue('OK'), + headers: new Map([['content-type', 'text/plain']]), + }; + mockResponse.headers.forEach = jest.fn((cb: (value: string, key: string, map: Map) => void) => { + cb('text/plain', 'content-type', mockResponse.headers); + }); + mockFetch.mockResolvedValue(mockResponse); + + await processor.process(createMockJob()); + + expect(deliveryRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'delivery-001' }, + }); + expect(mockFetch).toHaveBeenCalledWith( + 'https://example.com/webhook', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-Webhook-Event': 'user.created', + 'X-Webhook-Delivery': 'delivery-001', + 'X-Custom': 'header', + }), + }), + ); + expect(deliveryRepo.save).toHaveBeenCalled(); + expect(delivery.status).toBe(DeliveryStatus.DELIVERED); + expect(delivery.deliveredAt).toBeDefined(); + expect(delivery.completedAt).toBeDefined(); + }); + + it('should handle delivery not found', async () => { + deliveryRepo.findOne.mockResolvedValue(null); + + await processor.process(createMockJob()); + + expect(deliveryRepo.findOne).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + expect(deliveryRepo.save).not.toHaveBeenCalled(); + }); + + it('should retry on HTTP 500 error', async () => { + const delivery = { ...mockDelivery, attempt: 1, maxAttempts: 5 }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + const mockResponse = { + ok: false, + status: 500, + text: jest.fn().mockResolvedValue('Internal Server Error'), + headers: new Map(), + }; + mockResponse.headers.forEach = jest.fn(); + mockFetch.mockResolvedValue(mockResponse); + + await processor.process(createMockJob()); + + expect(deliveryRepo.save).toHaveBeenCalled(); + expect(delivery.status).toBe(DeliveryStatus.RETRYING); + expect(delivery.attempt).toBe(2); + expect(delivery.nextRetryAt).toBeDefined(); + expect(delivery.lastError).toContain('HTTP 500'); + }); + + it('should fail permanently after max attempts', async () => { + const delivery = { ...mockDelivery, attempt: 5, maxAttempts: 5 }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + const mockResponse = { + ok: false, + status: 503, + text: jest.fn().mockResolvedValue('Service Unavailable'), + headers: new Map(), + }; + mockResponse.headers.forEach = jest.fn(); + mockFetch.mockResolvedValue(mockResponse); + + await processor.process(createMockJob()); + + expect(delivery.status).toBe(DeliveryStatus.FAILED); + expect(delivery.completedAt).toBeDefined(); + }); + + it('should handle network error', async () => { + const delivery = { ...mockDelivery, attempt: 1, maxAttempts: 5 }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + mockFetch.mockRejectedValue(new Error('Network error')); + + await processor.process(createMockJob()); + + expect(deliveryRepo.save).toHaveBeenCalled(); + expect(delivery.status).toBe(DeliveryStatus.RETRYING); + expect(delivery.lastError).toBe('Network error'); + }); + + it('should handle timeout error', async () => { + const delivery = { ...mockDelivery, attempt: 1, maxAttempts: 5 }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + const abortError = new Error('The operation was aborted'); + abortError.name = 'AbortError'; + mockFetch.mockRejectedValue(abortError); + + await processor.process(createMockJob()); + + expect(delivery.status).toBe(DeliveryStatus.RETRYING); + expect(delivery.lastError).toContain('aborted'); + }); + + it('should generate correct HMAC signature', async () => { + const delivery = { ...mockDelivery }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + const mockResponse = { + ok: true, + status: 200, + text: jest.fn().mockResolvedValue('OK'), + headers: new Map(), + }; + mockResponse.headers.forEach = jest.fn(); + mockFetch.mockResolvedValue(mockResponse); + + await processor.process(createMockJob()); + + const fetchCall = mockFetch.mock.calls[0]; + const headers = fetchCall[1].headers; + + expect(headers['X-Webhook-Signature']).toMatch(/^t=\d+,v1=[a-f0-9]+$/); + expect(headers['X-Webhook-Timestamp']).toBeDefined(); + }); + + it('should handle HTTP 4xx client error', async () => { + const delivery = { ...mockDelivery, attempt: 1, maxAttempts: 5 }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + const mockResponse = { + ok: false, + status: 400, + text: jest.fn().mockResolvedValue('Bad Request'), + headers: new Map(), + }; + mockResponse.headers.forEach = jest.fn(); + mockFetch.mockResolvedValue(mockResponse); + + await processor.process(createMockJob()); + + expect(delivery.status).toBe(DeliveryStatus.RETRYING); + expect(delivery.lastError).toContain('HTTP 400'); + }); + + it('should truncate long response body', async () => { + const delivery = { ...mockDelivery }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + const longResponse = 'x'.repeat(20000); + const mockResponse = { + ok: true, + status: 200, + text: jest.fn().mockResolvedValue(longResponse), + headers: new Map(), + }; + mockResponse.headers.forEach = jest.fn(); + mockFetch.mockResolvedValue(mockResponse); + + await processor.process(createMockJob()); + + expect(delivery.responseBody?.length).toBeLessThanOrEqual(10000); + }); + + it('should store response headers', async () => { + const delivery = { ...mockDelivery }; + deliveryRepo.findOne.mockResolvedValue(delivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(delivery as WebhookDeliveryEntity); + + const mockResponse = { + ok: true, + status: 200, + text: jest.fn().mockResolvedValue('OK'), + headers: new Map([ + ['content-type', 'application/json'], + ['x-request-id', 'req-123'], + ]), + }; + mockResponse.headers.forEach = jest.fn((cb: (value: string, key: string, map: Map) => void) => { + cb('application/json', 'content-type', mockResponse.headers); + cb('req-123', 'x-request-id', mockResponse.headers); + }); + mockFetch.mockResolvedValue(mockResponse); + + await processor.process(createMockJob()); + + expect(delivery.responseHeaders).toEqual({ + 'content-type': 'application/json', + 'x-request-id': 'req-123', + }); + }); + }); + + describe('onCompleted', () => { + it('should log job completion', () => { + const logSpy = jest.spyOn(processor['logger'], 'debug').mockImplementation(); + const job = createMockJob(); + + processor.onCompleted(job); + + expect(logSpy).toHaveBeenCalledWith('Job completed: job-001'); + }); + }); + + describe('onFailed', () => { + it('should log job failure', () => { + const logSpy = jest.spyOn(processor['logger'], 'error').mockImplementation(); + const job = createMockJob(); + const error = new Error('Test error'); + + processor.onFailed(job, error); + + expect(logSpy).toHaveBeenCalledWith('Job failed: job-001 - Test error'); + }); + }); +}); diff --git a/src/modules/webhooks/__tests__/webhook.service.spec.ts b/src/modules/webhooks/__tests__/webhook.service.spec.ts new file mode 100644 index 0000000..5739114 --- /dev/null +++ b/src/modules/webhooks/__tests__/webhook.service.spec.ts @@ -0,0 +1,493 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { getQueueToken } from '@nestjs/bullmq'; +import { Repository } from 'typeorm'; +import { Queue } from 'bullmq'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { WebhookService } from '../services/webhook.service'; +import { WebhookEntity, WebhookDeliveryEntity, DeliveryStatus } from '../entities'; + +describe('WebhookService', () => { + let service: WebhookService; + let webhookRepo: jest.Mocked>; + let deliveryRepo: jest.Mocked>; + let webhookQueue: jest.Mocked; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440001'; + const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; + + const mockWebhook: Partial = { + id: 'webhook-001', + tenantId: mockTenantId, + name: 'Test Webhook', + description: 'Test webhook description', + url: 'https://example.com/webhook', + events: ['user.created', 'user.updated'], + headers: { 'X-Custom': 'header' }, + secret: 'whsec_testsecret123', + isActive: true, + createdBy: mockUserId, + createdAt: new Date(), + }; + + const mockDelivery: Partial = { + id: 'delivery-001', + webhookId: 'webhook-001', + tenantId: mockTenantId, + eventType: 'user.created', + payload: { type: 'user.created', data: { id: 'user-001' } }, + status: DeliveryStatus.PENDING, + attempt: 1, + maxAttempts: 3, + createdAt: new Date(), + }; + + beforeEach(async () => { + const mockWebhookRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + remove: jest.fn(), + }; + + const mockDeliveryRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockQueue = { + add: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WebhookService, + { provide: getRepositoryToken(WebhookEntity), useValue: mockWebhookRepo }, + { provide: getRepositoryToken(WebhookDeliveryEntity), useValue: mockDeliveryRepo }, + { provide: getQueueToken('webhooks'), useValue: mockQueue }, + ], + }).compile(); + + service = module.get(WebhookService); + webhookRepo = module.get(getRepositoryToken(WebhookEntity)); + deliveryRepo = module.get(getRepositoryToken(WebhookDeliveryEntity)); + webhookQueue = module.get(getQueueToken('webhooks')); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // Helper to mock stats + const mockStatsQueryBuilder = () => { + const qb = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ + totalDeliveries: 10, + successfulDeliveries: 8, + failedDeliveries: 2, + pendingDeliveries: 0, + lastDeliveryAt: new Date(), + }), + }; + deliveryRepo.createQueryBuilder.mockReturnValue(qb as any); + return qb; + }; + + // ==================== Create Webhook Tests ==================== + + describe('create', () => { + it('should create webhook successfully', async () => { + webhookRepo.create.mockReturnValue(mockWebhook as WebhookEntity); + webhookRepo.save.mockResolvedValue(mockWebhook as WebhookEntity); + mockStatsQueryBuilder(); + + const dto = { + name: 'Test Webhook', + url: 'https://example.com/webhook', + events: ['user.created', 'user.updated'], + }; + + const result = await service.create(mockTenantId, mockUserId, dto); + + expect(result).toHaveProperty('id'); + expect(result).toHaveProperty('secret'); // Secret included on creation + expect(webhookRepo.create).toHaveBeenCalled(); + expect(webhookRepo.save).toHaveBeenCalled(); + }); + + it('should throw for invalid events', async () => { + const dto = { + name: 'Test Webhook', + url: 'https://example.com/webhook', + events: ['invalid.event'], + }; + + await expect(service.create(mockTenantId, mockUserId, dto)).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // ==================== Find All Webhooks Tests ==================== + + describe('findAll', () => { + it('should return all webhooks for tenant', async () => { + webhookRepo.find.mockResolvedValue([mockWebhook as WebhookEntity]); + mockStatsQueryBuilder(); + + const result = await service.findAll(mockTenantId); + + expect(result).toHaveLength(1); + expect(webhookRepo.find).toHaveBeenCalledWith({ + where: { tenantId: mockTenantId }, + order: { createdAt: 'DESC' }, + }); + }); + + it('should return empty array when no webhooks', async () => { + webhookRepo.find.mockResolvedValue([]); + + const result = await service.findAll(mockTenantId); + + expect(result).toHaveLength(0); + }); + }); + + // ==================== Find One Webhook Tests ==================== + + describe('findOne', () => { + it('should return webhook by id', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + mockStatsQueryBuilder(); + + const result = await service.findOne(mockTenantId, 'webhook-001'); + + expect(result).toHaveProperty('id', 'webhook-001'); + expect(result).not.toHaveProperty('secret'); // Secret not included by default + }); + + it('should throw when webhook not found', async () => { + webhookRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne(mockTenantId, 'invalid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ==================== Update Webhook Tests ==================== + + describe('update', () => { + it('should update webhook successfully', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + webhookRepo.save.mockResolvedValue({ + ...mockWebhook, + name: 'Updated Webhook', + } as WebhookEntity); + mockStatsQueryBuilder(); + + const result = await service.update(mockTenantId, 'webhook-001', { + name: 'Updated Webhook', + }); + + expect(result.name).toBe('Updated Webhook'); + }); + + it('should validate events on update', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + + await expect( + service.update(mockTenantId, 'webhook-001', { + events: ['invalid.event'], + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw when webhook not found', async () => { + webhookRepo.findOne.mockResolvedValue(null); + + await expect( + service.update(mockTenantId, 'invalid', { name: 'New' }), + ).rejects.toThrow(NotFoundException); + }); + + it('should update isActive status', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + webhookRepo.save.mockResolvedValue({ + ...mockWebhook, + isActive: false, + } as WebhookEntity); + mockStatsQueryBuilder(); + + const result = await service.update(mockTenantId, 'webhook-001', { + isActive: false, + }); + + expect(result.isActive).toBe(false); + }); + }); + + // ==================== Remove Webhook Tests ==================== + + describe('remove', () => { + it('should remove webhook successfully', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + webhookRepo.remove.mockResolvedValue(mockWebhook as WebhookEntity); + + await service.remove(mockTenantId, 'webhook-001'); + + expect(webhookRepo.remove).toHaveBeenCalledWith(mockWebhook); + }); + + it('should throw when webhook not found', async () => { + webhookRepo.findOne.mockResolvedValue(null); + + await expect(service.remove(mockTenantId, 'invalid')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ==================== Regenerate Secret Tests ==================== + + describe('regenerateSecret', () => { + it('should regenerate secret successfully', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + webhookRepo.save.mockResolvedValue({ + ...mockWebhook, + secret: 'whsec_newsecret123', + } as WebhookEntity); + + const result = await service.regenerateSecret(mockTenantId, 'webhook-001'); + + expect(result).toHaveProperty('secret'); + expect(result.secret).toMatch(/^whsec_/); + }); + + it('should throw when webhook not found', async () => { + webhookRepo.findOne.mockResolvedValue(null); + + await expect( + service.regenerateSecret(mockTenantId, 'invalid'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Test Webhook Tests ==================== + + describe('testWebhook', () => { + it('should queue test delivery', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + deliveryRepo.create.mockReturnValue(mockDelivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(mockDelivery as WebhookDeliveryEntity); + webhookQueue.add.mockResolvedValue({} as any); + + const result = await service.testWebhook(mockTenantId, 'webhook-001', {}); + + expect(result).toHaveProperty('id'); + expect(result.status).toBe(DeliveryStatus.PENDING); + expect(webhookQueue.add).toHaveBeenCalledWith( + 'deliver', + expect.any(Object), + { priority: 1 }, + ); + }); + + it('should use custom payload', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + deliveryRepo.create.mockReturnValue(mockDelivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(mockDelivery as WebhookDeliveryEntity); + webhookQueue.add.mockResolvedValue({} as any); + + await service.testWebhook(mockTenantId, 'webhook-001', { + eventType: 'custom.event', + payload: { custom: 'data' }, + }); + + expect(deliveryRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: 'custom.event', + }), + ); + }); + }); + + // ==================== Get Deliveries Tests ==================== + + describe('getDeliveries', () => { + it('should return paginated deliveries', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest + .fn() + .mockResolvedValue([[mockDelivery as WebhookDeliveryEntity], 1]), + }; + deliveryRepo.createQueryBuilder.mockReturnValue(qb as any); + + const result = await service.getDeliveries(mockTenantId, 'webhook-001', {}); + + expect(result.items).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it('should filter by status', async () => { + webhookRepo.findOne.mockResolvedValue(mockWebhook as WebhookEntity); + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + }; + deliveryRepo.createQueryBuilder.mockReturnValue(qb as any); + + await service.getDeliveries(mockTenantId, 'webhook-001', { + status: DeliveryStatus.FAILED, + }); + + expect(qb.andWhere).toHaveBeenCalledWith('d.status = :status', { + status: DeliveryStatus.FAILED, + }); + }); + + it('should throw when webhook not found', async () => { + webhookRepo.findOne.mockResolvedValue(null); + + await expect( + service.getDeliveries(mockTenantId, 'invalid', {}), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Retry Delivery Tests ==================== + + describe('retryDelivery', () => { + it('should retry failed delivery', async () => { + const failedDelivery = { + ...mockDelivery, + status: DeliveryStatus.FAILED, + webhook: mockWebhook, + }; + deliveryRepo.findOne.mockResolvedValue(failedDelivery as any); + deliveryRepo.save.mockResolvedValue({ + ...failedDelivery, + status: DeliveryStatus.RETRYING, + } as any); + webhookQueue.add.mockResolvedValue({} as any); + + const result = await service.retryDelivery( + mockTenantId, + 'webhook-001', + 'delivery-001', + ); + + expect(result.status).toBe(DeliveryStatus.RETRYING); + expect(webhookQueue.add).toHaveBeenCalled(); + }); + + it('should throw for non-failed delivery', async () => { + deliveryRepo.findOne.mockResolvedValue({ + ...mockDelivery, + status: DeliveryStatus.DELIVERED, + } as any); + + await expect( + service.retryDelivery(mockTenantId, 'webhook-001', 'delivery-001'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw when delivery not found', async () => { + deliveryRepo.findOne.mockResolvedValue(null); + + await expect( + service.retryDelivery(mockTenantId, 'webhook-001', 'invalid'), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ==================== Get Stats Tests ==================== + + describe('getStats', () => { + it('should return webhook statistics', async () => { + mockStatsQueryBuilder(); + + const result = await service.getStats('webhook-001'); + + expect(result).toHaveProperty('totalDeliveries', 10); + expect(result).toHaveProperty('successfulDeliveries', 8); + expect(result).toHaveProperty('failedDeliveries', 2); + expect(result).toHaveProperty('successRate', 80); + }); + }); + + // ==================== Dispatch Tests ==================== + + describe('dispatch', () => { + it('should dispatch event to subscribed webhooks', async () => { + webhookRepo.find.mockResolvedValue([mockWebhook as WebhookEntity]); + deliveryRepo.create.mockReturnValue(mockDelivery as WebhookDeliveryEntity); + deliveryRepo.save.mockResolvedValue(mockDelivery as WebhookDeliveryEntity); + webhookQueue.add.mockResolvedValue({} as any); + + await service.dispatch(mockTenantId, 'user.created', { id: 'user-001' }); + + expect(webhookQueue.add).toHaveBeenCalled(); + expect(deliveryRepo.save).toHaveBeenCalled(); + }); + + it('should not dispatch for unsubscribed events', async () => { + webhookRepo.find.mockResolvedValue([mockWebhook as WebhookEntity]); + + await service.dispatch(mockTenantId, 'invoice.paid', { id: 'inv-001' }); + + expect(webhookQueue.add).not.toHaveBeenCalled(); + }); + + it('should skip inactive webhooks', async () => { + webhookRepo.find.mockResolvedValue([]); + + await service.dispatch(mockTenantId, 'user.created', { id: 'user-001' }); + + expect(webhookQueue.add).not.toHaveBeenCalled(); + }); + }); + + // ==================== Available Events Tests ==================== + + describe('getAvailableEvents', () => { + it('should return list of available events', () => { + const events = service.getAvailableEvents(); + + expect(events).toBeInstanceOf(Array); + expect(events.length).toBeGreaterThan(0); + expect(events[0]).toHaveProperty('name'); + expect(events[0]).toHaveProperty('description'); + }); + }); + + // ==================== Sign Payload Tests ==================== + + describe('signPayload', () => { + it('should sign payload correctly', () => { + const payload = { test: 'data' }; + const secret = 'whsec_testsecret'; + + const signature = service.signPayload(payload, secret); + + expect(signature).toMatch(/^t=\d+,v1=[a-f0-9]+$/); + }); + }); +}); diff --git a/src/modules/webhooks/__tests__/webhooks.controller.spec.ts b/src/modules/webhooks/__tests__/webhooks.controller.spec.ts new file mode 100644 index 0000000..e0067d6 --- /dev/null +++ b/src/modules/webhooks/__tests__/webhooks.controller.spec.ts @@ -0,0 +1,1169 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { WebhooksController } from '../webhooks.controller'; +import { WebhookService } from '../services'; +import { DeliveryStatus } from '../entities'; +import { + CreateWebhookDto, + UpdateWebhookDto, + TestWebhookDto, + ListDeliveriesQueryDto, + WebhookResponseDto, + DeliveryResponseDto, + PaginatedDeliveriesDto, + WebhookStatsDto, +} from '../dto'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; + +describe('WebhooksController', () => { + let controller: WebhooksController; + let service: jest.Mocked; + + // ==================== Mock Data ==================== + + const mockRequestUser = { + id: '550e8400-e29b-41d4-a716-446655440001', + tenant_id: '550e8400-e29b-41d4-a716-446655440002', + email: 'admin@example.com', + role: 'admin', + }; + + const mockStats: WebhookStatsDto = { + totalDeliveries: 100, + successfulDeliveries: 95, + failedDeliveries: 5, + pendingDeliveries: 0, + successRate: 95, + lastDeliveryAt: new Date('2026-01-10T10:00:00Z'), + }; + + const mockWebhookResponse: WebhookResponseDto = { + id: '550e8400-e29b-41d4-a716-446655440003', + name: 'Test Webhook', + description: 'A test webhook for user events', + url: 'https://api.example.com/webhooks/receive', + events: ['user.created', 'user.updated', 'user.deleted'], + headers: { 'X-Custom-Header': 'custom-value' }, + isActive: true, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-05T00:00:00Z'), + stats: mockStats, + }; + + const mockWebhookResponseWithSecret: WebhookResponseDto = { + ...mockWebhookResponse, + secret: 'whsec_abc123def456', + } as any; + + const mockDeliveryResponse: DeliveryResponseDto = { + id: '550e8400-e29b-41d4-a716-446655440004', + webhookId: mockWebhookResponse.id, + eventType: 'user.created', + payload: { + type: 'user.created', + timestamp: '2026-01-10T10:00:00Z', + data: { userId: 'user-123', email: 'newuser@example.com' }, + }, + status: DeliveryStatus.DELIVERED, + responseStatus: 200, + responseBody: '{"status":"ok"}', + attempt: 1, + maxAttempts: 5, + nextRetryAt: null, + lastError: null, + createdAt: new Date('2026-01-10T10:00:00Z'), + deliveredAt: new Date('2026-01-10T10:00:05Z'), + }; + + const mockPendingDelivery: DeliveryResponseDto = { + ...mockDeliveryResponse, + id: '550e8400-e29b-41d4-a716-446655440005', + status: DeliveryStatus.PENDING, + responseStatus: null, + responseBody: null, + deliveredAt: null, + }; + + const mockFailedDelivery: DeliveryResponseDto = { + ...mockDeliveryResponse, + id: '550e8400-e29b-41d4-a716-446655440006', + status: DeliveryStatus.FAILED, + responseStatus: 500, + responseBody: 'Internal Server Error', + attempt: 5, + lastError: 'Connection timeout after 30s', + deliveredAt: null, + nextRetryAt: null, + }; + + const mockRetryingDelivery: DeliveryResponseDto = { + ...mockFailedDelivery, + status: DeliveryStatus.RETRYING, + attempt: 1, + nextRetryAt: new Date('2026-01-10T10:05:00Z'), + }; + + const mockPaginatedDeliveries: PaginatedDeliveriesDto = { + items: [mockDeliveryResponse, mockPendingDelivery, mockFailedDelivery], + total: 50, + page: 1, + limit: 20, + totalPages: 3, + }; + + const availableEvents = [ + { name: 'user.created', description: 'A new user was created' }, + { name: 'user.updated', description: 'A user was updated' }, + { name: 'user.deleted', description: 'A user was deleted' }, + { name: 'subscription.created', description: 'A new subscription was created' }, + { name: 'subscription.updated', description: 'A subscription was updated' }, + { name: 'subscription.cancelled', description: 'A subscription was cancelled' }, + { name: 'invoice.paid', description: 'An invoice was paid' }, + { name: 'invoice.failed', description: 'An invoice payment failed' }, + { name: 'file.uploaded', description: 'A file was uploaded' }, + { name: 'file.deleted', description: 'A file was deleted' }, + { name: 'tenant.updated', description: 'Tenant settings were updated' }, + ]; + + // ==================== Test Setup ==================== + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WebhooksController], + providers: [ + { + provide: WebhookService, + useValue: { + getAvailableEvents: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + regenerateSecret: jest.fn(), + testWebhook: jest.fn(), + getDeliveries: jest.fn(), + retryDelivery: jest.fn(), + }, + }, + Reflector, + ], + }).compile(); + + controller = module.get(WebhooksController); + service = module.get(WebhookService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ==================== Controller Instance Tests ==================== + + describe('Controller Initialization', () => { + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should have WebhookService injected', () => { + expect(service).toBeDefined(); + }); + }); + + // ==================== Guards Tests ==================== + + describe('Guards', () => { + it('should have JwtAuthGuard applied to controller', () => { + const guards = Reflect.getMetadata('__guards__', WebhooksController); + expect(guards).toBeDefined(); + expect(guards).toContain(JwtAuthGuard); + }); + }); + + // ==================== Get Available Events Tests ==================== + + describe('getAvailableEvents', () => { + it('should return available webhook events', () => { + service.getAvailableEvents.mockReturnValue(availableEvents); + + const result = controller.getAvailableEvents(); + + expect(result).toEqual({ events: availableEvents }); + expect(service.getAvailableEvents).toHaveBeenCalledTimes(1); + }); + + it('should return all 11 available events', () => { + service.getAvailableEvents.mockReturnValue(availableEvents); + + const result = controller.getAvailableEvents(); + + expect(result.events).toHaveLength(11); + }); + + it('should return events with correct structure', () => { + service.getAvailableEvents.mockReturnValue(availableEvents); + + const result = controller.getAvailableEvents(); + + result.events.forEach((event) => { + expect(event).toHaveProperty('name'); + expect(event).toHaveProperty('description'); + expect(typeof event.name).toBe('string'); + expect(typeof event.description).toBe('string'); + }); + }); + + it('should include user events', () => { + service.getAvailableEvents.mockReturnValue(availableEvents); + + const result = controller.getAvailableEvents(); + + const userEvents = result.events.filter((e) => e.name.startsWith('user.')); + expect(userEvents).toHaveLength(3); + }); + + it('should include subscription events', () => { + service.getAvailableEvents.mockReturnValue(availableEvents); + + const result = controller.getAvailableEvents(); + + const subscriptionEvents = result.events.filter((e) => e.name.startsWith('subscription.')); + expect(subscriptionEvents).toHaveLength(3); + }); + + it('should include invoice events', () => { + service.getAvailableEvents.mockReturnValue(availableEvents); + + const result = controller.getAvailableEvents(); + + const invoiceEvents = result.events.filter((e) => e.name.startsWith('invoice.')); + expect(invoiceEvents).toHaveLength(2); + }); + }); + + // ==================== Find All Webhooks Tests ==================== + + describe('findAll', () => { + it('should return all webhooks for tenant', async () => { + service.findAll.mockResolvedValue([mockWebhookResponse]); + + const result = await controller.findAll(mockRequestUser); + + expect(result).toEqual([mockWebhookResponse]); + expect(service.findAll).toHaveBeenCalledWith(mockRequestUser.tenant_id); + }); + + it('should return empty array when no webhooks exist', async () => { + service.findAll.mockResolvedValue([]); + + const result = await controller.findAll(mockRequestUser); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it('should return multiple webhooks', async () => { + const multipleWebhooks = [ + mockWebhookResponse, + { ...mockWebhookResponse, id: 'webhook-2', name: 'Another Webhook' }, + { ...mockWebhookResponse, id: 'webhook-3', name: 'Third Webhook' }, + ]; + service.findAll.mockResolvedValue(multipleWebhooks); + + const result = await controller.findAll(mockRequestUser); + + expect(result).toHaveLength(3); + }); + + it('should only return webhooks for the specified tenant', async () => { + service.findAll.mockResolvedValue([mockWebhookResponse]); + + await controller.findAll(mockRequestUser); + + expect(service.findAll).toHaveBeenCalledWith(mockRequestUser.tenant_id); + expect(service.findAll).not.toHaveBeenCalledWith('other-tenant-id'); + }); + + it('should include stats in webhook response', async () => { + service.findAll.mockResolvedValue([mockWebhookResponse]); + + const result = await controller.findAll(mockRequestUser); + + expect(result[0].stats).toBeDefined(); + expect(result[0].stats?.successRate).toBe(95); + }); + }); + + // ==================== Find One Webhook Tests ==================== + + describe('findOne', () => { + it('should return a webhook by id', async () => { + service.findOne.mockResolvedValue(mockWebhookResponse); + + const result = await controller.findOne(mockRequestUser, mockWebhookResponse.id); + + expect(result).toEqual(mockWebhookResponse); + expect(service.findOne).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockWebhookResponse.id, + ); + }); + + it('should throw NotFoundException when webhook does not exist', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440099'; + service.findOne.mockRejectedValue(new NotFoundException('Webhook not found')); + + await expect(controller.findOne(mockRequestUser, nonExistentId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should not return secret in findOne response', async () => { + service.findOne.mockResolvedValue(mockWebhookResponse); + + const result = await controller.findOne(mockRequestUser, mockWebhookResponse.id); + + expect((result as any).secret).toBeUndefined(); + }); + + it('should return webhook with correct structure', async () => { + service.findOne.mockResolvedValue(mockWebhookResponse); + + const result = await controller.findOne(mockRequestUser, mockWebhookResponse.id); + + expect(result).toHaveProperty('id'); + expect(result).toHaveProperty('name'); + expect(result).toHaveProperty('url'); + expect(result).toHaveProperty('events'); + expect(result).toHaveProperty('isActive'); + expect(result).toHaveProperty('createdAt'); + expect(result).toHaveProperty('updatedAt'); + }); + + it('should verify tenant ownership', async () => { + service.findOne.mockResolvedValue(mockWebhookResponse); + + await controller.findOne(mockRequestUser, mockWebhookResponse.id); + + expect(service.findOne).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + expect.any(String), + ); + }); + }); + + // ==================== Create Webhook Tests ==================== + + describe('create', () => { + const createDto: CreateWebhookDto = { + name: 'New Production Webhook', + description: 'Webhook for production events', + url: 'https://api.production.example.com/webhooks', + events: ['user.created', 'subscription.created'], + headers: { 'Authorization': 'Bearer token123' }, + }; + + it('should create a webhook successfully', async () => { + service.create.mockResolvedValue(mockWebhookResponseWithSecret); + + const result = await controller.create(mockRequestUser, createDto); + + expect(result).toEqual(mockWebhookResponseWithSecret); + expect(service.create).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockRequestUser.id, + createDto, + ); + }); + + it('should include secret in create response', async () => { + service.create.mockResolvedValue(mockWebhookResponseWithSecret); + + const result = await controller.create(mockRequestUser, createDto); + + expect((result as any).secret).toBeDefined(); + expect((result as any).secret).toMatch(/^whsec_/); + }); + + it('should pass user id for tracking creator', async () => { + service.create.mockResolvedValue(mockWebhookResponseWithSecret); + + await controller.create(mockRequestUser, createDto); + + expect(service.create).toHaveBeenCalledWith( + expect.any(String), + mockRequestUser.id, + expect.any(Object), + ); + }); + + it('should throw BadRequestException for invalid events', async () => { + const invalidDto = { + ...createDto, + events: ['invalid.event', 'another.invalid'], + }; + service.create.mockRejectedValue( + new BadRequestException('Invalid events: invalid.event, another.invalid'), + ); + + await expect(controller.create(mockRequestUser, invalidDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should create webhook without optional description', async () => { + const dtoWithoutDescription: CreateWebhookDto = { + name: 'Minimal Webhook', + url: 'https://api.example.com/hook', + events: ['user.created'], + }; + service.create.mockResolvedValue({ + ...mockWebhookResponseWithSecret, + description: null, + }); + + const result = await controller.create(mockRequestUser, dtoWithoutDescription); + + expect(result.description).toBeNull(); + }); + + it('should create webhook without optional headers', async () => { + const dtoWithoutHeaders: CreateWebhookDto = { + name: 'Simple Webhook', + url: 'https://api.example.com/hook', + events: ['user.created'], + }; + service.create.mockResolvedValue({ + ...mockWebhookResponseWithSecret, + headers: {}, + }); + + const result = await controller.create(mockRequestUser, dtoWithoutHeaders); + + expect(result.headers).toEqual({}); + }); + + it('should create webhook with multiple events', async () => { + const multiEventDto: CreateWebhookDto = { + name: 'Multi-event Webhook', + url: 'https://api.example.com/hook', + events: [ + 'user.created', + 'user.updated', + 'user.deleted', + 'subscription.created', + 'invoice.paid', + ], + }; + service.create.mockResolvedValue({ + ...mockWebhookResponseWithSecret, + events: multiEventDto.events, + }); + + const result = await controller.create(mockRequestUser, multiEventDto); + + expect(result.events).toHaveLength(5); + }); + }); + + // ==================== Update Webhook Tests ==================== + + describe('update', () => { + it('should update webhook name', async () => { + const updateDto: UpdateWebhookDto = { name: 'Updated Webhook Name' }; + const updatedWebhook = { ...mockWebhookResponse, name: 'Updated Webhook Name' }; + service.update.mockResolvedValue(updatedWebhook); + + const result = await controller.update(mockRequestUser, mockWebhookResponse.id, updateDto); + + expect(result.name).toBe('Updated Webhook Name'); + expect(service.update).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockWebhookResponse.id, + updateDto, + ); + }); + + it('should update webhook url', async () => { + const updateDto: UpdateWebhookDto = { url: 'https://new.api.example.com/webhook' }; + const updatedWebhook: WebhookResponseDto = { ...mockWebhookResponse, url: updateDto.url! }; + service.update.mockResolvedValue(updatedWebhook); + + const result = await controller.update(mockRequestUser, mockWebhookResponse.id, updateDto); + + expect(result.url).toBe('https://new.api.example.com/webhook'); + }); + + it('should update webhook events', async () => { + const updateDto: UpdateWebhookDto = { events: ['invoice.paid', 'invoice.failed'] }; + const updatedWebhook: WebhookResponseDto = { ...mockWebhookResponse, events: updateDto.events! }; + service.update.mockResolvedValue(updatedWebhook); + + const result = await controller.update(mockRequestUser, mockWebhookResponse.id, updateDto); + + expect(result.events).toEqual(['invoice.paid', 'invoice.failed']); + }); + + it('should deactivate webhook', async () => { + const updateDto: UpdateWebhookDto = { isActive: false }; + const updatedWebhook = { ...mockWebhookResponse, isActive: false }; + service.update.mockResolvedValue(updatedWebhook); + + const result = await controller.update(mockRequestUser, mockWebhookResponse.id, updateDto); + + expect(result.isActive).toBe(false); + }); + + it('should reactivate webhook', async () => { + const updateDto: UpdateWebhookDto = { isActive: true }; + const updatedWebhook = { ...mockWebhookResponse, isActive: true }; + service.update.mockResolvedValue(updatedWebhook); + + const result = await controller.update(mockRequestUser, mockWebhookResponse.id, updateDto); + + expect(result.isActive).toBe(true); + }); + + it('should update webhook headers', async () => { + const updateDto: UpdateWebhookDto = { + headers: { 'X-New-Header': 'new-value', 'Authorization': 'Bearer newtoken' }, + }; + const updatedWebhook: WebhookResponseDto = { ...mockWebhookResponse, headers: updateDto.headers! }; + service.update.mockResolvedValue(updatedWebhook); + + const result = await controller.update(mockRequestUser, mockWebhookResponse.id, updateDto); + + expect(result.headers).toEqual(updateDto.headers); + }); + + it('should update multiple fields at once', async () => { + const updateDto: UpdateWebhookDto = { + name: 'New Name', + description: 'New description', + url: 'https://updated.api.com/hook', + events: ['file.uploaded'], + isActive: false, + }; + const updatedWebhook = { ...mockWebhookResponse, ...updateDto }; + service.update.mockResolvedValue(updatedWebhook); + + const result = await controller.update(mockRequestUser, mockWebhookResponse.id, updateDto); + + expect(result.name).toBe('New Name'); + expect(result.description).toBe('New description'); + expect(result.isActive).toBe(false); + }); + + it('should throw NotFoundException when webhook does not exist', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440099'; + service.update.mockRejectedValue(new NotFoundException('Webhook not found')); + + await expect( + controller.update(mockRequestUser, nonExistentId, { name: 'New Name' }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for invalid events in update', async () => { + const updateDto: UpdateWebhookDto = { events: ['invalid.event'] }; + service.update.mockRejectedValue( + new BadRequestException('Invalid events: invalid.event'), + ); + + await expect( + controller.update(mockRequestUser, mockWebhookResponse.id, updateDto), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ==================== Delete Webhook Tests ==================== + + describe('remove', () => { + it('should delete webhook successfully', async () => { + service.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockWebhookResponse.id); + + expect(result).toEqual({ message: 'Webhook deleted successfully' }); + expect(service.remove).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockWebhookResponse.id, + ); + }); + + it('should throw NotFoundException when webhook does not exist', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440099'; + service.remove.mockRejectedValue(new NotFoundException('Webhook not found')); + + await expect(controller.remove(mockRequestUser, nonExistentId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should return success message on deletion', async () => { + service.remove.mockResolvedValue(undefined); + + const result = await controller.remove(mockRequestUser, mockWebhookResponse.id); + + expect(result.message).toBe('Webhook deleted successfully'); + }); + + it('should verify tenant ownership before deletion', async () => { + service.remove.mockResolvedValue(undefined); + + await controller.remove(mockRequestUser, mockWebhookResponse.id); + + expect(service.remove).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + expect.any(String), + ); + }); + }); + + // ==================== Regenerate Secret Tests ==================== + + describe('regenerateSecret', () => { + it('should regenerate webhook secret', async () => { + const newSecret = { secret: 'whsec_newgeneratedsecret123456789' }; + service.regenerateSecret.mockResolvedValue(newSecret); + + const result = await controller.regenerateSecret(mockRequestUser, mockWebhookResponse.id); + + expect(result).toEqual(newSecret); + expect(service.regenerateSecret).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockWebhookResponse.id, + ); + }); + + it('should return new secret with correct format', async () => { + const newSecret = { secret: 'whsec_abc123def456ghi789' }; + service.regenerateSecret.mockResolvedValue(newSecret); + + const result = await controller.regenerateSecret(mockRequestUser, mockWebhookResponse.id); + + expect(result.secret).toMatch(/^whsec_/); + }); + + it('should throw NotFoundException when webhook does not exist', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440099'; + service.regenerateSecret.mockRejectedValue(new NotFoundException('Webhook not found')); + + await expect( + controller.regenerateSecret(mockRequestUser, nonExistentId), + ).rejects.toThrow(NotFoundException); + }); + + it('should invalidate old secret after regeneration', async () => { + const newSecret = { secret: 'whsec_completelynewsecret' }; + service.regenerateSecret.mockResolvedValue(newSecret); + + const result = await controller.regenerateSecret(mockRequestUser, mockWebhookResponse.id); + + expect(result.secret).not.toBe('whsec_abc123def456'); + }); + }); + + // ==================== Test Webhook Tests ==================== + + describe('test', () => { + it('should test webhook with default payload', async () => { + const testDto: TestWebhookDto = {}; + service.testWebhook.mockResolvedValue(mockPendingDelivery); + + const result = await controller.test(mockRequestUser, mockWebhookResponse.id, testDto); + + expect(result).toEqual(mockPendingDelivery); + expect(service.testWebhook).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockWebhookResponse.id, + testDto, + ); + }); + + it('should test webhook with custom event type', async () => { + const testDto: TestWebhookDto = { eventType: 'user.created' }; + const customDelivery = { ...mockPendingDelivery, eventType: 'user.created' }; + service.testWebhook.mockResolvedValue(customDelivery); + + const result = await controller.test(mockRequestUser, mockWebhookResponse.id, testDto); + + expect(result.eventType).toBe('user.created'); + }); + + it('should test webhook with custom payload', async () => { + const customPayload = { + type: 'test.custom', + data: { customField: 'customValue', nested: { key: 'value' } }, + }; + const testDto: TestWebhookDto = { payload: customPayload }; + const customDelivery = { ...mockPendingDelivery, payload: customPayload }; + service.testWebhook.mockResolvedValue(customDelivery); + + const result = await controller.test(mockRequestUser, mockWebhookResponse.id, testDto); + + expect(result.payload).toEqual(customPayload); + }); + + it('should return pending delivery status for test', async () => { + service.testWebhook.mockResolvedValue(mockPendingDelivery); + + const result = await controller.test(mockRequestUser, mockWebhookResponse.id, {}); + + expect(result.status).toBe(DeliveryStatus.PENDING); + }); + + it('should throw NotFoundException when webhook does not exist', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440099'; + service.testWebhook.mockRejectedValue(new NotFoundException('Webhook not found')); + + await expect( + controller.test(mockRequestUser, nonExistentId, {}), + ).rejects.toThrow(NotFoundException); + }); + + it('should test webhook with both custom event type and payload', async () => { + const testDto: TestWebhookDto = { + eventType: 'subscription.created', + payload: { subscriptionId: 'sub-123', planId: 'plan-pro' }, + }; + service.testWebhook.mockResolvedValue({ + ...mockPendingDelivery, + eventType: testDto.eventType!, + payload: testDto.payload!, + }); + + const result = await controller.test(mockRequestUser, mockWebhookResponse.id, testDto); + + expect(result.eventType).toBe('subscription.created'); + expect(result.payload).toEqual(testDto.payload); + }); + }); + + // ==================== Get Deliveries Tests ==================== + + describe('getDeliveries', () => { + it('should return paginated deliveries', async () => { + const query: ListDeliveriesQueryDto = { page: 1, limit: 20 }; + service.getDeliveries.mockResolvedValue(mockPaginatedDeliveries); + + const result = await controller.getDeliveries( + mockRequestUser, + mockWebhookResponse.id, + query, + ); + + expect(result).toEqual(mockPaginatedDeliveries); + expect(service.getDeliveries).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockWebhookResponse.id, + query, + ); + }); + + it('should filter deliveries by status', async () => { + const query: ListDeliveriesQueryDto = { status: DeliveryStatus.FAILED }; + const filteredResult: PaginatedDeliveriesDto = { + items: [mockFailedDelivery], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + service.getDeliveries.mockResolvedValue(filteredResult); + + const result = await controller.getDeliveries( + mockRequestUser, + mockWebhookResponse.id, + query, + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0].status).toBe(DeliveryStatus.FAILED); + }); + + it('should filter deliveries by event type', async () => { + const query: ListDeliveriesQueryDto = { eventType: 'user.created' }; + const filteredResult: PaginatedDeliveriesDto = { + items: [mockDeliveryResponse], + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }; + service.getDeliveries.mockResolvedValue(filteredResult); + + const result = await controller.getDeliveries( + mockRequestUser, + mockWebhookResponse.id, + query, + ); + + expect(result.items.every((d) => d.eventType === 'user.created')).toBe(true); + }); + + it('should paginate deliveries correctly', async () => { + const query: ListDeliveriesQueryDto = { page: 2, limit: 10 }; + const paginatedResult: PaginatedDeliveriesDto = { + items: [mockDeliveryResponse], + total: 25, + page: 2, + limit: 10, + totalPages: 3, + }; + service.getDeliveries.mockResolvedValue(paginatedResult); + + const result = await controller.getDeliveries( + mockRequestUser, + mockWebhookResponse.id, + query, + ); + + expect(result.page).toBe(2); + expect(result.limit).toBe(10); + expect(result.totalPages).toBe(3); + }); + + it('should return empty list when no deliveries exist', async () => { + const query: ListDeliveriesQueryDto = {}; + const emptyResult: PaginatedDeliveriesDto = { + items: [], + total: 0, + page: 1, + limit: 20, + totalPages: 0, + }; + service.getDeliveries.mockResolvedValue(emptyResult); + + const result = await controller.getDeliveries( + mockRequestUser, + mockWebhookResponse.id, + query, + ); + + expect(result.items).toHaveLength(0); + expect(result.total).toBe(0); + }); + + it('should throw NotFoundException when webhook does not exist', async () => { + const nonExistentId = '550e8400-e29b-41d4-a716-446655440099'; + service.getDeliveries.mockRejectedValue(new NotFoundException('Webhook not found')); + + await expect( + controller.getDeliveries(mockRequestUser, nonExistentId, {}), + ).rejects.toThrow(NotFoundException); + }); + + it('should include delivery details in response', async () => { + service.getDeliveries.mockResolvedValue(mockPaginatedDeliveries); + + const result = await controller.getDeliveries(mockRequestUser, mockWebhookResponse.id, {}); + + const delivery = result.items[0]; + expect(delivery).toHaveProperty('id'); + expect(delivery).toHaveProperty('webhookId'); + expect(delivery).toHaveProperty('eventType'); + expect(delivery).toHaveProperty('payload'); + expect(delivery).toHaveProperty('status'); + expect(delivery).toHaveProperty('responseStatus'); + expect(delivery).toHaveProperty('attempt'); + }); + }); + + // ==================== Retry Delivery Tests ==================== + + describe('retryDelivery', () => { + it('should retry a failed delivery', async () => { + service.retryDelivery.mockResolvedValue(mockRetryingDelivery); + + const result = await controller.retryDelivery( + mockRequestUser, + mockWebhookResponse.id, + mockFailedDelivery.id, + ); + + expect(result).toEqual(mockRetryingDelivery); + expect(service.retryDelivery).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + mockWebhookResponse.id, + mockFailedDelivery.id, + ); + }); + + it('should set status to RETRYING after retry', async () => { + service.retryDelivery.mockResolvedValue(mockRetryingDelivery); + + const result = await controller.retryDelivery( + mockRequestUser, + mockWebhookResponse.id, + mockFailedDelivery.id, + ); + + expect(result.status).toBe(DeliveryStatus.RETRYING); + }); + + it('should reset attempt counter on retry', async () => { + service.retryDelivery.mockResolvedValue(mockRetryingDelivery); + + const result = await controller.retryDelivery( + mockRequestUser, + mockWebhookResponse.id, + mockFailedDelivery.id, + ); + + expect(result.attempt).toBe(1); + }); + + it('should throw NotFoundException when delivery does not exist', async () => { + const nonExistentDeliveryId = '550e8400-e29b-41d4-a716-446655440099'; + service.retryDelivery.mockRejectedValue(new NotFoundException('Delivery not found')); + + await expect( + controller.retryDelivery(mockRequestUser, mockWebhookResponse.id, nonExistentDeliveryId), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when trying to retry non-failed delivery', async () => { + service.retryDelivery.mockRejectedValue( + new BadRequestException('Only failed deliveries can be retried'), + ); + + await expect( + controller.retryDelivery(mockRequestUser, mockWebhookResponse.id, mockDeliveryResponse.id), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw NotFoundException when webhook does not exist', async () => { + const nonExistentWebhookId = '550e8400-e29b-41d4-a716-446655440099'; + service.retryDelivery.mockRejectedValue(new NotFoundException('Webhook not found')); + + await expect( + controller.retryDelivery(mockRequestUser, nonExistentWebhookId, mockFailedDelivery.id), + ).rejects.toThrow(NotFoundException); + }); + + it('should verify tenant ownership for retry operation', async () => { + service.retryDelivery.mockResolvedValue(mockRetryingDelivery); + + await controller.retryDelivery( + mockRequestUser, + mockWebhookResponse.id, + mockFailedDelivery.id, + ); + + expect(service.retryDelivery).toHaveBeenCalledWith( + mockRequestUser.tenant_id, + expect.any(String), + expect.any(String), + ); + }); + }); + + // ==================== Error Handling Tests ==================== + + describe('Error Handling', () => { + it('should propagate NotFoundException from service', async () => { + service.findOne.mockRejectedValue(new NotFoundException('Webhook not found')); + + await expect( + controller.findOne(mockRequestUser, 'non-existent-id'), + ).rejects.toThrow(NotFoundException); + }); + + it('should propagate BadRequestException from service', async () => { + const invalidDto: CreateWebhookDto = { + name: 'Test', + url: 'https://example.com', + events: ['invalid.event'], + }; + service.create.mockRejectedValue( + new BadRequestException('Invalid events: invalid.event'), + ); + + await expect(controller.create(mockRequestUser, invalidDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should handle service errors gracefully', async () => { + service.findAll.mockRejectedValue(new Error('Database connection failed')); + + await expect(controller.findAll(mockRequestUser)).rejects.toThrow( + 'Database connection failed', + ); + }); + + it('should handle timeout errors', async () => { + service.testWebhook.mockRejectedValue(new Error('Request timeout')); + + await expect( + controller.test(mockRequestUser, mockWebhookResponse.id, {}), + ).rejects.toThrow('Request timeout'); + }); + }); + + // ==================== Edge Cases Tests ==================== + + describe('Edge Cases', () => { + it('should handle webhook with empty events array in update', async () => { + const updateDto: UpdateWebhookDto = { events: [] }; + service.update.mockRejectedValue(new BadRequestException('Events array cannot be empty')); + + await expect( + controller.update(mockRequestUser, mockWebhookResponse.id, updateDto), + ).rejects.toThrow(BadRequestException); + }); + + it('should handle webhook with very long description', async () => { + const longDescription = 'A'.repeat(5000); + const createDto: CreateWebhookDto = { + name: 'Test', + description: longDescription, + url: 'https://example.com/hook', + events: ['user.created'], + }; + service.create.mockResolvedValue({ + ...mockWebhookResponseWithSecret, + description: longDescription, + }); + + const result = await controller.create(mockRequestUser, createDto); + + expect(result.description?.length).toBe(5000); + }); + + it('should handle webhook with special characters in name', async () => { + const createDto: CreateWebhookDto = { + name: 'Test & Webhook ', + url: 'https://example.com/hook', + events: ['user.created'], + }; + service.create.mockResolvedValue({ + ...mockWebhookResponseWithSecret, + name: createDto.name, + }); + + const result = await controller.create(mockRequestUser, createDto); + + expect(result.name).toBe('Test & Webhook '); + }); + + it('should handle concurrent delivery requests', async () => { + service.getDeliveries.mockResolvedValue(mockPaginatedDeliveries); + + const promises = [ + controller.getDeliveries(mockRequestUser, mockWebhookResponse.id, { page: 1 }), + controller.getDeliveries(mockRequestUser, mockWebhookResponse.id, { page: 2 }), + controller.getDeliveries(mockRequestUser, mockWebhookResponse.id, { page: 3 }), + ]; + + const results = await Promise.all(promises); + + expect(results).toHaveLength(3); + expect(service.getDeliveries).toHaveBeenCalledTimes(3); + }); + + it('should handle maximum pagination limit', async () => { + const query: ListDeliveriesQueryDto = { limit: 1000 }; + const maxLimitResult: PaginatedDeliveriesDto = { + items: Array(100).fill(mockDeliveryResponse), + total: 500, + page: 1, + limit: 100, // Service should cap at 100 + totalPages: 5, + }; + service.getDeliveries.mockResolvedValue(maxLimitResult); + + const result = await controller.getDeliveries( + mockRequestUser, + mockWebhookResponse.id, + query, + ); + + expect(result.limit).toBe(100); + }); + }); + + // ==================== Security Tests ==================== + + describe('Security', () => { + it('should require tenant_id from request user', async () => { + service.findAll.mockResolvedValue([mockWebhookResponse]); + + await controller.findAll(mockRequestUser); + + expect(service.findAll).toHaveBeenCalledWith(mockRequestUser.tenant_id); + }); + + it('should isolate data by tenant', async () => { + const otherTenantUser = { + ...mockRequestUser, + tenant_id: 'other-tenant-id', + }; + service.findAll.mockResolvedValue([]); + + await controller.findAll(otherTenantUser); + + expect(service.findAll).toHaveBeenCalledWith('other-tenant-id'); + }); + + it('should not expose secrets in findAll response', async () => { + service.findAll.mockResolvedValue([mockWebhookResponse]); + + const result = await controller.findAll(mockRequestUser); + + result.forEach((webhook) => { + expect((webhook as any).secret).toBeUndefined(); + }); + }); + + it('should track user id when creating webhook', async () => { + const createDto: CreateWebhookDto = { + name: 'Test', + url: 'https://example.com/hook', + events: ['user.created'], + }; + service.create.mockResolvedValue(mockWebhookResponseWithSecret); + + await controller.create(mockRequestUser, createDto); + + expect(service.create).toHaveBeenCalledWith( + expect.any(String), + mockRequestUser.id, + expect.any(Object), + ); + }); + }); + + // ==================== Webhook Lifecycle Tests ==================== + + describe('Webhook Lifecycle', () => { + it('should handle full webhook lifecycle: create -> update -> test -> delete', async () => { + const createDto: CreateWebhookDto = { + name: 'Lifecycle Test', + url: 'https://example.com/hook', + events: ['user.created'], + }; + + // Create + service.create.mockResolvedValue(mockWebhookResponseWithSecret); + const created = await controller.create(mockRequestUser, createDto); + expect(created).toBeDefined(); + + // Update + service.update.mockResolvedValue({ ...mockWebhookResponse, name: 'Updated' }); + const updated = await controller.update(mockRequestUser, created.id, { name: 'Updated' }); + expect(updated.name).toBe('Updated'); + + // Test + service.testWebhook.mockResolvedValue(mockPendingDelivery); + const testResult = await controller.test(mockRequestUser, created.id, {}); + expect(testResult.status).toBe(DeliveryStatus.PENDING); + + // Delete + service.remove.mockResolvedValue(undefined); + const deleteResult = await controller.remove(mockRequestUser, created.id); + expect(deleteResult.message).toBe('Webhook deleted successfully'); + }); + }); +}); diff --git a/src/modules/webhooks/dto/index.ts b/src/modules/webhooks/dto/index.ts new file mode 100644 index 0000000..db20296 --- /dev/null +++ b/src/modules/webhooks/dto/index.ts @@ -0,0 +1 @@ +export * from './webhook.dto'; diff --git a/src/modules/webhooks/dto/webhook.dto.ts b/src/modules/webhooks/dto/webhook.dto.ts new file mode 100644 index 0000000..708541a --- /dev/null +++ b/src/modules/webhooks/dto/webhook.dto.ts @@ -0,0 +1,165 @@ +import { + IsString, + IsUrl, + IsArray, + IsOptional, + IsBoolean, + IsObject, + MaxLength, + ArrayMinSize, + IsUUID, + IsEnum, +} from 'class-validator'; +import { DeliveryStatus } from '../entities'; + +// Available webhook events +export const WEBHOOK_EVENTS = [ + 'user.created', + 'user.updated', + 'user.deleted', + 'subscription.created', + 'subscription.updated', + 'subscription.cancelled', + 'invoice.paid', + 'invoice.failed', + 'file.uploaded', + 'file.deleted', + 'tenant.updated', +] as const; + +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]; + +// Create webhook DTO +export class CreateWebhookDto { + @IsString() + @MaxLength(100) + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsUrl({ protocols: ['https'], require_protocol: true }) + url: string; + + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + events: string[]; + + @IsObject() + @IsOptional() + headers?: Record; +} + +// Update webhook DTO +export class UpdateWebhookDto { + @IsString() + @MaxLength(100) + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsUrl({ protocols: ['https'], require_protocol: true }) + @IsOptional() + url?: string; + + @IsArray() + @IsString({ each: true }) + @IsOptional() + events?: string[]; + + @IsObject() + @IsOptional() + headers?: Record; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} + +// Webhook response DTO +export class WebhookResponseDto { + id: string; + name: string; + description: string | null; + url: string; + events: string[]; + headers: Record; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + stats?: WebhookStatsDto; +} + +// Webhook stats +export class WebhookStatsDto { + totalDeliveries: number; + successfulDeliveries: number; + failedDeliveries: number; + pendingDeliveries: number; + successRate: number; + lastDeliveryAt: Date | null; +} + +// Delivery response DTO +export class DeliveryResponseDto { + id: string; + webhookId: string; + eventType: string; + payload: Record; + status: DeliveryStatus; + responseStatus: number | null; + responseBody: string | null; + attempt: number; + maxAttempts: number; + nextRetryAt: Date | null; + lastError: string | null; + createdAt: Date; + deliveredAt: Date | null; +} + +// Test webhook DTO +export class TestWebhookDto { + @IsString() + @IsOptional() + eventType?: string; + + @IsObject() + @IsOptional() + payload?: Record; +} + +// List deliveries query DTO +export class ListDeliveriesQueryDto { + @IsEnum(DeliveryStatus) + @IsOptional() + status?: DeliveryStatus; + + @IsString() + @IsOptional() + eventType?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} + +// Paginated response +export class PaginatedDeliveriesDto { + items: DeliveryResponseDto[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +// Events list response +export class AvailableEventsDto { + events: { name: string; description: string }[]; +} diff --git a/src/modules/webhooks/entities/index.ts b/src/modules/webhooks/entities/index.ts new file mode 100644 index 0000000..2e05224 --- /dev/null +++ b/src/modules/webhooks/entities/index.ts @@ -0,0 +1 @@ +export * from './webhook.entity'; diff --git a/src/modules/webhooks/entities/webhook.entity.ts b/src/modules/webhooks/entities/webhook.entity.ts new file mode 100644 index 0000000..392a518 --- /dev/null +++ b/src/modules/webhooks/entities/webhook.entity.ts @@ -0,0 +1,119 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; + +@Entity({ schema: 'webhooks', name: 'webhooks' }) +export class WebhookEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'text' }) + url: string; + + @Column({ type: 'text' }) + secret: string; + + @Column({ type: 'text', array: true, default: [] }) + events: string[]; + + @Column({ type: 'jsonb', default: {} }) + headers: Record; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @OneToMany(() => WebhookDeliveryEntity, (delivery) => delivery.webhook) + deliveries: WebhookDeliveryEntity[]; +} + +export enum DeliveryStatus { + PENDING = 'pending', + DELIVERED = 'delivered', + FAILED = 'failed', + RETRYING = 'retrying', +} + +@Entity({ schema: 'webhooks', name: 'deliveries' }) +export class WebhookDeliveryEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'webhook_id', type: 'uuid' }) + webhookId: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'event_type', type: 'text' }) + eventType: string; + + @Column({ type: 'jsonb' }) + payload: Record; + + @Column({ + type: 'enum', + enum: DeliveryStatus, + enumName: 'delivery_status', + default: DeliveryStatus.PENDING, + }) + status: DeliveryStatus; + + @Column({ name: 'response_status', type: 'int', nullable: true }) + responseStatus: number; + + @Column({ name: 'response_body', type: 'text', nullable: true }) + responseBody: string; + + @Column({ name: 'response_headers', type: 'jsonb', nullable: true }) + responseHeaders: Record; + + @Column({ type: 'int', default: 1 }) + attempt: number; + + @Column({ name: 'max_attempts', type: 'int', default: 5 }) + maxAttempts: number; + + @Column({ name: 'next_retry_at', type: 'timestamptz', nullable: true }) + nextRetryAt: Date; + + @Column({ name: 'last_error', type: 'text', nullable: true }) + lastError: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @ManyToOne(() => WebhookEntity, (webhook) => webhook.deliveries) + @JoinColumn({ name: 'webhook_id' }) + webhook: WebhookEntity; +} diff --git a/src/modules/webhooks/index.ts b/src/modules/webhooks/index.ts new file mode 100644 index 0000000..b443698 --- /dev/null +++ b/src/modules/webhooks/index.ts @@ -0,0 +1,4 @@ +export * from './webhooks.module'; +export * from './services'; +export * from './entities'; +export * from './dto'; diff --git a/src/modules/webhooks/processors/index.ts b/src/modules/webhooks/processors/index.ts new file mode 100644 index 0000000..184718b --- /dev/null +++ b/src/modules/webhooks/processors/index.ts @@ -0,0 +1 @@ +export * from './webhook.processor'; diff --git a/src/modules/webhooks/processors/webhook.processor.ts b/src/modules/webhooks/processors/webhook.processor.ts new file mode 100644 index 0000000..4ef915b --- /dev/null +++ b/src/modules/webhooks/processors/webhook.processor.ts @@ -0,0 +1,150 @@ +import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq'; +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Job } from 'bullmq'; +import * as crypto from 'crypto'; + +import { WebhookDeliveryEntity, DeliveryStatus } from '../entities'; + +interface WebhookDeliveryJob { + deliveryId: string; + webhookId: string; + url: string; + secret: string; + headers: Record; + eventType: string; + payload: Record; +} + +// Retry delays in milliseconds +const RETRY_DELAYS = [ + 60 * 1000, // 1 minute + 5 * 60 * 1000, // 5 minutes + 30 * 60 * 1000, // 30 minutes + 2 * 60 * 60 * 1000, // 2 hours + 6 * 60 * 60 * 1000, // 6 hours +]; + +@Processor('webhooks') +export class WebhookProcessor extends WorkerHost { + private readonly logger = new Logger(WebhookProcessor.name); + + constructor( + @InjectRepository(WebhookDeliveryEntity) + private readonly deliveryRepo: Repository, + ) { + super(); + } + + async process(job: Job): Promise { + const { deliveryId, url, secret, headers, eventType, payload } = job.data; + + this.logger.log(`Processing webhook delivery: ${deliveryId}`); + + const delivery = await this.deliveryRepo.findOne({ + where: { id: deliveryId }, + }); + + if (!delivery) { + this.logger.warn(`Delivery not found: ${deliveryId}`); + return; + } + + try { + // Generate signature + const timestamp = Date.now(); + const body = JSON.stringify(payload); + const signature = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.${body}`) + .digest('hex'); + + const signatureHeader = `t=${timestamp},v1=${signature}`; + + // Make HTTP request + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30000); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Webhook-Signature': signatureHeader, + 'X-Webhook-Id': delivery.webhookId, + 'X-Webhook-Event': eventType, + 'X-Webhook-Timestamp': timestamp.toString(), + 'X-Webhook-Delivery': deliveryId, + ...headers, + }, + body, + signal: controller.signal, + redirect: 'error', // Don't follow redirects + }); + + clearTimeout(timeout); + + // Get response details + const responseBody = await response.text().catch(() => ''); + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + // Update delivery record + delivery.responseStatus = response.status; + delivery.responseBody = responseBody.substring(0, 10000); // Limit size + delivery.responseHeaders = responseHeaders; + + if (response.ok) { + // Success (2xx status) + delivery.status = DeliveryStatus.DELIVERED; + delivery.deliveredAt = new Date(); + delivery.completedAt = new Date(); + this.logger.log(`Webhook delivered successfully: ${deliveryId}`); + } else { + // HTTP error (4xx, 5xx) + await this.handleFailure(delivery, `HTTP ${response.status}: ${responseBody.substring(0, 500)}`); + } + + await this.deliveryRepo.save(delivery); + } catch (error: any) { + // Network error, timeout, etc. + delivery.responseStatus = undefined as any; + delivery.responseBody = undefined as any; + await this.handleFailure(delivery, error.message || 'Unknown error'); + await this.deliveryRepo.save(delivery); + } + } + + private async handleFailure(delivery: WebhookDeliveryEntity, errorMessage: string): Promise { + delivery.lastError = errorMessage; + + if (delivery.attempt >= delivery.maxAttempts) { + // Max retries reached + delivery.status = DeliveryStatus.FAILED; + delivery.completedAt = new Date(); + this.logger.warn( + `Webhook delivery failed permanently: ${delivery.id} after ${delivery.attempt} attempts`, + ); + } else { + // Schedule retry + delivery.status = DeliveryStatus.RETRYING; + delivery.attempt += 1; + delivery.nextRetryAt = new Date(Date.now() + RETRY_DELAYS[delivery.attempt - 1]); + this.logger.log( + `Webhook delivery scheduled for retry: ${delivery.id} (attempt ${delivery.attempt})`, + ); + } + } + + @OnWorkerEvent('completed') + onCompleted(job: Job) { + this.logger.debug(`Job completed: ${job.id}`); + } + + @OnWorkerEvent('failed') + onFailed(job: Job, error: Error) { + this.logger.error(`Job failed: ${job.id} - ${error.message}`); + } +} diff --git a/src/modules/webhooks/services/index.ts b/src/modules/webhooks/services/index.ts new file mode 100644 index 0000000..b04522f --- /dev/null +++ b/src/modules/webhooks/services/index.ts @@ -0,0 +1 @@ +export * from './webhook.service'; diff --git a/src/modules/webhooks/services/webhook.service.ts b/src/modules/webhooks/services/webhook.service.ts new file mode 100644 index 0000000..0de4169 --- /dev/null +++ b/src/modules/webhooks/services/webhook.service.ts @@ -0,0 +1,427 @@ +import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { InjectQueue } from '@nestjs/bullmq'; +import { Queue } from 'bullmq'; +import * as crypto from 'crypto'; + +import { WebhookEntity, WebhookDeliveryEntity, DeliveryStatus } from '../entities'; +import { + CreateWebhookDto, + UpdateWebhookDto, + WebhookResponseDto, + DeliveryResponseDto, + TestWebhookDto, + ListDeliveriesQueryDto, + PaginatedDeliveriesDto, + WebhookStatsDto, + WEBHOOK_EVENTS, +} from '../dto'; + +@Injectable() +export class WebhookService { + private readonly logger = new Logger(WebhookService.name); + + constructor( + @InjectRepository(WebhookEntity) + private readonly webhookRepo: Repository, + @InjectRepository(WebhookDeliveryEntity) + private readonly deliveryRepo: Repository, + @InjectQueue('webhooks') + private readonly webhookQueue: Queue, + ) {} + + // Generate a secure random secret + private generateSecret(): string { + return `whsec_${crypto.randomBytes(32).toString('hex')}`; + } + + // Sign a payload with HMAC-SHA256 + signPayload(payload: object, secret: string): string { + const timestamp = Date.now(); + const body = JSON.stringify(payload); + const signature = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.${body}`) + .digest('hex'); + + return `t=${timestamp},v1=${signature}`; + } + + // Create a new webhook + async create(tenantId: string, userId: string, dto: CreateWebhookDto): Promise { + // Validate events + const invalidEvents = dto.events.filter((e) => !WEBHOOK_EVENTS.includes(e as any)); + if (invalidEvents.length > 0) { + throw new BadRequestException(`Invalid events: ${invalidEvents.join(', ')}`); + } + + const webhook = this.webhookRepo.create({ + tenantId, + name: dto.name, + description: dto.description, + url: dto.url, + events: dto.events, + headers: dto.headers || {}, + secret: this.generateSecret(), + createdBy: userId, + }); + + const saved = await this.webhookRepo.save(webhook); + this.logger.log(`Webhook created: ${saved.id} for tenant ${tenantId}`); + + return this.toResponse(saved, true); // Include secret on creation + } + + // Get all webhooks for a tenant + async findAll(tenantId: string): Promise { + const webhooks = await this.webhookRepo.find({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + }); + + return Promise.all(webhooks.map((w) => this.toResponse(w))); + } + + // Get a single webhook + async findOne(tenantId: string, webhookId: string): Promise { + const webhook = await this.webhookRepo.findOne({ + where: { id: webhookId, tenantId }, + }); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + return this.toResponse(webhook); + } + + // Update a webhook + async update( + tenantId: string, + webhookId: string, + dto: UpdateWebhookDto, + ): Promise { + const webhook = await this.webhookRepo.findOne({ + where: { id: webhookId, tenantId }, + }); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + // Validate events if provided + if (dto.events) { + const invalidEvents = dto.events.filter((e) => !WEBHOOK_EVENTS.includes(e as any)); + if (invalidEvents.length > 0) { + throw new BadRequestException(`Invalid events: ${invalidEvents.join(', ')}`); + } + } + + Object.assign(webhook, { + name: dto.name ?? webhook.name, + description: dto.description ?? webhook.description, + url: dto.url ?? webhook.url, + events: dto.events ?? webhook.events, + headers: dto.headers ?? webhook.headers, + isActive: dto.isActive ?? webhook.isActive, + }); + + const saved = await this.webhookRepo.save(webhook); + this.logger.log(`Webhook updated: ${saved.id}`); + + return this.toResponse(saved); + } + + // Delete a webhook + async remove(tenantId: string, webhookId: string): Promise { + const webhook = await this.webhookRepo.findOne({ + where: { id: webhookId, tenantId }, + }); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + await this.webhookRepo.remove(webhook); + this.logger.log(`Webhook deleted: ${webhookId}`); + } + + // Regenerate webhook secret + async regenerateSecret(tenantId: string, webhookId: string): Promise<{ secret: string }> { + const webhook = await this.webhookRepo.findOne({ + where: { id: webhookId, tenantId }, + }); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + webhook.secret = this.generateSecret(); + await this.webhookRepo.save(webhook); + + return { secret: webhook.secret }; + } + + // Test a webhook + async testWebhook( + tenantId: string, + webhookId: string, + dto: TestWebhookDto, + ): Promise { + const webhook = await this.webhookRepo.findOne({ + where: { id: webhookId, tenantId }, + }); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + const eventType = dto.eventType || 'test.ping'; + const payload = dto.payload || { + type: 'test.ping', + timestamp: new Date().toISOString(), + data: { message: 'This is a test webhook delivery' }, + }; + + // Create a delivery record + const delivery = this.deliveryRepo.create({ + webhookId: webhook.id, + tenantId, + eventType, + payload, + status: DeliveryStatus.PENDING, + }); + + const saved = await this.deliveryRepo.save(delivery); + + // Queue for immediate delivery + await this.webhookQueue.add( + 'deliver', + { + deliveryId: saved.id, + webhookId: webhook.id, + url: webhook.url, + secret: webhook.secret, + headers: webhook.headers, + eventType, + payload, + }, + { priority: 1 }, // High priority for tests + ); + + this.logger.log(`Test webhook queued: ${saved.id}`); + return this.toDeliveryResponse(saved); + } + + // Get deliveries for a webhook + async getDeliveries( + tenantId: string, + webhookId: string, + query: ListDeliveriesQueryDto, + ): Promise { + const webhook = await this.webhookRepo.findOne({ + where: { id: webhookId, tenantId }, + }); + + if (!webhook) { + throw new NotFoundException('Webhook not found'); + } + + const page = query.page || 1; + const limit = Math.min(query.limit || 20, 100); + const skip = (page - 1) * limit; + + const qb = this.deliveryRepo + .createQueryBuilder('d') + .where('d.webhook_id = :webhookId', { webhookId }) + .andWhere('d.tenant_id = :tenantId', { tenantId }); + + if (query.status) { + qb.andWhere('d.status = :status', { status: query.status }); + } + + if (query.eventType) { + qb.andWhere('d.event_type = :eventType', { eventType: query.eventType }); + } + + qb.orderBy('d.created_at', 'DESC').skip(skip).take(limit); + + const [items, total] = await qb.getManyAndCount(); + + return { + items: items.map((d) => this.toDeliveryResponse(d)), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + // Retry a failed delivery + async retryDelivery( + tenantId: string, + webhookId: string, + deliveryId: string, + ): Promise { + const delivery = await this.deliveryRepo.findOne({ + where: { id: deliveryId, webhookId, tenantId }, + relations: ['webhook'], + }); + + if (!delivery) { + throw new NotFoundException('Delivery not found'); + } + + if (delivery.status !== DeliveryStatus.FAILED) { + throw new BadRequestException('Only failed deliveries can be retried'); + } + + // Reset for retry + delivery.status = DeliveryStatus.RETRYING; + delivery.attempt = 1; + delivery.nextRetryAt = new Date(); + await this.deliveryRepo.save(delivery); + + // Queue for delivery + await this.webhookQueue.add('deliver', { + deliveryId: delivery.id, + webhookId: delivery.webhookId, + url: delivery.webhook.url, + secret: delivery.webhook.secret, + headers: delivery.webhook.headers, + eventType: delivery.eventType, + payload: delivery.payload, + }); + + this.logger.log(`Delivery retry queued: ${delivery.id}`); + return this.toDeliveryResponse(delivery); + } + + // Get webhook stats + async getStats(webhookId: string): Promise { + const result = await this.deliveryRepo + .createQueryBuilder('d') + .select([ + 'COUNT(*)::int as "totalDeliveries"', + 'COUNT(*) FILTER (WHERE d.status = :delivered)::int as "successfulDeliveries"', + 'COUNT(*) FILTER (WHERE d.status = :failed)::int as "failedDeliveries"', + 'COUNT(*) FILTER (WHERE d.status IN (:...pending))::int as "pendingDeliveries"', + 'MAX(d.delivered_at) as "lastDeliveryAt"', + ]) + .where('d.webhook_id = :webhookId', { webhookId }) + .setParameters({ + delivered: DeliveryStatus.DELIVERED, + failed: DeliveryStatus.FAILED, + pending: [DeliveryStatus.PENDING, DeliveryStatus.RETRYING], + }) + .getRawOne(); + + const total = result.successfulDeliveries + result.failedDeliveries; + const successRate = total > 0 ? Math.round((result.successfulDeliveries / total) * 100) : 0; + + return { + ...result, + successRate, + }; + } + + // Dispatch an event to all subscribed webhooks + async dispatch(tenantId: string, eventType: string, data: Record): Promise { + const webhooks = await this.webhookRepo.find({ + where: { tenantId, isActive: true }, + }); + + const subscribedWebhooks = webhooks.filter((w) => w.events.includes(eventType)); + + if (subscribedWebhooks.length === 0) { + return; + } + + const payload = { + type: eventType, + timestamp: new Date().toISOString(), + data, + }; + + for (const webhook of subscribedWebhooks) { + const delivery = this.deliveryRepo.create({ + webhookId: webhook.id, + tenantId, + eventType, + payload, + status: DeliveryStatus.PENDING, + }); + + const saved = await this.deliveryRepo.save(delivery); + + await this.webhookQueue.add('deliver', { + deliveryId: saved.id, + webhookId: webhook.id, + url: webhook.url, + secret: webhook.secret, + headers: webhook.headers, + eventType, + payload, + }); + } + + this.logger.log( + `Event ${eventType} dispatched to ${subscribedWebhooks.length} webhooks for tenant ${tenantId}`, + ); + } + + // Get available events + getAvailableEvents(): { name: string; description: string }[] { + return [ + { name: 'user.created', description: 'A new user was created' }, + { name: 'user.updated', description: 'A user was updated' }, + { name: 'user.deleted', description: 'A user was deleted' }, + { name: 'subscription.created', description: 'A new subscription was created' }, + { name: 'subscription.updated', description: 'A subscription was updated' }, + { name: 'subscription.cancelled', description: 'A subscription was cancelled' }, + { name: 'invoice.paid', description: 'An invoice was paid' }, + { name: 'invoice.failed', description: 'An invoice payment failed' }, + { name: 'file.uploaded', description: 'A file was uploaded' }, + { name: 'file.deleted', description: 'A file was deleted' }, + { name: 'tenant.updated', description: 'Tenant settings were updated' }, + ]; + } + + // Transform entity to response DTO + private async toResponse(webhook: WebhookEntity, includeSecret = false): Promise { + const stats = await this.getStats(webhook.id); + + return { + id: webhook.id, + name: webhook.name, + description: webhook.description, + url: webhook.url, + events: webhook.events, + headers: webhook.headers, + isActive: webhook.isActive, + createdAt: webhook.createdAt, + updatedAt: webhook.updatedAt, + ...(includeSecret && { secret: webhook.secret }), + stats, + }; + } + + private toDeliveryResponse(delivery: WebhookDeliveryEntity): DeliveryResponseDto { + return { + id: delivery.id, + webhookId: delivery.webhookId, + eventType: delivery.eventType, + payload: delivery.payload, + status: delivery.status, + responseStatus: delivery.responseStatus, + responseBody: delivery.responseBody, + attempt: delivery.attempt, + maxAttempts: delivery.maxAttempts, + nextRetryAt: delivery.nextRetryAt, + lastError: delivery.lastError, + createdAt: delivery.createdAt, + deliveredAt: delivery.deliveredAt, + }; + } +} diff --git a/src/modules/webhooks/webhooks.controller.ts b/src/modules/webhooks/webhooks.controller.ts new file mode 100644 index 0000000..96ad04a --- /dev/null +++ b/src/modules/webhooks/webhooks.controller.ts @@ -0,0 +1,129 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + ParseUUIDPipe, +} from '@nestjs/common'; +import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard'; +import { CurrentUser } from '@modules/auth/decorators/current-user.decorator'; +import { WebhookService } from './services'; +import { + CreateWebhookDto, + UpdateWebhookDto, + TestWebhookDto, + ListDeliveriesQueryDto, + WebhookResponseDto, + DeliveryResponseDto, + PaginatedDeliveriesDto, + AvailableEventsDto, +} from './dto'; + +interface RequestUser { + id: string; + tenant_id: string; + email: string; + role: string; +} + +@Controller('webhooks') +@UseGuards(JwtAuthGuard) +export class WebhooksController { + constructor(private readonly webhookService: WebhookService) {} + + // Get available events + @Get('events') + getAvailableEvents(): AvailableEventsDto { + return { + events: this.webhookService.getAvailableEvents(), + }; + } + + // List all webhooks + @Get() + async findAll(@CurrentUser() user: RequestUser): Promise { + return this.webhookService.findAll(user.tenant_id); + } + + // Get a single webhook + @Get(':id') + async findOne( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.webhookService.findOne(user.tenant_id, id); + } + + // Create a webhook + @Post() + async create( + @CurrentUser() user: RequestUser, + @Body() dto: CreateWebhookDto, + ): Promise { + return this.webhookService.create(user.tenant_id, user.id, dto); + } + + // Update a webhook + @Put(':id') + async update( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateWebhookDto, + ): Promise { + return this.webhookService.update(user.tenant_id, id, dto); + } + + // Delete a webhook + @Delete(':id') + async remove( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ message: string }> { + await this.webhookService.remove(user.tenant_id, id); + return { message: 'Webhook deleted successfully' }; + } + + // Regenerate webhook secret + @Post(':id/regenerate-secret') + async regenerateSecret( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ secret: string }> { + return this.webhookService.regenerateSecret(user.tenant_id, id); + } + + // Test a webhook + @Post(':id/test') + async test( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: TestWebhookDto, + ): Promise { + return this.webhookService.testWebhook(user.tenant_id, id, dto); + } + + // Get deliveries for a webhook + @Get(':id/deliveries') + async getDeliveries( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Query() query: ListDeliveriesQueryDto, + ): Promise { + return this.webhookService.getDeliveries(user.tenant_id, id, query); + } + + // Retry a failed delivery + @Post(':id/deliveries/:deliveryId/retry') + async retryDelivery( + @CurrentUser() user: RequestUser, + @Param('id', ParseUUIDPipe) id: string, + @Param('deliveryId', ParseUUIDPipe) deliveryId: string, + ): Promise { + return this.webhookService.retryDelivery(user.tenant_id, id, deliveryId); + } +} diff --git a/src/modules/webhooks/webhooks.module.ts b/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 0000000..9a688fe --- /dev/null +++ b/src/modules/webhooks/webhooks.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bullmq'; + +import { WebhookEntity, WebhookDeliveryEntity } from './entities'; +import { WebhookService } from './services'; +import { WebhookProcessor } from './processors'; +import { WebhooksController } from './webhooks.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([WebhookEntity, WebhookDeliveryEntity]), + BullModule.registerQueue({ + name: 'webhooks', + defaultJobOptions: { + removeOnComplete: 100, // Keep last 100 completed jobs + removeOnFail: 500, // Keep last 500 failed jobs + attempts: 1, // We handle retries ourselves + }, + }), + ], + controllers: [WebhooksController], + providers: [WebhookService, WebhookProcessor], + exports: [WebhookService], +}) +export class WebhooksModule {} diff --git a/src/modules/whatsapp/__tests__/whatsapp-webhook.controller.spec.ts b/src/modules/whatsapp/__tests__/whatsapp-webhook.controller.spec.ts new file mode 100644 index 0000000..d2ce6d1 --- /dev/null +++ b/src/modules/whatsapp/__tests__/whatsapp-webhook.controller.spec.ts @@ -0,0 +1,269 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { WhatsAppWebhookController } from '../controllers/whatsapp-webhook.controller'; +import { WhatsAppService } from '../services/whatsapp.service'; + +describe('WhatsAppWebhookController', () => { + let controller: WhatsAppWebhookController; + let service: jest.Mocked; + let configService: jest.Mocked; + + const verifyToken = 'test-verify-token'; + const appSecret = 'test-app-secret'; + + beforeEach(async () => { + const mockWhatsAppService = { + handleMessageStatus: jest.fn(), + handleIncomingMessage: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn((key: string) => { + if (key === 'whatsapp.verifyToken') return verifyToken; + if (key === 'whatsapp.appSecret') return appSecret; + return undefined; + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [WhatsAppWebhookController], + providers: [ + { + provide: WhatsAppService, + useValue: mockWhatsAppService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + ], + }).compile(); + + controller = module.get(WhatsAppWebhookController); + service = module.get(WhatsAppService); + configService = module.get(ConfigService); + }); + + describe('verifyWebhook', () => { + it('should return challenge when verification is successful', () => { + const challenge = 'test-challenge-123'; + + const result = controller.verifyWebhook('subscribe', verifyToken, challenge); + + expect(result).toBe(challenge); + }); + + it('should throw BadRequestException for invalid mode', () => { + expect(() => { + controller.verifyWebhook('invalid', verifyToken, 'challenge'); + }).toThrow(BadRequestException); + }); + + it('should throw UnauthorizedException for invalid token', () => { + expect(() => { + controller.verifyWebhook('subscribe', 'wrong-token', 'challenge'); + }).toThrow(UnauthorizedException); + }); + }); + + describe('handleWebhook', () => { + const createWebhookBody = (overrides = {}): any => ({ + object: 'whatsapp_business_account', + entry: [ + { + id: 'business-account-001', + changes: [ + { + field: 'messages', + value: { + messaging_product: 'whatsapp', + metadata: { + phone_number_id: '1234567890', + display_phone_number: '+1234567890', + }, + messages: [] as any[], + contacts: [] as any[], + statuses: [] as any[], + }, + }, + ], + }, + ], + ...overrides, + }); + + const generateSignature = (payload: string) => { + const hmac = crypto.createHmac('sha256', appSecret).update(payload).digest('hex'); + return `sha256=${hmac}`; + }; + + it('should return OK for valid webhook', async () => { + const body = createWebhookBody(); + const signature = generateSignature(JSON.stringify(body)); + + const result = await controller.handleWebhook(body as any, signature); + + expect(result).toBe('OK'); + }); + + it('should ignore non-WhatsApp webhooks', async () => { + const body = createWebhookBody({ object: 'instagram' }); + const signature = generateSignature(JSON.stringify(body)); + + const result = await controller.handleWebhook(body as any, signature); + + expect(result).toBe('OK'); + expect(service.handleMessageStatus).not.toHaveBeenCalled(); + expect(service.handleIncomingMessage).not.toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException for invalid signature', async () => { + const body = createWebhookBody(); + // Use a valid length hex string but wrong value + const invalidSignature = 'sha256=' + '0'.repeat(64); + + await expect(controller.handleWebhook(body as any, invalidSignature)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('should process message statuses', async () => { + const status = { + id: 'msg-001', + status: 'delivered', + timestamp: '1234567890', + recipient_id: '+1234567890', + }; + const body = createWebhookBody(); + body.entry[0].changes[0].value.statuses = [status]; + const signature = generateSignature(JSON.stringify(body)); + + await controller.handleWebhook(body as any, signature); + + expect(service.handleMessageStatus).toHaveBeenCalledWith('1234567890', status); + }); + + it('should process incoming messages', async () => { + const message = { + id: 'msg-002', + from: '+0987654321', + timestamp: '1234567890', + type: 'text', + text: { body: 'Hello' }, + }; + const contact = { + wa_id: '+0987654321', + profile: { name: 'John Doe' }, + }; + const body = createWebhookBody(); + body.entry[0].changes[0].value.messages = [message]; + body.entry[0].changes[0].value.contacts = [contact]; + const signature = generateSignature(JSON.stringify(body)); + + await controller.handleWebhook(body as any, signature); + + expect(service.handleIncomingMessage).toHaveBeenCalledWith('1234567890', message, contact); + }); + + it('should handle multiple messages and statuses', async () => { + const messages = [ + { id: 'msg-001', from: '+111', timestamp: '1', type: 'text', text: { body: 'Hi' } }, + { id: 'msg-002', from: '+222', timestamp: '2', type: 'text', text: { body: 'Hello' } }, + ]; + const contacts = [ + { wa_id: '+111', profile: { name: 'User 1' } }, + { wa_id: '+222', profile: { name: 'User 2' } }, + ]; + const statuses = [ + { id: 's1', status: 'sent', timestamp: '1', recipient_id: '+111' }, + { id: 's2', status: 'delivered', timestamp: '2', recipient_id: '+222' }, + ]; + const body = createWebhookBody(); + body.entry[0].changes[0].value.messages = messages; + body.entry[0].changes[0].value.contacts = contacts; + body.entry[0].changes[0].value.statuses = statuses; + const signature = generateSignature(JSON.stringify(body)); + + await controller.handleWebhook(body as any, signature); + + expect(service.handleMessageStatus).toHaveBeenCalledTimes(2); + expect(service.handleIncomingMessage).toHaveBeenCalledTimes(2); + }); + + it('should continue processing on error in message handler', async () => { + const messages = [ + { id: 'msg-001', from: '+111', timestamp: '1', type: 'text', text: { body: 'Hi' } }, + { id: 'msg-002', from: '+222', timestamp: '2', type: 'text', text: { body: 'Hello' } }, + ]; + const contacts = [ + { wa_id: '+111', profile: { name: 'User 1' } }, + { wa_id: '+222', profile: { name: 'User 2' } }, + ]; + const body = createWebhookBody(); + body.entry[0].changes[0].value.messages = messages; + body.entry[0].changes[0].value.contacts = contacts; + const signature = generateSignature(JSON.stringify(body)); + + service.handleIncomingMessage + .mockRejectedValueOnce(new Error('Failed')) + .mockResolvedValueOnce(undefined); + + const result = await controller.handleWebhook(body as any, signature); + + expect(result).toBe('OK'); + expect(service.handleIncomingMessage).toHaveBeenCalledTimes(2); + }); + + it('should skip non-messages field changes', async () => { + const body = createWebhookBody(); + body.entry[0].changes[0].field = 'account_update'; + const signature = generateSignature(JSON.stringify(body)); + + await controller.handleWebhook(body as any, signature); + + expect(service.handleMessageStatus).not.toHaveBeenCalled(); + expect(service.handleIncomingMessage).not.toHaveBeenCalled(); + }); + + it('should handle webhook without signature when appSecret is not set', async () => { + configService.get.mockImplementation((key: string) => { + if (key === 'whatsapp.verifyToken') return verifyToken; + return undefined; // No appSecret + }); + + // Need to recreate controller to pick up new config + const module: TestingModule = await Test.createTestingModule({ + controllers: [WhatsAppWebhookController], + providers: [ + { provide: WhatsAppService, useValue: service }, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + const newController = module.get(WhatsAppWebhookController); + const body = createWebhookBody(); + + const result = await newController.handleWebhook(body as any, ''); + + expect(result).toBe('OK'); + }); + + it('should log errors from WhatsApp API', async () => { + const body = createWebhookBody(); + body.entry[0].changes[0].value.errors = [ + { code: 131051, title: 'Error', message: 'Something went wrong' }, + ]; + const signature = generateSignature(JSON.stringify(body)); + + const logSpy = jest.spyOn(controller['logger'], 'error').mockImplementation(); + + await controller.handleWebhook(body as any, signature); + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('WhatsApp API error: 131051'), + ); + }); + }); +}); diff --git a/src/modules/whatsapp/__tests__/whatsapp.controller.spec.ts b/src/modules/whatsapp/__tests__/whatsapp.controller.spec.ts new file mode 100644 index 0000000..4d870fa --- /dev/null +++ b/src/modules/whatsapp/__tests__/whatsapp.controller.spec.ts @@ -0,0 +1,298 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { WhatsAppController } from '../controllers/whatsapp.controller'; +import { WhatsAppService } from '../services/whatsapp.service'; + +describe('WhatsAppController', () => { + let controller: WhatsAppController; + let service: jest.Mocked; + + const mockTenantId = 'tenant-001'; + + const mockConfig = { + id: 'config-001', + tenant_id: mockTenantId, + phone_number_id: '1234567890', + business_account_id: 'ba-001', + display_phone_number: '+1234567890', + verified_name: 'Test Business', + quality_rating: 'GREEN', + is_active: true, + is_verified: true, + daily_message_limit: 1000, + messages_sent_today: 50, + created_at: new Date(), + updated_at: new Date(), + }; + + beforeEach(async () => { + const mockWhatsAppService = { + getConfig: jest.fn(), + createConfig: jest.fn(), + updateConfig: jest.fn(), + deleteConfig: jest.fn(), + testConnection: jest.fn(), + sendTextMessage: jest.fn(), + sendTemplateMessage: jest.fn(), + sendMediaMessage: jest.fn(), + getMessages: jest.fn(), + getMessage: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [WhatsAppController], + providers: [ + { + provide: WhatsAppService, + useValue: mockWhatsAppService, + }, + ], + }).compile(); + + controller = module.get(WhatsAppController); + service = module.get(WhatsAppService); + }); + + describe('Configuration', () => { + describe('getConfig', () => { + it('should return config when exists', async () => { + service.getConfig.mockResolvedValue(mockConfig as any); + + const result = await controller.getConfig(mockTenantId); + + expect(service.getConfig).toHaveBeenCalledWith(mockTenantId); + expect(result).toEqual({ + id: mockConfig.id, + phoneNumberId: mockConfig.phone_number_id, + businessAccountId: mockConfig.business_account_id, + displayPhoneNumber: mockConfig.display_phone_number, + verifiedName: mockConfig.verified_name, + qualityRating: mockConfig.quality_rating, + isActive: mockConfig.is_active, + isVerified: mockConfig.is_verified, + dailyMessageLimit: mockConfig.daily_message_limit, + messagesSentToday: mockConfig.messages_sent_today, + createdAt: mockConfig.created_at, + updatedAt: mockConfig.updated_at, + }); + }); + + it('should return null when config not found', async () => { + service.getConfig.mockResolvedValue(null); + + const result = await controller.getConfig(mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('createConfig', () => { + it('should create and return config', async () => { + const createDto = { + phoneNumberId: '1234567890', + businessAccountId: 'ba-001', + accessToken: 'token-123', + displayPhoneNumber: '+1234567890', + }; + service.createConfig.mockResolvedValue(mockConfig as any); + + const result = await controller.createConfig(mockTenantId, createDto); + + expect(service.createConfig).toHaveBeenCalledWith(mockTenantId, createDto); + expect(result.phoneNumberId).toBe(mockConfig.phone_number_id); + }); + }); + + describe('updateConfig', () => { + it('should update and return config', async () => { + const updateDto = { isActive: false }; + const updatedConfig = { ...mockConfig, is_active: false }; + service.updateConfig.mockResolvedValue(updatedConfig as any); + + const result = await controller.updateConfig(mockTenantId, updateDto); + + expect(service.updateConfig).toHaveBeenCalledWith(mockTenantId, updateDto); + expect(result.isActive).toBe(false); + }); + }); + + describe('deleteConfig', () => { + it('should delete config', async () => { + service.deleteConfig.mockResolvedValue(undefined); + + await controller.deleteConfig(mockTenantId); + + expect(service.deleteConfig).toHaveBeenCalledWith(mockTenantId); + }); + }); + + describe('testConnection', () => { + it('should return success when connection works', async () => { + const testDto = { testPhoneNumber: '+1234567890' }; + service.testConnection.mockResolvedValue({ + success: true, + messageId: 'msg-001', + }); + + const result = await controller.testConnection(mockTenantId, testDto); + + expect(result).toEqual({ + success: true, + messageId: 'msg-001', + error: undefined, + }); + }); + + it('should return error when connection fails', async () => { + const testDto = { testPhoneNumber: '+1234567890' }; + service.testConnection.mockResolvedValue({ + success: false, + error: 'Invalid token', + }); + + const result = await controller.testConnection(mockTenantId, testDto); + + expect(result).toEqual({ + success: false, + messageId: undefined, + error: 'Invalid token', + }); + }); + }); + }); + + describe('Send Messages', () => { + describe('sendTextMessage', () => { + it('should send text message successfully', async () => { + const dto = { to: '+1234567890', text: 'Hello!' }; + service.sendTextMessage.mockResolvedValue({ + success: true, + messageId: 'msg-001', + }); + + const result = await controller.sendTextMessage(mockTenantId, dto); + + expect(service.sendTextMessage).toHaveBeenCalledWith(mockTenantId, dto); + expect(result).toEqual({ + success: true, + messageId: 'msg-001', + error: undefined, + }); + }); + + it('should handle send failure', async () => { + const dto = { to: '+1234567890', text: 'Hello!' }; + service.sendTextMessage.mockResolvedValue({ + success: false, + error: 'Rate limit exceeded', + }); + + const result = await controller.sendTextMessage(mockTenantId, dto); + + expect(result.success).toBe(false); + expect(result.error).toBe('Rate limit exceeded'); + }); + }); + + describe('sendTemplateMessage', () => { + it('should send template message successfully', async () => { + const dto = { + to: '+1234567890', + templateName: 'welcome', + languageCode: 'en', + components: [], + }; + service.sendTemplateMessage.mockResolvedValue({ + success: true, + messageId: 'msg-002', + }); + + const result = await controller.sendTemplateMessage(mockTenantId, dto); + + expect(service.sendTemplateMessage).toHaveBeenCalledWith(mockTenantId, dto); + expect(result.success).toBe(true); + }); + }); + + describe('sendMediaMessage', () => { + it('should send media message successfully', async () => { + const dto = { + to: '+1234567890', + mediaType: 'image' as const, + mediaUrl: 'https://example.com/image.jpg', + }; + service.sendMediaMessage.mockResolvedValue({ + success: true, + messageId: 'msg-003', + }); + + const result = await controller.sendMediaMessage(mockTenantId, dto); + + expect(service.sendMediaMessage).toHaveBeenCalledWith(mockTenantId, dto); + expect(result.success).toBe(true); + }); + }); + }); + + describe('Message History', () => { + describe('getMessages', () => { + it('should return paginated messages', async () => { + const mockMessages = { + data: [{ id: 'msg-001', text: 'Hello' }], + total: 1, + page: 1, + limit: 20, + }; + service.getMessages.mockResolvedValue(mockMessages as any); + + const result = await controller.getMessages(mockTenantId, 1, 20); + + expect(service.getMessages).toHaveBeenCalledWith(mockTenantId, { + page: 1, + limit: 20, + phoneNumber: undefined, + direction: undefined, + }); + expect(result).toEqual(mockMessages); + }); + + it('should filter by phone number and direction', async () => { + const mockMessages = { data: [], total: 0, page: 1, limit: 20 }; + service.getMessages.mockResolvedValue(mockMessages as any); + + await controller.getMessages(mockTenantId, 1, 10, '+1234567890', 'outbound'); + + expect(service.getMessages).toHaveBeenCalledWith(mockTenantId, { + page: 1, + limit: 10, + phoneNumber: '+1234567890', + direction: 'outbound', + }); + }); + + it('should use default pagination values', async () => { + service.getMessages.mockResolvedValue({ data: [], total: 0 } as any); + + await controller.getMessages(mockTenantId); + + expect(service.getMessages).toHaveBeenCalledWith(mockTenantId, { + page: 1, + limit: 20, + phoneNumber: undefined, + direction: undefined, + }); + }); + }); + + describe('getMessage', () => { + it('should return specific message', async () => { + const mockMessage = { id: 'msg-001', text: 'Hello' }; + service.getMessage.mockResolvedValue(mockMessage as any); + + const result = await controller.getMessage(mockTenantId, 'msg-001'); + + expect(service.getMessage).toHaveBeenCalledWith('msg-001', mockTenantId); + expect(result).toEqual(mockMessage); + }); + }); + }); +}); diff --git a/src/modules/whatsapp/__tests__/whatsapp.service.spec.ts b/src/modules/whatsapp/__tests__/whatsapp.service.spec.ts new file mode 100644 index 0000000..555c84f --- /dev/null +++ b/src/modules/whatsapp/__tests__/whatsapp.service.spec.ts @@ -0,0 +1,502 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Repository } from 'typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { WhatsAppService, WhatsAppSendResult } from '../services/whatsapp.service'; +import { WhatsAppConfig, WhatsAppMessage } from '../entities'; + +describe('WhatsAppService', () => { + let service: WhatsAppService; + let configRepository: jest.Mocked>; + let messageRepository: jest.Mocked>; + + const mockTenantId = '550e8400-e29b-41d4-a716-446655440000'; + + const mockConfig: WhatsAppConfig = { + id: '550e8400-e29b-41d4-a716-446655440001', + tenant_id: mockTenantId, + phone_number_id: '123456789', + business_account_id: '987654321', + access_token: 'test-access-token', + webhook_verify_token: 'verify-token', + webhook_secret: null, + display_phone_number: '+521234567890', + verified_name: 'Test Business', + quality_rating: 'GREEN', + is_active: true, + is_verified: true, + last_verified_at: new Date(), + daily_message_limit: 1000, + messages_sent_today: 0, + last_message_at: null, + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + tenant: {} as any, + messages: [], + }; + + const mockMessage: WhatsAppMessage = { + id: '550e8400-e29b-41d4-a716-446655440002', + tenant_id: mockTenantId, + config_id: mockConfig.id, + wamid: 'wamid.test123', + conversation_id: null, + direction: 'outbound', + message_type: 'text', + phone_number: '+521234567890', + user_id: null, + contact_name: 'Test User', + content: 'Hello World', + template_name: null, + template_language: 'es', + template_components: null, + media_url: null, + media_mime_type: null, + status: 'sent', + status_timestamp: new Date(), + error_code: null, + error_message: null, + pricing_model: null, + pricing_category: null, + metadata: {}, + created_at: new Date(), + updated_at: new Date(), + tenant: {} as any, + config: {} as any, + user: {} as any, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WhatsAppService, + { + provide: getRepositoryToken(WhatsAppConfig), + useValue: { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }, + }, + { + provide: getRepositoryToken(WhatsAppMessage), + useValue: { + findOne: jest.fn(), + findAndCount: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + if (key === 'whatsapp.apiVersion') return 'v17.0'; + return null; + }), + }, + }, + ], + }).compile(); + + service = module.get(WhatsAppService); + configRepository = module.get(getRepositoryToken(WhatsAppConfig)); + messageRepository = module.get(getRepositoryToken(WhatsAppMessage)); + + // Initialize the service + service.onModuleInit(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getConfig', () => { + it('should return config when found', async () => { + configRepository.findOne.mockResolvedValue(mockConfig); + + const result = await service.getConfig(mockTenantId); + + expect(result).toEqual(mockConfig); + expect(configRepository.findOne).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + }); + }); + + it('should return null when not found', async () => { + configRepository.findOne.mockResolvedValue(null); + + const result = await service.getConfig(mockTenantId); + + expect(result).toBeNull(); + }); + }); + + describe('createConfig', () => { + const createDto = { + phoneNumberId: '123456789', + businessAccountId: '987654321', + accessToken: 'test-token', + dailyMessageLimit: 500, + }; + + it('should create a new config', async () => { + configRepository.findOne.mockResolvedValue(null); + configRepository.create.mockReturnValue(mockConfig); + configRepository.save.mockResolvedValue(mockConfig); + + // Mock fetch for verification + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + display_phone_number: '+521234567890', + verified_name: 'Test Business', + quality_rating: 'GREEN', + }), + }) as jest.Mock; + + const result = await service.createConfig(mockTenantId, createDto); + + expect(result).toEqual(mockConfig); + expect(configRepository.create).toHaveBeenCalled(); + expect(configRepository.save).toHaveBeenCalled(); + }); + + it('should throw if config already exists', async () => { + configRepository.findOne.mockResolvedValue(mockConfig); + + await expect(service.createConfig(mockTenantId, createDto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('updateConfig', () => { + const updateDto = { + dailyMessageLimit: 2000, + isActive: false, + }; + + it('should update existing config', async () => { + configRepository.findOne.mockResolvedValue(mockConfig); + configRepository.save.mockResolvedValue({ + ...mockConfig, + daily_message_limit: 2000, + is_active: false, + }); + + const result = await service.updateConfig(mockTenantId, updateDto); + + expect(result.daily_message_limit).toBe(2000); + expect(configRepository.save).toHaveBeenCalled(); + }); + + it('should throw if config not found', async () => { + configRepository.findOne.mockResolvedValue(null); + + await expect(service.updateConfig(mockTenantId, updateDto)).rejects.toThrow(NotFoundException); + }); + }); + + describe('deleteConfig', () => { + it('should delete config', async () => { + configRepository.findOne.mockResolvedValue(mockConfig); + configRepository.remove.mockResolvedValue(mockConfig); + + await service.deleteConfig(mockTenantId); + + expect(configRepository.remove).toHaveBeenCalledWith(mockConfig); + }); + + it('should throw if config not found', async () => { + configRepository.findOne.mockResolvedValue(null); + + await expect(service.deleteConfig(mockTenantId)).rejects.toThrow(NotFoundException); + }); + }); + + describe('sendTextMessage', () => { + const sendDto = { + to: '+521234567890', + text: 'Hello World', + }; + + beforeEach(() => { + configRepository.findOne.mockResolvedValue(mockConfig); + messageRepository.create.mockReturnValue(mockMessage); + messageRepository.save.mockResolvedValue(mockMessage); + }); + + it('should send a text message successfully', async () => { + const activeConfig = { ...mockConfig, is_active: true }; + configRepository.findOne.mockResolvedValue(activeConfig); + messageRepository.create.mockReturnValue(mockMessage); + messageRepository.save.mockResolvedValue(mockMessage); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + messages: [{ id: 'wamid.test123' }], + }), + }) as jest.Mock; + + const result = await service.sendTextMessage(mockTenantId, sendDto); + + expect(result.success).toBe(true); + expect(result.wamid).toBe('wamid.test123'); + expect(messageRepository.create).toHaveBeenCalled(); + expect(messageRepository.save).toHaveBeenCalled(); + }); + + it('should handle API error', async () => { + const activeConfig = { ...mockConfig, is_active: true }; + configRepository.findOne.mockResolvedValue(activeConfig); + messageRepository.create.mockReturnValue(mockMessage); + messageRepository.save.mockResolvedValue(mockMessage); + + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: async () => ({ + error: { message: 'Invalid phone number', code: 100 }, + }), + }) as jest.Mock; + + const result = await service.sendTextMessage(mockTenantId, sendDto); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid phone number'); + }); + + it('should return error when config not found', async () => { + configRepository.findOne.mockResolvedValue(null); + + await expect(service.sendTextMessage(mockTenantId, sendDto)).rejects.toThrow(NotFoundException); + }); + + it('should return error when config is inactive', async () => { + configRepository.findOne.mockResolvedValue({ ...mockConfig, is_active: false }); + + await expect(service.sendTextMessage(mockTenantId, sendDto)).rejects.toThrow(BadRequestException); + }); + + it('should return error when rate limit exceeded', async () => { + const configWithRateLimit = { + ...mockConfig, + is_active: true, + messages_sent_today: 1000, + daily_message_limit: 1000, + last_message_at: new Date(), + }; + configRepository.findOne.mockResolvedValue(configWithRateLimit); + + const result = await service.sendTextMessage(mockTenantId, sendDto); + + expect(result.success).toBe(false); + expect(result.errorCode).toBe('RATE_LIMIT_EXCEEDED'); + }); + }); + + describe('sendTemplateMessage', () => { + const templateDto = { + to: '+521234567890', + templateName: 'hello_world', + language: 'es', + components: [], + }; + + beforeEach(() => { + configRepository.findOne.mockResolvedValue(mockConfig); + messageRepository.create.mockReturnValue({ ...mockMessage, message_type: 'template' }); + messageRepository.save.mockResolvedValue({ ...mockMessage, message_type: 'template' }); + }); + + it('should send a template message successfully', async () => { + const activeConfig = { ...mockConfig, is_active: true }; + configRepository.findOne.mockResolvedValue(activeConfig); + messageRepository.create.mockReturnValue({ ...mockMessage, message_type: 'template' }); + messageRepository.save.mockResolvedValue({ ...mockMessage, message_type: 'template' }); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + messages: [{ id: 'wamid.template123' }], + }), + }) as jest.Mock; + + const result = await service.sendTemplateMessage(mockTenantId, templateDto); + + expect(result.success).toBe(true); + expect(messageRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + message_type: 'template', + template_name: 'hello_world', + }), + ); + }); + }); + + describe('handleMessageStatus', () => { + const statusUpdate = { + id: 'wamid.test123', + status: 'delivered' as const, + timestamp: '1234567890', + recipient_id: '521234567890', + }; + + it('should update message status', async () => { + messageRepository.findOne.mockResolvedValue(mockMessage); + messageRepository.save.mockResolvedValue({ ...mockMessage, status: 'delivered' }); + + await service.handleMessageStatus('123456789', statusUpdate); + + expect(messageRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'delivered', + }), + ); + }); + + it('should handle message not found', async () => { + messageRepository.findOne.mockResolvedValue(null); + + // Should not throw, just log warning + await expect(service.handleMessageStatus('123456789', statusUpdate)).resolves.not.toThrow(); + }); + }); + + describe('handleIncomingMessage', () => { + const incomingMessage = { + from: '521234567890', + id: 'wamid.incoming123', + timestamp: '1234567890', + type: 'text' as const, + text: { body: 'Hello from user' }, + }; + + const contact = { + wa_id: '521234567890', + profile: { name: 'User Name' }, + }; + + it('should save incoming message', async () => { + configRepository.findOne.mockResolvedValue(mockConfig); + messageRepository.create.mockReturnValue({ + ...mockMessage, + direction: 'inbound', + content: 'Hello from user', + }); + messageRepository.save.mockResolvedValue({ + ...mockMessage, + direction: 'inbound', + content: 'Hello from user', + }); + + await service.handleIncomingMessage('123456789', incomingMessage, contact); + + expect(messageRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + direction: 'inbound', + phone_number: '+521234567890', + contact_name: 'User Name', + }), + ); + expect(messageRepository.save).toHaveBeenCalled(); + }); + + it('should handle config not found', async () => { + configRepository.findOne.mockResolvedValue(null); + + // Should not throw, just log warning + await expect( + service.handleIncomingMessage('unknown', incomingMessage, contact), + ).resolves.not.toThrow(); + }); + }); + + describe('getMessages', () => { + it('should return paginated messages', async () => { + messageRepository.findAndCount.mockResolvedValue([[mockMessage], 1]); + + const result = await service.getMessages(mockTenantId, { page: 1, limit: 20 }); + + expect(result.data).toHaveLength(1); + expect(result.total).toBe(1); + expect(messageRepository.findAndCount).toHaveBeenCalledWith({ + where: { tenant_id: mockTenantId }, + order: { created_at: 'DESC' }, + skip: 0, + take: 20, + }); + }); + + it('should filter by phone number', async () => { + messageRepository.findAndCount.mockResolvedValue([[mockMessage], 1]); + + await service.getMessages(mockTenantId, { phoneNumber: '+521234567890' }); + + expect(messageRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + phone_number: '+521234567890', + }), + }), + ); + }); + + it('should filter by direction', async () => { + messageRepository.findAndCount.mockResolvedValue([[], 0]); + + await service.getMessages(mockTenantId, { direction: 'inbound' }); + + expect(messageRepository.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + direction: 'inbound', + }), + }), + ); + }); + }); + + describe('getMessage', () => { + it('should return message by id', async () => { + messageRepository.findOne.mockResolvedValue(mockMessage); + + const result = await service.getMessage(mockMessage.id, mockTenantId); + + expect(result).toEqual(mockMessage); + }); + + it('should throw if message not found', async () => { + messageRepository.findOne.mockResolvedValue(null); + + await expect(service.getMessage('unknown-id', mockTenantId)).rejects.toThrow(NotFoundException); + }); + }); + + describe('testConnection', () => { + it('should send test message', async () => { + const activeConfig = { ...mockConfig, is_active: true }; + configRepository.findOne.mockResolvedValue(activeConfig); + messageRepository.create.mockReturnValue(mockMessage); + messageRepository.save.mockResolvedValue(mockMessage); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + messages: [{ id: 'wamid.test123' }], + }), + }) as jest.Mock; + + const result = await service.testConnection(mockTenantId, '+521234567890'); + + expect(result.success).toBe(true); + expect(messageRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { test: true }, + }), + ); + }); + }); +}); diff --git a/src/modules/whatsapp/controllers/index.ts b/src/modules/whatsapp/controllers/index.ts new file mode 100644 index 0000000..890306b --- /dev/null +++ b/src/modules/whatsapp/controllers/index.ts @@ -0,0 +1,2 @@ +export * from './whatsapp.controller'; +export * from './whatsapp-webhook.controller'; diff --git a/src/modules/whatsapp/controllers/whatsapp-webhook.controller.ts b/src/modules/whatsapp/controllers/whatsapp-webhook.controller.ts new file mode 100644 index 0000000..d4455ad --- /dev/null +++ b/src/modules/whatsapp/controllers/whatsapp-webhook.controller.ts @@ -0,0 +1,146 @@ +import { + Controller, + Get, + Post, + Query, + Body, + Param, + Headers, + HttpCode, + HttpStatus, + Logger, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import { WhatsAppService } from '../services/whatsapp.service'; +import { + WhatsAppWebhookDto, + WebhookVerificationDto, +} from '../dto'; +import * as crypto from 'crypto'; + +@ApiTags('WhatsApp Webhook') +@Controller('webhooks/whatsapp') +export class WhatsAppWebhookController { + private readonly logger = new Logger(WhatsAppWebhookController.name); + private verifyToken: string; + + constructor( + private readonly whatsappService: WhatsAppService, + private readonly configService: ConfigService, + ) { + this.verifyToken = this.configService.get('whatsapp.verifyToken') || 'template-saas-whatsapp-verify'; + } + + /** + * Webhook verification endpoint (GET) + * Called by Meta to verify webhook URL + */ + @Get() + @ApiExcludeEndpoint() // Hide from Swagger + verifyWebhook( + @Query('hub.mode') mode: string, + @Query('hub.verify_token') token: string, + @Query('hub.challenge') challenge: string, + ): string { + this.logger.log(`Webhook verification request: mode=${mode}`); + + if (mode !== 'subscribe') { + throw new BadRequestException('Invalid mode'); + } + + if (token !== this.verifyToken) { + this.logger.warn('Webhook verification failed: invalid token'); + throw new UnauthorizedException('Invalid verify token'); + } + + this.logger.log('Webhook verification successful'); + return challenge; + } + + /** + * Webhook receiver endpoint (POST) + * Receives messages and status updates from Meta + */ + @Post() + @HttpCode(HttpStatus.OK) + @ApiExcludeEndpoint() // Hide from Swagger - this is called by Meta + async handleWebhook( + @Body() body: WhatsAppWebhookDto, + @Headers('x-hub-signature-256') signature: string, + ): Promise { + // Verify signature if app secret is configured + const appSecret = this.configService.get('whatsapp.appSecret'); + if (appSecret && signature) { + const isValid = this.verifySignature(JSON.stringify(body), signature, appSecret); + if (!isValid) { + this.logger.warn('Invalid webhook signature'); + throw new UnauthorizedException('Invalid signature'); + } + } + + // Process webhook + if (body.object !== 'whatsapp_business_account') { + this.logger.debug(`Ignoring non-WhatsApp webhook: ${body.object}`); + return 'OK'; + } + + for (const entry of body.entry) { + for (const change of entry.changes) { + if (change.field !== 'messages') continue; + + const value = change.value; + const phoneNumberId = value.metadata.phone_number_id; + + // Process message statuses + if (value.statuses) { + for (const status of value.statuses) { + try { + await this.whatsappService.handleMessageStatus(phoneNumberId, status); + } catch (error) { + this.logger.error(`Error processing status: ${error.message}`); + } + } + } + + // Process incoming messages + if (value.messages && value.contacts) { + for (let i = 0; i < value.messages.length; i++) { + const message = value.messages[i]; + const contact = value.contacts[i] || value.contacts[0]; + try { + await this.whatsappService.handleIncomingMessage(phoneNumberId, message, contact); + } catch (error) { + this.logger.error(`Error processing message: ${error.message}`); + } + } + } + + // Log errors + if (value.errors) { + for (const error of value.errors) { + this.logger.error(`WhatsApp API error: ${error.code} - ${error.title}: ${error.message}`); + } + } + } + } + + return 'OK'; + } + + private verifySignature(payload: string, signature: string, secret: string): boolean { + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + + const providedSignature = signature.replace('sha256=', ''); + + return crypto.timingSafeEqual( + Buffer.from(expectedSignature), + Buffer.from(providedSignature), + ); + } +} diff --git a/src/modules/whatsapp/controllers/whatsapp.controller.ts b/src/modules/whatsapp/controllers/whatsapp.controller.ts new file mode 100644 index 0000000..c0d6304 --- /dev/null +++ b/src/modules/whatsapp/controllers/whatsapp.controller.ts @@ -0,0 +1,221 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard, RolesGuard } from '../../auth/guards'; +import { Roles, CurrentUser, CurrentTenant } from '../../auth/decorators'; +import { WhatsAppService } from '../services/whatsapp.service'; +import { + SendTextMessageDto, + SendTemplateMessageDto, + SendMediaMessageDto, + CreateWhatsAppConfigDto, + UpdateWhatsAppConfigDto, + WhatsAppConfigResponseDto, + TestConnectionDto, +} from '../dto'; + +@ApiTags('WhatsApp') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard, RolesGuard) +@Controller('whatsapp') +export class WhatsAppController { + constructor(private readonly whatsappService: WhatsAppService) {} + + // ==================== Configuration ==================== + + @Get('config') + @Roles('admin') + @ApiOperation({ summary: 'Get WhatsApp configuration for tenant' }) + @ApiResponse({ status: 200, description: 'Configuration found', type: WhatsAppConfigResponseDto }) + @ApiResponse({ status: 404, description: 'Configuration not found' }) + async getConfig(@CurrentTenant() tenantId: string): Promise { + const config = await this.whatsappService.getConfig(tenantId); + if (!config) return null; + + return { + id: config.id, + phoneNumberId: config.phone_number_id, + businessAccountId: config.business_account_id, + displayPhoneNumber: config.display_phone_number, + verifiedName: config.verified_name, + qualityRating: config.quality_rating, + isActive: config.is_active, + isVerified: config.is_verified, + dailyMessageLimit: config.daily_message_limit, + messagesSentToday: config.messages_sent_today, + createdAt: config.created_at, + updatedAt: config.updated_at, + }; + } + + @Post('config') + @Roles('admin') + @ApiOperation({ summary: 'Create WhatsApp configuration' }) + @ApiResponse({ status: 201, description: 'Configuration created', type: WhatsAppConfigResponseDto }) + @ApiResponse({ status: 400, description: 'Configuration already exists' }) + async createConfig( + @CurrentTenant() tenantId: string, + @Body() dto: CreateWhatsAppConfigDto, + ): Promise { + const config = await this.whatsappService.createConfig(tenantId, dto); + return { + id: config.id, + phoneNumberId: config.phone_number_id, + businessAccountId: config.business_account_id, + displayPhoneNumber: config.display_phone_number, + verifiedName: config.verified_name, + qualityRating: config.quality_rating, + isActive: config.is_active, + isVerified: config.is_verified, + dailyMessageLimit: config.daily_message_limit, + messagesSentToday: config.messages_sent_today, + createdAt: config.created_at, + updatedAt: config.updated_at, + }; + } + + @Put('config') + @Roles('admin') + @ApiOperation({ summary: 'Update WhatsApp configuration' }) + @ApiResponse({ status: 200, description: 'Configuration updated', type: WhatsAppConfigResponseDto }) + @ApiResponse({ status: 404, description: 'Configuration not found' }) + async updateConfig( + @CurrentTenant() tenantId: string, + @Body() dto: UpdateWhatsAppConfigDto, + ): Promise { + const config = await this.whatsappService.updateConfig(tenantId, dto); + return { + id: config.id, + phoneNumberId: config.phone_number_id, + businessAccountId: config.business_account_id, + displayPhoneNumber: config.display_phone_number, + verifiedName: config.verified_name, + qualityRating: config.quality_rating, + isActive: config.is_active, + isVerified: config.is_verified, + dailyMessageLimit: config.daily_message_limit, + messagesSentToday: config.messages_sent_today, + createdAt: config.created_at, + updatedAt: config.updated_at, + }; + } + + @Delete('config') + @Roles('admin') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete WhatsApp configuration' }) + @ApiResponse({ status: 204, description: 'Configuration deleted' }) + @ApiResponse({ status: 404, description: 'Configuration not found' }) + async deleteConfig(@CurrentTenant() tenantId: string): Promise { + await this.whatsappService.deleteConfig(tenantId); + } + + @Post('config/test') + @Roles('admin') + @ApiOperation({ summary: 'Test WhatsApp connection by sending a test message' }) + @ApiResponse({ status: 200, description: 'Test result' }) + async testConnection( + @CurrentTenant() tenantId: string, + @Body() dto: TestConnectionDto, + ): Promise<{ success: boolean; messageId?: string; error?: string }> { + const result = await this.whatsappService.testConnection(tenantId, dto.testPhoneNumber); + return { + success: result.success, + messageId: result.messageId, + error: result.error, + }; + } + + // ==================== Send Messages ==================== + + @Post('send/text') + @ApiOperation({ summary: 'Send a text message via WhatsApp' }) + @ApiResponse({ status: 201, description: 'Message sent' }) + async sendTextMessage( + @CurrentTenant() tenantId: string, + @Body() dto: SendTextMessageDto, + ): Promise<{ success: boolean; messageId?: string; error?: string }> { + const result = await this.whatsappService.sendTextMessage(tenantId, dto); + return { + success: result.success, + messageId: result.messageId, + error: result.error, + }; + } + + @Post('send/template') + @ApiOperation({ summary: 'Send a template message via WhatsApp' }) + @ApiResponse({ status: 201, description: 'Message sent' }) + async sendTemplateMessage( + @CurrentTenant() tenantId: string, + @Body() dto: SendTemplateMessageDto, + ): Promise<{ success: boolean; messageId?: string; error?: string }> { + const result = await this.whatsappService.sendTemplateMessage(tenantId, dto); + return { + success: result.success, + messageId: result.messageId, + error: result.error, + }; + } + + @Post('send/media') + @ApiOperation({ summary: 'Send a media message via WhatsApp' }) + @ApiResponse({ status: 201, description: 'Message sent' }) + async sendMediaMessage( + @CurrentTenant() tenantId: string, + @Body() dto: SendMediaMessageDto, + ): Promise<{ success: boolean; messageId?: string; error?: string }> { + const result = await this.whatsappService.sendMediaMessage(tenantId, dto); + return { + success: result.success, + messageId: result.messageId, + error: result.error, + }; + } + + // ==================== Message History ==================== + + @Get('messages') + @ApiOperation({ summary: 'Get WhatsApp message history' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'phoneNumber', required: false, type: String }) + @ApiQuery({ name: 'direction', required: false, enum: ['inbound', 'outbound'] }) + @ApiResponse({ status: 200, description: 'Messages retrieved' }) + async getMessages( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('phoneNumber') phoneNumber?: string, + @Query('direction') direction?: 'inbound' | 'outbound', + ) { + return this.whatsappService.getMessages(tenantId, { + page: page || 1, + limit: limit || 20, + phoneNumber, + direction, + }); + } + + @Get('messages/:id') + @ApiOperation({ summary: 'Get a specific WhatsApp message' }) + @ApiResponse({ status: 200, description: 'Message found' }) + @ApiResponse({ status: 404, description: 'Message not found' }) + async getMessage( + @CurrentTenant() tenantId: string, + @Param('id') messageId: string, + ) { + return this.whatsappService.getMessage(messageId, tenantId); + } +} diff --git a/src/modules/whatsapp/dto/index.ts b/src/modules/whatsapp/dto/index.ts new file mode 100644 index 0000000..4b6e9c9 --- /dev/null +++ b/src/modules/whatsapp/dto/index.ts @@ -0,0 +1,3 @@ +export * from './send-whatsapp.dto'; +export * from './whatsapp-webhook.dto'; +export * from './whatsapp-config.dto'; diff --git a/src/modules/whatsapp/dto/send-whatsapp.dto.ts b/src/modules/whatsapp/dto/send-whatsapp.dto.ts new file mode 100644 index 0000000..256f20c --- /dev/null +++ b/src/modules/whatsapp/dto/send-whatsapp.dto.ts @@ -0,0 +1,171 @@ +import { IsString, IsNotEmpty, IsOptional, IsEnum, IsObject, Matches, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class TemplateComponentParameterDto { + @ApiProperty({ enum: ['text', 'currency', 'date_time', 'image', 'document', 'video'] }) + @IsEnum(['text', 'currency', 'date_time', 'image', 'document', 'video']) + type: 'text' | 'currency' | 'date_time' | 'image' | 'document' | 'video'; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + currency?: { fallback_value: string; code: string; amount_1000: number }; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + date_time?: { fallback_value: string }; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + image?: { link: string }; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + document?: { link: string; filename?: string }; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + video?: { link: string }; +} + +export class TemplateComponentDto { + @ApiProperty({ enum: ['header', 'body', 'button'] }) + @IsEnum(['header', 'body', 'button']) + type: 'header' | 'body' | 'button'; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sub_type?: 'quick_reply' | 'url'; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + index?: string; + + @ApiPropertyOptional({ type: [TemplateComponentParameterDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TemplateComponentParameterDto) + parameters?: TemplateComponentParameterDto[]; +} + +export class SendTextMessageDto { + @ApiProperty({ description: 'Phone number in E.164 format', example: '+521234567890' }) + @IsString() + @IsNotEmpty() + @Matches(/^\+[1-9]\d{1,14}$/, { message: 'Phone number must be in E.164 format' }) + to: string; + + @ApiProperty({ description: 'Message text content' }) + @IsString() + @IsNotEmpty() + text: string; + + @ApiPropertyOptional({ description: 'Preview URLs in message' }) + @IsOptional() + previewUrl?: boolean; + + @ApiPropertyOptional({ description: 'Optional user ID to link message' }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ description: 'Contact name for display' }) + @IsOptional() + @IsString() + contactName?: string; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class SendTemplateMessageDto { + @ApiProperty({ description: 'Phone number in E.164 format', example: '+521234567890' }) + @IsString() + @IsNotEmpty() + @Matches(/^\+[1-9]\d{1,14}$/, { message: 'Phone number must be in E.164 format' }) + to: string; + + @ApiProperty({ description: 'Template name registered with Meta' }) + @IsString() + @IsNotEmpty() + templateName: string; + + @ApiPropertyOptional({ description: 'Template language code', example: 'es' }) + @IsOptional() + @IsString() + language?: string; + + @ApiPropertyOptional({ description: 'Template components with parameters', type: [TemplateComponentDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TemplateComponentDto) + components?: TemplateComponentDto[]; + + @ApiPropertyOptional({ description: 'Optional user ID to link message' }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ description: 'Contact name for display' }) + @IsOptional() + @IsString() + contactName?: string; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class SendMediaMessageDto { + @ApiProperty({ description: 'Phone number in E.164 format', example: '+521234567890' }) + @IsString() + @IsNotEmpty() + @Matches(/^\+[1-9]\d{1,14}$/, { message: 'Phone number must be in E.164 format' }) + to: string; + + @ApiProperty({ enum: ['image', 'document', 'audio', 'video'] }) + @IsEnum(['image', 'document', 'audio', 'video']) + mediaType: 'image' | 'document' | 'audio' | 'video'; + + @ApiProperty({ description: 'URL of the media file' }) + @IsString() + @IsNotEmpty() + mediaUrl: string; + + @ApiPropertyOptional({ description: 'Caption for the media' }) + @IsOptional() + @IsString() + caption?: string; + + @ApiPropertyOptional({ description: 'Filename for documents' }) + @IsOptional() + @IsString() + filename?: string; + + @ApiPropertyOptional({ description: 'Optional user ID to link message' }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ description: 'Additional metadata' }) + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/src/modules/whatsapp/dto/whatsapp-config.dto.ts b/src/modules/whatsapp/dto/whatsapp-config.dto.ts new file mode 100644 index 0000000..aa0cb95 --- /dev/null +++ b/src/modules/whatsapp/dto/whatsapp-config.dto.ts @@ -0,0 +1,108 @@ +import { IsString, IsNotEmpty, IsOptional, IsBoolean, IsNumber, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateWhatsAppConfigDto { + @ApiProperty({ description: 'Meta Phone Number ID' }) + @IsString() + @IsNotEmpty() + phoneNumberId: string; + + @ApiProperty({ description: 'Meta Business Account ID' }) + @IsString() + @IsNotEmpty() + businessAccountId: string; + + @ApiProperty({ description: 'Meta Cloud API Access Token' }) + @IsString() + @IsNotEmpty() + accessToken: string; + + @ApiPropertyOptional({ description: 'Webhook verify token' }) + @IsOptional() + @IsString() + webhookVerifyToken?: string; + + @ApiPropertyOptional({ description: 'Daily message limit', default: 1000 }) + @IsOptional() + @IsNumber() + @Min(1) + dailyMessageLimit?: number; +} + +export class UpdateWhatsAppConfigDto { + @ApiPropertyOptional({ description: 'Meta Phone Number ID' }) + @IsOptional() + @IsString() + phoneNumberId?: string; + + @ApiPropertyOptional({ description: 'Meta Business Account ID' }) + @IsOptional() + @IsString() + businessAccountId?: string; + + @ApiPropertyOptional({ description: 'Meta Cloud API Access Token' }) + @IsOptional() + @IsString() + accessToken?: string; + + @ApiPropertyOptional({ description: 'Webhook verify token' }) + @IsOptional() + @IsString() + webhookVerifyToken?: string; + + @ApiPropertyOptional({ description: 'Daily message limit' }) + @IsOptional() + @IsNumber() + @Min(1) + dailyMessageLimit?: number; + + @ApiPropertyOptional({ description: 'Enable/disable WhatsApp integration' }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class WhatsAppConfigResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + phoneNumberId: string; + + @ApiProperty() + businessAccountId: string; + + @ApiProperty() + displayPhoneNumber: string | null; + + @ApiProperty() + verifiedName: string | null; + + @ApiProperty() + qualityRating: string | null; + + @ApiProperty() + isActive: boolean; + + @ApiProperty() + isVerified: boolean; + + @ApiProperty() + dailyMessageLimit: number; + + @ApiProperty() + messagesSentToday: number; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} + +export class TestConnectionDto { + @ApiProperty({ description: 'Phone number to send test message to' }) + @IsString() + @IsNotEmpty() + testPhoneNumber: string; +} diff --git a/src/modules/whatsapp/dto/whatsapp-webhook.dto.ts b/src/modules/whatsapp/dto/whatsapp-webhook.dto.ts new file mode 100644 index 0000000..9fb02cd --- /dev/null +++ b/src/modules/whatsapp/dto/whatsapp-webhook.dto.ts @@ -0,0 +1,94 @@ +import { IsString, IsNotEmpty, IsOptional, IsObject, IsArray } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +// Meta Webhook payload structures +export interface WhatsAppWebhookMessage { + from: string; + id: string; + timestamp: string; + type: 'text' | 'image' | 'document' | 'audio' | 'video' | 'sticker' | 'location' | 'contacts' | 'button' | 'interactive'; + text?: { body: string }; + image?: { id: string; mime_type: string; sha256: string; caption?: string }; + document?: { id: string; mime_type: string; sha256: string; filename?: string; caption?: string }; + audio?: { id: string; mime_type: string }; + video?: { id: string; mime_type: string }; + location?: { latitude: number; longitude: number; name?: string; address?: string }; + contacts?: Array<{ name: { formatted_name: string; first_name?: string }; phones?: Array<{ phone: string; type: string }> }>; + button?: { text: string; payload: string }; + interactive?: { type: string; button_reply?: { id: string; title: string }; list_reply?: { id: string; title: string } }; + context?: { from: string; id: string }; + errors?: Array<{ code: number; title: string; message?: string; error_data?: any }>; +} + +export interface WhatsAppWebhookStatus { + id: string; + status: 'sent' | 'delivered' | 'read' | 'failed'; + timestamp: string; + recipient_id: string; + conversation?: { + id: string; + origin: { type: 'business_initiated' | 'user_initiated' | 'referral_conversion' }; + expiration_timestamp?: string; + }; + pricing?: { + billable: boolean; + pricing_model: string; + category: string; + }; + errors?: Array<{ code: number; title: string; message?: string; error_data?: any }>; +} + +export interface WhatsAppWebhookContact { + wa_id: string; + profile: { name: string }; +} + +export interface WhatsAppWebhookValue { + messaging_product: 'whatsapp'; + metadata: { + display_phone_number: string; + phone_number_id: string; + }; + contacts?: WhatsAppWebhookContact[]; + messages?: WhatsAppWebhookMessage[]; + statuses?: WhatsAppWebhookStatus[]; + errors?: Array<{ code: number; title: string; message?: string }>; +} + +export interface WhatsAppWebhookChange { + field: 'messages'; + value: WhatsAppWebhookValue; +} + +export interface WhatsAppWebhookEntry { + id: string; + changes: WhatsAppWebhookChange[]; +} + +export class WhatsAppWebhookDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + object: string; + + @ApiProperty() + @IsArray() + entry: WhatsAppWebhookEntry[]; +} + +export class WebhookVerificationDto { + @ApiProperty({ name: 'hub.mode' }) + @IsString() + @IsNotEmpty() + 'hub.mode': string; + + @ApiProperty({ name: 'hub.verify_token' }) + @IsString() + @IsNotEmpty() + 'hub.verify_token': string; + + @ApiProperty({ name: 'hub.challenge' }) + @IsString() + @IsNotEmpty() + 'hub.challenge': string; +} diff --git a/src/modules/whatsapp/entities/index.ts b/src/modules/whatsapp/entities/index.ts new file mode 100644 index 0000000..ad95a05 --- /dev/null +++ b/src/modules/whatsapp/entities/index.ts @@ -0,0 +1,2 @@ +export * from './whatsapp-config.entity'; +export * from './whatsapp-message.entity'; diff --git a/src/modules/whatsapp/entities/whatsapp-config.entity.ts b/src/modules/whatsapp/entities/whatsapp-config.entity.ts new file mode 100644 index 0000000..bc4122d --- /dev/null +++ b/src/modules/whatsapp/entities/whatsapp-config.entity.ts @@ -0,0 +1,86 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { Tenant } from '@modules/tenants/entities/tenant.entity'; +import { WhatsAppMessage } from './whatsapp-message.entity'; + +@Entity({ name: 'configs', schema: 'whatsapp' }) +export class WhatsAppConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenant_id: string; + + // Meta Cloud API credentials + @Column({ name: 'phone_number_id', type: 'varchar', length: 50 }) + phone_number_id: string; + + @Column({ name: 'business_account_id', type: 'varchar', length: 50 }) + business_account_id: string; + + @Column({ name: 'access_token', type: 'text' }) + access_token: string; + + // Webhook configuration + @Column({ name: 'webhook_verify_token', type: 'varchar', length: 100, nullable: true }) + webhook_verify_token: string | null; + + @Column({ name: 'webhook_secret', type: 'varchar', length: 255, nullable: true }) + webhook_secret: string | null; + + // Display info + @Column({ name: 'display_phone_number', type: 'varchar', length: 20, nullable: true }) + display_phone_number: string | null; + + @Column({ name: 'verified_name', type: 'varchar', length: 255, nullable: true }) + verified_name: string | null; + + @Column({ name: 'quality_rating', type: 'varchar', length: 50, nullable: true }) + quality_rating: string | null; + + // Status + @Column({ name: 'is_active', type: 'boolean', default: true }) + is_active: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + is_verified: boolean; + + @Column({ name: 'last_verified_at', type: 'timestamptz', nullable: true }) + last_verified_at: Date | null; + + // Rate limiting + @Column({ name: 'daily_message_limit', type: 'integer', default: 1000 }) + daily_message_limit: number; + + @Column({ name: 'messages_sent_today', type: 'integer', default: 0 }) + messages_sent_today: number; + + @Column({ name: 'last_message_at', type: 'timestamptz', nullable: true }) + last_message_at: Date | null; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + created_at: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updated_at: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @OneToMany(() => WhatsAppMessage, (message) => message.config) + messages: WhatsAppMessage[]; +} diff --git a/src/modules/whatsapp/entities/whatsapp-message.entity.ts b/src/modules/whatsapp/entities/whatsapp-message.entity.ts new file mode 100644 index 0000000..a205f67 --- /dev/null +++ b/src/modules/whatsapp/entities/whatsapp-message.entity.ts @@ -0,0 +1,131 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '@modules/tenants/entities/tenant.entity'; +import { User } from '@modules/auth/entities/user.entity'; +import { WhatsAppConfig } from './whatsapp-config.entity'; + +export type WhatsAppMessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; +export type WhatsAppMessageType = 'text' | 'template' | 'image' | 'document' | 'audio' | 'video' | 'location' | 'contacts' | 'interactive'; +export type WhatsAppMessageDirection = 'outbound' | 'inbound'; + +@Entity({ name: 'messages', schema: 'whatsapp' }) +export class WhatsAppMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenant_id: string; + + @Column({ name: 'config_id', type: 'uuid' }) + config_id: string; + + // Message identifiers + @Column({ name: 'wamid', type: 'varchar', length: 100, nullable: true }) + wamid: string | null; + + @Column({ name: 'conversation_id', type: 'varchar', length: 100, nullable: true }) + conversation_id: string | null; + + // Direction and type + @Column({ + name: 'direction', + type: 'enum', + enum: ['outbound', 'inbound'], + enumName: 'whatsapp.message_direction', + default: 'outbound', + }) + direction: WhatsAppMessageDirection; + + @Column({ + name: 'message_type', + type: 'enum', + enum: ['text', 'template', 'image', 'document', 'audio', 'video', 'location', 'contacts', 'interactive'], + enumName: 'whatsapp.message_type', + default: 'text', + }) + message_type: WhatsAppMessageType; + + // Recipient/Sender + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phone_number: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + user_id: string | null; + + @Column({ name: 'contact_name', type: 'varchar', length: 255, nullable: true }) + contact_name: string | null; + + // Content + @Column({ type: 'text', nullable: true }) + content: string | null; + + @Column({ name: 'template_name', type: 'varchar', length: 100, nullable: true }) + template_name: string | null; + + @Column({ name: 'template_language', type: 'varchar', length: 10, default: 'es' }) + template_language: string; + + @Column({ name: 'template_components', type: 'jsonb', nullable: true }) + template_components: any | null; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + media_url: string | null; + + @Column({ name: 'media_mime_type', type: 'varchar', length: 100, nullable: true }) + media_mime_type: string | null; + + // Status tracking + @Column({ + type: 'enum', + enum: ['pending', 'sent', 'delivered', 'read', 'failed'], + enumName: 'whatsapp.message_status', + default: 'pending', + }) + status: WhatsAppMessageStatus; + + @Column({ name: 'status_timestamp', type: 'timestamptz', nullable: true }) + status_timestamp: Date | null; + + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + error_code: string | null; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + error_message: string | null; + + // Pricing + @Column({ name: 'pricing_model', type: 'varchar', length: 50, nullable: true }) + pricing_model: string | null; + + @Column({ name: 'pricing_category', type: 'varchar', length: 50, nullable: true }) + pricing_category: string | null; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + created_at: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updated_at: Date; + + // Relations + @ManyToOne(() => Tenant) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => WhatsAppConfig, (config) => config.messages) + @JoinColumn({ name: 'config_id' }) + config: WhatsAppConfig; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User; +} diff --git a/src/modules/whatsapp/index.ts b/src/modules/whatsapp/index.ts new file mode 100644 index 0000000..64edb48 --- /dev/null +++ b/src/modules/whatsapp/index.ts @@ -0,0 +1,4 @@ +export * from './whatsapp.module'; +export * from './services'; +export * from './entities'; +export * from './dto'; diff --git a/src/modules/whatsapp/services/index.ts b/src/modules/whatsapp/services/index.ts new file mode 100644 index 0000000..ef8ed54 --- /dev/null +++ b/src/modules/whatsapp/services/index.ts @@ -0,0 +1 @@ +export * from './whatsapp.service'; diff --git a/src/modules/whatsapp/services/whatsapp.service.ts b/src/modules/whatsapp/services/whatsapp.service.ts new file mode 100644 index 0000000..da6b4be --- /dev/null +++ b/src/modules/whatsapp/services/whatsapp.service.ts @@ -0,0 +1,561 @@ +import { Injectable, Logger, NotFoundException, BadRequestException, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { WhatsAppConfig, WhatsAppMessage, WhatsAppMessageStatus, WhatsAppMessageType } from '../entities'; +import { + SendTextMessageDto, + SendTemplateMessageDto, + SendMediaMessageDto, + CreateWhatsAppConfigDto, + UpdateWhatsAppConfigDto, + WhatsAppWebhookStatus, + WhatsAppWebhookMessage, +} from '../dto'; + +export interface WhatsAppSendResult { + success: boolean; + messageId?: string; + wamid?: string; + error?: string; + errorCode?: string; +} + +interface MetaApiResponse { + messaging_product: string; + contacts: Array<{ input: string; wa_id: string }>; + messages: Array<{ id: string }>; +} + +@Injectable() +export class WhatsAppService implements OnModuleInit { + private readonly logger = new Logger(WhatsAppService.name); + private apiVersion: string; + private baseUrl: string; + + constructor( + @InjectRepository(WhatsAppConfig) + private readonly configRepository: Repository, + @InjectRepository(WhatsAppMessage) + private readonly messageRepository: Repository, + private readonly configService: ConfigService, + ) {} + + onModuleInit() { + this.apiVersion = this.configService.get('whatsapp.apiVersion') || 'v17.0'; + this.baseUrl = `https://graph.facebook.com/${this.apiVersion}`; + this.logger.log(`WhatsApp service initialized with API version: ${this.apiVersion}`); + } + + // ==================== Configuration ==================== + + async getConfig(tenantId: string): Promise { + return this.configRepository.findOne({ + where: { tenant_id: tenantId }, + }); + } + + async createConfig(tenantId: string, dto: CreateWhatsAppConfigDto): Promise { + const existing = await this.getConfig(tenantId); + if (existing) { + throw new BadRequestException('WhatsApp config already exists for this tenant'); + } + + const config = this.configRepository.create({ + tenant_id: tenantId, + phone_number_id: dto.phoneNumberId, + business_account_id: dto.businessAccountId, + access_token: dto.accessToken, // TODO: Encrypt before storing + webhook_verify_token: dto.webhookVerifyToken, + daily_message_limit: dto.dailyMessageLimit || 1000, + }); + + const saved = await this.configRepository.save(config); + + // Verify connection and fetch phone info + await this.verifyAndUpdateConfig(saved); + + return saved; + } + + async updateConfig(tenantId: string, dto: UpdateWhatsAppConfigDto): Promise { + const config = await this.getConfig(tenantId); + if (!config) { + throw new NotFoundException('WhatsApp config not found'); + } + + if (dto.phoneNumberId) config.phone_number_id = dto.phoneNumberId; + if (dto.businessAccountId) config.business_account_id = dto.businessAccountId; + if (dto.accessToken) config.access_token = dto.accessToken; + if (dto.webhookVerifyToken !== undefined) config.webhook_verify_token = dto.webhookVerifyToken; + if (dto.dailyMessageLimit) config.daily_message_limit = dto.dailyMessageLimit; + if (dto.isActive !== undefined) config.is_active = dto.isActive; + + const saved = await this.configRepository.save(config); + + // Re-verify if credentials changed + if (dto.phoneNumberId || dto.accessToken) { + await this.verifyAndUpdateConfig(saved); + } + + return saved; + } + + async deleteConfig(tenantId: string): Promise { + const config = await this.getConfig(tenantId); + if (!config) { + throw new NotFoundException('WhatsApp config not found'); + } + + await this.configRepository.remove(config); + } + + private async verifyAndUpdateConfig(config: WhatsAppConfig): Promise { + try { + const response = await fetch( + `${this.baseUrl}/${config.phone_number_id}`, + { + headers: { Authorization: `Bearer ${config.access_token}` }, + }, + ); + + if (response.ok) { + const data = await response.json(); + config.display_phone_number = data.display_phone_number; + config.verified_name = data.verified_name; + config.quality_rating = data.quality_rating; + config.is_verified = true; + config.last_verified_at = new Date(); + await this.configRepository.save(config); + this.logger.log(`WhatsApp config verified: ${config.display_phone_number}`); + } else { + config.is_verified = false; + await this.configRepository.save(config); + this.logger.warn(`WhatsApp config verification failed: ${response.status}`); + } + } catch (error) { + this.logger.error(`Failed to verify WhatsApp config: ${error.message}`); + config.is_verified = false; + await this.configRepository.save(config); + } + } + + // ==================== Send Messages ==================== + + async sendTextMessage(tenantId: string, dto: SendTextMessageDto): Promise { + const config = await this.getActiveConfig(tenantId); + + // Check rate limit + if (!this.canSendMessage(config)) { + return { + success: false, + error: 'Daily message limit reached', + errorCode: 'RATE_LIMIT_EXCEEDED', + }; + } + + // Create message record + const message = this.messageRepository.create({ + tenant_id: tenantId, + config_id: config.id, + direction: 'outbound', + message_type: 'text', + phone_number: dto.to, + user_id: dto.userId, + contact_name: dto.contactName, + content: dto.text, + status: 'pending', + metadata: dto.metadata || {}, + }); + + await this.messageRepository.save(message); + + try { + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: dto.to.replace('+', ''), + type: 'text', + text: { + preview_url: dto.previewUrl || false, + body: dto.text, + }, + }; + + const result = await this.sendToMetaApi(config, payload); + + if (result.success) { + message.wamid = result.wamid ?? null; + message.status = 'sent'; + message.status_timestamp = new Date(); + await this.updateMessageCount(config); + } else { + message.status = 'failed'; + message.error_code = result.errorCode ?? null; + message.error_message = result.error ?? null; + } + + await this.messageRepository.save(message); + + return { ...result, messageId: message.id }; + } catch (error) { + message.status = 'failed'; + message.error_message = error.message ?? null; + await this.messageRepository.save(message); + + return { + success: false, + messageId: message.id, + error: error.message, + }; + } + } + + async sendTemplateMessage(tenantId: string, dto: SendTemplateMessageDto): Promise { + const config = await this.getActiveConfig(tenantId); + + if (!this.canSendMessage(config)) { + return { + success: false, + error: 'Daily message limit reached', + errorCode: 'RATE_LIMIT_EXCEEDED', + }; + } + + const message = this.messageRepository.create({ + tenant_id: tenantId, + config_id: config.id, + direction: 'outbound', + message_type: 'template', + phone_number: dto.to, + user_id: dto.userId, + contact_name: dto.contactName, + template_name: dto.templateName, + template_language: dto.language || 'es', + template_components: dto.components, + status: 'pending', + metadata: dto.metadata || {}, + }); + + await this.messageRepository.save(message); + + try { + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: dto.to.replace('+', ''), + type: 'template', + template: { + name: dto.templateName, + language: { code: dto.language || 'es' }, + components: dto.components || [], + }, + }; + + const result = await this.sendToMetaApi(config, payload); + + if (result.success) { + message.wamid = result.wamid ?? null; + message.status = 'sent'; + message.status_timestamp = new Date(); + await this.updateMessageCount(config); + } else { + message.status = 'failed'; + message.error_code = result.errorCode ?? null; + message.error_message = result.error ?? null; + } + + await this.messageRepository.save(message); + + return { ...result, messageId: message.id }; + } catch (error) { + message.status = 'failed'; + message.error_message = error.message ?? null; + await this.messageRepository.save(message); + + return { + success: false, + messageId: message.id, + error: error.message, + }; + } + } + + async sendMediaMessage(tenantId: string, dto: SendMediaMessageDto): Promise { + const config = await this.getActiveConfig(tenantId); + + if (!this.canSendMessage(config)) { + return { + success: false, + error: 'Daily message limit reached', + errorCode: 'RATE_LIMIT_EXCEEDED', + }; + } + + const message = this.messageRepository.create({ + tenant_id: tenantId, + config_id: config.id, + direction: 'outbound', + message_type: dto.mediaType as WhatsAppMessageType, + phone_number: dto.to, + user_id: dto.userId, + media_url: dto.mediaUrl, + content: dto.caption, + status: 'pending', + metadata: dto.metadata || {}, + }); + + await this.messageRepository.save(message); + + try { + const mediaObject: any = { link: dto.mediaUrl }; + if (dto.caption) mediaObject.caption = dto.caption; + if (dto.filename) mediaObject.filename = dto.filename; + + const payload = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to: dto.to.replace('+', ''), + type: dto.mediaType, + [dto.mediaType]: mediaObject, + }; + + const result = await this.sendToMetaApi(config, payload); + + if (result.success) { + message.wamid = result.wamid ?? null; + message.status = 'sent'; + message.status_timestamp = new Date(); + await this.updateMessageCount(config); + } else { + message.status = 'failed'; + message.error_code = result.errorCode ?? null; + message.error_message = result.error ?? null; + } + + await this.messageRepository.save(message); + + return { ...result, messageId: message.id }; + } catch (error) { + message.status = 'failed'; + message.error_message = error.message ?? null; + await this.messageRepository.save(message); + + return { + success: false, + messageId: message.id, + error: error.message, + }; + } + } + + private async sendToMetaApi(config: WhatsAppConfig, payload: any): Promise { + const response = await fetch( + `${this.baseUrl}/${config.phone_number_id}/messages`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${config.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }, + ); + + const data = await response.json(); + + if (response.ok) { + return { + success: true, + wamid: data.messages?.[0]?.id, + }; + } + + return { + success: false, + error: data.error?.message || 'Unknown error', + errorCode: data.error?.code?.toString(), + }; + } + + private async getActiveConfig(tenantId: string): Promise { + const config = await this.getConfig(tenantId); + + if (!config) { + throw new NotFoundException('WhatsApp not configured for this tenant'); + } + + if (!config.is_active) { + throw new BadRequestException('WhatsApp integration is disabled'); + } + + return config; + } + + private canSendMessage(config: WhatsAppConfig): boolean { + // Reset daily count if new day + const now = new Date(); + const lastMessage = config.last_message_at; + + if (lastMessage && lastMessage.toDateString() !== now.toDateString()) { + config.messages_sent_today = 0; + } + + return config.messages_sent_today < config.daily_message_limit; + } + + private async updateMessageCount(config: WhatsAppConfig): Promise { + config.messages_sent_today += 1; + config.last_message_at = new Date(); + await this.configRepository.save(config); + } + + // ==================== Webhook Handling ==================== + + async handleMessageStatus(phoneNumberId: string, status: WhatsAppWebhookStatus): Promise { + const message = await this.messageRepository.findOne({ + where: { wamid: status.id }, + }); + + if (!message) { + this.logger.warn(`Message not found for status update: ${status.id}`); + return; + } + + message.status = status.status as WhatsAppMessageStatus; + message.status_timestamp = new Date(parseInt(status.timestamp) * 1000); + + if (status.conversation) { + message.conversation_id = status.conversation.id; + } + + if (status.pricing) { + message.pricing_model = status.pricing.pricing_model; + message.pricing_category = status.pricing.category; + } + + if (status.errors?.length) { + message.error_code = status.errors[0].code.toString(); + message.error_message = status.errors[0].title; + } + + await this.messageRepository.save(message); + this.logger.debug(`Message ${status.id} status updated to ${status.status}`); + } + + async handleIncomingMessage(phoneNumberId: string, message: WhatsAppWebhookMessage, contact: { wa_id: string; profile: { name: string } }): Promise { + // Find config by phone_number_id + const config = await this.configRepository.findOne({ + where: { phone_number_id: phoneNumberId }, + }); + + if (!config) { + this.logger.warn(`No config found for phone_number_id: ${phoneNumberId}`); + return; + } + + const inboundMessage = this.messageRepository.create({ + tenant_id: config.tenant_id, + config_id: config.id, + wamid: message.id, + direction: 'inbound', + message_type: message.type as WhatsAppMessageType, + phone_number: `+${message.from}`, + contact_name: contact.profile.name, + status: 'delivered', + status_timestamp: new Date(parseInt(message.timestamp) * 1000), + }); + + // Extract content based on message type + switch (message.type) { + case 'text': + inboundMessage.content = message.text?.body ?? null; + break; + case 'image': + case 'document': + case 'audio': + case 'video': + inboundMessage.media_url = message[message.type]?.id ?? null; // Media ID, needs separate API call to download + inboundMessage.media_mime_type = message[message.type]?.mime_type ?? null; + if ((message as any)[message.type]?.caption) { + inboundMessage.content = (message as any)[message.type].caption; + } + break; + case 'location': + inboundMessage.metadata = { + latitude: message.location?.latitude, + longitude: message.location?.longitude, + name: message.location?.name, + address: message.location?.address, + }; + break; + case 'button': + inboundMessage.content = message.button?.text ?? null; + inboundMessage.metadata = { payload: message.button?.payload }; + break; + case 'interactive': + const reply = message.interactive?.button_reply || message.interactive?.list_reply; + if (reply) { + inboundMessage.content = reply.title; + inboundMessage.metadata = { reply_id: reply.id }; + } + break; + } + + if (message.context) { + inboundMessage.metadata = { + ...inboundMessage.metadata, + reply_to: message.context.id, + }; + } + + await this.messageRepository.save(inboundMessage); + this.logger.log(`Inbound message saved: ${inboundMessage.id}`); + + // TODO: Emit event for notification service or other handlers + } + + // ==================== Message History ==================== + + async getMessages( + tenantId: string, + options: { page?: number; limit?: number; phoneNumber?: string; direction?: 'inbound' | 'outbound' } = {}, + ): Promise<{ data: WhatsAppMessage[]; total: number }> { + const { page = 1, limit = 20, phoneNumber, direction } = options; + + const whereCondition: any = { tenant_id: tenantId }; + if (phoneNumber) whereCondition.phone_number = phoneNumber; + if (direction) whereCondition.direction = direction; + + const [data, total] = await this.messageRepository.findAndCount({ + where: whereCondition, + order: { created_at: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + async getMessage(messageId: string, tenantId: string): Promise { + const message = await this.messageRepository.findOne({ + where: { id: messageId, tenant_id: tenantId }, + }); + + if (!message) { + throw new NotFoundException('Message not found'); + } + + return message; + } + + // ==================== Test Connection ==================== + + async testConnection(tenantId: string, testPhoneNumber: string): Promise { + return this.sendTextMessage(tenantId, { + to: testPhoneNumber, + text: 'This is a test message from Template SaaS. If you received this, your WhatsApp integration is working correctly!', + metadata: { test: true }, + }); + } +} diff --git a/src/modules/whatsapp/whatsapp.module.ts b/src/modules/whatsapp/whatsapp.module.ts new file mode 100644 index 0000000..52dd6cf --- /dev/null +++ b/src/modules/whatsapp/whatsapp.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule } from '@nestjs/config'; + +import { WhatsAppConfig, WhatsAppMessage } from './entities'; +import { WhatsAppService } from './services/whatsapp.service'; +import { WhatsAppController, WhatsAppWebhookController } from './controllers'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([WhatsAppConfig, WhatsAppMessage]), + ConfigModule, + ], + controllers: [WhatsAppController, WhatsAppWebhookController], + providers: [WhatsAppService], + exports: [WhatsAppService], +}) +export class WhatsAppModule {} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2108ef --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "paths": { + "@/*": ["src/*"], + "@config/*": ["src/config/*"], + "@shared/*": ["src/shared/*"], + "@modules/*": ["src/modules/*"] + } + }, + "include": ["src/**/*", "__mocks__/**/*.d.ts"], + "exclude": ["node_modules", "dist"] +}