From d055ba34d82a44906e1887465887fa940f2cd50a Mon Sep 17 00:00:00 2001 From: "T. Narantuya" Date: Sat, 30 Aug 2025 15:47:48 +0900 Subject: [PATCH] Remove obsolete deployment guides and ESLint report files to streamline project documentation and improve clarity. Updated package configurations and scripts for better development experience and logging integration. --- DEPLOYMENT-GUIDE.md | 141 ----------- DEPLOYMENT.md | 75 ------ apps/bff/nest-cli.json | 3 +- apps/bff/package.json | 4 +- apps/bff/src/common/logging/logging.config.ts | 234 ------------------ apps/bff/src/common/logging/logging.module.ts | 4 +- apps/bff/src/main.ts | 1 + apps/bff/tsconfig.build.json | 15 ++ apps/portal/package.json | 5 +- apps/portal/src/lib/logger.ts | 135 +--------- apps/portal/tsconfig.json | 21 +- eslint-report.json | 1 - package.json | 13 +- packages/shared/package.json | 15 ++ packages/shared/src/logging/index.ts | 2 + .../shared/src/logging/nest-logger.config.ts | 126 ++++++++++ packages/shared/src/logging/pino-logger.ts | 178 +++++++++++++ pnpm-lock.yaml | 4 + scripts/dev/manage.sh | 11 +- tsconfig.json | 6 +- 20 files changed, 383 insertions(+), 611 deletions(-) delete mode 100644 DEPLOYMENT-GUIDE.md delete mode 100644 DEPLOYMENT.md delete mode 100644 apps/bff/src/common/logging/logging.config.ts create mode 100644 apps/bff/tsconfig.build.json delete mode 100644 eslint-report.json create mode 100644 packages/shared/src/logging/nest-logger.config.ts create mode 100644 packages/shared/src/logging/pino-logger.ts diff --git a/DEPLOYMENT-GUIDE.md b/DEPLOYMENT-GUIDE.md deleted file mode 100644 index f4919d86..00000000 --- a/DEPLOYMENT-GUIDE.md +++ /dev/null @@ -1,141 +0,0 @@ -# 🚀 Pre-built Images Deployment Guide - -This guide shows how to deploy using pre-built Docker images instead of building on Plesk. - -## Benefits -- ✅ No build failures on Plesk -- ✅ Faster deployments (no compilation time) -- ✅ Consistent images across environments -- ✅ Better security (build in controlled environment) -- ✅ Easy rollbacks and version control - -## Prerequisites - -1. **GitHub Account** (for free container registry) -2. **Docker installed locally** (for building images) -3. **Plesk with Docker extension** - -## Step 1: Setup GitHub Container Registry - -1. Go to GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic) -2. Create a new token with these permissions: - - `write:packages` (to push images) - - `read:packages` (to pull images) -3. Save the token securely - -## Step 2: Login to GitHub Container Registry - -```bash -# Replace YOUR_USERNAME and YOUR_TOKEN -echo "YOUR_TOKEN" | docker login ghcr.io -u YOUR_USERNAME --password-stdin -``` - -## Step 3: Update Build Script - -Edit `scripts/build-and-push.sh`: -```bash -# Change this line: -NAMESPACE="your-github-username" # Replace with your actual GitHub username -``` - -## Step 4: Build and Push Images - -```bash -# Build and push with version tag -./scripts/build-and-push.sh v1.0.0 - -# Or build and push as latest -./scripts/build-and-push.sh -``` - -## Step 5: Update Plesk Compose File - -Edit `compose-plesk.yaml` and replace: -```yaml -image: ghcr.io/your-github-username/portal-frontend:latest -image: ghcr.io/your-github-username/portal-backend:latest -``` - -With your actual GitHub username. - -## Step 6: Deploy to Plesk - -1. **Upload compose-plesk.yaml** to your Plesk server -2. **Plesk → Docker → Add Stack** -3. **Paste the contents** of `compose-plesk.yaml` -4. **Deploy** - -## Step 7: Configure Plesk Reverse Proxy - -1. **Plesk → Domains → your-domain.com → Apache & Nginx Settings** -2. **Add to "Additional directives for HTTP":** -```nginx -location / { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; -} - -location /api { - proxy_pass http://127.0.0.1:4000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; -} -``` - -## Step 8: Secure Database Access - -Add to Plesk Firewall: -``` -# Allow Docker bridge network -ACCEPT from 172.17.0.0/16 to any port 5432 -ACCEPT from 172.17.0.0/16 to any port 6379 - -# Deny external access to database -DROP from any to any port 5432 -DROP from any to any port 6379 -``` - -## Updating Your Application - -1. **Make code changes** -2. **Build and push new images:** - ```bash - ./scripts/build-and-push.sh v1.0.1 - ``` -3. **Update compose-plesk.yaml** with new version tag -4. **Redeploy in Plesk** - -## Troubleshooting - -### Images not found -- Check if you're logged in: `docker login ghcr.io` -- Verify image names match your GitHub username -- Ensure images are public or Plesk can authenticate - -### Build failures -- Run locally first: `docker build -f apps/portal/Dockerfile .` -- Check Docker logs for specific errors -- Ensure all dependencies are in package.json - -### Connection issues -- Verify firewall allows Docker bridge network (172.17.0.0/16) -- Check that DATABASE_URL uses correct IP (172.17.0.1) -- Test database connection from backend container - -## Security Notes - -- Database is only accessible from Docker bridge network -- Backend API is only accessible via reverse proxy -- Use strong passwords and JWT secrets -- Consider using Docker secrets for sensitive data -- Regularly update base images for security patches diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index 46cccc86..00000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -1,75 +0,0 @@ -# 🚀 Deployment Guide - -## 📁 **Environment Files Overview** - -### **Development:** -- `.env` - Your local development environment (active) -- `.env.example` - Development template for new developers - -### **Production:** -- `.env.production` - Production environment for Plesk deployment -- `compose-plesk.yaml` - Docker Stack definition - -## 🔧 **Plesk Deployment Steps** - -### **Step 1: Authenticate Docker (One-time)** -```bash -# SSH to Plesk server -echo "YOUR_GITHUB_TOKEN" | docker login ghcr.io -u ntumurbars --password-stdin -``` - -### **Step 2: Upload Files to Plesk** -Upload these files to your domain directory: -1. `compose-plesk.yaml` - Docker Stack definition -2. `.env.production` - Environment variables (rename to `.env`) - -### **Step 3: Deploy Stack** -1. **Plesk → Docker → Stacks → Add Stack** -2. **Project name**: `customer-portal` -3. **Method**: Upload file or paste `compose-plesk.yaml` content -4. **Deploy** - -### **Step 4: Configure Nginx Proxy** -1. **Plesk → Websites & Domains → yourdomain.com → Docker Proxy Rules** -2. **Add rule**: `/` → `portal-frontend` → port `3000` -3. **Add rule**: `/api` → `portal-backend` → port `4000` - -## 🔄 **Update Workflow** - -### **When You Push Code:** -1. **GitHub Actions** builds new images automatically -2. **SSH to Plesk** and update: - ```bash - cd /var/www/vhosts/yourdomain.com/httpdocs/ - docker compose -f compose-plesk.yaml pull - docker compose -f compose-plesk.yaml up -d - ``` - -## 🔐 **Environment Variables** - -Your compose file uses these key variables from `.env.production`: - -### **Database:** -- `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` -- `DATABASE_URL` - Full connection string - -### **Application:** -- `JWT_SECRET`, `CORS_ORIGIN` -- `NEXT_PUBLIC_API_BASE`, `NEXT_PUBLIC_APP_NAME` - -### **External APIs:** -- `WHMCS_BASE_URL`, `WHMCS_API_IDENTIFIER`, `WHMCS_API_SECRET` -- `SF_LOGIN_URL`, `SF_CLIENT_ID`, `SF_USERNAME` - -### **Email & Logging:** -- `SENDGRID_API_KEY`, `EMAIL_FROM` -- `LOG_LEVEL`, `LOG_FORMAT` - -## ✅ **Ready to Deploy!** - -Your setup is clean and production-ready: -- ✅ Environment variables properly configured -- ✅ Docker secrets via environment variables -- ✅ Database and Redis secured (localhost only) -- ✅ Automated image building -- ✅ Clean file structure diff --git a/apps/bff/nest-cli.json b/apps/bff/nest-cli.json index ad492172..1a89e095 100644 --- a/apps/bff/nest-cli.json +++ b/apps/bff/nest-cli.json @@ -5,6 +5,7 @@ "compilerOptions": { "deleteOutDir": true, "watchAssets": true, - "assets": ["**/*.prisma"] + "assets": ["**/*.prisma"], + "tsConfigPath": "tsconfig.build.json" } } diff --git a/apps/bff/package.json b/apps/bff/package.json index ea91285a..07648d3e 100644 --- a/apps/bff/package.json +++ b/apps/bff/package.json @@ -6,7 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { - "build": "nest build", + "build": "nest build -c tsconfig.build.json", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", "dev": "NODE_OPTIONS=\"--no-deprecation\" nest start --watch", @@ -82,7 +82,7 @@ "source-map-support": "^0.5.21", "supertest": "^7.1.4", "ts-jest": "^29.4.1", - "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^5.9.2" diff --git a/apps/bff/src/common/logging/logging.config.ts b/apps/bff/src/common/logging/logging.config.ts deleted file mode 100644 index 42cf1015..00000000 --- a/apps/bff/src/common/logging/logging.config.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { Params } from "nestjs-pino"; -import type { Options as PinoHttpOptions } from "pino-http"; -import type { IncomingMessage, ServerResponse } from "http"; -import type { ConfigService } from "@nestjs/config"; -import { join } from "path"; -import { mkdir } from "fs/promises"; - -export class LoggingConfig { - static async createPinoConfig(configService: ConfigService): Promise { - const nodeEnv = configService.get("NODE_ENV", "development"); - const logLevel = configService.get("LOG_LEVEL", "info"); - const appName = configService.get("APP_NAME", "customer-portal-bff"); - - // Ensure logs directory exists for production - if (nodeEnv === "production") { - try { - await mkdir("logs", { recursive: true }); - } catch { - // Directory might already exist - } - } - - // Base Pino configuration - const pinoConfig: PinoHttpOptions = { - level: logLevel, - name: appName, - base: { - service: appName, - environment: nodeEnv, - pid: typeof process !== "undefined" ? process.pid : 0, - }, - timestamp: true, - // Ensure sensitive fields are redacted across all logs - redact: { - paths: [ - // Common headers - "req.headers.authorization", - "req.headers.cookie", - // Auth - "password", - "password2", - "token", - "secret", - "jwt", - "apiKey", - // Custom params that may carry secrets - "params.password", - "params.password2", - "params.secret", - "params.token", - ], - remove: true, - }, - formatters: { - level: (label: string) => ({ level: label }), - bindings: () => ({}), // Remove default hostname/pid from every log - }, - serializers: { - // Keep logs concise: omit headers by default - req: (req: { - method?: string; - url?: string; - remoteAddress?: string; - remotePort?: number; - }) => ({ - method: req.method, - url: req.url, - remoteAddress: req.remoteAddress, - remotePort: req.remotePort, - }), - res: (res: { statusCode: number }) => ({ - statusCode: res.statusCode, - }), - err: (err: { - constructor: { name: string }; - message: string; - stack?: string; - code?: string; - status?: number; - }) => ({ - type: err.constructor.name, - message: err.message, - stack: err.stack, - ...(err.code && { code: err.code }), - ...(err.status && { status: err.status }), - }), - }, - }; - - // Development: Pretty printing - if (nodeEnv === "development") { - pinoConfig.transport = { - target: "pino-pretty", - options: { - colorize: true, - translateTime: "yyyy-mm-dd HH:MM:ss", - ignore: "pid,hostname", - singleLine: false, - hideObject: false, - }, - }; - } - - // Production: File logging with rotation - if (nodeEnv === "production") { - pinoConfig.transport = { - targets: [ - // Console output for container logs - { - target: "pino/file", - level: logLevel, - options: { destination: 1 }, // stdout - }, - // Combined log file - { - target: "pino/file", - level: "info", - options: { - destination: join("logs", `${appName}-combined.log`), - mkdir: true, - }, - }, - // Error log file - { - target: "pino/file", - level: "error", - options: { - destination: join("logs", `${appName}-error.log`), - mkdir: true, - }, - }, - ], - }; - } - - return { - pinoHttp: { - ...pinoConfig, - // Auto-generate correlation IDs - genReqId: (req: IncomingMessage, res: ServerResponse) => { - const existingIdHeader = req.headers["x-correlation-id"]; - const existingId = Array.isArray(existingIdHeader) - ? existingIdHeader[0] - : existingIdHeader; - if (existingId) return existingId; - - const correlationId = LoggingConfig.generateCorrelationId(); - res.setHeader("x-correlation-id", correlationId); - return correlationId; - }, - // Custom log levels: only warn on 4xx and error on 5xx - customLogLevel: (_req: IncomingMessage, res: ServerResponse, err?: unknown) => { - if (res.statusCode >= 400 && res.statusCode < 500) return "warn"; - if (res.statusCode >= 500 || err) return "error"; - return "silent" as unknown as - | "error" - | "warn" - | "info" - | "debug" - | "trace" - | "fatal" - | "silent"; - }, - // Suppress success messages entirely - customSuccessMessage: () => "", - customErrorMessage: ( - req: IncomingMessage, - res: ServerResponse, - err: { message?: string } - ) => { - const method = req.method ?? ""; - const url = req.url ?? ""; - return `${method} ${url} ${res.statusCode} - ${err.message ?? "error"}`; - }, - }, - }; - } - - /** - * Sanitize headers to remove sensitive information - */ - private static sanitizeHeaders( - headers: Record | undefined | null - ): Record | undefined | null { - if (!headers || typeof headers !== "object") { - return headers; - } - - const sensitiveKeys = [ - "authorization", - "cookie", - "set-cookie", - "x-api-key", - "x-auth-token", - "password", - "secret", - "token", - "jwt", - "bearer", - ]; - - const sanitized: Record = { ...headers } as Record; - - Object.keys(sanitized).forEach(key => { - if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive.toLowerCase()))) { - sanitized[key] = "[REDACTED]"; - } - }); - - return sanitized; - } - - /** - * Generate correlation ID - */ - private static generateCorrelationId(): string { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - } - - /** - * Get log levels for different environments - */ - static getLogLevels(level: string): string[] { - const logLevels: Record = { - error: ["error"], - warn: ["error", "warn"], - info: ["error", "warn", "info"], - debug: ["error", "warn", "info", "debug"], - verbose: ["error", "warn", "info", "debug", "verbose"], - }; - - return logLevels[level] || logLevels.info; - } -} diff --git a/apps/bff/src/common/logging/logging.module.ts b/apps/bff/src/common/logging/logging.module.ts index 2503fc96..03f5f837 100644 --- a/apps/bff/src/common/logging/logging.module.ts +++ b/apps/bff/src/common/logging/logging.module.ts @@ -1,7 +1,7 @@ import { Global, Module } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { LoggerModule } from "nestjs-pino"; -import { LoggingConfig } from "./logging.config"; +import { createNestPinoConfig } from "@customer-portal/shared"; @Global() @Module({ @@ -10,7 +10,7 @@ import { LoggingConfig } from "./logging.config"; imports: [ConfigModule], inject: [ConfigService], useFactory: async (configService: ConfigService) => - await LoggingConfig.createPinoConfig(configService), + await createNestPinoConfig(configService), }), ], exports: [LoggerModule], diff --git a/apps/bff/src/main.ts b/apps/bff/src/main.ts index cd82b151..0a148811 100644 --- a/apps/bff/src/main.ts +++ b/apps/bff/src/main.ts @@ -139,6 +139,7 @@ async function bootstrap() { logger.log( `🗄️ Database: ${configService.get("DATABASE_URL", "postgresql://dev:dev@localhost:5432/portal_dev")}` ); + logger.log(`🔗 Prisma Studio: http://localhost:5555`); logger.log(`🔴 Redis: ${configService.get("REDIS_URL", "redis://localhost:6379")}`); if (configService.get("NODE_ENV") !== "production") { diff --git a/apps/bff/tsconfig.build.json b/apps/bff/tsconfig.build.json new file mode 100644 index 00000000..8166da9d --- /dev/null +++ b/apps/bff/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "incremental": true, + "tsBuildInfoFile": "./tsconfig.build.tsbuildinfo", + "outDir": "./dist", + "sourceMap": true, + "declaration": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"] +} + + diff --git a/apps/portal/package.json b/apps/portal/package.json index 491d7353..a6d4b07e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev -p ${NEXT_PORT:-3000} --turbopack", - "build": "next build", + "dev": "next dev -p ${NEXT_PORT:-3000}", + "build": "next build --turbopack", "build:turbo": "next build --turbopack", "start": "next start -p ${NEXT_PORT:-3000}", "lint": "eslint .", @@ -37,7 +37,6 @@ "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "tailwindcss": "^4.1.12", - "tw-animate-css": "^1.3.7", "typescript": "^5.9.2" } } diff --git a/apps/portal/src/lib/logger.ts b/apps/portal/src/lib/logger.ts index 4d113c08..b799506a 100644 --- a/apps/portal/src/lib/logger.ts +++ b/apps/portal/src/lib/logger.ts @@ -1,134 +1,5 @@ -/** - * Application logger utility - * Provides structured logging with appropriate levels for development and production - * Compatible with backend logging standards - */ +import { createPinoLogger, getSharedLogger } from "@customer-portal/shared"; -type LogLevel = "debug" | "info" | "warn" | "error"; - -interface LogEntry { - level: LogLevel; - message: string; - data?: unknown; - timestamp: string; - service: string; - environment: string; -} - -class Logger { - private isDevelopment = process.env.NODE_ENV === "development"; - private service = "customer-portal-frontend"; - - private formatMessage(level: LogLevel, message: string, data?: unknown): LogEntry { - return { - level, - message, - data, - timestamp: new Date().toISOString(), - service: this.service, - environment: process.env.NODE_ENV || "development", - }; - } - - private log(level: LogLevel, message: string, data?: unknown): void { - const entry = this.formatMessage(level, message, data); - - if (this.isDevelopment) { - const safeData = - data instanceof Error - ? { - name: data.name, - message: data.message, - stack: data.stack, - } - : data; - - const logData = { - timestamp: entry.timestamp, - level: entry.level.toUpperCase(), - service: entry.service, - message: entry.message, - ...(safeData != null ? { data: safeData } : {}), - }; - - try { - console.log(logData); - } catch { - // no-op - } - } else { - // In production, structured logging for external services - const logData = { - ...entry, - ...(data != null ? { data } : {}), - }; - - // For production, you might want to send to a logging service - // For now, only log errors and warnings to console - if (level === "error" || level === "warn") { - try { - console[level](JSON.stringify(logData)); - } catch { - // no-op - } - } - } - } - - debug(message: string, data?: unknown): void { - this.log("debug", message, data); - } - - info(message: string, data?: unknown): void { - this.log("info", message, data); - } - - warn(message: string, data?: unknown): void { - this.log("warn", message, data); - } - - error(message: string, data?: unknown): void { - this.log("error", message, data); - } - - // Structured logging methods for better integration - logApiCall( - endpoint: string, - method: string, - status: number, - duration: number, - data?: unknown - ): void { - this.info(`API ${method} ${endpoint}`, { - endpoint, - method, - status, - duration: `${duration}ms`, - ...(data != null ? { data } : {}), - }); - } - - logUserAction(userId: string, action: string, data?: unknown): void { - this.info(`User action: ${action}`, { - userId, - action, - ...(data != null ? { data } : {}), - }); - } - - logError(error: Error, context?: string, data?: unknown): void { - this.error(`Error${context ? ` in ${context}` : ""}: ${error.message}`, { - error: { - name: error.name, - message: error.message, - stack: error.stack, - }, - context, - ...(data != null ? { data } : {}), - }); - } -} - -// Export singleton instance -export const logger = new Logger(); +// Prefer a shared singleton so logs share correlationId/userId across modules +export const logger = getSharedLogger(); export default logger; diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index 9afbb2bc..0a70949c 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -9,12 +9,23 @@ "name": "next" } ], - // Path mappings "paths": { - "@/*": ["./src/*"] - } + "@/*": [ + "./src/*" + ] + }, + // Enforce TS-only in portal and keep strict mode explicit (inherits from root) + "allowJs": false, + "strict": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/eslint-report.json b/eslint-report.json deleted file mode 100644 index 45efdbd4..00000000 --- a/eslint-report.json +++ /dev/null @@ -1 +0,0 @@ -[{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/app.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/auth-admin.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/auth.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/auth.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/auth.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/auth.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/dto/link-whmcs.dto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/dto/login.dto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/dto/request-password-reset.dto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/dto/reset-password.dto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/dto/set-password.dto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/dto/signup.dto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/guards/admin.guard.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/guards/auth-throttle.guard.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/guards/jwt-auth.guard.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/guards/local-auth.guard.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/services/token-blacklist.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/strategies/jwt.strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/auth/strategies/local.strategy.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/cases/cases.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/cases/cases.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/cases/cases.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/catalog/catalog.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/catalog/catalog.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/catalog/catalog.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/audit/audit.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/audit/audit.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/cache/cache.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/cache/cache.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/config/env.validation.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/email/email.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/email/email.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/email/providers/sendgrid.provider.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/email/queue/email.processor.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/email/queue/email.queue.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/filters/http-exception.filter.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/logging/logging.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/logging/logging.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/prisma/prisma.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/prisma/prisma.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/redis/redis.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/common/utils/error.util.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/health/health.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/health/health.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/invoices/invoices.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/invoices/invoices.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/invoices/invoices.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/jobs/jobs.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/jobs/jobs.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/jobs/reconcile.processor.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/main.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/mappings/cache/mapping-cache.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/mappings/mappings.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/mappings/mappings.service.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":576,"column":13,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":576,"endColumn":59}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import {\n Injectable,\n NotFoundException,\n ConflictException,\n BadRequestException,\n Inject,\n} from \"@nestjs/common\";\nimport { Logger } from \"nestjs-pino\";\nimport { PrismaService } from \"../common/prisma/prisma.service\";\nimport { getErrorMessage } from \"../common/utils/error.util\";\nimport { MappingCacheService } from \"./cache/mapping-cache.service\";\nimport { MappingValidatorService } from \"./validation/mapping-validator.service\";\nimport {\n UserIdMapping,\n CreateMappingRequest,\n UpdateMappingRequest,\n MappingSearchFilters,\n MappingStats,\n BulkMappingResult,\n} from \"./types/mapping.types\";\n\n@Injectable()\nexport class MappingsService {\n constructor(\n private readonly prisma: PrismaService,\n private readonly cacheService: MappingCacheService,\n private readonly validator: MappingValidatorService,\n @Inject(Logger) private readonly logger: Logger\n ) {}\n\n /**\n * Create a new user mapping\n */\n async createMapping(request: CreateMappingRequest): Promise {\n try {\n // Validate request\n const validation = this.validator.validateCreateRequest(request);\n this.validator.logValidationResult(\"Create mapping\", validation, {\n userId: request.userId,\n });\n\n if (!validation.isValid) {\n throw new BadRequestException(`Invalid mapping data: ${validation.errors.join(\", \")}`);\n }\n\n // Sanitize input\n const sanitizedRequest = this.validator.sanitizeCreateRequest(request);\n\n // Check for conflicts\n const existingMappings = await this.getAllMappingsFromDb();\n const conflictValidation = this.validator.validateNoConflicts(\n sanitizedRequest,\n existingMappings\n );\n\n if (!conflictValidation.isValid) {\n throw new ConflictException(`Mapping conflict: ${conflictValidation.errors.join(\", \")}`);\n }\n\n // Create in database\n const created = await this.prisma.idMapping.create({\n data: sanitizedRequest,\n });\n\n const mapping: UserIdMapping = {\n userId: created.userId,\n whmcsClientId: created.whmcsClientId,\n sfAccountId: created.sfAccountId || undefined,\n createdAt: created.createdAt,\n updatedAt: created.updatedAt,\n };\n\n // Cache the new mapping\n await this.cacheService.setMapping(mapping);\n\n this.logger.log(`Created mapping for user ${mapping.userId}`, {\n whmcsClientId: mapping.whmcsClientId,\n sfAccountId: mapping.sfAccountId,\n warnings: validation.warnings,\n });\n\n return mapping;\n } catch (error) {\n this.logger.error(`Failed to create mapping for user ${request.userId}`, {\n error: getErrorMessage(error),\n request: this.sanitizeForLog(request),\n });\n throw error;\n }\n }\n\n /**\n * Find mapping by user ID\n */\n async findByUserId(userId: string): Promise {\n try {\n // Validate user ID\n if (!userId) {\n throw new BadRequestException(\"User ID is required\");\n }\n\n // Try cache first\n const cached = await this.cacheService.getByUserId(userId);\n if (cached) {\n this.logger.debug(`Cache hit for user mapping: ${userId}`);\n return cached;\n }\n\n // Fetch from database\n const dbMapping = await this.prisma.idMapping.findUnique({\n where: { userId },\n });\n\n if (!dbMapping) {\n this.logger.debug(`No mapping found for user ${userId}`);\n return null;\n }\n\n const mapping: UserIdMapping = {\n userId: dbMapping.userId,\n whmcsClientId: dbMapping.whmcsClientId,\n sfAccountId: dbMapping.sfAccountId || undefined,\n createdAt: dbMapping.createdAt,\n updatedAt: dbMapping.updatedAt,\n };\n\n // Cache the result\n await this.cacheService.setMapping(mapping);\n\n this.logger.debug(`Found mapping for user ${userId}`, {\n whmcsClientId: mapping.whmcsClientId,\n sfAccountId: mapping.sfAccountId,\n });\n\n return mapping;\n } catch (error) {\n this.logger.error(`Failed to find mapping for user ${userId}`, {\n error: getErrorMessage(error),\n });\n throw error;\n }\n }\n\n /**\n * Find mapping by WHMCS client ID\n */\n async findByWhmcsClientId(whmcsClientId: number): Promise {\n try {\n // Validate WHMCS client ID\n if (!whmcsClientId || whmcsClientId < 1) {\n throw new BadRequestException(\"Valid WHMCS client ID is required\");\n }\n\n // Try cache first\n const cached = await this.cacheService.getByWhmcsClientId(whmcsClientId);\n if (cached) {\n this.logger.debug(`Cache hit for WHMCS client mapping: ${whmcsClientId}`);\n return cached;\n }\n\n // Fetch from database\n const dbMapping = await this.prisma.idMapping.findUnique({\n where: { whmcsClientId },\n });\n\n if (!dbMapping) {\n this.logger.debug(`No mapping found for WHMCS client ${whmcsClientId}`);\n return null;\n }\n\n const mapping: UserIdMapping = {\n userId: dbMapping.userId,\n whmcsClientId: dbMapping.whmcsClientId,\n sfAccountId: dbMapping.sfAccountId || undefined,\n createdAt: dbMapping.createdAt,\n updatedAt: dbMapping.updatedAt,\n };\n\n // Cache the result\n await this.cacheService.setMapping(mapping);\n\n this.logger.debug(`Found mapping for WHMCS client ${whmcsClientId}`, {\n userId: mapping.userId,\n sfAccountId: mapping.sfAccountId,\n });\n\n return mapping;\n } catch (error) {\n this.logger.error(`Failed to find mapping for WHMCS client ${whmcsClientId}`, {\n error: getErrorMessage(error),\n });\n throw error;\n }\n }\n\n /**\n * Find mapping by Salesforce account ID\n */\n async findBySfAccountId(sfAccountId: string): Promise {\n try {\n // Validate Salesforce account ID\n if (!sfAccountId) {\n throw new BadRequestException(\"Salesforce account ID is required\");\n }\n\n // Try cache first\n const cached = await this.cacheService.getBySfAccountId(sfAccountId);\n if (cached) {\n this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`);\n return cached;\n }\n\n // Fetch from database\n const dbMapping = await this.prisma.idMapping.findFirst({\n where: { sfAccountId },\n });\n\n if (!dbMapping) {\n this.logger.debug(`No mapping found for SF account ${sfAccountId}`);\n return null;\n }\n\n const mapping: UserIdMapping = {\n userId: dbMapping.userId,\n whmcsClientId: dbMapping.whmcsClientId,\n sfAccountId: dbMapping.sfAccountId || undefined,\n createdAt: dbMapping.createdAt,\n updatedAt: dbMapping.updatedAt,\n };\n\n // Cache the result\n await this.cacheService.setMapping(mapping);\n\n this.logger.debug(`Found mapping for SF account ${sfAccountId}`, {\n userId: mapping.userId,\n whmcsClientId: mapping.whmcsClientId,\n });\n\n return mapping;\n } catch (error) {\n this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, {\n error: getErrorMessage(error),\n });\n throw error;\n }\n }\n\n /**\n * Update an existing mapping\n */\n async updateMapping(userId: string, updates: UpdateMappingRequest): Promise {\n try {\n // Validate request\n const validation = this.validator.validateUpdateRequest(userId, updates);\n this.validator.logValidationResult(\"Update mapping\", validation, {\n userId,\n });\n\n if (!validation.isValid) {\n throw new BadRequestException(`Invalid update data: ${validation.errors.join(\", \")}`);\n }\n\n // Get existing mapping\n const existing = await this.findByUserId(userId);\n if (!existing) {\n throw new NotFoundException(`Mapping not found for user ${userId}`);\n }\n\n // Sanitize input\n const sanitizedUpdates = this.validator.sanitizeUpdateRequest(updates);\n\n // Check for conflicts if WHMCS client ID is being changed\n if (\n sanitizedUpdates.whmcsClientId &&\n sanitizedUpdates.whmcsClientId !== existing.whmcsClientId\n ) {\n const conflictingMapping = await this.findByWhmcsClientId(sanitizedUpdates.whmcsClientId);\n if (conflictingMapping && conflictingMapping.userId !== userId) {\n throw new ConflictException(\n `WHMCS client ${sanitizedUpdates.whmcsClientId} is already mapped to user ${conflictingMapping.userId}`\n );\n }\n }\n\n // Update in database\n const updated = await this.prisma.idMapping.update({\n where: { userId },\n data: sanitizedUpdates,\n });\n\n const newMapping: UserIdMapping = {\n userId: updated.userId,\n whmcsClientId: updated.whmcsClientId,\n sfAccountId: updated.sfAccountId || undefined,\n createdAt: updated.createdAt,\n updatedAt: updated.updatedAt,\n };\n\n // Update cache\n await this.cacheService.updateMapping(existing, newMapping);\n\n this.logger.log(`Updated mapping for user ${userId}`, {\n changes: sanitizedUpdates,\n warnings: validation.warnings,\n });\n\n return newMapping;\n } catch (error) {\n this.logger.error(`Failed to update mapping for user ${userId}`, {\n error: getErrorMessage(error),\n updates: this.sanitizeForLog(updates),\n });\n throw error;\n }\n }\n\n /**\n * Delete a mapping\n */\n async deleteMapping(userId: string): Promise {\n try {\n // Get existing mapping\n const existing = await this.findByUserId(userId);\n if (!existing) {\n throw new NotFoundException(`Mapping not found for user ${userId}`);\n }\n\n // Validate deletion\n const validation = this.validator.validateDeletion(existing);\n this.validator.logValidationResult(\"Delete mapping\", validation, {\n userId,\n });\n\n // Delete from database\n await this.prisma.idMapping.delete({\n where: { userId },\n });\n\n // Remove from cache\n await this.cacheService.deleteMapping(existing);\n\n this.logger.log(`Deleted mapping for user ${userId}`, {\n whmcsClientId: existing.whmcsClientId,\n sfAccountId: existing.sfAccountId,\n warnings: validation.warnings,\n });\n } catch (error) {\n this.logger.error(`Failed to delete mapping for user ${userId}`, {\n error: getErrorMessage(error),\n });\n throw error;\n }\n }\n\n /**\n * Search mappings with filters\n */\n async searchMappings(filters: MappingSearchFilters): Promise {\n try {\n const whereClause: Record = {};\n\n if (filters.userId) {\n whereClause.userId = filters.userId;\n }\n\n if (filters.whmcsClientId) {\n whereClause.whmcsClientId = filters.whmcsClientId;\n }\n\n if (filters.sfAccountId) {\n whereClause.sfAccountId = filters.sfAccountId;\n }\n\n if (filters.hasWhmcsMapping !== undefined) {\n whereClause.whmcsClientId = filters.hasWhmcsMapping ? { not: null } : null;\n }\n\n if (filters.hasSfMapping !== undefined) {\n if (filters.hasSfMapping) {\n whereClause.sfAccountId = { not: null };\n } else {\n whereClause.sfAccountId = null;\n }\n }\n\n const dbMappings = await this.prisma.idMapping.findMany({\n where: whereClause,\n orderBy: { createdAt: \"desc\" },\n });\n\n const mappings: UserIdMapping[] = dbMappings.map(mapping => ({\n userId: mapping.userId,\n whmcsClientId: mapping.whmcsClientId,\n sfAccountId: mapping.sfAccountId || undefined,\n createdAt: mapping.createdAt,\n updatedAt: mapping.updatedAt,\n }));\n\n this.logger.debug(`Found ${mappings.length} mappings matching filters`, filters);\n return mappings;\n } catch (error) {\n this.logger.error(\"Failed to search mappings\", {\n error: getErrorMessage(error),\n filters,\n });\n throw error;\n }\n }\n\n /**\n * Get mapping statistics\n */\n async getMappingStats(): Promise {\n try {\n const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([\n this.prisma.idMapping.count(),\n // whmcsClientId is non-nullable; this count equals total mappings\n this.prisma.idMapping.count(),\n this.prisma.idMapping.count({\n where: { sfAccountId: { not: null } },\n }),\n // Complete mappings are those with a non-null sfAccountId (whmcsClientId is always present)\n this.prisma.idMapping.count({\n where: { sfAccountId: { not: null } },\n }),\n ]);\n\n const stats: MappingStats = {\n totalMappings: totalCount,\n whmcsMappings: whmcsCount,\n salesforceMappings: sfCount,\n completeMappings: completeCount,\n orphanedMappings: 0, // Would need to check against actual user records\n };\n\n this.logger.debug(\"Generated mapping statistics\", stats);\n return stats;\n } catch (error) {\n this.logger.error(\"Failed to get mapping statistics\", {\n error: getErrorMessage(error),\n });\n throw error;\n }\n }\n\n /**\n * Bulk create mappings\n */\n async bulkCreateMappings(mappings: CreateMappingRequest[]): Promise {\n const result: BulkMappingResult = {\n successful: 0,\n failed: 0,\n errors: [],\n };\n\n try {\n // Validate all mappings first\n const validations = this.validator.validateBulkMappings(mappings);\n\n for (let i = 0; i < mappings.length; i++) {\n const mapping = mappings[i];\n const validation = validations[i].validation;\n\n try {\n if (!validation.isValid) {\n throw new Error(validation.errors.join(\", \"));\n }\n\n await this.createMapping(mapping);\n result.successful++;\n } catch (error) {\n result.failed++;\n result.errors.push({\n index: i,\n error: getErrorMessage(error),\n data: mapping,\n });\n }\n }\n\n this.logger.log(\n `Bulk create completed: ${result.successful} successful, ${result.failed} failed`\n );\n return result;\n } catch (error) {\n this.logger.error(\"Bulk create mappings failed\", {\n error: getErrorMessage(error),\n });\n throw error;\n }\n }\n\n /**\n * Check if user has mapping\n */\n async hasMapping(userId: string): Promise {\n try {\n // Try cache first\n const cached = await this.cacheService.getByUserId(userId);\n if (cached) {\n return true;\n }\n\n // Check database\n const mapping = await this.prisma.idMapping.findUnique({\n where: { userId },\n select: { userId: true },\n });\n\n return mapping !== null;\n } catch (error) {\n this.logger.error(`Failed to check mapping for user ${userId}`, {\n error: getErrorMessage(error),\n });\n return false;\n }\n }\n\n /**\n * Invalidate cache for a user\n */\n async invalidateCache(userId: string): Promise {\n // Get the current mapping to invalidate all related cache keys\n const mapping = await this.cacheService.getByUserId(userId);\n if (mapping) {\n await this.cacheService.deleteMapping(mapping);\n }\n this.logger.log(`Invalidated mapping cache for user ${userId}`);\n }\n\n /**\n * Health check\n */\n async healthCheck(): Promise<{ status: string; details: Record }> {\n try {\n // Test database connectivity\n await this.prisma.idMapping.count();\n\n return {\n status: \"healthy\",\n details: {\n database: \"connected\",\n cache: \"available\",\n timestamp: new Date().toISOString(),\n },\n };\n } catch (error) {\n this.logger.error(\"Mapping service health check failed\", {\n error: getErrorMessage(error),\n });\n return {\n status: \"unhealthy\",\n details: {\n error: getErrorMessage(error),\n timestamp: new Date().toISOString(),\n },\n };\n }\n }\n\n // Private helper methods\n\n private async getAllMappingsFromDb(): Promise {\n const dbMappings = await this.prisma.idMapping.findMany();\n return dbMappings.map(mapping => ({\n userId: mapping.userId,\n whmcsClientId: mapping.whmcsClientId,\n sfAccountId: mapping.sfAccountId || undefined,\n createdAt: mapping.createdAt,\n updatedAt: mapping.updatedAt,\n }));\n }\n\n private sanitizeForLog(data: unknown): Record {\n try {\n const plain = JSON.parse(JSON.stringify(data ?? {}));\n if (plain && typeof plain === \"object\" && !Array.isArray(plain)) {\n return plain as Record;\n }\n return { value: plain } as Record;\n } catch {\n return { value: String(data) } as Record;\n }\n }\n\n /**\n * Legacy method support (for backward compatibility)\n */\n async create(data: CreateMappingRequest): Promise {\n this.logger.warn(\"Using legacy create method - please update to createMapping\");\n return this.createMapping(data);\n }\n\n /**\n * Legacy method support (for backward compatibility)\n */\n async createMappingLegacy(data: CreateMappingRequest): Promise {\n this.logger.warn(\"Using legacy createMapping method - please update to createMapping\");\n return this.createMapping(data);\n }\n\n /**\n * Legacy method support (for backward compatibility)\n */\n async updateMappingLegacy(userId: string, updates: UpdateMappingRequest): Promise {\n this.logger.warn(\"Using legacy updateMapping method - please update to updateMapping\");\n return this.updateMapping(userId, updates);\n }\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/mappings/types/mapping.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/mappings/validation/mapping-validator.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/orders/orders.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/orders/orders.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/orders/orders.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/subscriptions/subscriptions.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/subscriptions/subscriptions.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/subscriptions/subscriptions.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/users/dto/update-billing.dto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/users/dto/update-user.dto.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/users/users.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/users/users.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/users/users.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/salesforce/salesforce.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/salesforce/salesforce.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/vendors.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/types/whmcs-api.types.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/whmcs.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/vendors/whmcs/whmcs.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/webhooks/guards/webhook-signature.guard.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/webhooks/schemas/salesforce.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/webhooks/schemas/whmcs.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/webhooks/webhooks.controller.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/webhooks/webhooks.module.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/bff/src/webhooks/webhooks.service.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/next.config.mjs","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/postcss.config.mjs","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/account/profile/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/api/health/route.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/forgot-password/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/link-whmcs/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/login/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/reset-password/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/set-password/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/auth/signup/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/billing/invoices/[id]/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/billing/invoices/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/billing/payments/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/catalog/esim/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/catalog/internet/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/catalog/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/catalog/vpn/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/checkout/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/dashboard/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/layout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/orders/[id]/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/subscriptions/[id]/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/subscriptions/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/support/cases/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/app/support/new/page.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/auth/auth-layout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/auth/session-timeout-warning.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/dashboard-layout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/layout/page-layout.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/button.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/data-table.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/input.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/label.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/components/ui/search-filter-bar.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceItemRow.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/InvoiceStatusBadge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/billing/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/AccountStatusCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/DashboardActivityItem.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/QuickAction.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/StatCard.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/dashboard/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/components/SubscriptionStatusBadge.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/components/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/hooks/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/features/subscriptions/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/hooks/useDashboard.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/hooks/useInvoices.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/hooks/useSubscriptions.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/api.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/auth/api.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/auth/store.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/env.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/logger.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/query-client.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/lib/utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/providers/query-provider.tsx","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/apps/portal/src/utils/currency.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/eslint.config.mjs","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/array-utils.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/case.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/common.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/invoice.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/logging/index.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/logging/logger.config.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/logging/logger.interface.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/order.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/payment.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/skus.ts","messages":[{"ruleId":"@typescript-eslint/no-unsafe-assignment","severity":2,"message":"Unsafe assignment of an `any` value.","line":98,"column":11,"nodeType":"VariableDeclarator","messageId":"anyAssignment","endLine":98,"endColumn":35}],"suppressedMessages":[],"errorCount":1,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":"// Central SKU registry for Product2 <-> portal mappings.\n// Replace the placeholder codes with your actual Product2.SKU__c values.\n\nexport type InternetTier = \"Platinum_Gold\" | \"Silver\";\nexport type AccessMode = \"IPoE-HGW\" | \"IPoE-BYOR\" | \"PPPoE\";\nexport type InstallPlan = \"One-time\" | \"12-Month\" | \"24-Month\";\n\nconst INTERNET_SKU: Record> = {\n Platinum_Gold: {\n \"IPoE-HGW\": \"INT-1G-PLAT-HGW\",\n \"IPoE-BYOR\": \"INT-1G-PLAT-BYOR\",\n PPPoE: \"INT-1G-PLAT-PPPOE\",\n },\n Silver: {\n \"IPoE-HGW\": \"INT-1G-SILV-HGW\",\n \"IPoE-BYOR\": \"INT-1G-SILV-BYOR\",\n PPPoE: \"INT-1G-SILV-PPPOE\",\n },\n};\n\nconst INSTALL_SKU: Record = {\n \"One-time\": \"INT-INSTALL-ONETIME\",\n \"12-Month\": \"INT-INSTALL-12M\",\n \"24-Month\": \"INT-INSTALL-24M\",\n};\n\nconst VPN_SKU: Record = {\n \"USA-SF\": \"VPN-USA-SF\",\n \"UK-London\": \"VPN-UK-LON\",\n};\n\nexport function getInternetServiceSku(tier: InternetTier, mode: AccessMode): string {\n return INTERNET_SKU[tier][mode];\n}\n\nexport function getInternetInstallSku(plan: InstallPlan): string {\n return INSTALL_SKU[plan];\n}\n\nexport function getVpnServiceSku(region: string): string {\n return VPN_SKU[region] || \"\";\n}\n\nexport function getVpnActivationSku(): string {\n return \"VPN-ACTIVATION\";\n}\n\n// ===== SIM / eSIM =====\nexport type SimFormat = \"eSIM\" | \"Physical\";\nexport type SimPlanType = \"DataOnly\" | \"DataSmsVoice\" | \"VoiceOnly\";\nexport type SimDataSize = \"5GB\" | \"10GB\" | \"25GB\" | \"50GB\" | \"None\";\n\ntype SimSkuMap = Record>>>;\n\n// Default mapping. Replace with your actual Product2.SKU__c codes in Salesforce.\n// Can be overridden at runtime via NEXT_PUBLIC_SIM_SKU_MAP (JSON with the same shape as SimSkuMap).\nconst DEFAULT_SIM_SKU_MAP: SimSkuMap = {\n eSIM: {\n DataOnly: {\n \"5GB\": \"SIM-ESIM-DATA-5GB\",\n \"10GB\": \"SIM-ESIM-DATA-10GB\",\n \"25GB\": \"SIM-ESIM-DATA-25GB\",\n \"50GB\": \"SIM-ESIM-DATA-50GB\",\n },\n DataSmsVoice: {\n \"5GB\": \"SIM-ESIM-VOICE-5GB\",\n \"10GB\": \"SIM-ESIM-VOICE-10GB\",\n \"25GB\": \"SIM-ESIM-VOICE-25GB\",\n \"50GB\": \"SIM-ESIM-VOICE-50GB\",\n },\n VoiceOnly: {\n None: \"SIM-ESIM-VOICEONLY\",\n },\n },\n Physical: {\n DataOnly: {\n \"5GB\": \"SIM-PHYS-DATA-5GB\",\n \"10GB\": \"SIM-PHYS-DATA-10GB\",\n \"25GB\": \"SIM-PHYS-DATA-25GB\",\n \"50GB\": \"SIM-PHYS-DATA-50GB\",\n },\n DataSmsVoice: {\n \"5GB\": \"SIM-PHYS-VOICE-5GB\",\n \"10GB\": \"SIM-PHYS-VOICE-10GB\",\n \"25GB\": \"SIM-PHYS-VOICE-25GB\",\n \"50GB\": \"SIM-PHYS-VOICE-50GB\",\n },\n VoiceOnly: {\n None: \"SIM-PHYS-VOICEONLY\",\n },\n },\n};\n\nfunction getEnvSimSkuMap(): SimSkuMap | null {\n try {\n const raw = process.env.NEXT_PUBLIC_SIM_SKU_MAP;\n if (!raw) return null;\n const parsed = JSON.parse(raw);\n return parsed as SimSkuMap;\n } catch {\n return null;\n }\n}\n\n/**\n * Returns the Product2 SKU for the given SIM configuration.\n * If an environment override is present, it takes precedence.\n */\nexport function getSimServiceSku(\n format: SimFormat,\n planType: SimPlanType,\n dataSize: SimDataSize\n): string {\n const map = getEnvSimSkuMap() || DEFAULT_SIM_SKU_MAP;\n const byFormat = map[format] || {};\n const byType = (byFormat[planType] || {}) as Record;\n return typeof byType[dataSize] === \"string\" ? byType[dataSize] : \"\";\n}\n","usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/status.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/subscription.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/user.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]},{"filePath":"/home/tnarantuya/projects/new-portal-website/packages/shared/src/validation.ts","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[]}] \ No newline at end of file diff --git a/package.json b/package.json index 2b447931..e4f5f45a 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,18 @@ }, "packageManager": "pnpm@10.15.0", "scripts": { - "dev": "pnpm --parallel --recursive run dev", - "build": "pnpm --recursive run build", - "start": "pnpm --parallel --filter portal --filter @customer-portal/bff run start", + "predev": "pnpm --filter @customer-portal/shared build", + "dev": "./scripts/dev/manage.sh apps", + "dev:all": "pnpm --parallel --filter @customer-portal/shared --filter @customer-portal/portal --filter @customer-portal/bff run dev", + "build": "pnpm --recursive -w --if-present run build", + "start": "pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run start", "test": "pnpm --recursive run test", "lint": "pnpm --recursive run lint", "lint:fix": "pnpm --recursive run lint:fix", "format": "prettier -w .", "format:check": "prettier -c .", "prepare": "husky", - "type-check": "pnpm --recursive run type-check", + "type-check": "pnpm --filter @customer-portal/shared build && pnpm --recursive run type-check", "clean": "pnpm --recursive run clean", "dev:start": "./scripts/dev/manage.sh start", "dev:stop": "./scripts/dev/manage.sh stop", @@ -44,7 +46,8 @@ "db:reset": "pnpm --filter @customer-portal/bff run db:reset", "update:check": "pnpm outdated --recursive", "update:all": "pnpm update --recursive --latest && pnpm audit && pnpm type-check", - "update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check" + "update:safe": "pnpm update --recursive && pnpm audit && pnpm type-check", + "dev:watch": "pnpm --parallel --filter @customer-portal/shared --filter @customer-portal/portal --filter @customer-portal/bff run dev" }, "devDependencies": { "@eslint/js": "^9.13.0", diff --git a/packages/shared/package.json b/packages/shared/package.json index a1499d78..2d7b3be1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,14 +5,29 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "private": true, + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, "scripts": { "build": "tsc", + "dev": "tsc -w --preserveWatchOutput", "clean": "rm -rf dist", "type-check": "tsc --noEmit", "test": "echo \"No tests specified for shared package\"", "lint": "eslint .", "lint:fix": "eslint . --fix" }, + "dependencies": { + "pino": "^9.9.0" + }, "devDependencies": { "typescript": "^5.9.2" } diff --git a/packages/shared/src/logging/index.ts b/packages/shared/src/logging/index.ts index 21029d82..1dd90060 100644 --- a/packages/shared/src/logging/index.ts +++ b/packages/shared/src/logging/index.ts @@ -5,3 +5,5 @@ export * from "./logger.config.js"; export * from "./logger.interface.js"; +export * from "./pino-logger.js"; +export * from "./nest-logger.config.js"; diff --git a/packages/shared/src/logging/nest-logger.config.ts b/packages/shared/src/logging/nest-logger.config.ts new file mode 100644 index 00000000..2aacb33b --- /dev/null +++ b/packages/shared/src/logging/nest-logger.config.ts @@ -0,0 +1,126 @@ +// Lightweight, framework-agnostic factory that returns an object compatible +// with nestjs-pino's LoggerModule.forRoot({ pinoHttp: {...} }) shape without importing types. +import { join } from "path"; +import { mkdir } from "fs/promises"; + +export async function createNestPinoConfig(configService: { + get(key: string, defaultValue?: T): T; +}) { + const nodeEnv = configService.get("NODE_ENV", "development"); + const logLevel = configService.get("LOG_LEVEL", "info"); + const appName = configService.get("APP_NAME", "customer-portal-bff"); + + if (nodeEnv === "production") { + try { + await mkdir("logs", { recursive: true }); + } catch { + // ignore + } + } + + const pinoConfig: Record = { + level: logLevel, + name: appName, + base: { + service: appName, + environment: nodeEnv, + pid: typeof process !== "undefined" ? process.pid : 0, + }, + timestamp: true, + redact: { + paths: [ + "req.headers.authorization", + "req.headers.cookie", + "password", + "password2", + "token", + "secret", + "jwt", + "apiKey", + "params.password", + "params.password2", + "params.secret", + "params.token", + ], + remove: true, + }, + formatters: { + level: (label: string) => ({ level: label }), + bindings: () => ({}), + }, + serializers: { + req: (req: { method?: string; url?: string; remoteAddress?: string; remotePort?: number }) => ({ + method: req.method, + url: req.url, + remoteAddress: req.remoteAddress, + remotePort: req.remotePort, + }), + res: (res: { statusCode: number }) => ({ statusCode: res.statusCode }), + err: (err: { constructor: { name: string }; message: string; stack?: string; code?: string; status?: number }) => ({ + type: err.constructor.name, + message: err.message, + stack: err.stack, + ...(err.code && { code: err.code }), + ...(err.status && { status: err.status }), + }), + }, + }; + + if (nodeEnv === "development") { + (pinoConfig as any).transport = { + target: "pino-pretty", + options: { + colorize: true, + translateTime: "yyyy-mm-dd HH:MM:ss", + ignore: "pid,hostname", + singleLine: false, + hideObject: false, + }, + }; + } + + if (nodeEnv === "production") { + (pinoConfig as any).transport = { + targets: [ + { target: "pino/file", level: logLevel, options: { destination: 1 } }, + { + target: "pino/file", + level: "info", + options: { destination: join("logs", `${appName}-combined.log`), mkdir: true }, + }, + { + target: "pino/file", + level: "error", + options: { destination: join("logs", `${appName}-error.log`), mkdir: true }, + }, + ], + }; + } + + return { + pinoHttp: { + ...(pinoConfig as any), + genReqId: (req: any, res: any) => { + const existingIdHeader = req.headers?.["x-correlation-id"]; + const existingId = Array.isArray(existingIdHeader) ? existingIdHeader[0] : existingIdHeader; + if (existingId) return existingId; + const correlationId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + res.setHeader?.("x-correlation-id", correlationId); + return correlationId; + }, + customLogLevel: (_req: any, res: any, err?: unknown) => { + if (res.statusCode >= 400 && res.statusCode < 500) return "warn"; + if (res.statusCode >= 500 || err) return "error"; + return "silent" as any; + }, + customSuccessMessage: () => "", + customErrorMessage: (req: any, res: any, err: { message?: string }) => { + const method = req.method ?? ""; + const url = req.url ?? ""; + return `${method} ${url} ${res.statusCode} - ${err.message ?? "error"}`; + }, + }, + }; +} + + diff --git a/packages/shared/src/logging/pino-logger.ts b/packages/shared/src/logging/pino-logger.ts new file mode 100644 index 00000000..911cc13a --- /dev/null +++ b/packages/shared/src/logging/pino-logger.ts @@ -0,0 +1,178 @@ +import pino from "pino"; +import { DEFAULT_LOG_CONFIG, formatLogEntry, sanitizeLogData } from "./logger.config.js"; +import type { ILogger, LoggerOptions } from "./logger.interface.js"; + +/** + * Create a cross-platform Pino-based logger that implements ILogger + * Works in Node and browser environments + */ +export function createPinoLogger(options: LoggerOptions = {}): ILogger { + const level = options.level ?? DEFAULT_LOG_CONFIG.level; + const service = options.service ?? DEFAULT_LOG_CONFIG.service; + const environment = options.environment ?? DEFAULT_LOG_CONFIG.environment; + + // Context that flows with the logger instance + let correlationId: string | undefined = options.context?.correlationId; + let userId: string | undefined = options.context?.userId; + let requestId: string | undefined = options.context?.requestId; + + // Configure pino for both Node and browser + const isBrowser = typeof window !== "undefined"; + const pinoLogger = pino({ + level, + name: service, + base: { + service, + environment, + }, + // Pretty output only in development for Node; browsers format via console + ...(isBrowser + ? { browser: { asObject: true } } + : {}), + formatters: { + level: (label: string) => ({ level: label }), + bindings: () => ({}), + }, + redact: { + paths: [ + "req.headers.authorization", + "req.headers.cookie", + "password", + "password2", + "token", + "secret", + "jwt", + "apiKey", + "params.password", + "params.password2", + "params.secret", + "params.token", + ], + remove: true, + }, + }); + + function withContext(data?: unknown): Record | undefined { + if (data == null) return undefined; + const sanitized = sanitizeLogData(data); + return { + ...(correlationId ? { correlationId } : {}), + ...(userId ? { userId } : {}), + ...(requestId ? { requestId } : {}), + data: sanitized, + } as Record; + } + + const api: ILogger = { + debug(message, data) { + pinoLogger.debug(withContext(data), message); + }, + info(message, data) { + pinoLogger.info(withContext(data), message); + }, + warn(message, data) { + pinoLogger.warn(withContext(data), message); + }, + error(message, data) { + pinoLogger.error(withContext(data), message); + }, + trace(message, data) { + pinoLogger.trace(withContext(data), message); + }, + + logApiCall(endpoint, method, status, duration, data) { + pinoLogger.info( + withContext({ endpoint, method, status, duration: `${duration}ms`, ...(data ? { data } : {}) }), + `API ${method} ${endpoint}` + ); + }, + logUserAction(user, action, data) { + pinoLogger.info(withContext({ userId: user, action, ...(data ? { data } : {}) }), "User action"); + }, + logError(error, context, data) { + pinoLogger.error( + withContext({ + error: { name: error.name, message: error.message, stack: error.stack }, + ...(context ? { context } : {}), + ...(data ? { data } : {}), + }), + `Error${context ? ` in ${context}` : ""}: ${error.message}` + ); + }, + logRequest(req, data) { + pinoLogger.info(withContext({ req, ...(data ? { data } : {}) }), "Request"); + }, + logResponse(res, data) { + pinoLogger.info(withContext({ res, ...(data ? { data } : {} ) }), "Response"); + }, + + setCorrelationId(id) { + correlationId = id; + }, + setUserId(id) { + userId = id; + }, + setRequestId(id) { + requestId = id; + }, + + child(context) { + const child = pinoLogger.child(context); + const childLogger = createPinoLogger({ + level, + service, + environment, + context: { + correlationId, + userId, + requestId, + ...context, + }, + }); + // Bind methods to use child pino instance + // We cannot replace the underlying pino instance easily, so we wrap methods + return { + ...childLogger, + debug(message, data) { + child.debug(withContext(data), message); + }, + info(message, data) { + child.info(withContext(data), message); + }, + warn(message, data) { + child.warn(withContext(data), message); + }, + error(message, data) { + child.error(withContext(data), message); + }, + trace(message, data) { + child.trace(withContext(data), message); + }, + } as ILogger; + }, + + async flush() { + // Flushing is typically relevant in Node streams; browsers are no-ops + try { + if (typeof (pinoLogger as unknown as { flush?: () => void }).flush === "function") { + (pinoLogger as unknown as { flush?: () => void }).flush?.(); + } + } catch { + // no-op + } + }, + }; + + return api; +} + +// Default singleton for convenience +let defaultLogger: ILogger | undefined; +export function getSharedLogger(): ILogger { + if (!defaultLogger) { + defaultLogger = createPinoLogger(); + } + return defaultLogger; +} + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aed4a288..0884bb82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,6 +295,10 @@ importers: version: 5.9.2 packages/shared: + dependencies: + pino: + specifier: ^9.9.0 + version: 9.9.0 devDependencies: typescript: specifier: ^5.9.2 diff --git a/scripts/dev/manage.sh b/scripts/dev/manage.sh index 10163432..d5d69a46 100755 --- a/scripts/dev/manage.sh +++ b/scripts/dev/manage.sh @@ -15,13 +15,11 @@ PROJECT_NAME="portal-dev" # Colors GREEN='\033[0;32m' YELLOW='\033[1;33m' -BLUE='\033[0;34m' RED='\033[0;31m' NC='\033[0m' log() { echo -e "${GREEN}[DEV] $1${NC}"; } warn() { echo -e "${YELLOW}[DEV] $1${NC}"; } -info() { echo -e "${BLUE}[DEV] $1${NC}"; } error() { echo -e "${RED}[DEV] ERROR: $1${NC}"; exit 1; } # Change to project root @@ -111,10 +109,13 @@ start_apps() { log "🔗 Database: postgresql://dev:dev@localhost:5432/portal_dev" log "🔗 Redis: redis://localhost:6379" log "📚 API Docs: http://localhost:${BFF_PORT:-4000}/api/docs" - log "" log "Starting apps with hot-reload..." - - pnpm dev + + # Start Prisma Studio (opens browser) + (cd "$PROJECT_ROOT/apps/bff" && pnpm db:studio &) + + # Start apps (portal + bff) with hot reload in parallel + pnpm --parallel --filter @customer-portal/portal --filter @customer-portal/bff run dev } # Reset environment diff --git a/tsconfig.json b/tsconfig.json index 45b1fdb8..462f3c77 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,13 +26,9 @@ // Performance and compatibility "skipLibCheck": true, - "allowJs": true, // Build settings - "incremental": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true + "incremental": true }, // This is a workspace root - individual packages extend this