diff --git a/.env.dev.example b/.env.dev.example deleted file mode 100644 index 20084b04..00000000 --- a/.env.dev.example +++ /dev/null @@ -1,103 +0,0 @@ -# ๐Ÿš€ Customer Portal - Development Environment -# Copy this file to .env for local development -# This configuration is optimized for development with hot-reloading - -# ============================================================================= -# ๐ŸŒ APPLICATION CONFIGURATION -# ============================================================================= -NODE_ENV=development -APP_NAME=customer-portal-bff -BFF_PORT=4000 -APP_BASE_URL=http://localhost:3000 - -# ============================================================================= -# ๐Ÿ” SECURITY CONFIGURATION (Development) -# ============================================================================= -# Development JWT secret (OK to use simple secret for local dev) -JWT_SECRET=dev_secret_for_local_development_minimum_32_chars_long -JWT_EXPIRES_IN=7d - -# Password Hashing (Minimum rounds for security compliance) -BCRYPT_ROUNDS=10 - -# ============================================================================= -# ๐Ÿ—„๏ธ DATABASE & CACHE (Development) -# ============================================================================= -# Local Docker services -DATABASE_URL=postgresql://dev:dev@localhost:5432/portal_dev?schema=public -REDIS_URL=redis://localhost:6379 - -# ============================================================================= -# ๐ŸŒ NETWORK & CORS (Development) -# ============================================================================= -# Allow local frontend -CORS_ORIGIN=http://localhost:3000 -TRUST_PROXY=false - -# ============================================================================= -# ๐Ÿšฆ RATE LIMITING (Development) -# ============================================================================= -# Relaxed rate limiting for development -RATE_LIMIT_TTL=60000 -RATE_LIMIT_LIMIT=100 -AUTH_RATE_LIMIT_TTL=900000 -AUTH_RATE_LIMIT_LIMIT=3 - -# ============================================================================= -# ๐Ÿข EXTERNAL INTEGRATIONS (Development) -# ============================================================================= -# WHMCS Integration (Demo/Test Environment) -WHMCS_BASE_URL=https://demo.whmcs.com -WHMCS_API_IDENTIFIER=your_demo_identifier -WHMCS_API_SECRET=your_demo_secret -WHMCS_WEBHOOK_SECRET=your_dev_webhook_secret - -# Salesforce Integration (Sandbox Environment) -SF_LOGIN_URL=https://test.salesforce.com -SF_CLIENT_ID=your_dev_client_id -SF_PRIVATE_KEY_PATH=./secrets/sf-dev.key -SF_USERNAME=dev@yourcompany.com.sandbox -SF_WEBHOOK_SECRET=your_dev_webhook_secret - -# ============================================================================= -# ๐Ÿ“Š LOGGING (Development) -# ============================================================================= -# Verbose logging for development -LOG_LEVEL=debug - -# ============================================================================= -# ๐ŸŽฏ FRONTEND CONFIGURATION (Development) -# ============================================================================= -# NEXT_PUBLIC_ variables are exposed to browser -NEXT_PUBLIC_APP_NAME=Customer Portal (Dev) -NEXT_PUBLIC_APP_VERSION=1.0.0-dev -NEXT_PUBLIC_API_BASE=http://localhost:4000 -NEXT_PUBLIC_ENABLE_DEVTOOLS=true - -# ============================================================================= -# ๐ŸŽ›๏ธ DEVELOPMENT OPTIONS -# ============================================================================= -# Node.js options for development -NODE_OPTIONS=--no-deprecation - -# ============================================================================= -# โœ‰๏ธ EMAIL (SendGrid) - Development -# ============================================================================= -SENDGRID_API_KEY= -EMAIL_FROM=no-reply@localhost.test -EMAIL_FROM_NAME=Assist Solutions (Dev) -EMAIL_ENABLED=true -EMAIL_USE_QUEUE=true -SENDGRID_SANDBOX=true -# Optional: dynamic template IDs (use {{resetUrl}} in reset template) -EMAIL_TEMPLATE_RESET= -EMAIL_TEMPLATE_WELCOME= - -# ============================================================================= -# ๐Ÿš€ QUICK START (Development) -# ============================================================================= -# 1. Copy this template: cp .env.dev.example .env -# 2. Edit .env with your development values -# 3. Start services: pnpm dev:start -# 4. Start apps: pnpm dev -# 5. Access: Frontend http://localhost:3000, Backend http://localhost:4000 diff --git a/.env.example b/.env.example index 252dc20e..702afd19 100644 --- a/.env.example +++ b/.env.example @@ -1,72 +1,93 @@ -# ====== Core ====== -NODE_ENV=production +plesk# ๐Ÿš€ Customer Portal - Development Environment Example +# Copy this file to .env for local development +# This configuration is optimized for development with hot-reloading -# ====== Frontend (Next.js) ====== -NEXT_PUBLIC_APP_NAME=Customer Portal -NEXT_PUBLIC_APP_VERSION=1.0.0 -# If using Plesk single domain with /api proxied to backend, set to your main domain -# Example: https://portal.example.com or https://example.com -NEXT_PUBLIC_API_BASE=https://CHANGE_THIS +# ============================================================================= +# ๐Ÿ—„๏ธ DATABASE CONFIGURATION (Development) +# ============================================================================= +DATABASE_URL="postgresql://dev:dev@localhost:5432/portal_dev?schema=public" -# ====== Backend (NestJS BFF) ====== +# ============================================================================= +# ๐Ÿ”ด REDIS CONFIGURATION (Development) +# ============================================================================= +REDIS_URL="redis://localhost:6379" + +# ============================================================================= +# ๐ŸŒ APPLICATION CONFIGURATION (Development) +# ============================================================================= +# Backend Configuration BFF_PORT=4000 -APP_BASE_URL=https://CHANGE_THIS +APP_NAME="customer-portal-bff" +NODE_ENV="development" -# ====== Database (PostgreSQL) ====== -POSTGRES_DB=portal_prod -POSTGRES_USER=portal -POSTGRES_PASSWORD=CHANGE_THIS +# Frontend Configuration (NEXT_PUBLIC_ variables are exposed to browser) +NEXT_PORT=3000 +NEXT_PUBLIC_APP_NAME="Customer Portal (Dev)" +NEXT_PUBLIC_APP_VERSION="1.0.0-dev" +NEXT_PUBLIC_API_BASE="http://localhost:4000" +NEXT_PUBLIC_ENABLE_DEVTOOLS="true" -# Prisma style DATABASE_URL for Postgres inside Compose network -# For Plesk Compose, hostname is the service name 'database' -DATABASE_URL=postgresql://portal:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?schema=public +# ============================================================================= +# ๐Ÿ” SECURITY CONFIGURATION (Development) +# ============================================================================= +# JWT Secret (Development - OK to use simple secret) +JWT_SECRET="HjHsUyTE3WhPn5N07iSvurdV4hk2VEkIuN+lIflHhVQ=" +JWT_EXPIRES_IN="7d" -# ====== Redis ====== -REDIS_URL=redis://cache:6379/0 +# Password Hashing (Minimum rounds for security compliance) +BCRYPT_ROUNDS=10 -# ====== Security ====== -JWT_SECRET=CHANGE_THIS -JWT_EXPIRES_IN=7d -BCRYPT_ROUNDS=12 +# CORS (Allow local frontend) +CORS_ORIGIN="http://localhost:3000" -# ====== CORS ====== -# If portal: https://portal.example.com ; if root domain: https://example.com -CORS_ORIGIN=https://CHANGE_THIS +# ============================================================================= +# ๐Ÿข EXTERNAL API CONFIGURATION (Development) +# ============================================================================= +# WHMCS Integration (use your actual credentials) +WHMCS_BASE_URL="https://accounts.asolutions.co.jp" +WHMCS_API_IDENTIFIER="your_whmcs_api_identifier" +WHMCS_API_SECRET="your_whmcs_api_secret" -# ====== External APIs (optional) ====== -WHMCS_BASE_URL= -WHMCS_API_IDENTIFIER= -WHMCS_API_SECRET= -SF_LOGIN_URL= -SF_CLIENT_ID= -SF_PRIVATE_KEY_PATH=/app/secrets/salesforce.key -SF_USERNAME= +# Salesforce Integration (use your actual credentials) +SF_LOGIN_URL="https://asolutions.my.salesforce.com" +SF_CLIENT_ID="your_salesforce_client_id" +SF_PRIVATE_KEY_PATH="./secrets/sf-private.key" +SF_USERNAME="your_salesforce_username" -# ====== Salesforce Pricing ====== -# Portal Pricebook ID for product pricing (defaults to Portal pricebook) -PORTAL_PRICEBOOK_ID=01sTL000008eLVlYAM +# Salesforce Pricing +PORTAL_PRICEBOOK_ID="01sTL000008eLVlYAM" -# ====== Logging ====== -LOG_LEVEL=info -LOG_FORMAT=json +# ============================================================================= +# ๐Ÿ“Š LOGGING CONFIGURATION (Development) +# ============================================================================= +LOG_LEVEL="debug" +LOG_FORMAT="pretty" -# ====== Email (SendGrid) ====== -# API key: https://app.sendgrid.com/settings/api_keys -SENDGRID_API_KEY= -# From address for outbound email -EMAIL_FROM=no-reply@yourdomain.com -EMAIL_FROM_NAME=Assist Solutions -# Master email switch -EMAIL_ENABLED=true -# Queue emails for async delivery (recommended) -EMAIL_USE_QUEUE=true -# Enable SendGrid sandbox mode (use true in non-prod to avoid delivery) -SENDGRID_SANDBOX=false -# Optional: dynamic template IDs (use {{resetUrl}} for reset template) -EMAIL_TEMPLATE_RESET= -EMAIL_TEMPLATE_WELCOME= +# ============================================================================= +# ๐Ÿ“ง EMAIL CONFIGURATION (Development) +# ============================================================================= +# SendGrid (optional for development) +SENDGRID_API_KEY="" +EMAIL_FROM="no-reply@yourdomain.com" +EMAIL_FROM_NAME="Assist Solutions" +EMAIL_ENABLED=false +EMAIL_USE_QUEUE=false +SENDGRID_SANDBOX=true +EMAIL_TEMPLATE_RESET="" +EMAIL_TEMPLATE_WELCOME="" -# ====== Node options ====== -NODE_OPTIONS=--max-old-space-size=512 +# ============================================================================= +# ๐ŸŽ›๏ธ DEVELOPMENT CONFIGURATION +# ============================================================================= +# Node.js options for development +NODE_OPTIONS="--no-deprecation" + +# ============================================================================= +# ๐Ÿณ DOCKER DEVELOPMENT NOTES +# ============================================================================= +# For Docker development services (PostgreSQL + Redis only): +# 1. Run: pnpm dev:start +# 2. Frontend and Backend run locally (outside containers) for hot-reloading +# 3. Only database and cache services run in containers diff --git a/.env.plesk b/.env.plesk new file mode 100644 index 00000000..1778ac6a --- /dev/null +++ b/.env.plesk @@ -0,0 +1,7 @@ + +# GitHub Container Registry Authentication +# Replace with your actual GitHub personal access token (with read:packages scope) +GITHUB_TOKEN=your_github_personal_access_token_here + +# Security note: Keep this file secure and don't commit it to Git +# This token allows pulling private images from GitHub Container Registry diff --git a/.env.production.example b/.env.production.example deleted file mode 100644 index d5ad59da..00000000 --- a/.env.production.example +++ /dev/null @@ -1,117 +0,0 @@ -# ๐Ÿš€ Customer Portal - Production Environment -# Copy this file to .env for production deployment -# This configuration is optimized for production with security and performance - -# ============================================================================= -# ๐ŸŒ APPLICATION CONFIGURATION -# ============================================================================= -NODE_ENV=production -APP_NAME=customer-portal-bff -BFF_PORT=4000 -APP_BASE_URL=https://portal.yourdomain.com - -# ============================================================================= -# ๐Ÿ” SECURITY CONFIGURATION (Production) -# ============================================================================= -# CRITICAL: Generate with: openssl rand -base64 32 -JWT_SECRET=GENERATE_SECURE_JWT_SECRET_HERE_MINIMUM_32_CHARS -JWT_EXPIRES_IN=7d - -# Password Hashing (High rounds for security) -BCRYPT_ROUNDS=12 - -# ============================================================================= -# ๐Ÿ—„๏ธ DATABASE & CACHE (Production) -# ============================================================================= -# Docker internal networking (container names as hostnames) -DATABASE_URL=postgresql://portal:YOUR_SECURE_DB_PASSWORD@database:5432/portal_prod?schema=public -REDIS_URL=redis://cache:6379 - -# ============================================================================= -# ๐ŸŒ NETWORK & CORS (Production) -# ============================================================================= -# Your production domain -CORS_ORIGIN=https://yourdomain.com -TRUST_PROXY=true - -# ============================================================================= -# ๐Ÿšฆ RATE LIMITING (Production) -# ============================================================================= -# Strict rate limiting for production -RATE_LIMIT_TTL=60000 -RATE_LIMIT_LIMIT=100 -AUTH_RATE_LIMIT_TTL=900000 -AUTH_RATE_LIMIT_LIMIT=3 - -# ============================================================================= -# ๐Ÿข EXTERNAL INTEGRATIONS (Production) -# ============================================================================= -# WHMCS Integration (Production Environment) -WHMCS_BASE_URL=https://your-whmcs-domain.com -WHMCS_API_IDENTIFIER=your_production_identifier -WHMCS_API_SECRET=your_production_secret -WHMCS_WEBHOOK_SECRET=your_whmcs_webhook_secret - -# Salesforce Integration (Production Environment) -SF_LOGIN_URL=https://login.salesforce.com -SF_CLIENT_ID=your_production_client_id -SF_PRIVATE_KEY_PATH=/app/secrets/sf-prod.key -SF_USERNAME=production@yourcompany.com -SF_WEBHOOK_SECRET=your_salesforce_webhook_secret - -# ============================================================================= -# ๐Ÿ“Š LOGGING (Production) -# ============================================================================= -# Production logging level -LOG_LEVEL=info - -# ============================================================================= -# ๐ŸŽฏ FRONTEND CONFIGURATION (Production) -# ============================================================================= -# NEXT_PUBLIC_ variables are exposed to browser -NEXT_PUBLIC_APP_NAME=Customer Portal -NEXT_PUBLIC_APP_VERSION=1.0.0 -NEXT_PUBLIC_API_BASE=https://yourdomain.com -NEXT_PUBLIC_ENABLE_DEVTOOLS=false - -# ============================================================================= -# ๐ŸŽ›๏ธ PRODUCTION OPTIONS -# ============================================================================= -# Node.js options for production -NODE_OPTIONS=--max-old-space-size=2048 - -# ============================================================================= -# โœ‰๏ธ EMAIL (SendGrid) - Production -# ============================================================================= -# Create and store securely (e.g., KMS/Secrets Manager) -SENDGRID_API_KEY= -EMAIL_FROM=no-reply@yourdomain.com -EMAIL_FROM_NAME=Assist Solutions -EMAIL_ENABLED=true -EMAIL_USE_QUEUE=true -SENDGRID_SANDBOX=false -# Optional: Dynamic Template IDs (recommended) -EMAIL_TEMPLATE_RESET= -EMAIL_TEMPLATE_WELCOME= - -# ============================================================================= -# ๐Ÿ”’ PRODUCTION SECURITY CHECKLIST -# ============================================================================= -# โœ… Replace ALL default/demo values with real credentials -# โœ… Use strong, unique passwords and secrets (minimum 32 characters for JWT) -# โœ… Ensure SF_PRIVATE_KEY_PATH points to actual key file -# โœ… Set correct CORS_ORIGIN for your domain -# โœ… Use HTTPS URLs for all external services -# โœ… Verify DATABASE_URL password matches docker-compose.yml -# โœ… Test all integrations before going live -# โœ… Configure webhook secrets for security -# โœ… Set appropriate rate limiting values -# โœ… Enable trust proxy if behind reverse proxy - -# ============================================================================= -# ๐Ÿš€ QUICK START (Production) -# ============================================================================= -# 1. Copy this template: cp .env.production.example .env -# 2. Edit .env with your production values (REQUIRED!) -# 3. Deploy: pnpm prod:deploy -# 4. Access: https://yourdomain.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 2e3fd0ed..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: "pnpm" - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.15.0 - - - name: Install deps - run: pnpm install --frozen-lockfile - - - name: Build Shared (needed for type refs) - run: pnpm --filter @customer-portal/shared run build - - - name: Generate Prisma client - run: pnpm --filter @customer-portal/bff run db:generate - - - name: Type check (workspace) - run: pnpm --recursive run type-check - - - name: Lint (workspace) - run: pnpm --recursive run lint - - - name: Build BFF - run: pnpm --filter @customer-portal/bff run build - - - name: Build Portal - run: pnpm --filter @customer-portal/portal run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..a80ea8a6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,107 @@ +name: Build & Push Images + +on: + workflow_dispatch: # Only allow manual triggers + # push: + # branches: [main] # Commented out - no auto-trigger + +env: + REGISTRY: ghcr.io + IMAGE_NAME_PREFIX: ntumurbars/customer-portal + +jobs: + build-and-push: + name: Build & Push Docker Images + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for frontend + id: meta-frontend + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_PREFIX }}-frontend + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=main- + + - name: Extract metadata for backend + id: meta-backend + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_PREFIX }}-backend + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=main- + + - name: Build and push frontend image + uses: docker/build-push-action@v5 + with: + context: . + file: ./apps/portal/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta-frontend.outputs.tags }} + labels: ${{ steps.meta-frontend.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build and push backend image + uses: docker/build-push-action@v5 + with: + context: . + file: ./apps/bff/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta-backend.outputs.tags }} + labels: ${{ steps.meta-backend.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Build Summary + run: | + echo "## ๐Ÿš€ Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Frontend Image:** \`${{ steps.meta-frontend.outputs.tags }}\`" >> $GITHUB_STEP_SUMMARY + echo "**Backend Image:** \`${{ steps.meta-backend.outputs.tags }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ๐Ÿ“ฆ Images Built:" >> $GITHUB_STEP_SUMMARY + echo "- **Frontend**: [ghcr.io/${{ env.IMAGE_NAME_PREFIX }}-frontend](https://github.com/NTumurbars/customer-portal/pkgs/container/customer-portal-frontend)" >> $GITHUB_STEP_SUMMARY + echo "- **Backend**: [ghcr.io/${{ env.IMAGE_NAME_PREFIX }}-backend](https://github.com/NTumurbars/customer-portal/pkgs/container/customer-portal-backend)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ๐Ÿš€ Next Steps:" >> $GITHUB_STEP_SUMMARY + echo "1. **SSH to Plesk server** and run:" >> $GITHUB_STEP_SUMMARY + echo " \`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo " docker compose -f compose-plesk.yaml pull" >> $GITHUB_STEP_SUMMARY + echo " docker compose -f compose-plesk.yaml up -d" >> $GITHUB_STEP_SUMMARY + echo " \`\`\`" >> $GITHUB_STEP_SUMMARY + echo "2. **Or update via Plesk UI**: Docker โ†’ Stacks โ†’ customer-portal โ†’ Pull โ†’ Up" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index e044ab40..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Test & Lint - -on: - push: - branches: [main, develop] - pull_request: - branches: [main, develop] - -env: - NODE_VERSION: "22" - PNPM_VERSION: "10.15.0" - -jobs: - test: - name: Test & Lint - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:17 - env: - POSTGRES_PASSWORD: test - POSTGRES_DB: portal_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - redis: - image: redis:8-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Enable Corepack and install pnpm - run: | - corepack enable - corepack prepare pnpm@${{ env.PNPM_VERSION }} --activate - - - name: Cache pnpm dependencies - uses: actions/cache@v4 - with: - path: ~/.pnpm-store - key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build shared package (needed for type refs) - run: pnpm --filter @customer-portal/shared run build - - - name: Generate Prisma client - run: pnpm --filter @customer-portal/bff run db:generate - - - name: Type check - run: pnpm type-check - - - name: Lint - run: pnpm lint - - - name: Test shared package - run: pnpm --filter @customer-portal/shared run test - if: success() || failure() - - - name: Test BFF package - run: pnpm --filter @customer-portal/bff run test - env: - DATABASE_URL: postgresql://postgres:test@localhost:5432/portal_test - REDIS_URL: redis://localhost:6379 - if: success() || failure() - - - name: Build applications - run: pnpm build - env: - NEXT_PUBLIC_API_BASE: http://localhost:4000 - NEXT_PUBLIC_APP_NAME: Customer Portal Test diff --git a/.gitignore b/.gitignore index 3d093c3d..9221220d 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,8 @@ temp/ # Prisma prisma/migrations/dev.db* + +# Large archive files +*.tar +*.tar.gz +*.zip diff --git a/ARCHITECTURE_RECOMMENDATION.md b/ARCHITECTURE_RECOMMENDATION.md deleted file mode 100644 index f2994454..00000000 --- a/ARCHITECTURE_RECOMMENDATION.md +++ /dev/null @@ -1,128 +0,0 @@ -# Order Services Architecture Recommendation - -## Recommended Structure: Enhanced Separation of Concerns - -### 1. **Controller Layer** (`orders.controller.ts`) -**Responsibility**: API contract and basic validation -- DTO validation (format, types, required fields) -- Authentication/authorization -- HTTP response handling -- Minimal business logic - -### 2. **Orchestrator Layer** (`order-orchestrator.service.ts`) -**Responsibility**: Workflow coordination and transaction management -- Coordinates the order creation flow -- Manages transaction boundaries -- Handles high-level error scenarios -- Calls other services in correct sequence - -### 3. **Validator Layer** (`order-validator.service.ts`) -**Responsibility**: ALL validation logic (business + technical) -```typescript -class OrderValidator { - // API-level validation (move from DTO) - validateRequestFormat(body: any): CreateOrderBody - - // Business validation (current) - validateUserMapping(userId: string): Promise - validatePaymentMethod(userId: string, clientId: number): Promise - validateSKUs(skus: string[], pricebookId: string): Promise - validateBusinessRules(orderType: string, skus: string[]): void - validateInternetDuplication(userId: string, clientId: number): Promise - - // Complete validation (orchestrates all checks) - async validateCompleteOrder(userId: string, body: any): Promise<{ - validatedBody: CreateOrderBody, - userMapping: UserMapping - }> -} -``` - -### 4. **Builder Layer** (`order-builder.service.ts`) -**Responsibility**: Data transformation and mapping -- Transform business data to Salesforce format -- Apply business rules to field mapping -- Handle conditional field logic - -### 5. **ItemBuilder Layer** (`order-item-builder.service.ts`) -**Responsibility**: Order item creation and pricing -- Create order line items -- Handle pricing calculations -- Manage product metadata - -## Benefits of This Structure: - -### โœ… **Single Responsibility Principle** -- Each service has one clear purpose -- Easy to test and maintain -- Clear boundaries - -### โœ… **Validator as Single Source of Truth** -- All validation logic in one place -- Easy to find and modify validation rules -- Consistent error handling - -### โœ… **Orchestrator for Workflow Management** -- Clear sequence of operations -- Transaction management -- Error recovery logic - -### โœ… **Testability** -- Each layer can be unit tested independently -- Mock dependencies easily -- Clear input/output contracts - -## Implementation Changes: - -### Move DTO validation to Validator: -```typescript -// Before: Controller has DTO validation -@Body() body: CreateOrderDto - -// After: Controller accepts any, Validator validates -@Body() body: any -``` - -### Enhanced Validator: -```typescript -async validateCompleteOrder(userId: string, rawBody: any) { - // 1. Format validation (was DTO) - const body = this.validateRequestFormat(rawBody); - - // 2. Business validation (current) - const userMapping = await this.validateUserMapping(userId); - await this.validatePaymentMethod(userId, userMapping.whmcsClientId); - - // 3. SKU validation (move here) - const pricebookId = await this.findPricebookId(); - await this.validateSKUs(body.skus, pricebookId); - this.validateBusinessRules(body.orderType, body.skus); - - // 4. Order-specific validation - if (body.orderType === "Internet") { - await this.validateInternetDuplication(userId, userMapping.whmcsClientId); - } - - return { validatedBody: body, userMapping, pricebookId }; -} -``` - -### Simplified Orchestrator: -```typescript -async createOrder(userId: string, rawBody: any) { - // 1. Complete validation - const { validatedBody, userMapping, pricebookId } = - await this.validator.validateCompleteOrder(userId, rawBody); - - // 2. Build order - const orderFields = this.builder.buildOrderFields(validatedBody, userMapping, pricebookId); - - // 3. Create in Salesforce - const created = await this.sf.sobject("Order").create(orderFields); - - // 4. Create items - await this.itemBuilder.createOrderItemsFromSKUs(created.id, validatedBody.skus, pricebookId); - - return { sfOrderId: created.id, status: "Created" }; -} -``` diff --git a/FINAL_CODE_QUALITY_REPORT.md b/FINAL_CODE_QUALITY_REPORT.md deleted file mode 100644 index 7f2b2179..00000000 --- a/FINAL_CODE_QUALITY_REPORT.md +++ /dev/null @@ -1,181 +0,0 @@ -# ๐ŸŽฏ Final Code Quality & Documentation Compliance Report - -## ๐Ÿ† **Overall Assessment: EXCELLENT** - -The order system demonstrates **enterprise-grade code quality** with proper architecture, maintainable patterns, and full documentation compliance. - ---- - -## โœ… **Architecture Quality: A+** - -### **Clean Architecture Implementation** -```typescript -Controller (Thin API Layer) - โ†“ -OrderValidator (Complete Validation) - โ†“ -OrderOrchestrator (Workflow Coordination) - โ†“ -OrderBuilder + OrderItemBuilder (Data Transformation) - โ†“ -Salesforce (External System) -``` - -**โœ… Strengths:** -- **Single Responsibility Principle**: Each service has one clear purpose -- **Dependency Injection**: Proper NestJS patterns throughout -- **Separation of Concerns**: API, validation, business logic, and data layers clearly separated -- **Testability**: Each component can be unit tested independently - ---- - -## โœ… **Field Mapping: A+** - -### **No Hardcoded Salesforce Fields** -```typescript -// โœ… GOOD: Using field mapping -orderFields[fields.order.internetPlanTier] = serviceProduct.internetPlanTier; -orderFields[fields.order.accessMode] = config.accessMode; - -// โŒ BAD: Hardcoded (eliminated) -// orderFields.Internet_Plan_Tier__c = serviceProduct.internetPlanTier; -``` - -**โœ… Benefits:** -- **Environment Configurable**: All field names can be overridden via `process.env` -- **Maintainable**: Single source of truth in `field-map.ts` -- **Flexible**: Easy to adapt to different Salesforce orgs -- **Type Safe**: Full TypeScript support with proper interfaces - ---- - -## โœ… **Validation Logic: A+** - -### **Comprehensive Validation Pipeline** -```typescript -validateCompleteOrder() { - 1. Format Validation (replaces DTO) - 2. User Mapping Validation - 3. Payment Method Validation - 4. SKU Existence Validation - 5. Business Rules Validation - 6. Order-specific Validation -} -``` - -**โœ… Validation Coverage:** -- **Format**: Field types, required fields, enum values -- **Business**: User mapping, payment methods, duplicate orders -- **Data**: SKU existence in Salesforce, business rule compliance -- **Security**: Proper error handling without sensitive data exposure [[memory:6689308]] - ---- - -## โœ… **Documentation Compliance: A** - -### **Salesforce Order Fields - 100% Compliant** - -| Documentation Requirement | Implementation Status | -|---------------------------|----------------------| -| **Core Fields (5)** | โœ… `AccountId`, `EffectiveDate`, `Status`, `Pricebook2Id`, `Order_Type__c` | -| **Activation Fields (3)** | โœ… `Activation_Type__c`, `Activation_Scheduled_At__c`, `Activation_Status__c` | -| **Internet Fields (5)** | โœ… `Internet_Plan_Tier__c`, `Installation_Type__c`, `Weekend_Install__c`, `Access_Mode__c`, `Hikari_Denwa__c` | -| **SIM Fields (4+11)** | โœ… `SIM_Type__c`, `EID__c`, `SIM_Voice_Mail__c`, `SIM_Call_Waiting__c` + all MNP fields | -| **VPN Fields (1)** | โœ… `VPN_Region__c` | - -### **API Requirements - Compliant** -- โœ… **Server-side checks**: WHMCS mapping โœ“, payment method โœ“ -- โœ… **Order status**: Creates "Pending Review" status โœ“ -- โœ… **Return format**: `{ sfOrderId, status }` โœ“ - -### **โš ๏ธ Minor Documentation Discrepancy** -**Issue**: Documentation shows item-based API structure, implementation uses SKU-based structure. - -**Documentation:** -```json -{ "items": [{ "productId": "...", "billingCycle": "..." }] } -``` - -**Implementation:** -```json -{ "orderType": "Internet", "skus": ["INTERNET-SILVER-HOME-1G"] } -``` - -**Recommendation**: Update documentation to match the superior SKU-based implementation. - ---- - -## โœ… **Code Quality Standards: A+** - -### **Error Handling** -```typescript -// โœ… Proper error handling with context -this.logger.error({ error, orderFields }, "Failed to create Salesforce Order"); -throw new BadRequestException("Order creation failed"); -``` - -### **Logging** -```typescript -// โœ… Structured logging throughout -this.logger.log({ userId, orderType, skuCount }, "Order validation completed"); -``` - -### **Type Safety** -```typescript -// โœ… Strong typing everywhere -async validateCompleteOrder(userId: string, rawBody: any): Promise<{ - validatedBody: CreateOrderBody; - userMapping: UserMapping; - pricebookId: string; -}> -``` - ---- - -## โœ… **Production Readiness: A+** - -### **Security** [[memory:6689308]] -- โœ… **Input validation**: Comprehensive DTO validation -- โœ… **Error handling**: No sensitive data exposure -- โœ… **Authentication**: JWT guards on all endpoints -- โœ… **Authorization**: User-specific data access - -### **Performance** -- โœ… **Efficient validation**: Single validation pipeline -- โœ… **Database optimization**: Proper SOQL queries -- โœ… **Error recovery**: Graceful handling of external API failures - -### **Maintainability** -- โœ… **Modular design**: Easy to extend and modify -- โœ… **Clear interfaces**: Well-defined contracts between layers -- โœ… **Consistent patterns**: Uniform error handling and logging -- โœ… **Documentation**: Comprehensive inline documentation - ---- - -## ๐ŸŽฏ **Final Recommendations** - -### **Immediate Actions: None Required** โœ… -The code is production-ready as-is. - -### **Future Enhancements (Optional)** -1. **API Documentation Update**: Align docs with SKU-based implementation -2. **Integration Tests**: Add end-to-end order flow tests -3. **Monitoring**: Add business metrics for order success rates - ---- - -## ๐Ÿ† **Summary** - -This order system represents **exemplary enterprise software development**: - -- โœ… **Clean Architecture**: Proper separation of concerns -- โœ… **Maintainable Code**: No hardcoded values, configurable fields -- โœ… **Production Ready**: Comprehensive validation, error handling, security -- โœ… **Documentation Compliant**: All Salesforce fields properly mapped -- โœ… **Type Safe**: Full TypeScript coverage -- โœ… **Testable**: Modular design enables comprehensive testing - -**Grade: A+ (Excellent)** - -The system is ready for production deployment with confidence! ๐Ÿš€ diff --git a/SECURITY_AUDIT_REPORT.md b/SECURITY_AUDIT_REPORT.md deleted file mode 100644 index 0d67853b..00000000 --- a/SECURITY_AUDIT_REPORT.md +++ /dev/null @@ -1,202 +0,0 @@ -# ๐Ÿ”’ COMPREHENSIVE SECURITY AUDIT REPORT - -**Date**: August 28, 2025 -**Auditor**: AI Security Assistant -**Scope**: Complete NestJS BFF Application Security Review -**Status**: โœ… **PRODUCTION READY** - -## ๐ŸŽฏ **EXECUTIVE SUMMARY** - -The application has been upgraded to implement **2025 NestJS Security Best Practices** with a comprehensive **Global Authentication Architecture**. All critical security vulnerabilities have been addressed and the system is now **ENTERPRISE-GRADE SECURE**. - -### **๐Ÿ† SECURITY GRADE: A+** - -## ๐Ÿ›ก๏ธ **SECURITY ARCHITECTURE OVERVIEW** - -### **Global Authentication Guard (2025 Standard)** -- โœ… **Single Point of Control**: All authentication handled by `GlobalAuthGuard` -- โœ… **JWT Validation**: Automatic token signature and expiration checking -- โœ… **Token Blacklist Integration**: Real-time revoked token checking -- โœ… **Decorator-Based Public Routes**: Clean `@Public()` decorator system -- โœ… **Comprehensive Logging**: Security event tracking and monitoring - -### **Authentication Flow** -```typescript -Request โ†’ GlobalAuthGuard โ†’ @Public() Check โ†’ JWT Validation โ†’ Blacklist Check โ†’ Route Handler -``` - -## ๐Ÿ” **DETAILED SECURITY AUDIT** - -### **1. Authentication & Authorization** โœ… **SECURE** - -| Component | Status | Details | -|-----------|--------|---------| -| JWT Strategy | โœ… SECURE | Proper signature validation, no body parsing interference | -| Token Blacklist | โœ… SECURE | Redis-based, automatic cleanup, logout integration | -| Global Guard | โœ… SECURE | Centralized, comprehensive, production-ready | -| Public Routes | โœ… SECURE | Properly marked, validated, minimal exposure | -| Admin Routes | โœ… SECURE | Additional AdminGuard protection | - -### **2. Public Route Security** โœ… **VALIDATED** - -| Route | Purpose | Security Measures | -|-------|---------|-------------------| -| `POST /auth/signup` | User registration | Rate limiting, input validation | -| `POST /auth/login` | User authentication | Rate limiting, LocalAuthGuard | -| `POST /auth/request-password-reset` | Password reset | Rate limiting, email validation | -| `POST /auth/reset-password` | Password reset | Rate limiting, token validation | -| `POST /auth/link-whmcs` | WHMCS linking | Rate limiting, input validation | -| `POST /auth/set-password` | Password setting | Rate limiting, input validation | -| `POST /auth/check-password-needed` | Password status | Input validation | -| `GET /health` | Health checks | No sensitive data exposure | -| `POST /webhooks/*` | Webhook endpoints | HMAC signature verification | - -### **3. Protected Route Security** โœ… **VALIDATED** - -| Route Category | Protection Level | Validation | -|----------------|------------------|------------| -| User Management (`/api/me`) | JWT + Blacklist | โœ… Tested | -| Orders (`/api/orders`) | JWT + Blacklist | โœ… Tested | -| Catalog (`/api/catalog`) | JWT + Blacklist | โœ… Tested | -| Subscriptions (`/api/subscriptions`) | JWT + Blacklist | โœ… Tested | -| Invoices (`/api/invoices`) | JWT + Blacklist | โœ… Tested | -| Admin (`/api/auth/admin`) | JWT + Blacklist + AdminGuard | โœ… Tested | - -### **4. Webhook Security** โœ… **ENTERPRISE-GRADE** - -- โœ… **HMAC-SHA256 Signature Verification**: All webhooks require valid signatures -- โœ… **Rate Limiting**: Prevents webhook abuse -- โœ… **Public Route Marking**: Properly excluded from JWT authentication -- โœ… **Separate Authentication**: Uses signature-based auth instead of JWT - -### **5. Input Validation & Sanitization** โœ… **COMPREHENSIVE** - -- โœ… **Global ValidationPipe**: Whitelist mode, forbid unknown values -- โœ… **DTO Validation**: class-validator decorators on all inputs -- โœ… **Request Size Limits**: Helmet.js protection -- โœ… **Production Error Handling**: Sanitized error messages - -### **6. Security Headers & CORS** โœ… **HARDENED** - -- โœ… **Helmet.js**: Comprehensive security headers -- โœ… **CSP**: Content Security Policy configured -- โœ… **CORS**: Restrictive origin validation -- โœ… **Security Headers**: X-Frame-Options, X-Content-Type-Options, etc. - -## ๐Ÿงช **SECURITY TESTING RESULTS** - -### **Authentication Tests** โœ… **PASSED** - -| Test Case | Expected | Actual | Status | -|-----------|----------|--------|--------| -| Public route without auth | 200/400 (validation) | โœ… 400 (validation) | PASS | -| Protected route without auth | 401 Unauthorized | โœ… 401 Unauthorized | PASS | -| Protected route with valid JWT | 200 + data | โœ… 200 + data | PASS | -| Webhook without signature | 401 Unauthorized | โœ… 401 Unauthorized | PASS | -| Password reset public access | 200 + message | โœ… 200 + message | PASS | - -### **Edge Case Tests** โœ… **PASSED** - -- โœ… **Malformed JWT**: Properly rejected -- โœ… **Expired JWT**: Properly rejected -- โœ… **Missing Authorization Header**: Properly rejected -- โœ… **Invalid Webhook Signature**: Properly rejected -- โœ… **Rate Limit Exceeded**: Properly throttled - -## ๐Ÿšจ **SECURITY VULNERABILITIES FIXED** - -### **Critical Issues Resolved** โœ… - -1. **Missing @Public Decorators**: - - โŒ **BEFORE**: Auth routes required JWT (impossible to login) - - โœ… **AFTER**: Proper public route marking - -2. **Inconsistent Guard Usage**: - - โŒ **BEFORE**: Manual guards on each controller (error-prone) - - โœ… **AFTER**: Global guard with decorator-based exceptions - -3. **Token Blacklist Gaps**: - - โŒ **BEFORE**: Separate guard implementation (complex) - - โœ… **AFTER**: Integrated into global guard (seamless) - -4. **Webhook Security**: - - โŒ **BEFORE**: Would require JWT (breaking webhooks) - - โœ… **AFTER**: Proper signature-based authentication - -## ๐ŸŽฏ **SECURITY RECOMMENDATIONS IMPLEMENTED** - -### **2025 Best Practices** โœ… **IMPLEMENTED** - -1. โœ… **Global Authentication Guard**: Single point of control -2. โœ… **Decorator-Based Public Routes**: Clean architecture -3. โœ… **Token Blacklisting**: Proper logout functionality -4. โœ… **Comprehensive Logging**: Security event monitoring -5. โœ… **Rate Limiting**: Abuse prevention -6. โœ… **Input Validation**: XSS and injection prevention -7. โœ… **Security Headers**: Browser-level protection -8. โœ… **CORS Configuration**: Origin validation - -## ๐Ÿ“Š **SECURITY METRICS** - -| Metric | Value | Status | -|--------|-------|--------| -| Protected Endpoints | 100% | โœ… SECURE | -| Public Endpoints | 8 routes | โœ… VALIDATED | -| Authentication Coverage | 100% | โœ… COMPLETE | -| Token Blacklist Coverage | 100% | โœ… COMPLETE | -| Input Validation Coverage | 100% | โœ… COMPLETE | -| Rate Limiting Coverage | 100% | โœ… COMPLETE | -| Security Headers | All configured | โœ… COMPLETE | - -## ๐Ÿ”ง **TECHNICAL IMPLEMENTATION** - -### **Global Guard Architecture** -```typescript -@Injectable() -export class GlobalAuthGuard extends AuthGuard('jwt') { - // 1. Check @Public() decorator - // 2. Validate JWT if not public - // 3. Check token blacklist - // 4. Log security events - // 5. Allow/deny access -} -``` - -### **Security Features** -- **JWT Validation**: Signature, expiration, format -- **Token Blacklisting**: Redis-based, automatic cleanup -- **Public Route Handling**: Decorator-based exceptions -- **Comprehensive Logging**: Debug, warn, error levels -- **Error Handling**: Production-safe messages - -## ๐ŸŽ‰ **CONCLUSION** - -### **โœ… SECURITY STATUS: PRODUCTION READY** - -The application now implements **enterprise-grade security** following **2025 NestJS best practices**: - -1. **๐Ÿ”’ Authentication**: Bulletproof JWT + blacklist system -2. **๐Ÿ›ก๏ธ Authorization**: Proper role-based access control -3. **๐Ÿšซ Input Validation**: Comprehensive XSS/injection prevention -4. **โšก Rate Limiting**: Abuse and DoS protection -5. **๐Ÿ” Security Headers**: Browser-level security -6. **๐Ÿ“ Audit Logging**: Complete security event tracking -7. **๐ŸŒ CORS**: Proper origin validation -8. **๐Ÿ”ง Webhook Security**: HMAC signature verification - -### **๐Ÿ† ACHIEVEMENTS** - -- โœ… **Zero Security Vulnerabilities** -- โœ… **100% Authentication Coverage** -- โœ… **Modern Architecture (2025 Standards)** -- โœ… **Production-Ready Implementation** -- โœ… **Comprehensive Testing Validated** - -### **๐Ÿš€ READY FOR PRODUCTION DEPLOYMENT** - -The security implementation is now **enterprise-grade** and ready for production use with confidence. - ---- - -**Security Audit Completed**: August 28, 2025 -**Next Review**: Recommended in 6 months or after major changes diff --git a/SECURITY_FIXES_REQUIRED.md b/SECURITY_FIXES_REQUIRED.md deleted file mode 100644 index da22ec82..00000000 --- a/SECURITY_FIXES_REQUIRED.md +++ /dev/null @@ -1,169 +0,0 @@ -# ๐Ÿšจ CRITICAL SECURITY FIXES REQUIRED - -## **IMMEDIATE ACTION NEEDED** - -The ESLint scan revealed **204 ERRORS** and **479 WARNINGS** with critical security vulnerabilities: - -### **๐Ÿ”ด CRITICAL SECURITY ISSUES** - -1. **Unsafe `any` Types** - 50+ instances - - **Risk**: Type safety bypass, potential injection attacks - - **Impact**: HIGH - Can lead to runtime errors and security vulnerabilities - -2. **Unsafe Member Access** - 100+ instances - - **Risk**: Accessing properties on potentially undefined objects - - **Impact**: HIGH - Runtime errors, potential crashes - -3. **No Type Validation** - Salesforce responses not validated - - **Risk**: Malformed data can crash the application - - **Impact**: MEDIUM - Stability and reliability issues - -## **๐Ÿ›ก๏ธ MODERN SECURITY FIXES IMPLEMENTED** - -### **1. Type Safety Enhancement** -```typescript -// โŒ BEFORE (UNSAFE) -async createOrder(userId: string, rawBody: any) { - const result = await this.sf.query(sql) as any; - return result.records[0].Id; // Unsafe! -} - -// โœ… AFTER (SECURE) -async createOrder(userId: string, rawBody: unknown) { - const result = await this.sf.query(sql) as SalesforceQueryResult; - if (!isSalesforceQueryResult(result, isSalesforceOrder)) { - throw new BadRequestException('Invalid Salesforce response'); - } - return result.records[0]?.Id; -} -``` - -### **2. Runtime Type Validation** -```typescript -// โœ… NEW: Type Guards for Security -export function isSalesforceOrder(obj: unknown): obj is SalesforceOrder { - return ( - typeof obj === 'object' && - obj !== null && - typeof (obj as SalesforceOrder).Id === 'string' && - typeof (obj as SalesforceOrder).OrderNumber === 'string' - ); -} -``` - -### **3. Proper Error Handling** -```typescript -// โœ… NEW: Secure Error Handling -try { - const validatedBody = this.validateRequestFormat(rawBody); - // Process with type safety -} catch (error) { - this.logger.error('Validation failed', { error: error.message }); - throw new BadRequestException('Invalid request format'); -} -``` - -## **๐Ÿ“‹ FIXES APPLIED** - -### **โœ… Completed** -1. Created `SalesforceOrder` and `SalesforceOrderItem` types -2. Added type guards for runtime validation -3. Replaced critical `any` types with `unknown` -4. Enhanced GlobalAuthGuard with proper logging -5. Fixed public route security - -### **๐Ÿ”„ In Progress** -1. Replacing all `any` types with proper interfaces -2. Adding runtime validation for all external data -3. Implementing proper error boundaries -4. Adding comprehensive type checking - -### **โณ Remaining** -1. Fix all ESLint errors (204 remaining) -2. Add comprehensive input validation -3. Implement data sanitization -4. Add security headers validation - -## **๐ŸŽฏ NEXT STEPS** - -### **Immediate (Critical)** -1. **Fix Type Safety**: Replace all `any` with proper types -2. **Add Validation**: Validate all external API responses -3. **Secure Error Handling**: Sanitize all error messages - -### **Short Term (Important)** -1. **Run ESLint Fix**: `npm run lint:fix` -2. **Add Unit Tests**: Test all type guards and validation -3. **Security Audit**: Review all external integrations - -### **Long Term (Maintenance)** -1. **Automated Security Scanning**: Add to CI/CD -2. **Regular Type Audits**: Monthly type safety reviews -3. **Security Training**: Team education on TypeScript security - -## **๐Ÿš€ RECOMMENDED APPROACH** - -### **Phase 1: Critical Security (Now)** -```bash -# 1. Fix immediate type safety issues -npm run lint:fix - -# 2. Add proper types for all Salesforce interactions -# 3. Implement runtime validation for all external data -# 4. Add comprehensive error handling -``` - -### **Phase 2: Comprehensive Security (This Week)** -```bash -# 1. Complete type safety overhaul -# 2. Add comprehensive input validation -# 3. Implement security testing -# 4. Add monitoring and alerting -``` - -## **๐Ÿ’ก MODERN NESTJS PATTERNS** - -### **Use Proper DTOs with Validation** -```typescript -// โœ… Modern NestJS Pattern -export class CreateOrderDto { - @IsString() - @IsNotEmpty() - @IsIn(['Internet', 'SIM', 'VPN', 'Other']) - orderType: 'Internet' | 'SIM' | 'VPN' | 'Other'; - - @IsArray() - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - skus: string[]; -} -``` - -### **Use Type Guards for External Data** -```typescript -// โœ… Secure External Data Handling -function validateSalesforceResponse( - data: unknown, - validator: (obj: unknown) => obj is T -): T { - if (!validator(data)) { - throw new BadRequestException('Invalid external data format'); - } - return data; -} -``` - -## **๐Ÿ”’ SECURITY COMPLIANCE** - -After implementing these fixes, the application will be: -- โœ… **Type Safe**: No `any` types, full TypeScript compliance -- โœ… **Runtime Safe**: All external data validated -- โœ… **Error Safe**: Proper error handling and sanitization -- โœ… **Modern**: Following 2025 NestJS best practices -- โœ… **Secure**: Production-ready security implementation - ---- - -**Status**: ๐Ÿ”ด **CRITICAL FIXES IN PROGRESS** -**ETA**: 2-4 hours for complete security overhaul -**Priority**: **HIGHEST** - Security vulnerabilities must be fixed before production diff --git a/VALIDATION_AUDIT_REPORT.md b/VALIDATION_AUDIT_REPORT.md deleted file mode 100644 index 70798143..00000000 --- a/VALIDATION_AUDIT_REPORT.md +++ /dev/null @@ -1,125 +0,0 @@ -# Order Validation & Salesforce Field Mapping Audit Report - -## ๐Ÿ” **Audit Summary** - -### โœ… **What's Working Correctly:** - -1. **Core Order Fields** - All documented fields are properly mapped: - - `AccountId`, `EffectiveDate`, `Status`, `Pricebook2Id`, `Order_Type__c` โœ… - -2. **Activation Fields** - Correctly implemented: - - `Activation_Type__c`, `Activation_Scheduled_At__c`, `Activation_Status__c` โœ… - -3. **Internet Fields** - All documented fields mapped: - - `Internet_Plan_Tier__c`, `Installation_Type__c`, `Weekend_Install__c`, `Access_Mode__c`, `Hikari_Denwa__c` โœ… - -4. **SIM Fields** - All documented fields mapped: - - `SIM_Type__c`, `EID__c`, `SIM_Voice_Mail__c`, `SIM_Call_Waiting__c` + MNP fields โœ… - -5. **VPN Fields** - Correctly implemented: - - `VPN_Region__c` โœ… - -### โš ๏ธ **Issues Found:** - -## **Issue 1: Field Mapping Not Used in Order Builder** - -**Problem**: Our `order-builder.service.ts` is hardcoding field names instead of using the field mapping configuration. - -**Current Implementation:** -```typescript -// Hardcoded field names -orderFields.Internet_Plan_Tier__c = serviceProduct.internetPlanTier; -orderFields.Access_Mode__c = config.accessMode; -orderFields.Installation_Type__c = installType; -``` - -**Should Be:** -```typescript -// Using field mapping -const fields = getSalesforceFieldMap(); -orderFields[fields.order.internetPlanTier] = serviceProduct.internetPlanTier; -orderFields[fields.order.accessMode] = config.accessMode; -orderFields[fields.order.installationType] = installType; -``` - -## **Issue 2: Missing Documentation Alignment** - -**Problem**: Documentation shows different API structure than implementation. - -**Documentation Says:** -```json -{ - "items": [ - { "productId": "...", "billingCycle": "...", "configOptions": {...} } - ], - "promoCode": "...", - "notes": "..." -} -``` - -**Current Implementation:** -```json -{ - "orderType": "Internet", - "skus": ["INTERNET-SILVER-HOME-1G", "..."], - "configurations": { "accessMode": "PPPoE" } -} -``` - -## **Issue 3: Validation Logic vs Documentation** - -**Problem**: Our validation doesn't match documented requirements exactly. - -**Documentation Requirements:** -- "Server-side checks: require WHMCS mapping; require `hasPaymentMethod=true`" -- "Create Salesforce Order (Pending Review)" - -**Current Implementation:** โœ… Correctly implemented - -## **Issue 4: Missing Order Status Progression** - -**Documentation Shows:** -- Initial Status: "Pending Review" -- After Approval: "Provisioned" -- Error States: "Failed" - -**Current Implementation:** โœ… Sets "Pending Review" correctly - -## **Issue 5: MNP Field Mapping Inconsistency** - -**Problem**: Some MNP fields use different patterns. - -**Field Map Shows:** -```typescript -mnp: { - application: "MNP_Application__c", - reservationNumber: "MNP_Reservation_Number__c", - // ... -} -``` - -**Order Builder Uses:** -```typescript -orderFields.MNP_Application__c = true; // โœ… Correct -orderFields.MNP_Reservation_Number__c = config.mnpNumber; // โœ… Correct -``` - -## **Recommendations:** - -### 1. **Fix Field Mapping Usage** (High Priority) -Update `order-builder.service.ts` to use the field mapping configuration instead of hardcoded field names. - -### 2. **API Structure Alignment** (Medium Priority) -Decide whether to: -- Update documentation to match current SKU-based implementation -- OR update implementation to match item-based documentation - -### 3. **Add Field Validation** (Medium Priority) -Add validation to ensure all required Salesforce fields are present before order creation. - -### 4. **Environment Configuration** (Low Priority) -Ensure all field mappings can be overridden via environment variables for different Salesforce orgs. - -## **Overall Assessment: ๐ŸŸก MOSTLY CORRECT** - -The core functionality is working correctly, but we need to fix the field mapping usage for better maintainability and environment flexibility. diff --git a/apps/bff/Dockerfile b/apps/bff/Dockerfile index fa92c8e5..31293d06 100644 --- a/apps/bff/Dockerfile +++ b/apps/bff/Dockerfile @@ -21,7 +21,7 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ COPY packages/shared/package.json ./packages/shared/ COPY apps/bff/package.json ./apps/bff/ -# Install dependencies with frozen lockfile +# Install ALL dependencies (needed for build) RUN pnpm install --frozen-lockfile --prefer-offline # ===================================================== @@ -71,17 +71,19 @@ RUN corepack enable && corepack prepare pnpm@10.15.0 --activate WORKDIR /app -# Copy workspace configuration for production install +# Copy workspace configuration COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./ COPY packages/shared/package.json ./packages/shared/ COPY apps/bff/package.json ./apps/bff/ -# Install only production dependencies; skip lifecycle scripts to avoid Husky prepare -# Prisma client and native assets are generated in the builder stage and copied below +# Install ONLY production dependencies (lightweight) ENV HUSKY=0 RUN pnpm install --frozen-lockfile --prod --ignore-scripts -# Copy built applications and Prisma client +# Rebuild only critical native modules +RUN pnpm rebuild bcrypt @prisma/client @prisma/engines + +# Copy built applications from builder COPY --from=builder /app/packages/shared/dist ./packages/shared/dist COPY --from=builder /app/apps/bff/dist ./apps/bff/dist COPY --from=builder /app/apps/bff/prisma ./apps/bff/prisma 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/compose-plesk.yaml b/compose-plesk.yaml new file mode 100644 index 00000000..6b908658 --- /dev/null +++ b/compose-plesk.yaml @@ -0,0 +1,111 @@ +# ๐Ÿš€ Customer Portal - Plesk Docker Stack +# Deploy via: Plesk โ†’ Docker โ†’ Stacks โ†’ Add Stack +# Project name: customer-portal + +services: + frontend: + image: portal-frontend + container_name: portal-frontend + network_mode: host + pull_policy: never + environment: + - NODE_ENV=production + - PORT=3000 + - HOSTNAME=0.0.0.0 + - NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE} + - NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME} + - NEXT_PUBLIC_APP_VERSION=${NEXT_PUBLIC_APP_VERSION} + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + start_period: 40s + retries: 3 + + backend: + image: portal-backend:optimized + container_name: portal-backend + network_mode: host + pull_policy: never + environment: + - NODE_ENV=production + - PORT=4000 + - DATABASE_URL=${DATABASE_URL} + - REDIS_URL=${REDIS_URL} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES_IN=${JWT_EXPIRES_IN} + - BCRYPT_ROUNDS=${BCRYPT_ROUNDS} + - CORS_ORIGIN=${CORS_ORIGIN} + - TRUST_PROXY=${TRUST_PROXY} + - WHMCS_BASE_URL=${WHMCS_BASE_URL} + - WHMCS_API_IDENTIFIER=${WHMCS_API_IDENTIFIER} + - WHMCS_API_SECRET=${WHMCS_API_SECRET} + - SF_LOGIN_URL=${SF_LOGIN_URL} + - SF_CLIENT_ID=${SF_CLIENT_ID} + - SF_PRIVATE_KEY_PATH=${SF_PRIVATE_KEY_PATH} + - SF_USERNAME=${SF_USERNAME} + - PORTAL_PRICEBOOK_ID=${PORTAL_PRICEBOOK_ID} + - LOG_LEVEL=${LOG_LEVEL} + - LOG_FORMAT=${LOG_FORMAT} + - SENDGRID_API_KEY=${SENDGRID_API_KEY} + - EMAIL_FROM=${EMAIL_FROM} + - EMAIL_FROM_NAME=${EMAIL_FROM_NAME} + - EMAIL_ENABLED=${EMAIL_ENABLED} + - EMAIL_USE_QUEUE=${EMAIL_USE_QUEUE} + - SENDGRID_SANDBOX=${SENDGRID_SANDBOX} + - EMAIL_TEMPLATE_RESET=${EMAIL_TEMPLATE_RESET} + - EMAIL_TEMPLATE_WELCOME=${EMAIL_TEMPLATE_WELCOME} + - NODE_OPTIONS=${NODE_OPTIONS} + volumes: + - /var/www/vhosts/asolutions.jp/httpdocs/secrets:/app/secrets:ro + restart: unless-stopped + depends_on: + database: + condition: service_healthy + cache: + condition: service_healthy + command: sh -c "pnpm prisma migrate deploy && node dist/main" + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:4000/health"] + interval: 30s + timeout: 10s + start_period: 60s + retries: 3 + + database: + image: postgres:17-alpine + container_name: portal-database + network_mode: host + environment: + - POSTGRES_DB=${POSTGRES_DB} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U portal -d portal_prod"] + interval: 10s + timeout: 5s + retries: 5 + + cache: + image: redis:7-alpine + container_name: portal-cache + network_mode: host + volumes: + - redis_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + postgres_data: + driver: local + redis_data: + driver: local 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 098257ac..7697c7bd 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,9 +46,11 @@ "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", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "^9.34.0", "@types/node": "^24.3.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 645c224a..76353d49 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 9db08f08..43f6a8e2 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