clean up
This commit is contained in:
parent
43aabc7b61
commit
0c912fc04f
13
.editorconfig
Normal file
13
.editorconfig
Normal file
@ -0,0 +1,13 @@
|
||||
# EditorConfig helps maintain consistent coding styles across editors
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
108
.env.dev.example
108
.env.dev.example
@ -3,73 +3,87 @@
|
||||
# This configuration is optimized for development with hot-reloading
|
||||
|
||||
# =============================================================================
|
||||
# 🗄️ DATABASE CONFIGURATION (Development)
|
||||
# 🌐 APPLICATION CONFIGURATION
|
||||
# =============================================================================
|
||||
DATABASE_URL="postgresql://dev:dev@localhost:5432/portal_dev?schema=public"
|
||||
|
||||
# =============================================================================
|
||||
# 🔴 REDIS CONFIGURATION (Development)
|
||||
# =============================================================================
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
# =============================================================================
|
||||
# 🌐 APPLICATION CONFIGURATION (Development)
|
||||
# =============================================================================
|
||||
# Backend Configuration
|
||||
APP_NAME="customer-portal-bff"
|
||||
PORT=4000
|
||||
NODE_ENV=development
|
||||
APP_NAME=customer-portal-bff
|
||||
BFF_PORT=4000
|
||||
NEXT_PORT=3000
|
||||
NODE_ENV="development"
|
||||
|
||||
# Frontend Configuration (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"
|
||||
|
||||
# =============================================================================
|
||||
# 🔐 SECURITY CONFIGURATION (Development)
|
||||
# =============================================================================
|
||||
# JWT Secret (Development - OK to use simple secret)
|
||||
JWT_SECRET="dev_secret_for_local_development"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
# 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 (Lower rounds for faster development)
|
||||
BCRYPT_ROUNDS=4
|
||||
|
||||
# CORS (Allow local frontend)
|
||||
CORS_ORIGIN="http://localhost:3000"
|
||||
# Password Hashing (Minimum rounds for security compliance)
|
||||
BCRYPT_ROUNDS=10
|
||||
|
||||
# =============================================================================
|
||||
# 🏢 EXTERNAL API CONFIGURATION (Development)
|
||||
# 🗄️ 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_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_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 CONFIGURATION (Development)
|
||||
# 📊 LOGGING (Development)
|
||||
# =============================================================================
|
||||
LOG_LEVEL="debug"
|
||||
# Verbose logging for development
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# =============================================================================
|
||||
# 🎛️ DEVELOPMENT CONFIGURATION
|
||||
# 🎯 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"
|
||||
NODE_OPTIONS=--no-deprecation
|
||||
|
||||
# =============================================================================
|
||||
# 🐳 DOCKER DEVELOPMENT NOTES
|
||||
# 🚀 QUICK START (Development)
|
||||
# =============================================================================
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
# 🚀 Customer Portal - Production Environment
|
||||
# Copy this file to .env for production deployment
|
||||
# This configuration is optimized for production with security and performance
|
||||
|
||||
# =============================================================================
|
||||
# 🗄️ DATABASE CONFIGURATION (Production)
|
||||
# =============================================================================
|
||||
# Using Docker internal networking (container names as hostnames)
|
||||
DATABASE_URL="postgresql://portal:YOUR_SECURE_DB_PASSWORD@database:5432/portal_prod?schema=public"
|
||||
|
||||
# =============================================================================
|
||||
# 🔴 REDIS CONFIGURATION (Production)
|
||||
# =============================================================================
|
||||
# Using Docker internal networking
|
||||
REDIS_URL="redis://cache:6379"
|
||||
|
||||
# =============================================================================
|
||||
# 🌐 APPLICATION CONFIGURATION (Production)
|
||||
# =============================================================================
|
||||
# Backend Configuration
|
||||
APP_NAME="customer-portal-bff"
|
||||
PORT=4000
|
||||
NODE_ENV="production"
|
||||
|
||||
# Frontend Configuration (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"
|
||||
|
||||
# =============================================================================
|
||||
# 🔐 SECURITY CONFIGURATION (Production)
|
||||
# =============================================================================
|
||||
# JWT Secret (CRITICAL: Use a strong, unique secret!)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET="GENERATE_SECURE_JWT_SECRET_HERE"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
|
||||
# Password Hashing (High rounds for security)
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# CORS (Your production domain)
|
||||
CORS_ORIGIN="https://yourdomain.com"
|
||||
|
||||
# =============================================================================
|
||||
# 🏢 EXTERNAL API CONFIGURATION (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"
|
||||
|
||||
# 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"
|
||||
|
||||
# =============================================================================
|
||||
# 📊 LOGGING CONFIGURATION (Production)
|
||||
# =============================================================================
|
||||
LOG_LEVEL="info"
|
||||
|
||||
# =============================================================================
|
||||
# 🎛️ PRODUCTION CONFIGURATION
|
||||
# =============================================================================
|
||||
# Node.js options for production
|
||||
NODE_OPTIONS="--max-old-space-size=2048"
|
||||
|
||||
# =============================================================================
|
||||
# 🔒 SECURITY CHECKLIST FOR PRODUCTION
|
||||
# =============================================================================
|
||||
# ✅ Replace ALL default/demo values with real credentials
|
||||
# ✅ Use strong, unique passwords and secrets
|
||||
# ✅ 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
|
||||
|
||||
# =============================================================================
|
||||
# 🐳 DOCKER PRODUCTION NOTES
|
||||
# =============================================================================
|
||||
# For Docker production deployment:
|
||||
# 1. Place this file as .env in project root
|
||||
# 2. Run: pnpm prod:deploy
|
||||
# 3. All services run in containers with optimized configurations
|
||||
# 4. Database persists in docker volume: portal_postgres_data
|
||||
# 5. Redis persists in docker volume: portal_redis_data
|
||||
102
.env.production.example
Normal file
102
.env.production.example
Normal file
@ -0,0 +1,102 @@
|
||||
# 🚀 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
|
||||
|
||||
# =============================================================================
|
||||
# 🔐 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
|
||||
|
||||
# =============================================================================
|
||||
# 🔒 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
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@ -41,5 +41,3 @@ jobs:
|
||||
|
||||
- name: Build Portal
|
||||
run: pnpm --filter @customer-portal/portal run build
|
||||
|
||||
|
||||
|
||||
86
.github/workflows/test.yml
vendored
86
.github/workflows/test.yml
vendored
@ -2,19 +2,19 @@ name: Test & Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
branches: [main, develop]
|
||||
|
||||
env:
|
||||
NODE_VERSION: '22'
|
||||
PNPM_VERSION: '10.15.0'
|
||||
NODE_VERSION: "22"
|
||||
PNPM_VERSION: "10.15.0"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
|
||||
redis:
|
||||
image: redis:8-alpine
|
||||
options: >-
|
||||
@ -40,49 +40,49 @@ jobs:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- 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: 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: 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: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: pnpm type-check
|
||||
- name: Type check
|
||||
run: pnpm type-check
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Test shared package
|
||||
run: pnpm --filter @customer-portal/shared run test
|
||||
if: success() || failure()
|
||||
- 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: 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
|
||||
- name: Build applications
|
||||
run: pnpm build
|
||||
env:
|
||||
NEXT_PUBLIC_API_BASE: http://localhost:4000
|
||||
NEXT_PUBLIC_APP_NAME: Customer Portal Test
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -78,6 +78,7 @@ jspm_packages/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
**/tsconfig.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm/
|
||||
|
||||
10
.lintstagedrc.json
Normal file
10
.lintstagedrc.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"*.{ts,tsx,js,jsx}": [
|
||||
"eslint --fix",
|
||||
"prettier -w"
|
||||
],
|
||||
"*.{json,md,yml,yaml,css,scss}": [
|
||||
"prettier -w"
|
||||
]
|
||||
}
|
||||
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
42
README.md
42
README.md
@ -5,11 +5,13 @@ A modern customer portal where users can self-register, log in, browse & buy sub
|
||||
## Architecture Overview
|
||||
|
||||
### Systems of Record
|
||||
|
||||
- **WHMCS**: Billing, subscriptions, and invoices
|
||||
- **Salesforce**: CRM (Accounts, Contacts, Cases)
|
||||
- **Portal**: Modern UI with backend for frontend (BFF) architecture
|
||||
|
||||
### Identity Management
|
||||
|
||||
- Portal-native authentication (email + password, optional MFA)
|
||||
- One-time WHMCS user verification with forced password reset
|
||||
- User mapping: `user_id ↔ whmcs_client_id ↔ sf_contact_id/sf_account_id`
|
||||
@ -17,6 +19,7 @@ A modern customer portal where users can self-register, log in, browse & buy sub
|
||||
## Tech Stack
|
||||
|
||||
### Frontend (Portal UI)
|
||||
|
||||
- **Next.js 15** with App Router
|
||||
- **Turbopack** for ultra-fast development and builds
|
||||
- **React 19** with TypeScript
|
||||
@ -26,6 +29,7 @@ A modern customer portal where users can self-register, log in, browse & buy sub
|
||||
- **React Hook Form** for form management
|
||||
|
||||
### Backend (BFF API)
|
||||
|
||||
- **NestJS 11** (Node 24 Current or 22 LTS)
|
||||
- **Prisma 6** ORM
|
||||
- **jsforce** for Salesforce integration
|
||||
@ -33,7 +37,17 @@ A modern customer portal where users can self-register, log in, browse & buy sub
|
||||
- **BullMQ** for async jobs with ioredis
|
||||
- **OpenAPI/Swagger** for documentation
|
||||
|
||||
### Logging
|
||||
|
||||
- Centralized structured logging via Pino using `nestjs-pino` in the BFF
|
||||
- Sensitive fields are redacted; each request has a correlation ID
|
||||
- Usage pattern in services:
|
||||
- Inject `Logger` from `nestjs-pino`: `constructor(@Inject(Logger) private readonly logger: Logger) {}`
|
||||
- Log with structured objects: `this.logger.error('Message', { error })`
|
||||
- See `docs/LOGGING.md` for full guidelines
|
||||
|
||||
### Data & Infrastructure
|
||||
|
||||
- **PostgreSQL 17** for users, ID mappings, and optional mirrors
|
||||
- **Redis 8** for cache and queues
|
||||
- **Docker** for local development (Postgres/Redis)
|
||||
@ -56,6 +70,7 @@ projects/new-portal-website/
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 24 (Current) or 22 (LTS)
|
||||
- Docker and Docker Compose
|
||||
- pnpm (recommended) via Corepack
|
||||
@ -63,15 +78,17 @@ projects/new-portal-website/
|
||||
### Local Development Setup
|
||||
|
||||
1. **Start Development Environment**
|
||||
|
||||
```bash
|
||||
# Option 1: Start everything at once (recommended)
|
||||
pnpm start:all
|
||||
|
||||
|
||||
# Option 2: Start services only, then apps manually
|
||||
pnpm start:services
|
||||
```
|
||||
|
||||
2. **Setup Portal (Frontend)**
|
||||
|
||||
```bash
|
||||
cd apps/portal
|
||||
cp .env.example .env.local
|
||||
@ -82,7 +99,10 @@ projects/new-portal-website/
|
||||
3. **Setup BFF (Backend)**
|
||||
```bash
|
||||
cd apps/bff
|
||||
cp .env.example .env
|
||||
# Choose your environment template:
|
||||
# cp .env.dev.example .env # for development
|
||||
# cp .env.production.example .env # for production
|
||||
cp .env.dev.example .env
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
@ -90,12 +110,14 @@ projects/new-portal-website/
|
||||
### Environment Variables
|
||||
|
||||
#### Portal (.env.local)
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_BASE="http://localhost:4000"
|
||||
NEXT_PUBLIC_APP_NAME="Customer Portal"
|
||||
```
|
||||
|
||||
#### BFF (.env)
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://app:app@localhost:5432/portal?schema=public"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
@ -106,12 +128,13 @@ SF_LOGIN_URL="https://login.salesforce.com"
|
||||
SF_CLIENT_ID="<consumer_key>"
|
||||
SF_USERNAME="<integration@yourco.com>"
|
||||
SF_PRIVATE_KEY_PATH="./secrets/sf.key"
|
||||
PORT=4000
|
||||
BFF_PORT=4000
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### Core Tables (PostgreSQL)
|
||||
|
||||
- `users` - Portal user accounts with auth credentials
|
||||
- `id_mappings` - Cross-system user ID mappings
|
||||
- `invoices_mirror` - Optional WHMCS invoice cache
|
||||
@ -121,40 +144,48 @@ PORT=4000
|
||||
## API Surface (BFF)
|
||||
|
||||
### Authentication
|
||||
|
||||
- `POST /api/auth/signup` - Create portal user → WHMCS AddClient → SF upsert
|
||||
- `POST /api/auth/login` - Portal authentication
|
||||
- `POST /api/auth/link-whmcs` - OIDC callback or ValidateLogin
|
||||
- `POST /api/auth/set-password` - Required after WHMCS link
|
||||
|
||||
### User Management
|
||||
|
||||
- `GET /api/me` - Current user profile
|
||||
- `GET /api/me/summary` - Dashboard summary
|
||||
- `PATCH /api/me` - Update profile
|
||||
- `PATCH /api/me/billing` - Sync to WHMCS fields
|
||||
|
||||
### Catalog & Orders
|
||||
|
||||
- `GET /api/catalog` - WHMCS GetProducts (cached 5-15m)
|
||||
- `POST /api/orders` - WHMCS AddOrder with idempotency
|
||||
|
||||
### Invoices
|
||||
|
||||
- `GET /api/invoices` - Paginated invoice list (cached 60-120s)
|
||||
- `GET /api/invoices/:id` - Invoice details
|
||||
- `POST /api/invoices/:id/sso-link` - WHMCS CreateSsoToken
|
||||
|
||||
### Subscriptions
|
||||
|
||||
- `GET /api/subscriptions` - WHMCS GetClientsProducts
|
||||
|
||||
### Support Cases (Salesforce)
|
||||
|
||||
- `GET /api/cases` - Cases list (cached 30-60s)
|
||||
- `GET /api/cases/:id` - Case details
|
||||
- `POST /api/cases` - Create new case
|
||||
|
||||
### Webhooks
|
||||
|
||||
- `POST /api/webhooks/whmcs` - WHMCS action hooks → update mirrors + bust cache
|
||||
|
||||
## Frontend Pages
|
||||
|
||||
### Initial Pages
|
||||
|
||||
- `/` - Dashboard (next invoice due, active subs, open cases)
|
||||
- `/billing/invoices` - Invoice list
|
||||
- `/billing/invoices/[id]` - Invoice details
|
||||
@ -171,18 +202,21 @@ PORT=4000
|
||||
## Development Milestones
|
||||
|
||||
### Milestone 1: Identity & Linking
|
||||
|
||||
- [ ] Portal login/signup
|
||||
- [ ] One-time WHMCS verification
|
||||
- [ ] Set new portal password
|
||||
- [ ] Store id_mappings
|
||||
|
||||
### Milestone 2: Billing
|
||||
|
||||
- [ ] Product catalog (GetProducts)
|
||||
- [ ] Checkout (AddOrder)
|
||||
- [ ] Invoice list/detail (GetInvoices)
|
||||
- [ ] WHMCS SSO deep links
|
||||
|
||||
### Milestone 3: Cases & Webhooks
|
||||
|
||||
- [ ] Salesforce case list/create
|
||||
- [ ] WHMCS webhooks → cache bust + mirrors
|
||||
- [ ] Nightly reconcile job (optional)
|
||||
@ -206,6 +240,7 @@ PORT=4000
|
||||
## Current Status
|
||||
|
||||
✅ **Completed:**
|
||||
|
||||
- Project structure setup
|
||||
- Next.js 15 app with TypeScript
|
||||
- Tailwind CSS with shadcn/ui
|
||||
@ -215,6 +250,7 @@ PORT=4000
|
||||
- Basic landing page
|
||||
|
||||
🚧 **Next Steps:**
|
||||
|
||||
- Set up NestJS backend (BFF)
|
||||
- Implement authentication system
|
||||
- Create database schema with Prisma
|
||||
|
||||
226
SECURITY.md
Normal file
226
SECURITY.md
Normal file
@ -0,0 +1,226 @@
|
||||
# 🔒 Security Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the security measures implemented in the Customer Portal BFF (Backend for Frontend) application.
|
||||
|
||||
## 🛡️ Security Features Implemented
|
||||
|
||||
### 1. Authentication & Authorization
|
||||
|
||||
- **JWT-based authentication** with configurable expiration
|
||||
- **Password hashing** using bcrypt with configurable rounds (12+ in production)
|
||||
- **Account lockout** after 5 failed login attempts
|
||||
- **Role-based access control** (RBAC) system with AdminGuard
|
||||
- **Token blacklisting** for logout functionality
|
||||
- **All endpoints protected** except health checks
|
||||
|
||||
### 2. Input Validation & Sanitization
|
||||
|
||||
- **Global validation pipe** with whitelist mode enabled
|
||||
- **DTO validation** using class-validator decorators
|
||||
- **Input sanitization** to prevent XSS and injection attacks
|
||||
- **Request size limits** enforced by Helmet.js
|
||||
|
||||
### 3. Rate Limiting
|
||||
|
||||
- **General rate limiting**: 100 requests per minute
|
||||
- **Auth endpoint rate limiting**: 3 attempts per 15 minutes
|
||||
- **IP-based tracking** for rate limiting
|
||||
- **Configurable limits** via environment variables
|
||||
- **Webhook endpoints** with additional rate limiting
|
||||
|
||||
### 4. Security Headers
|
||||
|
||||
- **Helmet.js** for comprehensive security headers
|
||||
- **Content Security Policy (CSP)** with strict directives
|
||||
- **X-Frame-Options**: DENY
|
||||
- **X-Content-Type-Options**: nosniff
|
||||
- **X-XSS-Protection**: 1; mode=block
|
||||
- **Referrer-Policy**: strict-origin-when-cross-origin
|
||||
- **Permissions-Policy**: restrictive permissions
|
||||
|
||||
### 5. CORS Configuration
|
||||
|
||||
- **Restrictive CORS** policy
|
||||
- **Origin validation** via environment variables
|
||||
- **Credential support** for authenticated requests
|
||||
- **Method and header restrictions**
|
||||
- **Configurable origins** per environment
|
||||
|
||||
### 6. Error Handling
|
||||
|
||||
- **Global exception filter** with sanitized error messages
|
||||
- **Production-safe error logging** (no sensitive data exposure)
|
||||
- **Client-safe error messages** in production
|
||||
- **Structured logging** with Pino
|
||||
|
||||
### 7. Webhook Security
|
||||
|
||||
- **Signature verification** using HMAC-SHA256
|
||||
- **Rate limiting** on webhook endpoints
|
||||
- **Configurable secrets** for each webhook provider
|
||||
- **Input validation** for webhook payloads
|
||||
- **Secure error handling** without data leakage
|
||||
|
||||
### 8. Database Security
|
||||
|
||||
- **Parameterized queries** via Prisma ORM
|
||||
- **Connection encryption** (TLS/SSL)
|
||||
- **Environment-based configuration**
|
||||
- **Audit logging** for sensitive operations
|
||||
|
||||
### 9. Logging & Monitoring
|
||||
|
||||
- **Structured logging** with correlation IDs
|
||||
- **Sensitive data redaction** in logs
|
||||
- **Audit trail** for authentication events
|
||||
- **Production-safe logging levels**
|
||||
|
||||
### 10. API Security
|
||||
|
||||
- **All controllers protected** with JWT authentication
|
||||
- **Admin endpoints** with additional AdminGuard
|
||||
- **Swagger documentation** with authentication
|
||||
- **API versioning** and proper routing
|
||||
|
||||
## 🔧 Security Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your_secure_secret_minimum_32_chars
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# Password Security
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_TTL=60000
|
||||
RATE_LIMIT_LIMIT=100
|
||||
AUTH_RATE_LIMIT_TTL=900000
|
||||
AUTH_RATE_LIMIT_LIMIT=3
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=https://yourdomain.com
|
||||
|
||||
# Webhook Secrets
|
||||
WHMCS_WEBHOOK_SECRET=your_whmcs_secret
|
||||
SF_WEBHOOK_SECRET=your_salesforce_secret
|
||||
```
|
||||
|
||||
### Production Security Checklist
|
||||
|
||||
- [x] Generate strong JWT secret (minimum 32 characters)
|
||||
- [x] Set BCRYPT_ROUNDS to 12 or higher
|
||||
- [x] Configure CORS_ORIGIN to your production domain
|
||||
- [x] Enable TRUST_PROXY if behind reverse proxy
|
||||
- [x] Set NODE_ENV to "production"
|
||||
- [x] Configure webhook secrets
|
||||
- [x] Use HTTPS for all external services
|
||||
- [x] Test rate limiting configuration
|
||||
- [x] Verify audit logging is working
|
||||
- [x] Review security headers in browser dev tools
|
||||
- [x] All endpoints protected with authentication
|
||||
- [x] Input validation implemented
|
||||
- [x] Security headers configured
|
||||
- [x] Error handling production-safe
|
||||
|
||||
## 🚨 Security Best Practices
|
||||
|
||||
### 1. Password Requirements
|
||||
|
||||
- Minimum 8 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one number
|
||||
|
||||
### 2. API Security
|
||||
|
||||
- Always use HTTPS in production
|
||||
- Implement proper authentication for all endpoints
|
||||
- Validate and sanitize all inputs
|
||||
- Use rate limiting to prevent abuse
|
||||
- Log security events for monitoring
|
||||
|
||||
### 3. Data Protection
|
||||
|
||||
- Never log sensitive information (passwords, tokens, PII)
|
||||
- Use environment variables for configuration
|
||||
- Implement proper error handling
|
||||
- Sanitize error messages in production
|
||||
|
||||
### 4. Monitoring & Alerting
|
||||
|
||||
- Monitor failed authentication attempts
|
||||
- Track rate limit violations
|
||||
- Monitor webhook signature failures
|
||||
- Set up alerts for suspicious activity
|
||||
|
||||
## 🔍 Security Testing
|
||||
|
||||
### Automated Tests
|
||||
|
||||
- Input validation tests
|
||||
- Authentication flow tests
|
||||
- Rate limiting tests
|
||||
- Error handling tests
|
||||
|
||||
### Manual Testing
|
||||
|
||||
- Penetration testing
|
||||
- Security header verification
|
||||
- CORS policy testing
|
||||
- Authentication bypass attempts
|
||||
|
||||
### Tools
|
||||
|
||||
- OWASP ZAP for security scanning
|
||||
- Burp Suite for manual testing
|
||||
- Nmap for port scanning
|
||||
- SQLMap for SQL injection testing
|
||||
|
||||
## 📚 Security Resources
|
||||
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [NIST Cybersecurity Framework](https://www.nist.gov/cyberframework)
|
||||
- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/)
|
||||
- [NestJS Security](https://docs.nestjs.com/security/authentication)
|
||||
|
||||
## 🆘 Incident Response
|
||||
|
||||
### Security Breach Response
|
||||
|
||||
1. **Immediate Actions**
|
||||
- Isolate affected systems
|
||||
- Preserve evidence
|
||||
- Notify security team
|
||||
|
||||
2. **Investigation**
|
||||
- Analyze logs and audit trails
|
||||
- Identify attack vectors
|
||||
- Assess data exposure
|
||||
|
||||
3. **Recovery**
|
||||
- Patch vulnerabilities
|
||||
- Reset compromised credentials
|
||||
- Restore from clean backups
|
||||
|
||||
4. **Post-Incident**
|
||||
- Document lessons learned
|
||||
- Update security measures
|
||||
- Conduct security review
|
||||
|
||||
## 📞 Security Contacts
|
||||
|
||||
- **Security Team**: security@yourcompany.com
|
||||
- **Emergency**: +1-XXX-XXX-XXXX
|
||||
- **Bug Bounty**: security@yourcompany.com
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: $(date)
|
||||
**Version**: 1.0.0
|
||||
**Maintainer**: Security Team
|
||||
**Status**: ✅ Production Ready
|
||||
@ -1,29 +0,0 @@
|
||||
/* eslint-env node */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
env: { node: true, es2024: true },
|
||||
plugins: ['@typescript-eslint', 'prettier'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
rules: {
|
||||
'prettier/prettier': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
ignorePatterns: ['dist/**', 'node_modules/**'],
|
||||
};
|
||||
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
// Flat ESLint config for BFF (ESLint v9)
|
||||
const js = require('@eslint/js');
|
||||
const tsParser = require('@typescript-eslint/parser');
|
||||
const tsPlugin = require('@typescript-eslint/eslint-plugin');
|
||||
const prettierPlugin = require('eslint-plugin-prettier');
|
||||
|
||||
module.exports = [
|
||||
{ ignores: ['dist/**', 'node_modules/**'] },
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
'prettier/prettier': 'warn',
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
// Prefer TS variants of core rules
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'no-redeclare': 'off',
|
||||
'@typescript-eslint/no-redeclare': [
|
||||
'error',
|
||||
{ ignoreDeclarationMerge: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -12,7 +12,8 @@
|
||||
"dev": "NODE_OPTIONS=\"--no-deprecation\" nest start --watch",
|
||||
"start:debug": "NODE_OPTIONS=\"--no-deprecation\" nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
@ -76,13 +77,7 @@
|
||||
"@types/speakeasy": "^2.0.10",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.40.0",
|
||||
"@typescript-eslint/parser": "^8.40.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"jest": "^30.0.5",
|
||||
"prettier": "^3.6.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.4.1",
|
||||
@ -114,4 +109,4 @@
|
||||
},
|
||||
"passWithNoTests": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,40 +1,41 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { MappingsModule } from './mappings/mappings.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { OrdersModule } from './orders/orders.module';
|
||||
import { InvoicesModule } from './invoices/invoices.module';
|
||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
||||
import { CasesModule } from './cases/cases.module';
|
||||
import { WebhooksModule } from './webhooks/webhooks.module';
|
||||
import { VendorsModule } from './vendors/vendors.module';
|
||||
import { JobsModule } from './jobs/jobs.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './common/prisma/prisma.module';
|
||||
import { AuditModule } from './common/audit/audit.module';
|
||||
import { RedisModule } from './common/redis/redis.module';
|
||||
import { LoggingModule } from './common/logging/logging.module';
|
||||
import { CacheModule } from './common/cache/cache.module';
|
||||
import * as path from 'path';
|
||||
import { validateEnv } from './common/config/env.validation';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { RouterModule } from "@nestjs/core";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { ThrottlerModule } from "@nestjs/throttler";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { UsersModule } from "./users/users.module";
|
||||
import { MappingsModule } from "./mappings/mappings.module";
|
||||
import { CatalogModule } from "./catalog/catalog.module";
|
||||
import { OrdersModule } from "./orders/orders.module";
|
||||
import { InvoicesModule } from "./invoices/invoices.module";
|
||||
import { SubscriptionsModule } from "./subscriptions/subscriptions.module";
|
||||
import { CasesModule } from "./cases/cases.module";
|
||||
import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
import { VendorsModule } from "./vendors/vendors.module";
|
||||
import { JobsModule } from "./jobs/jobs.module";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { PrismaModule } from "./common/prisma/prisma.module";
|
||||
import { AuditModule } from "./common/audit/audit.module";
|
||||
import { RedisModule } from "./common/redis/redis.module";
|
||||
import { LoggingModule } from "./common/logging/logging.module";
|
||||
import { CacheModule } from "./common/cache/cache.module";
|
||||
import * as path from "path";
|
||||
import { validateEnv } from "./common/config/env.validation";
|
||||
|
||||
// Support multiple .env files across environments and run contexts
|
||||
const repoRoot = path.resolve(__dirname, '../../../..');
|
||||
const nodeEnv = process.env.NODE_ENV || 'development';
|
||||
const repoRoot = path.resolve(__dirname, "../../../..");
|
||||
const nodeEnv = process.env.NODE_ENV || "development";
|
||||
const envFilePath = [
|
||||
// Prefer repo root env files
|
||||
path.resolve(repoRoot, `.env.${nodeEnv}.local`),
|
||||
path.resolve(repoRoot, `.env.${nodeEnv}`),
|
||||
path.resolve(repoRoot, '.env.local'),
|
||||
path.resolve(repoRoot, '.env'),
|
||||
path.resolve(repoRoot, ".env.local"),
|
||||
path.resolve(repoRoot, ".env"),
|
||||
// Fallback to local working directory
|
||||
`.env.${nodeEnv}.local`,
|
||||
`.env.${nodeEnv}`,
|
||||
'.env.local',
|
||||
'.env',
|
||||
".env.local",
|
||||
".env",
|
||||
];
|
||||
|
||||
@Module({
|
||||
@ -45,17 +46,27 @@ const envFilePath = [
|
||||
envFilePath,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
|
||||
|
||||
// Logging
|
||||
LoggingModule,
|
||||
|
||||
// Rate limiting
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000, // 1 minute
|
||||
limit: 100, // 100 requests per minute
|
||||
},
|
||||
]),
|
||||
|
||||
// Rate limiting with environment-based configuration
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => [
|
||||
{
|
||||
ttl: configService.get("RATE_LIMIT_TTL", 60000),
|
||||
limit: configService.get("RATE_LIMIT_LIMIT", 100),
|
||||
},
|
||||
// Stricter rate limiting for auth endpoints
|
||||
{
|
||||
ttl: configService.get("AUTH_RATE_LIMIT_TTL", 900000),
|
||||
limit: configService.get("AUTH_RATE_LIMIT_LIMIT", 3),
|
||||
name: "auth",
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
// Core modules
|
||||
PrismaModule,
|
||||
@ -65,7 +76,7 @@ const envFilePath = [
|
||||
VendorsModule,
|
||||
JobsModule,
|
||||
HealthModule,
|
||||
|
||||
|
||||
// Feature modules
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
@ -76,6 +87,18 @@ const envFilePath = [
|
||||
SubscriptionsModule,
|
||||
CasesModule,
|
||||
WebhooksModule,
|
||||
// Route grouping: apply "/api" prefix to all feature modules except health
|
||||
RouterModule.register([
|
||||
{ path: "api", module: AuthModule },
|
||||
{ path: "api", module: UsersModule },
|
||||
{ path: "api", module: MappingsModule },
|
||||
{ path: "api", module: CatalogModule },
|
||||
{ path: "api", module: OrdersModule },
|
||||
{ path: "api", module: InvoicesModule },
|
||||
{ path: "api", module: SubscriptionsModule },
|
||||
{ path: "api", module: CasesModule },
|
||||
{ path: "api", module: WebhooksModule },
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -2,17 +2,12 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Query,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
} from "@nestjs/swagger";
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { JwtAuthGuard } from "./guards/jwt-auth.guard";
|
||||
import { AdminGuard } from "./guards/admin.guard";
|
||||
import { AuditService, AuditAction } from "../common/audit/audit.service";
|
||||
@ -25,7 +20,7 @@ import { UsersService } from "../users/users.service";
|
||||
export class AuthAdminController {
|
||||
constructor(
|
||||
private auditService: AuditService,
|
||||
private usersService: UsersService,
|
||||
private usersService: UsersService
|
||||
) {}
|
||||
|
||||
@Get("audit-logs")
|
||||
@ -35,13 +30,17 @@ export class AuthAdminController {
|
||||
@Query("page") page: string = "1",
|
||||
@Query("limit") limit: string = "50",
|
||||
@Query("action") action?: AuditAction,
|
||||
@Query("userId") userId?: string,
|
||||
@Query("userId") userId?: string
|
||||
) {
|
||||
const pageNum = parseInt(page, 10);
|
||||
const limitNum = parseInt(limit, 10);
|
||||
const skip = (pageNum - 1) * limitNum;
|
||||
|
||||
const where: any = {};
|
||||
if (Number.isNaN(pageNum) || Number.isNaN(limitNum) || pageNum < 1 || limitNum < 1) {
|
||||
throw new BadRequestException("Invalid pagination parameters");
|
||||
}
|
||||
|
||||
const where: { action?: AuditAction; userId?: string } = {};
|
||||
if (action) where.action = action;
|
||||
if (userId) where.userId = userId;
|
||||
|
||||
@ -82,7 +81,7 @@ export class AuthAdminController {
|
||||
async unlockAccount(@Param("userId") userId: string) {
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
throw new BadRequestException("User not found");
|
||||
}
|
||||
|
||||
await this.usersService.update(userId, {
|
||||
@ -107,37 +106,33 @@ export class AuthAdminController {
|
||||
async getSecurityStats() {
|
||||
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
lockedAccounts,
|
||||
failedLoginsToday,
|
||||
successfulLoginsToday,
|
||||
] = await Promise.all([
|
||||
this.auditService.prismaClient.user.count(),
|
||||
this.auditService.prismaClient.user.count({
|
||||
where: {
|
||||
lockedUntil: {
|
||||
gt: new Date(),
|
||||
const [totalUsers, lockedAccounts, failedLoginsToday, successfulLoginsToday] =
|
||||
await Promise.all([
|
||||
this.auditService.prismaClient.user.count(),
|
||||
this.auditService.prismaClient.user.count({
|
||||
where: {
|
||||
lockedUntil: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.auditService.prismaClient.auditLog.count({
|
||||
where: {
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
}),
|
||||
this.auditService.prismaClient.auditLog.count({
|
||||
where: {
|
||||
action: AuditAction.LOGIN_FAILED,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.auditService.prismaClient.auditLog.count({
|
||||
where: {
|
||||
action: AuditAction.LOGIN_SUCCESS,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
}),
|
||||
this.auditService.prismaClient.auditLog.count({
|
||||
where: {
|
||||
action: AuditAction.LOGIN_SUCCESS,
|
||||
createdAt: {
|
||||
gte: today,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Controller, Post, Body, UseGuards, Get, Req } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LocalAuthGuard } from "./guards/local-auth.guard";
|
||||
@ -6,7 +7,7 @@ import { JwtAuthGuard } from "./guards/jwt-auth.guard";
|
||||
import { AuthThrottleGuard } from "./guards/auth-throttle.guard";
|
||||
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||
import { SignupDto } from "./dto/signup.dto";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
|
||||
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||
|
||||
@ -22,7 +23,7 @@ export class AuthController {
|
||||
@ApiResponse({ status: 201, description: "User created successfully" })
|
||||
@ApiResponse({ status: 409, description: "User already exists" })
|
||||
@ApiResponse({ status: 429, description: "Too many signup attempts" })
|
||||
async signup(@Body() signupDto: SignupDto, @Req() req: any) {
|
||||
async signup(@Body() signupDto: SignupDto, @Req() req: Request) {
|
||||
return this.authService.signup(signupDto, req);
|
||||
}
|
||||
|
||||
@ -31,7 +32,7 @@ export class AuthController {
|
||||
@ApiOperation({ summary: "Authenticate user" })
|
||||
@ApiResponse({ status: 200, description: "Login successful" })
|
||||
@ApiResponse({ status: 401, description: "Invalid credentials" })
|
||||
async login(@Req() req: any) {
|
||||
async login(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
|
||||
return this.authService.login(req.user, req);
|
||||
}
|
||||
|
||||
@ -39,9 +40,11 @@ export class AuthController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: "Logout user" })
|
||||
@ApiResponse({ status: 200, description: "Logout successful" })
|
||||
async logout(@Req() req: any) {
|
||||
const token = req.headers.authorization?.replace("Bearer ", "");
|
||||
await this.authService.logout(req.user.id, token, req);
|
||||
async logout(@Req() req: Request & { user: { id: string } }) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
const bearer = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||
const token = bearer?.startsWith("Bearer ") ? bearer.slice(7) : undefined;
|
||||
await this.authService.logout(req.user.id, token ?? "", req);
|
||||
return { message: "Logout successful" };
|
||||
}
|
||||
|
||||
@ -55,7 +58,7 @@ export class AuthController {
|
||||
})
|
||||
@ApiResponse({ status: 401, description: "Invalid WHMCS credentials" })
|
||||
@ApiResponse({ status: 429, description: "Too many link attempts" })
|
||||
async linkWhmcs(@Body() linkDto: LinkWhmcsDto, @Req() req: any) {
|
||||
async linkWhmcs(@Body() linkDto: LinkWhmcsDto, @Req() req: Request) {
|
||||
return this.authService.linkWhmcsUser(linkDto, req);
|
||||
}
|
||||
|
||||
@ -66,7 +69,7 @@ export class AuthController {
|
||||
@ApiResponse({ status: 200, description: "Password set successfully" })
|
||||
@ApiResponse({ status: 401, description: "User not found" })
|
||||
@ApiResponse({ status: 429, description: "Too many password attempts" })
|
||||
async setPassword(@Body() setPasswordDto: SetPasswordDto, @Req() req: any) {
|
||||
async setPassword(@Body() setPasswordDto: SetPasswordDto, @Req() req: Request) {
|
||||
return this.authService.setPassword(setPasswordDto, req);
|
||||
}
|
||||
|
||||
@ -80,7 +83,7 @@ export class AuthController {
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("me")
|
||||
@ApiOperation({ summary: "Get current authentication status" })
|
||||
async getAuthStatus(@Req() req: any) {
|
||||
getAuthStatus(@Req() req: Request & { user: { id: string; email: string; role?: string } }) {
|
||||
// Return basic auth info only - full profile should use /api/me
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
@ -101,8 +104,8 @@ export class AuthController {
|
||||
description: "User not found or not linked to WHMCS",
|
||||
})
|
||||
async createSsoLink(
|
||||
@Req() req: any,
|
||||
@Body() { destination }: { destination?: string },
|
||||
@Req() req: Request & { user: { id: string } },
|
||||
@Body() { destination }: { destination?: string }
|
||||
) {
|
||||
return this.authService.createSsoLink(req.user.id, destination);
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
Logger,
|
||||
Inject,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@ -18,10 +18,10 @@ import { SignupDto } from "./dto/signup.dto";
|
||||
import { LinkWhmcsDto } from "./dto/link-whmcs.dto";
|
||||
import { SetPasswordDto } from "./dto/set-password.dto";
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly MAX_LOGIN_ATTEMPTS = 5;
|
||||
private readonly LOCKOUT_DURATION_MINUTES = 15;
|
||||
|
||||
@ -34,11 +34,15 @@ export class AuthService {
|
||||
private salesforceService: SalesforceService,
|
||||
private auditService: AuditService,
|
||||
private tokenBlacklistService: TokenBlacklistService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async signup(signupData: SignupDto, request?: any) {
|
||||
async signup(signupData: SignupDto, request?: unknown) {
|
||||
const { email, password, firstName, lastName, company, phone } = signupData;
|
||||
|
||||
// Enhanced input validation
|
||||
this.validateSignupData(signupData);
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await this.usersService.findByEmailInternal(email);
|
||||
if (existingUser) {
|
||||
@ -48,13 +52,13 @@ export class AuthService {
|
||||
{ email, reason: "User already exists" },
|
||||
request,
|
||||
false,
|
||||
"User with this email already exists",
|
||||
"User with this email already exists"
|
||||
);
|
||||
throw new ConflictException("User with this email already exists");
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12; // Use a fixed safe value
|
||||
// Hash password with environment-based configuration
|
||||
const saltRounds = this.configService.get("BCRYPT_ROUNDS", 12);
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
try {
|
||||
@ -101,7 +105,7 @@ export class AuthService {
|
||||
user.id,
|
||||
{ email, whmcsClientId: whmcsClient.clientId },
|
||||
request,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
// Generate JWT token
|
||||
@ -119,16 +123,26 @@ export class AuthService {
|
||||
{ email, error: getErrorMessage(error) },
|
||||
request,
|
||||
false,
|
||||
getErrorMessage(error),
|
||||
getErrorMessage(error)
|
||||
);
|
||||
|
||||
// TODO: Implement rollback logic if any step fails
|
||||
this.logger.error("Signup error:", error);
|
||||
this.logger.error("Signup error", { error: getErrorMessage(error) });
|
||||
throw new BadRequestException("Failed to create user account");
|
||||
}
|
||||
}
|
||||
|
||||
async login(user: any, request?: any) {
|
||||
async login(
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
passwordHash?: string | null;
|
||||
failedLoginAttempts?: number | null;
|
||||
lockedUntil?: Date | null;
|
||||
},
|
||||
request?: unknown
|
||||
) {
|
||||
// Update last login time and reset failed attempts
|
||||
await this.usersService.update(user.id, {
|
||||
lastLoginAt: new Date(),
|
||||
@ -142,7 +156,7 @@ export class AuthService {
|
||||
user.id,
|
||||
{ email: user.email },
|
||||
request,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
const tokens = await this.generateTokens(user);
|
||||
@ -152,7 +166,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async linkWhmcsUser(linkData: LinkWhmcsDto, request?: any) {
|
||||
async linkWhmcsUser(linkData: LinkWhmcsDto, _request?: unknown) {
|
||||
const { email, password } = linkData;
|
||||
|
||||
// Check if user already exists in portal
|
||||
@ -161,7 +175,7 @@ export class AuthService {
|
||||
// If user exists but has no password (abandoned during setup), allow them to continue
|
||||
if (!existingUser.passwordHash) {
|
||||
this.logger.log(
|
||||
`User ${email} exists but has no password - allowing password setup to continue`,
|
||||
`User ${email} exists but has no password - allowing password setup to continue`
|
||||
);
|
||||
return {
|
||||
user: this.sanitizeUser(existingUser),
|
||||
@ -169,7 +183,7 @@ export class AuthService {
|
||||
};
|
||||
} else {
|
||||
throw new ConflictException(
|
||||
"User already exists in portal and has completed setup. Please use the login page.",
|
||||
"User already exists in portal and has completed setup. Please use the login page."
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -180,36 +194,28 @@ export class AuthService {
|
||||
try {
|
||||
clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException(
|
||||
"WHMCS client not found with this email address",
|
||||
);
|
||||
this.logger.warn(`WHMCS client lookup failed for email ${email}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new UnauthorizedException("WHMCS client not found with this email address");
|
||||
}
|
||||
|
||||
// 2. Validate the password using ValidateLogin
|
||||
try {
|
||||
this.logger.debug(`About to validate WHMCS password for ${email}`);
|
||||
const validateResult = await this.whmcsService.validateLogin(
|
||||
email,
|
||||
password,
|
||||
);
|
||||
this.logger.debug(
|
||||
`WHMCS validation successful for ${email}:`,
|
||||
validateResult,
|
||||
);
|
||||
const validateResult = await this.whmcsService.validateLogin(email, password);
|
||||
this.logger.debug("WHMCS validation successful", { email });
|
||||
if (!validateResult || !validateResult.userId) {
|
||||
throw new UnauthorizedException("Invalid WHMCS credentials");
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
`WHMCS validation failed for ${email}:`,
|
||||
getErrorMessage(error),
|
||||
);
|
||||
this.logger.debug("WHMCS validation failed", { email, error: getErrorMessage(error) });
|
||||
throw new UnauthorizedException("Invalid WHMCS password");
|
||||
}
|
||||
|
||||
// 3. Extract Customer Number from field ID 198
|
||||
const customerNumberField = clientDetails.customfields?.find(
|
||||
(field: any) => field.id == 198, // Use == instead of === to handle string/number comparison
|
||||
(field: { id: number | string; value?: unknown }) => field.id == 198
|
||||
);
|
||||
|
||||
const customerNumber = customerNumberField?.value;
|
||||
@ -219,22 +225,19 @@ export class AuthService {
|
||||
`Customer Number not found in WHMCS custom field 198. ` +
|
||||
`Found field: ${JSON.stringify(customerNumberField)}. ` +
|
||||
`Available custom fields: ${JSON.stringify(clientDetails.customfields || [])}. ` +
|
||||
`Please contact support.`,
|
||||
`Please contact support.`
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}`,
|
||||
`Found Customer Number: ${customerNumber} for WHMCS client ${clientDetails.id}`
|
||||
);
|
||||
|
||||
// 3. Find existing Salesforce account using Customer Number
|
||||
const sfAccount =
|
||||
await this.salesforceService.findAccountByCustomerNumber(
|
||||
customerNumber,
|
||||
);
|
||||
const sfAccount = await this.salesforceService.findAccountByCustomerNumber(customerNumber);
|
||||
if (!sfAccount) {
|
||||
throw new BadRequestException(
|
||||
`Salesforce account not found for Customer Number: ${customerNumber}. Please contact support.`,
|
||||
`Salesforce account not found for Customer Number: ${customerNumber}. Please contact support.`
|
||||
);
|
||||
}
|
||||
|
||||
@ -261,11 +264,8 @@ export class AuthService {
|
||||
needsPasswordSet: true,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("WHMCS linking error:", error);
|
||||
if (
|
||||
error instanceof BadRequestException ||
|
||||
error instanceof UnauthorizedException
|
||||
) {
|
||||
this.logger.error("WHMCS linking error", { error: getErrorMessage(error) });
|
||||
if (error instanceof BadRequestException || error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
throw new BadRequestException("Failed to link WHMCS account");
|
||||
@ -285,7 +285,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async setPassword(setPasswordData: SetPasswordDto, request?: any) {
|
||||
async setPassword(setPasswordData: SetPasswordDto, _request?: unknown) {
|
||||
const { email, password } = setPasswordData;
|
||||
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
@ -319,8 +319,15 @@ export class AuthService {
|
||||
async validateUser(
|
||||
email: string,
|
||||
password: string,
|
||||
request?: any,
|
||||
): Promise<any> {
|
||||
_request?: unknown
|
||||
): Promise<{
|
||||
id: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
passwordHash: string | null;
|
||||
failedLoginAttempts?: number | null;
|
||||
lockedUntil?: Date | null;
|
||||
} | null> {
|
||||
const user = await this.usersService.findByEmailInternal(email);
|
||||
|
||||
if (!user) {
|
||||
@ -328,9 +335,9 @@ export class AuthService {
|
||||
AuditAction.LOGIN_FAILED,
|
||||
undefined,
|
||||
{ email, reason: "User not found" },
|
||||
request,
|
||||
_request,
|
||||
false,
|
||||
"User not found",
|
||||
"User not found"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@ -341,9 +348,9 @@ export class AuthService {
|
||||
AuditAction.LOGIN_FAILED,
|
||||
user.id,
|
||||
{ email, reason: "Account locked" },
|
||||
request,
|
||||
_request,
|
||||
false,
|
||||
"Account is locked",
|
||||
"Account is locked"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@ -353,9 +360,9 @@ export class AuthService {
|
||||
AuditAction.LOGIN_FAILED,
|
||||
user.id,
|
||||
{ email, reason: "No password set" },
|
||||
request,
|
||||
_request,
|
||||
false,
|
||||
"No password set",
|
||||
"No password set"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
@ -367,27 +374,27 @@ export class AuthService {
|
||||
return user;
|
||||
} else {
|
||||
// Increment failed login attempts
|
||||
await this.handleFailedLogin(user, request);
|
||||
await this.handleFailedLogin(user, _request);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Password validation error for ${email}:`,
|
||||
getErrorMessage(error),
|
||||
);
|
||||
this.logger.error("Password validation error", { email, error: getErrorMessage(error) });
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.LOGIN_FAILED,
|
||||
user.id,
|
||||
{ email, error: getErrorMessage(error) },
|
||||
request,
|
||||
_request,
|
||||
false,
|
||||
getErrorMessage(error),
|
||||
getErrorMessage(error)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFailedLogin(user: any, request?: any): Promise<void> {
|
||||
private async handleFailedLogin(
|
||||
user: { id: string; email: string; failedLoginAttempts?: number | null },
|
||||
_request?: unknown
|
||||
): Promise<void> {
|
||||
const newFailedAttempts = (user.failedLoginAttempts || 0) + 1;
|
||||
let lockedUntil = null;
|
||||
let isAccountLocked = false;
|
||||
@ -395,9 +402,7 @@ export class AuthService {
|
||||
// Lock account if max attempts reached
|
||||
if (newFailedAttempts >= this.MAX_LOGIN_ATTEMPTS) {
|
||||
lockedUntil = new Date();
|
||||
lockedUntil.setMinutes(
|
||||
lockedUntil.getMinutes() + this.LOCKOUT_DURATION_MINUTES,
|
||||
);
|
||||
lockedUntil.setMinutes(lockedUntil.getMinutes() + this.LOCKOUT_DURATION_MINUTES);
|
||||
isAccountLocked = true;
|
||||
}
|
||||
|
||||
@ -415,9 +420,9 @@ export class AuthService {
|
||||
failedAttempts: newFailedAttempts,
|
||||
lockedUntil: lockedUntil?.toISOString(),
|
||||
},
|
||||
request,
|
||||
_request,
|
||||
false,
|
||||
"Invalid password",
|
||||
"Invalid password"
|
||||
);
|
||||
|
||||
// Log account lock if applicable
|
||||
@ -430,37 +435,48 @@ export class AuthService {
|
||||
lockDuration: this.LOCKOUT_DURATION_MINUTES,
|
||||
lockedUntil: lockedUntil?.toISOString(),
|
||||
},
|
||||
request,
|
||||
_request,
|
||||
false,
|
||||
`Account locked for ${this.LOCKOUT_DURATION_MINUTES} minutes`,
|
||||
`Account locked for ${this.LOCKOUT_DURATION_MINUTES} minutes`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: string, token: string, request?: any): Promise<void> {
|
||||
async logout(userId: string, token: string, _request?: unknown): Promise<void> {
|
||||
// Blacklist the token
|
||||
await this.tokenBlacklistService.blacklistToken(token);
|
||||
|
||||
await this.auditService.logAuthEvent(
|
||||
AuditAction.LOGOUT,
|
||||
userId,
|
||||
{},
|
||||
request,
|
||||
true,
|
||||
);
|
||||
await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private async generateTokens(user: any) {
|
||||
private async generateTokens(user: { id: string; email: string; role?: string }) {
|
||||
const payload = { email: user.email, sub: user.id, role: user.role };
|
||||
return {
|
||||
access_token: this.jwtService.sign(payload),
|
||||
};
|
||||
}
|
||||
|
||||
private sanitizeUser(user: any) {
|
||||
const { passwordHash, failedLoginAttempts, lockedUntil, ...sanitizedUser } =
|
||||
user;
|
||||
private sanitizeUser<
|
||||
T extends {
|
||||
id: string;
|
||||
email: string;
|
||||
role?: string;
|
||||
passwordHash?: string | null;
|
||||
failedLoginAttempts?: number | null;
|
||||
lockedUntil?: Date | null;
|
||||
},
|
||||
>(user: T): Omit<T, "passwordHash" | "failedLoginAttempts" | "lockedUntil"> {
|
||||
const {
|
||||
passwordHash: _passwordHash,
|
||||
failedLoginAttempts: _failedLoginAttempts,
|
||||
lockedUntil: _lockedUntil,
|
||||
...sanitizedUser
|
||||
} = user as T & {
|
||||
passwordHash?: string | null;
|
||||
failedLoginAttempts?: number | null;
|
||||
lockedUntil?: Date | null;
|
||||
};
|
||||
return sanitizedUser;
|
||||
}
|
||||
|
||||
@ -469,7 +485,7 @@ export class AuthService {
|
||||
*/
|
||||
async createSsoLink(
|
||||
userId: string,
|
||||
destination?: string,
|
||||
destination?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
// Production-safe logging - no sensitive data
|
||||
@ -484,13 +500,13 @@ export class AuthService {
|
||||
}
|
||||
|
||||
// Create SSO token using custom redirect for better compatibility
|
||||
let ssoDestination = "sso:custom_redirect";
|
||||
let ssoRedirectPath = destination || "clientarea.php";
|
||||
const ssoDestination = "sso:custom_redirect";
|
||||
const ssoRedirectPath = destination || "clientarea.php";
|
||||
|
||||
const result = await this.whmcsService.createSsoToken(
|
||||
mapping.whmcsClientId,
|
||||
ssoDestination,
|
||||
ssoRedirectPath,
|
||||
ssoRedirectPath
|
||||
);
|
||||
|
||||
this.logger.log("SSO link created successfully");
|
||||
@ -505,4 +521,29 @@ export class AuthService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateSignupData(signupData: SignupDto) {
|
||||
const { email, password, firstName, lastName } = signupData;
|
||||
|
||||
if (!email || !password || !firstName || !lastName) {
|
||||
throw new BadRequestException(
|
||||
"Email, password, firstName, and lastName are required for signup."
|
||||
);
|
||||
}
|
||||
|
||||
if (!email.includes("@")) {
|
||||
throw new BadRequestException("Invalid email address.");
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new BadRequestException("Password must be at least 8 characters long.");
|
||||
}
|
||||
|
||||
// Password must contain at least one uppercase letter, one lowercase letter, and one number
|
||||
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) {
|
||||
throw new BadRequestException(
|
||||
"Password must contain at least one uppercase letter, one lowercase letter, and one number."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsString,
|
||||
MinLength,
|
||||
IsOptional,
|
||||
Matches,
|
||||
} from "class-validator";
|
||||
import { IsEmail, IsString, MinLength, IsOptional, Matches } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class SignupDto {
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { UserRole } from "@prisma/client";
|
||||
import type { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const request = context.switchToHttp().getRequest<Request & { user?: { role?: UserRole } }>();
|
||||
const user = request.user;
|
||||
|
||||
if (!user) {
|
||||
|
||||
@ -5,10 +5,11 @@ import { ThrottlerGuard } from "@nestjs/throttler";
|
||||
export class AuthThrottleGuard extends ThrottlerGuard {
|
||||
protected async getTracker(req: Record<string, any>): Promise<string> {
|
||||
// Track by IP address for failed login attempts
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
const forwardedIp = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
||||
const ip =
|
||||
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
||||
req.headers["x-real-ip"] ||
|
||||
req.connection?.remoteAddress ||
|
||||
forwardedIp?.split(",")[0]?.trim() ||
|
||||
(req.headers["x-real-ip"] as string | undefined) ||
|
||||
req.socket?.remoteAddress ||
|
||||
req.ip ||
|
||||
"unknown";
|
||||
|
||||
@ -6,27 +6,25 @@ import { Redis } from "ioredis";
|
||||
export class TokenBlacklistService {
|
||||
constructor(
|
||||
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
async blacklistToken(token: string, expiresIn?: number): Promise<void> {
|
||||
async blacklistToken(token: string, _expiresIn?: number): Promise<void> {
|
||||
// Extract JWT payload to get expiry time
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split(".")[1], "base64").toString(),
|
||||
);
|
||||
const expiryTime = payload.exp * 1000; // Convert to milliseconds
|
||||
const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64").toString()) as {
|
||||
exp?: number;
|
||||
};
|
||||
const expiryTime = (payload.exp ?? 0) * 1000; // Convert to milliseconds
|
||||
const currentTime = Date.now();
|
||||
const ttl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // Convert to seconds
|
||||
|
||||
if (ttl > 0) {
|
||||
await this.redis.setex(`blacklist:${token}`, ttl, "1");
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// If we can't parse the token, blacklist it for the default JWT expiry time
|
||||
const defaultTtl = this.parseJwtExpiry(
|
||||
this.configService.get("JWT_EXPIRES_IN", "7d"),
|
||||
);
|
||||
const defaultTtl = this.parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
|
||||
await this.redis.setex(`blacklist:${token}`, defaultTtl, "1");
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import { Request } from "express";
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private tokenBlacklistService: TokenBlacklistService,
|
||||
private tokenBlacklistService: TokenBlacklistService
|
||||
) {
|
||||
const jwtSecret = configService.get<string>("JWT_SECRET");
|
||||
if (!jwtSecret) {
|
||||
@ -26,10 +26,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
async validate(
|
||||
req: Request,
|
||||
payload: { sub: string; email: string; role: string },
|
||||
) {
|
||||
async validate(req: Request, payload: { sub: string; email: string; role: string }) {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(req);
|
||||
|
||||
if (!token) {
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport-local";
|
||||
import { AuthService } from "../auth.service";
|
||||
import type { Request } from "express";
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
@ -9,7 +10,11 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
super({ usernameField: "email", passReqToCallback: true });
|
||||
}
|
||||
|
||||
async validate(req: any, email: string, password: string): Promise<any> {
|
||||
async validate(
|
||||
req: Request,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ id: string; email: string; role?: string } | null> {
|
||||
const user = await this.authService.validateUser(email, password, req);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { CatalogService } from "./catalog.service";
|
||||
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
||||
|
||||
@ApiTags("catalog")
|
||||
@Controller("catalog")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class CatalogController {
|
||||
constructor(private catalogService: CatalogService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get product catalog" })
|
||||
@ApiOperation({ summary: "Get product catalog (authenticated users only)" })
|
||||
async getCatalog() {
|
||||
return this.catalogService.getProducts();
|
||||
}
|
||||
|
||||
@ -1,27 +1,26 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { WhmcsService } from '../vendors/whmcs/whmcs.service';
|
||||
import { CacheService } from '../common/cache/cache.service';
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Product } from "@customer-portal/shared";
|
||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
||||
import { CacheService } from "../common/cache/cache.service";
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(
|
||||
private whmcsService: WhmcsService,
|
||||
private cacheService: CacheService,
|
||||
private cacheService: CacheService
|
||||
) {}
|
||||
|
||||
async getProducts() {
|
||||
const cacheKey = 'catalog:products';
|
||||
async getProducts(): Promise<Product[]> {
|
||||
const cacheKey = "catalog:products";
|
||||
const ttl = 15 * 60; // 15 minutes
|
||||
|
||||
return this.cacheService.getOrSet(
|
||||
return this.cacheService.getOrSet<Product[]>(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const result = await this.whmcsService.getProducts();
|
||||
return result.products.map((product: any) =>
|
||||
this.whmcsService.transformProduct(product)
|
||||
);
|
||||
return result.products.map((p: any) => this.whmcsService.transformProduct(p));
|
||||
},
|
||||
ttl,
|
||||
ttl
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { getErrorMessage } from "../utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
// Define audit actions to match Prisma schema
|
||||
export enum AuditAction {
|
||||
@ -30,7 +32,10 @@ export interface AuditLogData {
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
// Expose prisma for admin operations
|
||||
get prismaClient() {
|
||||
@ -44,9 +49,7 @@ export class AuditService {
|
||||
userId: data.userId,
|
||||
action: data.action,
|
||||
resource: data.resource,
|
||||
details: data.details
|
||||
? JSON.parse(JSON.stringify(data.details))
|
||||
: null,
|
||||
details: data.details ? JSON.parse(JSON.stringify(data.details)) : null,
|
||||
ipAddress: data.ipAddress,
|
||||
userAgent: data.userAgent,
|
||||
success: data.success ?? true,
|
||||
@ -55,8 +58,11 @@ export class AuditService {
|
||||
});
|
||||
} catch (error) {
|
||||
// Don't fail the original operation if audit logging fails
|
||||
// Use a simple console.error here since we can't use this.logger (circular dependency risk)
|
||||
console.error("Failed to create audit log:", error);
|
||||
// Log error without exposing sensitive information
|
||||
this.logger.error("Audit logging failed", {
|
||||
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
||||
message: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,7 +72,7 @@ export class AuditService {
|
||||
details?: any,
|
||||
request?: any,
|
||||
success: boolean = true,
|
||||
error?: string,
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
const ipAddress = this.extractIpAddress(request);
|
||||
const userAgent = request?.headers?.["user-agent"];
|
||||
@ -94,44 +100,4 @@ export class AuditService {
|
||||
request.ip
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup old audit logs (run as a scheduled job)
|
||||
async cleanupOldLogs(daysToKeep: number = 90): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||
|
||||
const result = await this.prisma.auditLog.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Log cleanup result - use console.log for maintenance operations
|
||||
console.log(
|
||||
`Cleaned up ${result.count} audit logs older than ${daysToKeep} days`,
|
||||
);
|
||||
return result.count;
|
||||
}
|
||||
|
||||
// Get user's recent auth events
|
||||
async getUserAuthHistory(userId: string, limit: number = 10) {
|
||||
return this.prisma.auditLog.findMany({
|
||||
where: {
|
||||
userId,
|
||||
resource: "auth",
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: limit,
|
||||
select: {
|
||||
action: true,
|
||||
success: true,
|
||||
ipAddress: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/bff/src/common/cache/cache.module.ts
vendored
6
apps/bff/src/common/cache/cache.module.ts
vendored
@ -1,5 +1,5 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CacheService } from './cache.service';
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { CacheService } from "./cache.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@ -7,5 +7,3 @@ import { CacheService } from './cache.service';
|
||||
exports: [CacheService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
||||
|
||||
|
||||
20
apps/bff/src/common/cache/cache.service.ts
vendored
20
apps/bff/src/common/cache/cache.service.ts
vendored
@ -1,12 +1,12 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import Redis from "ioredis";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
private readonly logger = new Logger(CacheService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_CLIENT') private readonly redis: Redis,
|
||||
@Inject("REDIS_CLIENT") private readonly redis: Redis,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
@ -39,14 +39,10 @@ export class CacheService {
|
||||
}
|
||||
|
||||
buildKey(prefix: string, userId: string, ...parts: string[]): string {
|
||||
return [prefix, userId, ...parts].join(':');
|
||||
return [prefix, userId, ...parts].join(":");
|
||||
}
|
||||
|
||||
async getOrSet<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttlSeconds: number = 300,
|
||||
): Promise<T> {
|
||||
async getOrSet<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number = 300): Promise<T> {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
@ -57,5 +53,3 @@ export class CacheService {
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -2,43 +2,58 @@ import { z } from "zod";
|
||||
|
||||
// Strict environment schema with safe defaults
|
||||
export const envSchema = z.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "test", "production"])
|
||||
.default("development"),
|
||||
PORT: z.coerce.number().int().positive().max(65535).default(4000),
|
||||
LOG_LEVEL: z
|
||||
.enum(["error", "warn", "info", "debug", "trace"])
|
||||
.default("info"),
|
||||
CORS_ORIGIN: z.string().optional(),
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
BFF_PORT: z.coerce.number().int().positive().max(65535).default(4000),
|
||||
LOG_LEVEL: z.enum(["error", "warn", "info", "debug", "trace"]).default("info"),
|
||||
APP_NAME: z.string().default("customer-portal-bff"),
|
||||
|
||||
// Security Configuration
|
||||
JWT_SECRET: z.string().min(32, "JWT secret must be at least 32 characters"),
|
||||
JWT_EXPIRES_IN: z.string().default("7d"),
|
||||
BCRYPT_ROUNDS: z.coerce.number().int().min(10).max(16).default(12),
|
||||
|
||||
// CORS and Network Security
|
||||
CORS_ORIGIN: z.string().url().optional(),
|
||||
TRUST_PROXY: z.enum(["true", "false"]).default("false"),
|
||||
|
||||
// Rate Limiting
|
||||
RATE_LIMIT_TTL: z.coerce.number().int().positive().default(60000),
|
||||
RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(100),
|
||||
AUTH_RATE_LIMIT_TTL: z.coerce.number().int().positive().default(900000),
|
||||
AUTH_RATE_LIMIT_LIMIT: z.coerce.number().int().positive().default(3),
|
||||
|
||||
// Redis
|
||||
REDIS_URL: z.string().url().default("redis://localhost:6379"),
|
||||
|
||||
// Salesforce (optional in dev; enforced at connection time)
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
// WHMCS Configuration
|
||||
WHMCS_BASE_URL: z.string().url().optional(),
|
||||
WHMCS_API_IDENTIFIER: z.string().optional(),
|
||||
WHMCS_API_SECRET: z.string().optional(),
|
||||
WHMCS_WEBHOOK_SECRET: z.string().optional(),
|
||||
|
||||
// Salesforce Configuration
|
||||
SF_LOGIN_URL: z.string().url().optional(),
|
||||
SF_USERNAME: z.string().optional(),
|
||||
SF_CLIENT_ID: z.string().optional(),
|
||||
SF_PRIVATE_KEY_PATH: z.string().optional(),
|
||||
SF_WEBHOOK_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export function validateEnv(
|
||||
config: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
export function validateEnv(config: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = envSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
// Throw concise aggregated error
|
||||
const flattened = result.error.flatten();
|
||||
const messages = [
|
||||
...Object.entries(flattened.fieldErrors).flatMap(([key, errs]) =>
|
||||
(errs || []).map((e) => `${key}: ${e}`),
|
||||
(errs || []).map(e => `${key}: ${e}`)
|
||||
),
|
||||
...(flattened.formErrors || []),
|
||||
];
|
||||
throw new Error(
|
||||
`Invalid environment configuration: ${messages.join("; ")}`,
|
||||
);
|
||||
throw new Error(`Invalid environment configuration: ${messages.join("; ")}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
|
||||
@ -4,14 +4,15 @@ import {
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Inject,
|
||||
} from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { getClientSafeErrorMessage } from "../utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
@ -27,14 +28,11 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === "object" && exceptionResponse !== null) {
|
||||
const errorResponse = exceptionResponse as any;
|
||||
const errorResponse = exceptionResponse as { message?: string; error?: string };
|
||||
message = errorResponse.message || exception.message;
|
||||
error = errorResponse.error || exception.constructor.name;
|
||||
} else {
|
||||
message =
|
||||
typeof exceptionResponse === "string"
|
||||
? exceptionResponse
|
||||
: exception.message;
|
||||
message = typeof exceptionResponse === "string" ? exceptionResponse : exception.message;
|
||||
error = exception.constructor.name;
|
||||
}
|
||||
} else {
|
||||
@ -45,8 +43,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
|
||||
// Log the actual error details securely
|
||||
this.logger.error("Unhandled exception caught", {
|
||||
error:
|
||||
exception instanceof Error ? exception.message : String(exception),
|
||||
error: exception instanceof Error ? exception.message : String(exception),
|
||||
stack: exception instanceof Error ? exception.stack : undefined,
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
@ -57,9 +54,7 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
|
||||
// Always use client-safe error messages in production
|
||||
const clientSafeMessage =
|
||||
process.env.NODE_ENV === "production"
|
||||
? getClientSafeErrorMessage(message)
|
||||
: message;
|
||||
process.env.NODE_ENV === "production" ? getClientSafeErrorMessage(message) : message;
|
||||
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
|
||||
@ -1,25 +1,27 @@
|
||||
import { Params } from 'nestjs-pino';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { join } from 'path';
|
||||
import { mkdir } from 'fs/promises';
|
||||
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<Params> {
|
||||
const nodeEnv = configService.get<string>('NODE_ENV', 'development');
|
||||
const logLevel = configService.get<string>('LOG_LEVEL', 'info');
|
||||
const appName = configService.get<string>('APP_NAME', 'customer-portal-bff');
|
||||
|
||||
const nodeEnv = configService.get<string>("NODE_ENV", "development");
|
||||
const logLevel = configService.get<string>("LOG_LEVEL", "info");
|
||||
const appName = configService.get<string>("APP_NAME", "customer-portal-bff");
|
||||
|
||||
// Ensure logs directory exists for production
|
||||
if (nodeEnv === 'production') {
|
||||
if (nodeEnv === "production") {
|
||||
try {
|
||||
await mkdir('logs', { recursive: true });
|
||||
} catch (error) {
|
||||
await mkdir("logs", { recursive: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
}
|
||||
|
||||
// Base Pino configuration
|
||||
const pinoConfig: any = {
|
||||
const pinoConfig: PinoHttpOptions = {
|
||||
level: logLevel,
|
||||
name: appName,
|
||||
base: {
|
||||
@ -28,24 +30,43 @@ export class LoggingConfig {
|
||||
pid: process.pid,
|
||||
},
|
||||
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: {
|
||||
// Custom serializers for sensitive data
|
||||
req: (req: any) => ({
|
||||
// Keep logs concise: omit headers by default
|
||||
req: (req: { method?: string; url?: string; remoteAddress?: string; remotePort?: number }) => ({
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
headers: LoggingConfig.sanitizeHeaders(req.headers),
|
||||
remoteAddress: req.remoteAddress,
|
||||
remotePort: req.remotePort,
|
||||
}),
|
||||
res: (res: any) => ({
|
||||
res: (res: { statusCode: number }) => ({
|
||||
statusCode: res.statusCode,
|
||||
headers: LoggingConfig.sanitizeHeaders(res.getHeaders?.() || {}),
|
||||
}),
|
||||
err: (err: any) => ({
|
||||
err: (err: { constructor: { name: string }; message: string; stack?: string; code?: string; status?: number }) => ({
|
||||
type: err.constructor.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
@ -56,13 +77,13 @@ export class LoggingConfig {
|
||||
};
|
||||
|
||||
// Development: Pretty printing
|
||||
if (nodeEnv === 'development') {
|
||||
if (nodeEnv === "development") {
|
||||
pinoConfig.transport = {
|
||||
target: 'pino-pretty',
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'yyyy-mm-dd HH:MM:ss',
|
||||
ignore: 'pid,hostname',
|
||||
translateTime: "yyyy-mm-dd HH:MM:ss",
|
||||
ignore: "pid,hostname",
|
||||
singleLine: false,
|
||||
hideObject: false,
|
||||
},
|
||||
@ -70,30 +91,30 @@ export class LoggingConfig {
|
||||
}
|
||||
|
||||
// Production: File logging with rotation
|
||||
if (nodeEnv === 'production') {
|
||||
if (nodeEnv === "production") {
|
||||
pinoConfig.transport = {
|
||||
targets: [
|
||||
// Console output for container logs
|
||||
{
|
||||
target: 'pino/file',
|
||||
target: "pino/file",
|
||||
level: logLevel,
|
||||
options: { destination: 1 }, // stdout
|
||||
},
|
||||
// Combined log file
|
||||
{
|
||||
target: 'pino/file',
|
||||
level: 'info',
|
||||
target: "pino/file",
|
||||
level: "info",
|
||||
options: {
|
||||
destination: join('logs', `${appName}-combined.log`),
|
||||
destination: join("logs", `${appName}-combined.log`),
|
||||
mkdir: true,
|
||||
},
|
||||
},
|
||||
// Error log file
|
||||
{
|
||||
target: 'pino/file',
|
||||
level: 'error',
|
||||
target: "pino/file",
|
||||
level: "error",
|
||||
options: {
|
||||
destination: join('logs', `${appName}-error.log`),
|
||||
destination: join("logs", `${appName}-error.log`),
|
||||
mkdir: true,
|
||||
},
|
||||
},
|
||||
@ -105,26 +126,27 @@ export class LoggingConfig {
|
||||
pinoHttp: {
|
||||
...pinoConfig,
|
||||
// Auto-generate correlation IDs
|
||||
genReqId: (req: any, res: any) => {
|
||||
const existingId = req.headers['x-correlation-id'];
|
||||
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);
|
||||
res.setHeader("x-correlation-id", correlationId);
|
||||
return correlationId;
|
||||
},
|
||||
// Custom log messages
|
||||
customLogLevel: (req: any, res: any, err: any) => {
|
||||
if (res.statusCode >= 400 && res.statusCode < 500) return 'warn';
|
||||
if (res.statusCode >= 500 || err) return 'error';
|
||||
if (res.statusCode >= 300 && res.statusCode < 400) return 'debug';
|
||||
return 'info';
|
||||
// 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 any;
|
||||
},
|
||||
customSuccessMessage: (req: any, res: any) => {
|
||||
return `${req.method} ${req.url} ${res.statusCode}`;
|
||||
},
|
||||
customErrorMessage: (req: any, res: any, err: any) => {
|
||||
return `${req.method} ${req.url} ${res.statusCode} - ${err.message}`;
|
||||
// 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"}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -134,22 +156,28 @@ export class LoggingConfig {
|
||||
* Sanitize headers to remove sensitive information
|
||||
*/
|
||||
private static sanitizeHeaders(headers: any): any {
|
||||
if (!headers || typeof headers !== 'object') {
|
||||
if (!headers || typeof headers !== "object") {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const sensitiveKeys = [
|
||||
'authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-auth-token',
|
||||
'password', 'secret', 'token', 'jwt', 'bearer'
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"x-api-key",
|
||||
"x-auth-token",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"jwt",
|
||||
"bearer",
|
||||
];
|
||||
|
||||
const sanitized = { ...headers };
|
||||
|
||||
for (const key in sanitized) {
|
||||
if (sensitiveKeys.some(sensitive =>
|
||||
key.toLowerCase().includes(sensitive.toLowerCase())
|
||||
)) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
if (sensitiveKeys.some(sensitive => key.toLowerCase().includes(sensitive.toLowerCase()))) {
|
||||
sanitized[key] = "[REDACTED]";
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,13 +196,13 @@ export class LoggingConfig {
|
||||
*/
|
||||
static getLogLevels(level: string): string[] {
|
||||
const logLevels: Record<string, string[]> = {
|
||||
error: ['error'],
|
||||
warn: ['error', 'warn'],
|
||||
info: ['error', 'warn', 'info'],
|
||||
debug: ['error', 'warn', 'info', 'debug'],
|
||||
verbose: ['error', 'warn', 'info', 'debug', 'verbose'],
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,7 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
@ -1,34 +1,34 @@
|
||||
import { Module, Global, OnModuleDestroy, Injectable, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
import { Module, Global, OnModuleDestroy } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import Redis from "ioredis";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'REDIS_CLIENT',
|
||||
provide: "REDIS_CLIENT",
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const redisUrl = configService.get('REDIS_URL', 'redis://localhost:6379');
|
||||
const redisUrl = configService.get("REDIS_URL", "redis://localhost:6379");
|
||||
return new Redis(redisUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
{
|
||||
provide: 'REDIS_SHUTDOWN',
|
||||
provide: "REDIS_SHUTDOWN",
|
||||
useFactory: (client: Redis) => {
|
||||
return {
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
await client.quit();
|
||||
} catch {
|
||||
await client.disconnect();
|
||||
client.disconnect();
|
||||
}
|
||||
},
|
||||
} as OnModuleDestroy;
|
||||
},
|
||||
inject: ['REDIS_CLIENT'],
|
||||
inject: ["REDIS_CLIENT"],
|
||||
},
|
||||
],
|
||||
exports: ['REDIS_CLIENT'],
|
||||
exports: ["REDIS_CLIENT"],
|
||||
})
|
||||
export class RedisModule {}
|
||||
|
||||
@ -51,11 +51,16 @@ export function getErrorMessage(error: unknown): string {
|
||||
|
||||
// Use ES2024 Object.hasOwn for better property checking
|
||||
if (typeof error === "object" && error !== null) {
|
||||
if (
|
||||
Object.hasOwn(error, "toString") &&
|
||||
typeof error.toString === "function"
|
||||
) {
|
||||
return sanitizeErrorMessage(error.toString());
|
||||
const maybeToString = (error as { toString?: () => string }).toString;
|
||||
if (typeof maybeToString === "function") {
|
||||
try {
|
||||
const result = maybeToString.call(error);
|
||||
if (typeof result === "string") {
|
||||
return sanitizeErrorMessage(result);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,11 +113,11 @@ function sanitizeErrorMessage(message: string): string {
|
||||
return (
|
||||
message
|
||||
// Remove file paths
|
||||
.replace(/\/[a-zA-Z0-9._\-\/]+\.(js|ts|py|php)/g, "[file]")
|
||||
.replace(/\/[a-zA-Z0-9._\-/]+\.(js|ts|py|php)/g, "[file]")
|
||||
// Remove stack trace patterns
|
||||
.replace(/\s+at\s+.*/g, "")
|
||||
// Remove absolute paths
|
||||
.replace(/[a-zA-Z]:[\\\/][^:]+/g, "[path]")
|
||||
.replace(/[a-zA-Z]:[\\/][^:]+/g, "[path]")
|
||||
// Remove IP addresses
|
||||
.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]")
|
||||
// Remove URLs with credentials
|
||||
@ -154,9 +159,17 @@ export function createDeferredPromise<T>(): {
|
||||
// Use native Promise.withResolvers if available (ES2024)
|
||||
if (
|
||||
"withResolvers" in Promise &&
|
||||
typeof (Promise as any).withResolvers === "function"
|
||||
typeof (Promise as unknown as { withResolvers?: <_U>() => unknown }).withResolvers === "function"
|
||||
) {
|
||||
return (Promise as any).withResolvers();
|
||||
return (
|
||||
Promise as unknown as {
|
||||
withResolvers: <U>() => {
|
||||
promise: Promise<U>;
|
||||
resolve: (value: U | PromiseLike<U>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
}
|
||||
).withResolvers<T>();
|
||||
}
|
||||
|
||||
// Fallback polyfill
|
||||
@ -176,7 +189,7 @@ export function createDeferredPromise<T>(): {
|
||||
*/
|
||||
export async function safeAsync<T>(
|
||||
operation: () => Promise<T>,
|
||||
fallback?: T,
|
||||
fallback?: T
|
||||
): Promise<{ data: T | null; error: EnhancedError | null }> {
|
||||
try {
|
||||
const data = await operation();
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { PrismaService } from "../common/prisma/prisma.service";
|
||||
import { getErrorMessage, safeAsync } from "../common/utils/error.util";
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
|
||||
@ApiTags("Health")
|
||||
@Controller("health")
|
||||
|
||||
@ -1,222 +1,267 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
ValidationPipe,
|
||||
UsePipes,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { InvoicesService } from './invoices.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { Invoice, InvoiceList, InvoiceSsoLink, Subscription, PaymentMethodList, PaymentGatewayList, InvoicePaymentLink } from '@customer-portal/shared';
|
||||
} from "@nestjs/swagger";
|
||||
import { InvoicesService } from "./invoices.service";
|
||||
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
InvoiceSsoLink,
|
||||
Subscription,
|
||||
PaymentMethodList,
|
||||
PaymentGatewayList,
|
||||
InvoicePaymentLink,
|
||||
} from "@customer-portal/shared";
|
||||
|
||||
@ApiTags('invoices')
|
||||
@Controller('invoices')
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
@ApiTags("invoices")
|
||||
@Controller("invoices")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class InvoicesController {
|
||||
constructor(private readonly invoicesService: InvoicesService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Get paginated list of user invoices',
|
||||
description: 'Retrieves invoices for the authenticated user with pagination and optional status filtering'
|
||||
@ApiOperation({
|
||||
summary: "Get paginated list of user invoices",
|
||||
description:
|
||||
"Retrieves invoices for the authenticated user with pagination and optional status filtering",
|
||||
})
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 10)' })
|
||||
@ApiQuery({ name: 'status', required: false, type: String, description: 'Filter by invoice status' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of invoices with pagination',
|
||||
@ApiQuery({
|
||||
name: "page",
|
||||
required: false,
|
||||
type: Number,
|
||||
description: "Page number (default: 1)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "limit",
|
||||
required: false,
|
||||
type: Number,
|
||||
description: "Items per page (default: 10)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "status",
|
||||
required: false,
|
||||
type: String,
|
||||
description: "Filter by invoice status",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of invoices with pagination",
|
||||
type: Object, // Would be InvoiceList if we had proper DTO decorators
|
||||
})
|
||||
async getInvoices(
|
||||
@Request() req: any,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('status') status?: string,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string,
|
||||
@Query("status") status?: string
|
||||
): Promise<InvoiceList> {
|
||||
// Validate and sanitize input
|
||||
const pageNum = this.validatePositiveInteger(page, 1, 'page');
|
||||
const limitNum = this.validatePositiveInteger(limit, 10, 'limit');
|
||||
|
||||
const pageNum = this.validatePositiveInteger(page, 1, "page");
|
||||
const limitNum = this.validatePositiveInteger(limit, 10, "limit");
|
||||
|
||||
// Limit max page size for performance
|
||||
if (limitNum > 100) {
|
||||
throw new BadRequestException('Limit cannot exceed 100 items per page');
|
||||
throw new BadRequestException("Limit cannot exceed 100 items per page");
|
||||
}
|
||||
|
||||
// Validate status if provided
|
||||
if (status && !['Paid', 'Unpaid', 'Overdue', 'Cancelled'].includes(status)) {
|
||||
throw new BadRequestException('Invalid status filter');
|
||||
if (status && !["Paid", "Unpaid", "Overdue", "Cancelled"].includes(status)) {
|
||||
throw new BadRequestException("Invalid status filter");
|
||||
}
|
||||
|
||||
return this.invoicesService.getInvoices(
|
||||
req.user.id,
|
||||
{ page: pageNum, limit: limitNum, status }
|
||||
);
|
||||
|
||||
return this.invoicesService.getInvoices(req.user.id, {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Get invoice details by ID',
|
||||
description: 'Retrieves detailed information for a specific invoice'
|
||||
@Get(":id")
|
||||
@ApiOperation({
|
||||
summary: "Get invoice details by ID",
|
||||
description: "Retrieves detailed information for a specific invoice",
|
||||
})
|
||||
@ApiParam({ name: 'id', type: Number, description: 'Invoice ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Invoice details',
|
||||
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Invoice details",
|
||||
type: Object, // Would be Invoice if we had proper DTO decorators
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Invoice not found' })
|
||||
@ApiResponse({ status: 404, description: "Invoice not found" })
|
||||
async getInvoiceById(
|
||||
@Request() req: any,
|
||||
@Param('id', ParseIntPipe) invoiceId: number,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param("id", ParseIntPipe) invoiceId: number
|
||||
): Promise<Invoice> {
|
||||
if (invoiceId <= 0) {
|
||||
throw new BadRequestException('Invoice ID must be a positive number');
|
||||
throw new BadRequestException("Invoice ID must be a positive number");
|
||||
}
|
||||
|
||||
|
||||
return this.invoicesService.getInvoiceById(req.user.id, invoiceId);
|
||||
}
|
||||
|
||||
@Get(':id/subscriptions')
|
||||
@ApiOperation({
|
||||
summary: 'Get subscriptions related to an invoice',
|
||||
description: 'Retrieves all subscriptions that are referenced in the invoice items'
|
||||
@Get(":id/subscriptions")
|
||||
@ApiOperation({
|
||||
summary: "Get subscriptions related to an invoice",
|
||||
description: "Retrieves all subscriptions that are referenced in the invoice items",
|
||||
})
|
||||
@ApiParam({ name: 'id', type: Number, description: 'Invoice ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of related subscriptions',
|
||||
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of related subscriptions",
|
||||
type: [Object], // Would be Subscription[] if we had proper DTO decorators
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Invoice not found' })
|
||||
@ApiResponse({ status: 404, description: "Invoice not found" })
|
||||
async getInvoiceSubscriptions(
|
||||
@Request() req: any,
|
||||
@Param('id', ParseIntPipe) invoiceId: number,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param("id", ParseIntPipe) invoiceId: number
|
||||
): Promise<Subscription[]> {
|
||||
if (invoiceId <= 0) {
|
||||
throw new BadRequestException('Invoice ID must be a positive number');
|
||||
throw new BadRequestException("Invoice ID must be a positive number");
|
||||
}
|
||||
|
||||
|
||||
return this.invoicesService.getInvoiceSubscriptions(req.user.id, invoiceId);
|
||||
}
|
||||
|
||||
@Post(':id/sso-link')
|
||||
@Post(":id/sso-link")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Create SSO link for invoice',
|
||||
description: 'Generates a single sign-on link to view/pay the invoice or download PDF in WHMCS'
|
||||
@ApiOperation({
|
||||
summary: "Create SSO link for invoice",
|
||||
description: "Generates a single sign-on link to view/pay the invoice or download PDF in WHMCS",
|
||||
})
|
||||
@ApiParam({ name: 'id', type: Number, description: 'Invoice ID' })
|
||||
@ApiQuery({ name: 'target', required: false, enum: ['view', 'download', 'pay'], description: 'Link target: view invoice, download PDF, or go to payment page (default: view)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'SSO link created successfully',
|
||||
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
|
||||
@ApiQuery({
|
||||
name: "target",
|
||||
required: false,
|
||||
enum: ["view", "download", "pay"],
|
||||
description: "Link target: view invoice, download PDF, or go to payment page (default: view)",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "SSO link created successfully",
|
||||
type: Object, // Would be InvoiceSsoLink if we had proper DTO decorators
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Invoice not found' })
|
||||
@ApiResponse({ status: 404, description: "Invoice not found" })
|
||||
async createSsoLink(
|
||||
@Request() req: any,
|
||||
@Param('id', ParseIntPipe) invoiceId: number,
|
||||
@Query('target') target?: 'view' | 'download' | 'pay',
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param("id", ParseIntPipe) invoiceId: number,
|
||||
@Query("target") target?: "view" | "download" | "pay"
|
||||
): Promise<InvoiceSsoLink> {
|
||||
if (invoiceId <= 0) {
|
||||
throw new BadRequestException('Invoice ID must be a positive number');
|
||||
throw new BadRequestException("Invoice ID must be a positive number");
|
||||
}
|
||||
|
||||
// Validate target parameter
|
||||
if (target && !['view', 'download', 'pay'].includes(target)) {
|
||||
if (target && !["view", "download", "pay"].includes(target)) {
|
||||
throw new BadRequestException('Target must be "view", "download", or "pay"');
|
||||
}
|
||||
|
||||
return this.invoicesService.createSsoLink(req.user.id, invoiceId, target || 'view');
|
||||
|
||||
return this.invoicesService.createSsoLink(req.user.id, invoiceId, target || "view");
|
||||
}
|
||||
|
||||
@Get('payment-methods')
|
||||
@ApiOperation({
|
||||
summary: 'Get user payment methods',
|
||||
description: 'Retrieves all saved payment methods for the authenticated user'
|
||||
@Get("payment-methods")
|
||||
@ApiOperation({
|
||||
summary: "Get user payment methods",
|
||||
description: "Retrieves all saved payment methods for the authenticated user",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of payment methods',
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of payment methods",
|
||||
type: Object, // Would be PaymentMethodList if we had proper DTO decorators
|
||||
})
|
||||
async getPaymentMethods(
|
||||
@Request() req: any,
|
||||
): Promise<PaymentMethodList> {
|
||||
async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise<PaymentMethodList> {
|
||||
return this.invoicesService.getPaymentMethods(req.user.id);
|
||||
}
|
||||
|
||||
@Get('payment-gateways')
|
||||
@ApiOperation({
|
||||
summary: 'Get available payment gateways',
|
||||
description: 'Retrieves all active payment gateways available for payments'
|
||||
@Get("payment-gateways")
|
||||
@ApiOperation({
|
||||
summary: "Get available payment gateways",
|
||||
description: "Retrieves all active payment gateways available for payments",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of payment gateways',
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of payment gateways",
|
||||
type: Object, // Would be PaymentGatewayList if we had proper DTO decorators
|
||||
})
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList> {
|
||||
return this.invoicesService.getPaymentGateways();
|
||||
}
|
||||
|
||||
@Post(':id/payment-link')
|
||||
@Post(":id/payment-link")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Create payment link for invoice with payment method',
|
||||
description: 'Generates a payment link for the invoice with a specific payment method or gateway'
|
||||
@ApiOperation({
|
||||
summary: "Create payment link for invoice with payment method",
|
||||
description:
|
||||
"Generates a payment link for the invoice with a specific payment method or gateway",
|
||||
})
|
||||
@ApiParam({ name: 'id', type: Number, description: 'Invoice ID' })
|
||||
@ApiQuery({ name: 'paymentMethodId', required: false, type: Number, description: 'Payment method ID' })
|
||||
@ApiQuery({ name: 'gatewayName', required: false, type: String, description: 'Payment gateway name' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Payment link created successfully',
|
||||
@ApiParam({ name: "id", type: Number, description: "Invoice ID" })
|
||||
@ApiQuery({
|
||||
name: "paymentMethodId",
|
||||
required: false,
|
||||
type: Number,
|
||||
description: "Payment method ID",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "gatewayName",
|
||||
required: false,
|
||||
type: String,
|
||||
description: "Payment gateway name",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Payment link created successfully",
|
||||
type: Object, // Would be InvoicePaymentLink if we had proper DTO decorators
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Invoice not found' })
|
||||
@ApiResponse({ status: 404, description: "Invoice not found" })
|
||||
async createPaymentLink(
|
||||
@Request() req: any,
|
||||
@Param('id', ParseIntPipe) invoiceId: number,
|
||||
@Query('paymentMethodId') paymentMethodId?: string,
|
||||
@Query('gatewayName') gatewayName?: string,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param("id", ParseIntPipe) invoiceId: number,
|
||||
@Query("paymentMethodId") paymentMethodId?: string,
|
||||
@Query("gatewayName") gatewayName?: string
|
||||
): Promise<InvoicePaymentLink> {
|
||||
if (invoiceId <= 0) {
|
||||
throw new BadRequestException('Invoice ID must be a positive number');
|
||||
throw new BadRequestException("Invoice ID must be a positive number");
|
||||
}
|
||||
|
||||
const paymentMethodIdNum = paymentMethodId ? parseInt(paymentMethodId, 10) : undefined;
|
||||
if (paymentMethodId && (isNaN(paymentMethodIdNum!) || paymentMethodIdNum! <= 0)) {
|
||||
throw new BadRequestException('Payment method ID must be a positive number');
|
||||
throw new BadRequestException("Payment method ID must be a positive number");
|
||||
}
|
||||
|
||||
|
||||
return this.invoicesService.createPaymentSsoLink(
|
||||
req.user.id,
|
||||
invoiceId,
|
||||
paymentMethodIdNum,
|
||||
req.user.id,
|
||||
invoiceId,
|
||||
paymentMethodIdNum,
|
||||
gatewayName
|
||||
);
|
||||
}
|
||||
|
||||
private validatePositiveInteger(value: string | undefined, defaultValue: number, fieldName: string): number {
|
||||
private validatePositiveInteger(
|
||||
value: string | undefined,
|
||||
defaultValue: number,
|
||||
fieldName: string
|
||||
): number {
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
@ -10,7 +10,8 @@ import {
|
||||
} from "@customer-portal/shared";
|
||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
||||
import { MappingsService } from "../mappings/mappings.service";
|
||||
import { getErrorMessage, safeAsync } from "../common/utils/error.util";
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
export interface GetInvoicesOptions {
|
||||
page?: number;
|
||||
@ -20,20 +21,16 @@ export interface GetInvoicesOptions {
|
||||
|
||||
@Injectable()
|
||||
export class InvoicesService {
|
||||
private readonly logger = new Logger(InvoicesService.name);
|
||||
|
||||
constructor(
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get paginated invoices for a user
|
||||
*/
|
||||
async getInvoices(
|
||||
userId: string,
|
||||
options: GetInvoicesOptions = {},
|
||||
): Promise<InvoiceList> {
|
||||
async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise<InvoiceList> {
|
||||
const { page = 1, limit = 10, status } = options;
|
||||
|
||||
try {
|
||||
@ -52,21 +49,18 @@ export class InvoicesService {
|
||||
}
|
||||
|
||||
// Fetch invoices from WHMCS
|
||||
const invoiceList = await this.whmcsService.getInvoices(
|
||||
mapping.whmcsClientId,
|
||||
userId,
|
||||
{ page, limit, status },
|
||||
);
|
||||
const invoiceList = await this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`,
|
||||
{
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
totalItems: invoiceList.pagination?.totalItems,
|
||||
},
|
||||
);
|
||||
this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, {
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
totalItems: invoiceList.pagination?.totalItems,
|
||||
});
|
||||
|
||||
return invoiceList;
|
||||
} catch (error) {
|
||||
@ -103,7 +97,7 @@ export class InvoicesService {
|
||||
const invoice = await this.whmcsService.getInvoiceById(
|
||||
mapping.whmcsClientId,
|
||||
userId,
|
||||
invoiceId,
|
||||
invoiceId
|
||||
);
|
||||
|
||||
this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`, {
|
||||
@ -115,12 +109,9 @@ export class InvoicesService {
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get invoice ${invoiceId} for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get invoice ${invoiceId} for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
@ -136,7 +127,7 @@ export class InvoicesService {
|
||||
async createSsoLink(
|
||||
userId: string,
|
||||
invoiceId: number,
|
||||
target: "view" | "download" | "pay" = "view",
|
||||
target: "view" | "download" | "pay" = "view"
|
||||
): Promise<InvoiceSsoLink> {
|
||||
try {
|
||||
// Validate invoice ID
|
||||
@ -175,7 +166,7 @@ export class InvoicesService {
|
||||
const ssoResult = await this.whmcsService.createSsoToken(
|
||||
mapping.whmcsClientId,
|
||||
"sso:custom_redirect",
|
||||
path,
|
||||
path
|
||||
);
|
||||
|
||||
const result: InvoiceSsoLink = {
|
||||
@ -183,24 +174,18 @@ export class InvoicesService {
|
||||
expiresAt: ssoResult.expiresAt,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Created SSO link for invoice ${invoiceId}, user ${userId}`,
|
||||
{
|
||||
target,
|
||||
path,
|
||||
expiresAt: result.expiresAt,
|
||||
},
|
||||
);
|
||||
this.logger.log(`Created SSO link for invoice ${invoiceId}, user ${userId}`, {
|
||||
target,
|
||||
path,
|
||||
expiresAt: result.expiresAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create SSO link for invoice ${invoiceId}, user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
target,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to create SSO link for invoice ${invoiceId}, user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
target,
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
@ -216,23 +201,15 @@ export class InvoicesService {
|
||||
async getInvoicesByStatus(
|
||||
userId: string,
|
||||
status: string,
|
||||
options: Pick<GetInvoicesOptions, "page" | "limit"> = {},
|
||||
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
|
||||
): Promise<InvoiceList> {
|
||||
const { page = 1, limit = 10 } = options;
|
||||
|
||||
try {
|
||||
// Validate status
|
||||
const validStatuses = [
|
||||
"Paid",
|
||||
"Unpaid",
|
||||
"Cancelled",
|
||||
"Overdue",
|
||||
"Collections",
|
||||
];
|
||||
const validStatuses = ["Paid", "Unpaid", "Cancelled", "Overdue", "Collections"];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(
|
||||
`Invalid status. Must be one of: ${validStatuses.join(", ")}`,
|
||||
);
|
||||
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(", ")}`);
|
||||
}
|
||||
|
||||
return await this.getInvoices(userId, { page, limit, status });
|
||||
@ -250,7 +227,7 @@ export class InvoicesService {
|
||||
*/
|
||||
async getUnpaidInvoices(
|
||||
userId: string,
|
||||
options: Pick<GetInvoicesOptions, "page" | "limit"> = {},
|
||||
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.getInvoicesByStatus(userId, "Unpaid", options);
|
||||
}
|
||||
@ -260,7 +237,7 @@ export class InvoicesService {
|
||||
*/
|
||||
async getOverdueInvoices(
|
||||
userId: string,
|
||||
options: Pick<GetInvoicesOptions, "page" | "limit"> = {},
|
||||
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.getInvoicesByStatus(userId, "Overdue", options);
|
||||
}
|
||||
@ -300,12 +277,12 @@ export class InvoicesService {
|
||||
// Calculate statistics
|
||||
const stats = {
|
||||
total: invoices.length,
|
||||
paid: invoices.filter((i) => i.status === "Paid").length,
|
||||
unpaid: invoices.filter((i) => i.status === "Unpaid").length,
|
||||
overdue: invoices.filter((i) => i.status === "Overdue").length,
|
||||
paid: invoices.filter(i => i.status === "Paid").length,
|
||||
unpaid: invoices.filter(i => i.status === "Unpaid").length,
|
||||
overdue: invoices.filter(i => i.status === "Overdue").length,
|
||||
totalAmount: invoices.reduce((sum, i) => sum + i.total, 0),
|
||||
unpaidAmount: invoices
|
||||
.filter((i) => ["Unpaid", "Overdue"].includes(i.status))
|
||||
.filter(i => ["Unpaid", "Overdue"].includes(i.status))
|
||||
.reduce((sum, i) => sum + i.total, 0),
|
||||
currency: invoices[0]?.currency || "USD",
|
||||
};
|
||||
@ -323,10 +300,7 @@ export class InvoicesService {
|
||||
/**
|
||||
* Get subscriptions related to an invoice
|
||||
*/
|
||||
async getInvoiceSubscriptions(
|
||||
userId: string,
|
||||
invoiceId: number,
|
||||
): Promise<Subscription[]> {
|
||||
async getInvoiceSubscriptions(userId: string, invoiceId: number): Promise<Subscription[]> {
|
||||
try {
|
||||
// Get the invoice with items
|
||||
const invoice = await this.getInvoiceById(userId, invoiceId);
|
||||
@ -343,8 +317,8 @@ export class InvoicesService {
|
||||
|
||||
// Get subscription IDs from invoice items
|
||||
const subscriptionIds = invoice.items
|
||||
.filter((item) => item.serviceId && item.serviceId > 0)
|
||||
.map((item) => item.serviceId!);
|
||||
.filter(item => item.serviceId && item.serviceId > 0)
|
||||
.map(item => item.serviceId!);
|
||||
|
||||
if (subscriptionIds.length === 0) {
|
||||
return [];
|
||||
@ -353,12 +327,12 @@ export class InvoicesService {
|
||||
// Get all subscriptions for the user
|
||||
const allSubscriptions = await this.whmcsService.getSubscriptions(
|
||||
mapping.whmcsClientId,
|
||||
userId,
|
||||
userId
|
||||
);
|
||||
|
||||
// Filter subscriptions that are referenced in the invoice
|
||||
const relatedSubscriptions = allSubscriptions.subscriptions.filter(
|
||||
(subscription) => subscriptionIds.includes(subscription.serviceId),
|
||||
const relatedSubscriptions = allSubscriptions.subscriptions.filter(subscription =>
|
||||
subscriptionIds.includes(subscription.serviceId)
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
@ -367,27 +341,22 @@ export class InvoicesService {
|
||||
userId,
|
||||
invoiceId,
|
||||
subscriptionIds,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return relatedSubscriptions;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get subscriptions for invoice ${invoiceId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
invoiceId,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get subscriptions for invoice ${invoiceId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
invoiceId,
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to retrieve invoice subscriptions: ${getErrorMessage(error)}`,
|
||||
);
|
||||
throw new Error(`Failed to retrieve invoice subscriptions: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -403,16 +372,13 @@ export class InvoicesService {
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Invalidated invoice cache for user ${userId}${invoiceId ? `, invoice ${invoiceId}` : ""}`,
|
||||
`Invalidated invoice cache for user ${userId}${invoiceId ? `, invoice ${invoiceId}` : ""}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to invalidate invoice cache for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
invoiceId,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to invalidate invoice cache for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
invoiceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -430,11 +396,11 @@ export class InvoicesService {
|
||||
// Fetch payment methods from WHMCS
|
||||
const paymentMethods = await this.whmcsService.getPaymentMethods(
|
||||
mapping.whmcsClientId,
|
||||
userId,
|
||||
userId
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId}`,
|
||||
`Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId}`
|
||||
);
|
||||
return paymentMethods;
|
||||
} catch (error) {
|
||||
@ -446,9 +412,7 @@ export class InvoicesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to retrieve payment methods: ${getErrorMessage(error)}`,
|
||||
);
|
||||
throw new Error(`Failed to retrieve payment methods: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -460,18 +424,14 @@ export class InvoicesService {
|
||||
// Fetch payment gateways from WHMCS
|
||||
const paymentGateways = await this.whmcsService.getPaymentGateways();
|
||||
|
||||
this.logger.log(
|
||||
`Retrieved ${paymentGateways.gateways.length} payment gateways`,
|
||||
);
|
||||
this.logger.log(`Retrieved ${paymentGateways.gateways.length} payment gateways`);
|
||||
return paymentGateways;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get payment gateways", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Failed to retrieve payment gateways: ${getErrorMessage(error)}`,
|
||||
);
|
||||
throw new Error(`Failed to retrieve payment gateways: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -482,7 +442,7 @@ export class InvoicesService {
|
||||
userId: string,
|
||||
invoiceId: number,
|
||||
paymentMethodId?: number,
|
||||
gatewayName?: string,
|
||||
gatewayName?: string
|
||||
): Promise<InvoicePaymentLink> {
|
||||
try {
|
||||
// Validate invoice ID
|
||||
@ -504,7 +464,7 @@ export class InvoicesService {
|
||||
mapping.whmcsClientId,
|
||||
invoiceId,
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
gatewayName
|
||||
);
|
||||
|
||||
const result: InvoicePaymentLink = {
|
||||
@ -514,14 +474,11 @@ export class InvoicesService {
|
||||
gatewayName,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Created payment SSO link for invoice ${invoiceId}, user ${userId}`,
|
||||
{
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
expiresAt: result.expiresAt,
|
||||
},
|
||||
);
|
||||
this.logger.log(`Created payment SSO link for invoice ${invoiceId}, user ${userId}`, {
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
expiresAt: result.expiresAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
@ -531,16 +488,14 @@ export class InvoicesService {
|
||||
error: getErrorMessage(error),
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to create payment SSO link: ${getErrorMessage(error)}`,
|
||||
);
|
||||
throw new Error(`Failed to create payment SSO link: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,8 +3,8 @@ import { Job } from "bullmq";
|
||||
|
||||
@Processor("reconcile")
|
||||
export class ReconcileProcessor extends WorkerHost {
|
||||
async process(job: Job) {
|
||||
async process(_job: Job) {
|
||||
// TODO: Implement reconciliation logic
|
||||
console.log("Processing reconcile job:", job.data);
|
||||
// Note: In production, this should use proper logging
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,97 +1,132 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import helmet from 'helmet';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import helmet from "helmet";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { AppModule } from "./app.module";
|
||||
import { GlobalExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { bufferLogs: true });
|
||||
|
||||
|
||||
// Set Pino as the logger
|
||||
app.useLogger(app.get(Logger));
|
||||
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const logger = app.get(Logger);
|
||||
|
||||
// Security
|
||||
app.use(helmet());
|
||||
app.getHttpAdapter().getInstance().disable('x-powered-by');
|
||||
// Enhanced Security Headers
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
},
|
||||
},
|
||||
crossOriginEmbedderPolicy: false,
|
||||
crossOriginResourcePolicy: { policy: "cross-origin" },
|
||||
})
|
||||
);
|
||||
|
||||
// Disable x-powered-by header
|
||||
app.getHttpAdapter().getInstance().disable("x-powered-by");
|
||||
|
||||
// Enhanced cookie parser with security options
|
||||
app.use(cookieParser());
|
||||
// Behind reverse proxies (e.g., Nginx) to ensure correct IPs and secure cookies
|
||||
if (configService.get('TRUST_PROXY', 'false') === 'true') {
|
||||
|
||||
// Trust proxy configuration for reverse proxies
|
||||
if (configService.get("TRUST_PROXY", "false") === "true") {
|
||||
const httpAdapter = app.getHttpAdapter();
|
||||
const instance: any = httpAdapter.getInstance();
|
||||
if (instance?.set) {
|
||||
instance.set('trust proxy', 1);
|
||||
const instance = httpAdapter.getInstance() as {
|
||||
set?: (key: string, value: unknown) => void;
|
||||
};
|
||||
if (typeof instance?.set === "function") {
|
||||
instance.set("trust proxy", 1);
|
||||
}
|
||||
}
|
||||
|
||||
// CORS
|
||||
|
||||
// Enhanced CORS configuration
|
||||
const corsOrigin = configService.get("CORS_ORIGIN");
|
||||
app.enableCors({
|
||||
origin: configService.get('CORS_ORIGIN', 'http://localhost:3000'),
|
||||
origin: corsOrigin ? [corsOrigin] : false,
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Origin",
|
||||
"X-Requested-With",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Authorization",
|
||||
"X-API-Key",
|
||||
],
|
||||
exposedHeaders: ["X-Total-Count", "X-Page-Count"],
|
||||
maxAge: 86400, // 24 hours
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
// Global validation pipe with enhanced security
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
forbidUnknownValues: true,
|
||||
disableErrorMessages: configService.get("NODE_ENV") === "production",
|
||||
validationError: {
|
||||
target: false,
|
||||
value: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Global exception filter
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(app.get(Logger)));
|
||||
|
||||
// Ensure proper shutdown for Prisma/Redis and logger flush
|
||||
// Rely on Nest's built-in shutdown hooks. External orchestrator will send signals.
|
||||
app.enableShutdownHooks();
|
||||
const shutdown = async (signal: string) => {
|
||||
try {
|
||||
logger.warn(`Received ${signal}. Shutting down gracefully...`);
|
||||
await app.close();
|
||||
if (typeof (logger as any).flush === 'function') {
|
||||
await (logger as any).flush();
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error during shutdown', err);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Swagger documentation
|
||||
if (configService.get('NODE_ENV') !== 'production') {
|
||||
// Swagger documentation (only in non-production) - SETUP BEFORE GLOBAL PREFIX
|
||||
if (configService.get("NODE_ENV") !== "production") {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Customer Portal API')
|
||||
.setDescription('Backend for Frontend API for customer portal')
|
||||
.setVersion('1.0')
|
||||
.setTitle("Customer Portal API")
|
||||
.setDescription("Backend for Frontend API for customer portal")
|
||||
.setVersion("1.0")
|
||||
.addBearerAuth()
|
||||
.addCookieAuth('auth-cookie')
|
||||
.addCookieAuth("auth-cookie")
|
||||
.addServer("http://localhost:4000", "Development server")
|
||||
.addServer("https://api.yourdomain.com", "Production server")
|
||||
.build();
|
||||
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
SwaggerModule.setup("docs", app, document);
|
||||
}
|
||||
|
||||
const port = Number(configService.get('BFF_PORT', 4000));
|
||||
|
||||
// API routing prefix is applied via RouterModule in AppModule for clarity and modern routing.
|
||||
|
||||
const port = Number(configService.get("BFF_PORT", 4000));
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
|
||||
// Enhanced startup information
|
||||
logger.log(`🚀 BFF API running on: http://localhost:${port}/api`);
|
||||
if (configService.get('NODE_ENV') !== 'production') {
|
||||
logger.log(`📚 API Documentation: http://localhost:${port}/api/docs`);
|
||||
logger.log(`🌐 Frontend Portal: http://localhost:${configService.get("NEXT_PORT", 3000)}`);
|
||||
logger.log(
|
||||
`🗄️ Database: ${configService.get("DATABASE_URL", "postgresql://dev:dev@localhost:5432/portal_dev")}`
|
||||
);
|
||||
logger.log(`🔴 Redis: ${configService.get("REDIS_URL", "redis://localhost:6379")}`);
|
||||
|
||||
if (configService.get("NODE_ENV") !== "production") {
|
||||
logger.log(`📚 API Documentation: http://localhost:${port}/docs`);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
194
apps/bff/src/mappings/cache/mapping-cache.service.ts
vendored
194
apps/bff/src/mappings/cache/mapping-cache.service.ts
vendored
@ -1,21 +1,24 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CacheService } from '../../common/cache/cache.service';
|
||||
import { UserIdMapping, MappingCacheKey, CachedMapping } from '../types/mapping.types';
|
||||
import { getErrorMessage } from '../../common/utils/error.util';
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CacheService } from "../../common/cache/cache.service";
|
||||
import { UserIdMapping } from "../types/mapping.types";
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
|
||||
@Injectable()
|
||||
export class MappingCacheService {
|
||||
private readonly logger = new Logger(MappingCacheService.name);
|
||||
private readonly CACHE_TTL = 1800; // 30 minutes
|
||||
private readonly CACHE_PREFIX = 'mapping';
|
||||
private readonly CACHE_PREFIX = "mapping";
|
||||
|
||||
constructor(private readonly cacheService: CacheService) {}
|
||||
constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get mapping by user ID
|
||||
*/
|
||||
async getByUserId(userId: string): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey('userId', userId);
|
||||
const key = this.buildCacheKey("userId", userId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
@ -23,7 +26,7 @@ export class MappingCacheService {
|
||||
* Get mapping by WHMCS client ID
|
||||
*/
|
||||
async getByWhmcsClientId(whmcsClientId: number): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey('whmcsClientId', whmcsClientId);
|
||||
const key = this.buildCacheKey("whmcsClientId", whmcsClientId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
@ -31,7 +34,7 @@ export class MappingCacheService {
|
||||
* Get mapping by Salesforce account ID
|
||||
*/
|
||||
async getBySfAccountId(sfAccountId: string): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey('sfAccountId', sfAccountId);
|
||||
const key = this.buildCacheKey("sfAccountId", sfAccountId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
@ -42,18 +45,18 @@ export class MappingCacheService {
|
||||
const operations: Promise<void>[] = [];
|
||||
|
||||
// Cache by user ID
|
||||
const userKey = this.buildCacheKey('userId', mapping.userId);
|
||||
const userKey = this.buildCacheKey("userId", mapping.userId);
|
||||
operations.push(this.set(userKey, mapping));
|
||||
|
||||
// Cache by WHMCS client ID
|
||||
if (mapping.whmcsClientId) {
|
||||
const whmcsKey = this.buildCacheKey('whmcsClientId', mapping.whmcsClientId);
|
||||
const whmcsKey = this.buildCacheKey("whmcsClientId", mapping.whmcsClientId);
|
||||
operations.push(this.set(whmcsKey, mapping));
|
||||
}
|
||||
|
||||
// Cache by Salesforce account ID
|
||||
if (mapping.sfAccountId) {
|
||||
const sfKey = this.buildCacheKey('sfAccountId', mapping.sfAccountId);
|
||||
const sfKey = this.buildCacheKey("sfAccountId", mapping.sfAccountId);
|
||||
operations.push(this.set(sfKey, mapping));
|
||||
}
|
||||
|
||||
@ -71,14 +74,14 @@ export class MappingCacheService {
|
||||
const keys: string[] = [];
|
||||
|
||||
// Remove all possible cache keys
|
||||
keys.push(this.buildCacheKey('userId', mapping.userId));
|
||||
|
||||
keys.push(this.buildCacheKey("userId", mapping.userId));
|
||||
|
||||
if (mapping.whmcsClientId) {
|
||||
keys.push(this.buildCacheKey('whmcsClientId', mapping.whmcsClientId));
|
||||
keys.push(this.buildCacheKey("whmcsClientId", mapping.whmcsClientId));
|
||||
}
|
||||
|
||||
|
||||
if (mapping.sfAccountId) {
|
||||
keys.push(this.buildCacheKey('sfAccountId', mapping.sfAccountId));
|
||||
keys.push(this.buildCacheKey("sfAccountId", mapping.sfAccountId));
|
||||
}
|
||||
|
||||
await Promise.all(keys.map(key => this.cacheService.del(key)));
|
||||
@ -88,140 +91,99 @@ export class MappingCacheService {
|
||||
/**
|
||||
* Update mapping in cache (handles key changes)
|
||||
*/
|
||||
async updateMapping(
|
||||
oldMapping: UserIdMapping,
|
||||
newMapping: UserIdMapping
|
||||
): Promise<void> {
|
||||
async updateMapping(oldMapping: UserIdMapping, newMapping: UserIdMapping): Promise<void> {
|
||||
// Remove old cache entries
|
||||
await this.deleteMapping(oldMapping);
|
||||
|
||||
// Cache new mapping
|
||||
|
||||
// Add new cache entries
|
||||
await this.setMapping(newMapping);
|
||||
|
||||
this.logger.debug(`Updated mapping cache for user ${newMapping.userId}`);
|
||||
|
||||
this.logger.debug(`Updated mapping cache for user ${newMapping.userId}`, {
|
||||
oldWhmcsClientId: oldMapping.whmcsClientId,
|
||||
newWhmcsClientId: newMapping.whmcsClientId,
|
||||
oldSfAccountId: oldMapping.sfAccountId,
|
||||
newSfAccountId: newMapping.sfAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all mapping cache for a user
|
||||
* Clear all mapping cache
|
||||
*/
|
||||
async invalidateUserMapping(userId: string): Promise<void> {
|
||||
async clearAll(): Promise<void> {
|
||||
try {
|
||||
// Get the mapping first to know all cache keys to remove
|
||||
const mapping = await this.getByUserId(userId);
|
||||
if (mapping) {
|
||||
await this.deleteMapping(mapping);
|
||||
} else {
|
||||
// Fallback: just remove the user ID key
|
||||
const userKey = this.buildCacheKey('userId', userId);
|
||||
await this.cacheService.del(userKey);
|
||||
}
|
||||
|
||||
this.logger.log(`Invalidated mapping cache for user ${userId}`);
|
||||
await this.cacheService.delPattern(`${this.CACHE_PREFIX}:*`);
|
||||
this.logger.log("Cleared all mapping cache");
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate mapping cache for user ${userId}`, {
|
||||
this.logger.error("Failed to clear mapping cache", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mapping cache
|
||||
*/
|
||||
async clearAllCache(): Promise<void> {
|
||||
try {
|
||||
const pattern = `${this.CACHE_PREFIX}:*`;
|
||||
await this.cacheService.delPattern(pattern);
|
||||
this.logger.warn('Cleared all mapping cache');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to clear mapping cache', { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
async getCacheStats(): Promise<{
|
||||
totalKeys: number;
|
||||
keysByType: Record<string, number>;
|
||||
}> {
|
||||
// This would require Redis SCAN functionality
|
||||
// For now, return placeholder
|
||||
return {
|
||||
totalKeys: 0,
|
||||
keysByType: {
|
||||
userId: 0,
|
||||
whmcsClientId: 0,
|
||||
sfAccountId: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
async getStats(): Promise<{ totalKeys: number; memoryUsage: number }> {
|
||||
let result = { totalKeys: 0, memoryUsage: 0 };
|
||||
|
||||
/**
|
||||
* Warm up cache with frequently accessed mappings
|
||||
*/
|
||||
async warmUpCache(mappings: UserIdMapping[]): Promise<void> {
|
||||
try {
|
||||
await Promise.all(mappings.map(mapping => this.setMapping(mapping)));
|
||||
this.logger.log(`Warmed up cache with ${mappings.length} mappings`);
|
||||
// This is a simplified implementation
|
||||
// In production, you might want to use Redis INFO command
|
||||
result = { totalKeys: 0, memoryUsage: 0 };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to warm up mapping cache', { error: getErrorMessage(error) });
|
||||
this.logger.error("Failed to get cache stats", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mapping exists in cache
|
||||
* Health check for cache service
|
||||
*/
|
||||
async hasMapping(userId: string): Promise<boolean> {
|
||||
const key = this.buildCacheKey('userId', userId);
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const cached = await this.cacheService.get(key);
|
||||
return cached !== null;
|
||||
} catch {
|
||||
// Test cache operations
|
||||
const testKey = `${this.CACHE_PREFIX}:health:test`;
|
||||
const testValue = { test: true, timestamp: Date.now() };
|
||||
|
||||
await this.set(testKey, testValue, 10);
|
||||
const retrieved = await this.get<{ test: boolean; timestamp: number }>(testKey);
|
||||
await this.cacheService.del(testKey);
|
||||
|
||||
return retrieved?.test === true;
|
||||
} catch (error) {
|
||||
this.logger.error("Cache health check failed", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached mapping with metadata
|
||||
*/
|
||||
async getCachedMappingInfo(userId: string): Promise<CachedMapping | null> {
|
||||
const mapping = await this.getByUserId(userId);
|
||||
if (!mapping) {
|
||||
// Private helper methods
|
||||
private async get<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
return await this.cacheService.get<T>(key);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get cache key: ${key}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mapping,
|
||||
cachedAt: new Date(), // Would be actual cache timestamp in real implementation
|
||||
ttl: this.CACHE_TTL,
|
||||
};
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
|
||||
try {
|
||||
await this.cacheService.set(key, value, ttlSeconds || this.CACHE_TTL);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to set cache key: ${key}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private buildCacheKey(type: string, value: string | number): string {
|
||||
return `${this.CACHE_PREFIX}:${type}:${value}`;
|
||||
}
|
||||
|
||||
private async get(key: string): Promise<UserIdMapping | null> {
|
||||
try {
|
||||
const cached = await this.cacheService.get<UserIdMapping>(key);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit: ${key}`);
|
||||
}
|
||||
return cached;
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache get error for key ${key}`, { error: getErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async set(key: string, mapping: UserIdMapping): Promise<void> {
|
||||
try {
|
||||
await this.cacheService.set(key, mapping, this.CACHE_TTL);
|
||||
this.logger.debug(`Cache set: ${key} (TTL: ${this.CACHE_TTL}s)`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache set error for key ${key}`, { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
224
apps/bff/src/mappings/cache/mapping-cache.service.ts.backup.20250822_120236
vendored
Normal file
224
apps/bff/src/mappings/cache/mapping-cache.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,224 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { CacheService } from "../../common/cache/cache.service";
|
||||
import { UserIdMapping, CachedMapping } from "../types/mapping.types";
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
|
||||
@Injectable()
|
||||
export class MappingCacheService {
|
||||
private readonly logger = new Logger(MappingCacheService.name);
|
||||
private readonly CACHE_TTL = 1800; // 30 minutes
|
||||
private readonly CACHE_PREFIX = "mapping";
|
||||
|
||||
constructor(private readonly cacheService: CacheService) {}
|
||||
|
||||
/**
|
||||
* Get mapping by user ID
|
||||
*/
|
||||
async getByUserId(userId: string): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey("userId", userId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by WHMCS client ID
|
||||
*/
|
||||
async getByWhmcsClientId(whmcsClientId: number): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey("whmcsClientId", whmcsClientId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by Salesforce account ID
|
||||
*/
|
||||
async getBySfAccountId(sfAccountId: string): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey("sfAccountId", sfAccountId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache mapping with all possible keys
|
||||
*/
|
||||
async setMapping(mapping: UserIdMapping): Promise<void> {
|
||||
const operations: Promise<void>[] = [];
|
||||
|
||||
// Cache by user ID
|
||||
const userKey = this.buildCacheKey("userId", mapping.userId);
|
||||
operations.push(this.set(userKey, mapping));
|
||||
|
||||
// Cache by WHMCS client ID
|
||||
if (mapping.whmcsClientId) {
|
||||
const whmcsKey = this.buildCacheKey("whmcsClientId", mapping.whmcsClientId);
|
||||
operations.push(this.set(whmcsKey, mapping));
|
||||
}
|
||||
|
||||
// Cache by Salesforce account ID
|
||||
if (mapping.sfAccountId) {
|
||||
const sfKey = this.buildCacheKey("sfAccountId", mapping.sfAccountId);
|
||||
operations.push(this.set(sfKey, mapping));
|
||||
}
|
||||
|
||||
await Promise.all(operations);
|
||||
this.logger.debug(`Cached mapping for user ${mapping.userId}`, {
|
||||
whmcsClientId: mapping.whmcsClientId,
|
||||
sfAccountId: mapping.sfAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove mapping from cache
|
||||
*/
|
||||
async deleteMapping(mapping: UserIdMapping): Promise<void> {
|
||||
const keys: string[] = [];
|
||||
|
||||
// Remove all possible cache keys
|
||||
keys.push(this.buildCacheKey("userId", mapping.userId));
|
||||
|
||||
if (mapping.whmcsClientId) {
|
||||
keys.push(this.buildCacheKey("whmcsClientId", mapping.whmcsClientId));
|
||||
}
|
||||
|
||||
if (mapping.sfAccountId) {
|
||||
keys.push(this.buildCacheKey("sfAccountId", mapping.sfAccountId));
|
||||
}
|
||||
|
||||
await Promise.all(keys.map(key => this.cacheService.del(key)));
|
||||
this.logger.debug(`Deleted mapping cache for user ${mapping.userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mapping in cache (handles key changes)
|
||||
*/
|
||||
async updateMapping(oldMapping: UserIdMapping, newMapping: UserIdMapping): Promise<void> {
|
||||
// Remove old cache entries
|
||||
await this.deleteMapping(oldMapping);
|
||||
|
||||
// Cache new mapping
|
||||
await this.setMapping(newMapping);
|
||||
|
||||
this.logger.debug(`Updated mapping cache for user ${newMapping.userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all mapping cache for a user
|
||||
*/
|
||||
async invalidateUserMapping(userId: string): Promise<void> {
|
||||
try {
|
||||
// Get the mapping first to know all cache keys to remove
|
||||
const mapping = await this.getByUserId(userId);
|
||||
if (mapping) {
|
||||
await this.deleteMapping(mapping);
|
||||
} else {
|
||||
// Fallback: just remove the user ID key
|
||||
const userKey = this.buildCacheKey("userId", userId);
|
||||
await this.cacheService.del(userKey);
|
||||
}
|
||||
|
||||
this.logger.log(`Invalidated mapping cache for user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate mapping cache for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mapping cache
|
||||
*/
|
||||
async clearAllCache(): Promise<void> {
|
||||
try {
|
||||
const pattern = `${this.CACHE_PREFIX}:*`;
|
||||
await this.cacheService.delPattern(pattern);
|
||||
this.logger.warn("Cleared all mapping cache");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to clear mapping cache", { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
async getCacheStats(): Promise<{
|
||||
totalKeys: number;
|
||||
keysByType: Record<string, number>;
|
||||
}> {
|
||||
// This would require Redis SCAN functionality
|
||||
// For now, return placeholder
|
||||
return {
|
||||
totalKeys: 0,
|
||||
keysByType: {
|
||||
userId: 0,
|
||||
whmcsClientId: 0,
|
||||
sfAccountId: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm up cache with frequently accessed mappings
|
||||
*/
|
||||
async warmUpCache(mappings: UserIdMapping[]): Promise<void> {
|
||||
try {
|
||||
await Promise.all(mappings.map(mapping => this.setMapping(mapping)));
|
||||
this.logger.log(`Warmed up cache with ${mappings.length} mappings`);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to warm up mapping cache", { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mapping exists in cache
|
||||
*/
|
||||
async hasMapping(userId: string): Promise<boolean> {
|
||||
const key = this.buildCacheKey("userId", userId);
|
||||
try {
|
||||
const cached = await this.cacheService.get(key);
|
||||
return cached !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached mapping with metadata
|
||||
*/
|
||||
async getCachedMappingInfo(userId: string): Promise<CachedMapping | null> {
|
||||
const mapping = await this.getByUserId(userId);
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mapping,
|
||||
cachedAt: new Date(), // Would be actual cache timestamp in real implementation
|
||||
ttl: this.CACHE_TTL,
|
||||
};
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private buildCacheKey(type: string, value: string | number): string {
|
||||
return `${this.CACHE_PREFIX}:${type}:${value}`;
|
||||
}
|
||||
|
||||
private async get(key: string): Promise<UserIdMapping | null> {
|
||||
try {
|
||||
const cached = await this.cacheService.get<UserIdMapping>(key);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit: ${key}`);
|
||||
}
|
||||
return cached;
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache get error for key ${key}`, { error: getErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async set(key: string, mapping: UserIdMapping): Promise<void> {
|
||||
try {
|
||||
await this.cacheService.set(key, mapping, this.CACHE_TTL);
|
||||
this.logger.debug(`Cache set: ${key} (TTL: ${this.CACHE_TTL}s)`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache set error for key ${key}`, { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
229
apps/bff/src/mappings/cache/mapping-cache.service.ts.backup.20250822_120518
vendored
Normal file
229
apps/bff/src/mappings/cache/mapping-cache.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,229 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CacheService } from "../../common/cache/cache.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { UserIdMapping, CachedMapping } from "../types/mapping.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class MappingCacheService {
|
||||
|
||||
private readonly CACHE_TTL = 1800; // 30 minutes
|
||||
private readonly CACHE_PREFIX = "mapping";
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,private readonly cacheService: CacheService) {}
|
||||
|
||||
/**
|
||||
* Get mapping by user ID
|
||||
*/
|
||||
async getByUserId(userId: string): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey("userId", userId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by WHMCS client ID
|
||||
*/
|
||||
async getByWhmcsClientId(whmcsClientId: number): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey("whmcsClientId", whmcsClientId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapping by Salesforce account ID
|
||||
*/
|
||||
async getBySfAccountId(sfAccountId: string): Promise<UserIdMapping | null> {
|
||||
const key = this.buildCacheKey("sfAccountId", sfAccountId);
|
||||
return this.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache mapping with all possible keys
|
||||
*/
|
||||
async setMapping(mapping: UserIdMapping): Promise<void> {
|
||||
const operations: Promise<void>[] = [];
|
||||
|
||||
// Cache by user ID
|
||||
const userKey = this.buildCacheKey("userId", mapping.userId);
|
||||
operations.push(this.set(userKey, mapping));
|
||||
|
||||
// Cache by WHMCS client ID
|
||||
if (mapping.whmcsClientId) {
|
||||
const whmcsKey = this.buildCacheKey("whmcsClientId", mapping.whmcsClientId);
|
||||
operations.push(this.set(whmcsKey, mapping));
|
||||
}
|
||||
|
||||
// Cache by Salesforce account ID
|
||||
if (mapping.sfAccountId) {
|
||||
const sfKey = this.buildCacheKey("sfAccountId", mapping.sfAccountId);
|
||||
operations.push(this.set(sfKey, mapping));
|
||||
}
|
||||
|
||||
await Promise.all(operations);
|
||||
this.logger.debug(`Cached mapping for user ${mapping.userId}`, {
|
||||
whmcsClientId: mapping.whmcsClientId,
|
||||
sfAccountId: mapping.sfAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove mapping from cache
|
||||
*/
|
||||
async deleteMapping(mapping: UserIdMapping): Promise<void> {
|
||||
const keys: string[] = [];
|
||||
|
||||
// Remove all possible cache keys
|
||||
keys.push(this.buildCacheKey("userId", mapping.userId));
|
||||
|
||||
if (mapping.whmcsClientId) {
|
||||
keys.push(this.buildCacheKey("whmcsClientId", mapping.whmcsClientId));
|
||||
}
|
||||
|
||||
if (mapping.sfAccountId) {
|
||||
keys.push(this.buildCacheKey("sfAccountId", mapping.sfAccountId));
|
||||
}
|
||||
|
||||
await Promise.all(keys.map(key => this.cacheService.del(key)));
|
||||
this.logger.debug(`Deleted mapping cache for user ${mapping.userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mapping in cache (handles key changes)
|
||||
*/
|
||||
async updateMapping(oldMapping: UserIdMapping, newMapping: UserIdMapping): Promise<void> {
|
||||
// Remove old cache entries
|
||||
await this.deleteMapping(oldMapping);
|
||||
|
||||
// Cache new mapping
|
||||
await this.setMapping(newMapping);
|
||||
|
||||
this.logger.debug(`Updated mapping cache for user ${newMapping.userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all mapping cache for a user
|
||||
*/
|
||||
async invalidateUserMapping(userId: string): Promise<void> {
|
||||
try {
|
||||
// Get the mapping first to know all cache keys to remove
|
||||
const mapping = await this.getByUserId(userId);
|
||||
if (mapping) {
|
||||
await this.deleteMapping(mapping);
|
||||
} else {
|
||||
// Fallback: just remove the user ID key
|
||||
const userKey = this.buildCacheKey("userId", userId);
|
||||
await this.cacheService.del(userKey);
|
||||
}
|
||||
|
||||
this.logger.log(`Invalidated mapping cache for user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate mapping cache for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mapping cache
|
||||
*/
|
||||
async clearAllCache(): Promise<void> {
|
||||
try {
|
||||
const pattern = `${this.CACHE_PREFIX}:*`;
|
||||
await this.cacheService.delPattern(pattern);
|
||||
this.logger.warn("Cleared all mapping cache");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to clear mapping cache", { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
async getCacheStats(): Promise<{
|
||||
totalKeys: number;
|
||||
keysByType: Record<string, number>;
|
||||
}> {
|
||||
// This would require Redis SCAN functionality
|
||||
// For now, return placeholder
|
||||
return {
|
||||
totalKeys: 0,
|
||||
keysByType: {
|
||||
userId: 0,
|
||||
whmcsClientId: 0,
|
||||
sfAccountId: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm up cache with frequently accessed mappings
|
||||
*/
|
||||
async warmUpCache(mappings: UserIdMapping[]): Promise<void> {
|
||||
try {
|
||||
await Promise.all(mappings.map(mapping => this.setMapping(mapping)));
|
||||
this.logger.log(`Warmed up cache with ${mappings.length} mappings`);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to warm up mapping cache", { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if mapping exists in cache
|
||||
*/
|
||||
async hasMapping(userId: string): Promise<boolean> {
|
||||
const key = this.buildCacheKey("userId", userId);
|
||||
try {
|
||||
const cached = await this.cacheService.get(key);
|
||||
return cached !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached mapping with metadata
|
||||
*/
|
||||
async getCachedMappingInfo(userId: string): Promise<CachedMapping | null> {
|
||||
const mapping = await this.getByUserId(userId);
|
||||
if (!mapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mapping,
|
||||
cachedAt: new Date(), // Would be actual cache timestamp in real implementation
|
||||
ttl: this.CACHE_TTL,
|
||||
};
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private buildCacheKey(type: string, value: string | number): string {
|
||||
return `${this.CACHE_PREFIX}:${type}:${value}`;
|
||||
}
|
||||
|
||||
private async get(key: string): Promise<UserIdMapping | null> {
|
||||
try {
|
||||
const cached = await this.cacheService.get<UserIdMapping>(key);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit: ${key}`);
|
||||
}
|
||||
return cached;
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache get error for key ${key}`, { error: getErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async set(key: string, mapping: UserIdMapping): Promise<void> {
|
||||
try {
|
||||
await this.cacheService.set(key, mapping, this.CACHE_TTL);
|
||||
this.logger.debug(`Cache set: ${key} (TTL: ${this.CACHE_TTL}s)`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache set error for key ${key}`, { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,22 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MappingsService } from './mappings.service';
|
||||
import { MappingCacheService } from './cache/mapping-cache.service';
|
||||
import { MappingValidatorService } from './validation/mapping-validator.service';
|
||||
import { CacheModule } from '../common/cache/cache.module';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { MappingsService } from "./mappings.service";
|
||||
import { MappingCacheService } from "./cache/mapping-cache.service";
|
||||
import { MappingValidatorService } from "./validation/mapping-validator.service";
|
||||
import { CacheModule } from "../common/cache/cache.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
CacheModule, // For CacheService
|
||||
],
|
||||
providers: [
|
||||
MappingsService,
|
||||
MappingCacheService,
|
||||
MappingValidatorService,
|
||||
],
|
||||
exports: [
|
||||
MappingsService,
|
||||
MappingCacheService,
|
||||
MappingValidatorService,
|
||||
],
|
||||
providers: [MappingsService, MappingCacheService, MappingValidatorService],
|
||||
exports: [MappingsService, MappingCacheService, MappingValidatorService],
|
||||
})
|
||||
export class MappingsModule {}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
} from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { PrismaService } from "../common/prisma/prisma.service";
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
import { MappingCacheService } from "./cache/mapping-cache.service";
|
||||
@ -15,18 +16,16 @@ import {
|
||||
UpdateMappingRequest,
|
||||
MappingSearchFilters,
|
||||
MappingStats,
|
||||
BulkMappingOperation,
|
||||
BulkMappingResult,
|
||||
} from "./types/mapping.types";
|
||||
|
||||
@Injectable()
|
||||
export class MappingsService {
|
||||
private readonly logger = new Logger(MappingsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly cacheService: MappingCacheService,
|
||||
private readonly validator: MappingValidatorService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -41,9 +40,7 @@ export class MappingsService {
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new BadRequestException(
|
||||
`Invalid mapping data: ${validation.errors.join(", ")}`,
|
||||
);
|
||||
throw new BadRequestException(`Invalid mapping data: ${validation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
// Sanitize input
|
||||
@ -53,13 +50,11 @@ export class MappingsService {
|
||||
const existingMappings = await this.getAllMappingsFromDb();
|
||||
const conflictValidation = await this.validator.validateNoConflicts(
|
||||
sanitizedRequest,
|
||||
existingMappings,
|
||||
existingMappings
|
||||
);
|
||||
|
||||
if (!conflictValidation.isValid) {
|
||||
throw new ConflictException(
|
||||
`Mapping conflict: ${conflictValidation.errors.join(", ")}`,
|
||||
);
|
||||
throw new ConflictException(`Mapping conflict: ${conflictValidation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
// Create in database
|
||||
@ -149,9 +144,7 @@ export class MappingsService {
|
||||
/**
|
||||
* Find mapping by WHMCS client ID
|
||||
*/
|
||||
async findByWhmcsClientId(
|
||||
whmcsClientId: number,
|
||||
): Promise<UserIdMapping | null> {
|
||||
async findByWhmcsClientId(whmcsClientId: number): Promise<UserIdMapping | null> {
|
||||
try {
|
||||
// Validate WHMCS client ID
|
||||
if (!whmcsClientId || whmcsClientId < 1) {
|
||||
@ -161,9 +154,7 @@ export class MappingsService {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getByWhmcsClientId(whmcsClientId);
|
||||
if (cached) {
|
||||
this.logger.debug(
|
||||
`Cache hit for WHMCS client mapping: ${whmcsClientId}`,
|
||||
);
|
||||
this.logger.debug(`Cache hit for WHMCS client mapping: ${whmcsClientId}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
@ -195,12 +186,9 @@ export class MappingsService {
|
||||
|
||||
return mapping;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to find mapping for WHMCS client ${whmcsClientId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to find mapping for WHMCS client ${whmcsClientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -250,12 +238,9 @@ export class MappingsService {
|
||||
|
||||
return mapping;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to find mapping for SF account ${sfAccountId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -263,10 +248,7 @@ export class MappingsService {
|
||||
/**
|
||||
* Update an existing mapping
|
||||
*/
|
||||
async updateMapping(
|
||||
userId: string,
|
||||
updates: UpdateMappingRequest,
|
||||
): Promise<UserIdMapping> {
|
||||
async updateMapping(userId: string, updates: UpdateMappingRequest): Promise<UserIdMapping> {
|
||||
try {
|
||||
// Validate request
|
||||
const validation = this.validator.validateUpdateRequest(userId, updates);
|
||||
@ -275,9 +257,7 @@ export class MappingsService {
|
||||
});
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new BadRequestException(
|
||||
`Invalid update data: ${validation.errors.join(", ")}`,
|
||||
);
|
||||
throw new BadRequestException(`Invalid update data: ${validation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
// Get existing mapping
|
||||
@ -294,12 +274,10 @@ export class MappingsService {
|
||||
sanitizedUpdates.whmcsClientId &&
|
||||
sanitizedUpdates.whmcsClientId !== existing.whmcsClientId
|
||||
) {
|
||||
const conflictingMapping = await this.findByWhmcsClientId(
|
||||
sanitizedUpdates.whmcsClientId,
|
||||
);
|
||||
const conflictingMapping = await this.findByWhmcsClientId(sanitizedUpdates.whmcsClientId);
|
||||
if (conflictingMapping && conflictingMapping.userId !== userId) {
|
||||
throw new ConflictException(
|
||||
`WHMCS client ${sanitizedUpdates.whmcsClientId} is already mapped to user ${conflictingMapping.userId}`,
|
||||
`WHMCS client ${sanitizedUpdates.whmcsClientId} is already mapped to user ${conflictingMapping.userId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -377,9 +355,7 @@ export class MappingsService {
|
||||
/**
|
||||
* Search mappings with filters
|
||||
*/
|
||||
async searchMappings(
|
||||
filters: MappingSearchFilters,
|
||||
): Promise<UserIdMapping[]> {
|
||||
async searchMappings(filters: MappingSearchFilters): Promise<UserIdMapping[]> {
|
||||
try {
|
||||
const whereClause: any = {};
|
||||
|
||||
@ -416,7 +392,7 @@ export class MappingsService {
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const mappings: UserIdMapping[] = dbMappings.map((mapping) => ({
|
||||
const mappings: UserIdMapping[] = dbMappings.map(mapping => ({
|
||||
userId: mapping.userId,
|
||||
whmcsClientId: mapping.whmcsClientId,
|
||||
sfAccountId: mapping.sfAccountId || undefined,
|
||||
@ -424,10 +400,7 @@ export class MappingsService {
|
||||
updatedAt: mapping.updatedAt,
|
||||
}));
|
||||
|
||||
this.logger.debug(
|
||||
`Found ${mappings.length} mappings matching filters`,
|
||||
filters,
|
||||
);
|
||||
this.logger.debug(`Found ${mappings.length} mappings matching filters`, filters);
|
||||
return mappings;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to search mappings", {
|
||||
@ -443,24 +416,20 @@ export class MappingsService {
|
||||
*/
|
||||
async getMappingStats(): Promise<MappingStats> {
|
||||
try {
|
||||
const [totalCount, whmcsCount, sfCount, completeCount] =
|
||||
await Promise.all([
|
||||
this.prisma.idMapping.count(),
|
||||
this.prisma.idMapping.count({
|
||||
where: { whmcsClientId: { not: null as any } },
|
||||
}),
|
||||
this.prisma.idMapping.count({
|
||||
where: { sfAccountId: { not: null } },
|
||||
}),
|
||||
this.prisma.idMapping.count({
|
||||
where: {
|
||||
AND: [
|
||||
{ whmcsClientId: { not: null as any } },
|
||||
{ sfAccountId: { not: null } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const [totalCount, whmcsCount, sfCount, completeCount] = await Promise.all([
|
||||
this.prisma.idMapping.count(),
|
||||
this.prisma.idMapping.count({
|
||||
where: { whmcsClientId: { not: null as any } },
|
||||
}),
|
||||
this.prisma.idMapping.count({
|
||||
where: { sfAccountId: { not: null } },
|
||||
}),
|
||||
this.prisma.idMapping.count({
|
||||
where: {
|
||||
AND: [{ whmcsClientId: { not: null as any } }, { sfAccountId: { not: null } }],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const stats: MappingStats = {
|
||||
totalMappings: totalCount,
|
||||
@ -483,9 +452,7 @@ export class MappingsService {
|
||||
/**
|
||||
* Bulk create mappings
|
||||
*/
|
||||
async bulkCreateMappings(
|
||||
mappings: CreateMappingRequest[],
|
||||
): Promise<BulkMappingResult> {
|
||||
async bulkCreateMappings(mappings: CreateMappingRequest[]): Promise<BulkMappingResult> {
|
||||
const result: BulkMappingResult = {
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
@ -518,7 +485,7 @@ export class MappingsService {
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Bulk create completed: ${result.successful} successful, ${result.failed} failed`,
|
||||
`Bulk create completed: ${result.successful} successful, ${result.failed} failed`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
@ -535,8 +502,8 @@ export class MappingsService {
|
||||
async hasMapping(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// Try cache first
|
||||
const hasCache = await this.cacheService.hasMapping(userId);
|
||||
if (hasCache) {
|
||||
const cached = await this.cacheService.getByUserId(userId);
|
||||
if (cached) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -559,7 +526,11 @@ export class MappingsService {
|
||||
* Invalidate cache for a user
|
||||
*/
|
||||
async invalidateCache(userId: string): Promise<void> {
|
||||
await this.cacheService.invalidateUserMapping(userId);
|
||||
// Get the current mapping to invalidate all related cache keys
|
||||
const mapping = await this.cacheService.getByUserId(userId);
|
||||
if (mapping) {
|
||||
await this.cacheService.deleteMapping(mapping);
|
||||
}
|
||||
this.logger.log(`Invalidated mapping cache for user ${userId}`);
|
||||
}
|
||||
|
||||
@ -597,7 +568,7 @@ export class MappingsService {
|
||||
|
||||
private async getAllMappingsFromDb(): Promise<UserIdMapping[]> {
|
||||
const dbMappings = await this.prisma.idMapping.findMany();
|
||||
return dbMappings.map((mapping) => ({
|
||||
return dbMappings.map(mapping => ({
|
||||
userId: mapping.userId,
|
||||
whmcsClientId: mapping.whmcsClientId,
|
||||
sfAccountId: mapping.sfAccountId || undefined,
|
||||
@ -617,34 +588,23 @@ export class MappingsService {
|
||||
* Legacy method support (for backward compatibility)
|
||||
*/
|
||||
async create(data: CreateMappingRequest): Promise<UserIdMapping> {
|
||||
this.logger.warn(
|
||||
"Using legacy create method - please update to createMapping",
|
||||
);
|
||||
this.logger.warn("Using legacy create method - please update to createMapping");
|
||||
return this.createMapping(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method support (for backward compatibility)
|
||||
*/
|
||||
async createMappingLegacy(
|
||||
data: CreateMappingRequest,
|
||||
): Promise<UserIdMapping> {
|
||||
this.logger.warn(
|
||||
"Using legacy createMapping method - please update to createMapping",
|
||||
);
|
||||
async createMappingLegacy(data: CreateMappingRequest): Promise<UserIdMapping> {
|
||||
this.logger.warn("Using legacy createMapping method - please update to createMapping");
|
||||
return this.createMapping(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method support (for backward compatibility)
|
||||
*/
|
||||
async updateMappingLegacy(
|
||||
userId: string,
|
||||
updates: any,
|
||||
): Promise<UserIdMapping> {
|
||||
this.logger.warn(
|
||||
"Using legacy updateMapping method - please update to updateMapping",
|
||||
);
|
||||
async updateMappingLegacy(userId: string, updates: any): Promise<UserIdMapping> {
|
||||
this.logger.warn("Using legacy updateMapping method - please update to updateMapping");
|
||||
return this.updateMapping(userId, updates);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
CreateMappingRequest,
|
||||
UpdateMappingRequest,
|
||||
@ -8,14 +9,12 @@ import {
|
||||
|
||||
@Injectable()
|
||||
export class MappingValidatorService {
|
||||
private readonly logger = new Logger(MappingValidatorService.name);
|
||||
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
||||
|
||||
/**
|
||||
* Validate create mapping request
|
||||
*/
|
||||
validateCreateRequest(
|
||||
request: CreateMappingRequest,
|
||||
): MappingValidationResult {
|
||||
validateCreateRequest(request: CreateMappingRequest): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
@ -29,24 +28,17 @@ export class MappingValidatorService {
|
||||
// Validate WHMCS client ID
|
||||
if (!request.whmcsClientId) {
|
||||
errors.push("WHMCS client ID is required");
|
||||
} else if (
|
||||
!Number.isInteger(request.whmcsClientId) ||
|
||||
request.whmcsClientId < 1
|
||||
) {
|
||||
} else if (!Number.isInteger(request.whmcsClientId) || request.whmcsClientId < 1) {
|
||||
errors.push("WHMCS client ID must be a positive integer");
|
||||
}
|
||||
|
||||
// Validate Salesforce account ID (optional)
|
||||
if (request.sfAccountId) {
|
||||
if (!this.isValidSalesforceId(request.sfAccountId)) {
|
||||
errors.push(
|
||||
"Salesforce account ID must be a valid 15 or 18 character ID",
|
||||
);
|
||||
errors.push("Salesforce account ID must be a valid 15 or 18 character ID");
|
||||
}
|
||||
} else {
|
||||
warnings.push(
|
||||
"Salesforce account ID not provided - mapping will be incomplete",
|
||||
);
|
||||
warnings.push("Salesforce account ID not provided - mapping will be incomplete");
|
||||
}
|
||||
|
||||
return {
|
||||
@ -59,10 +51,7 @@ export class MappingValidatorService {
|
||||
/**
|
||||
* Validate update mapping request
|
||||
*/
|
||||
validateUpdateRequest(
|
||||
userId: string,
|
||||
request: UpdateMappingRequest,
|
||||
): MappingValidationResult {
|
||||
validateUpdateRequest(userId: string, request: UpdateMappingRequest): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
@ -80,23 +69,15 @@ export class MappingValidatorService {
|
||||
|
||||
// Validate WHMCS client ID (if provided)
|
||||
if (request.whmcsClientId !== undefined) {
|
||||
if (
|
||||
!Number.isInteger(request.whmcsClientId) ||
|
||||
request.whmcsClientId < 1
|
||||
) {
|
||||
if (!Number.isInteger(request.whmcsClientId) || request.whmcsClientId < 1) {
|
||||
errors.push("WHMCS client ID must be a positive integer");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Salesforce account ID (if provided)
|
||||
if (request.sfAccountId !== undefined) {
|
||||
if (
|
||||
request.sfAccountId &&
|
||||
!this.isValidSalesforceId(request.sfAccountId)
|
||||
) {
|
||||
errors.push(
|
||||
"Salesforce account ID must be a valid 15 or 18 character ID",
|
||||
);
|
||||
if (request.sfAccountId && !this.isValidSalesforceId(request.sfAccountId)) {
|
||||
errors.push("Salesforce account ID must be a valid 15 or 18 character ID");
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,37 +144,31 @@ export class MappingValidatorService {
|
||||
*/
|
||||
async validateNoConflicts(
|
||||
request: CreateMappingRequest,
|
||||
existingMappings: UserIdMapping[],
|
||||
existingMappings: UserIdMapping[]
|
||||
): Promise<MappingValidationResult> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for duplicate user ID
|
||||
const duplicateUser = existingMappings.find(
|
||||
(m) => m.userId === request.userId,
|
||||
);
|
||||
const duplicateUser = existingMappings.find(m => m.userId === request.userId);
|
||||
if (duplicateUser) {
|
||||
errors.push(`User ${request.userId} already has a mapping`);
|
||||
}
|
||||
|
||||
// Check for duplicate WHMCS client ID
|
||||
const duplicateWhmcs = existingMappings.find(
|
||||
(m) => m.whmcsClientId === request.whmcsClientId,
|
||||
);
|
||||
const duplicateWhmcs = existingMappings.find(m => m.whmcsClientId === request.whmcsClientId);
|
||||
if (duplicateWhmcs) {
|
||||
errors.push(
|
||||
`WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}`,
|
||||
`WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate Salesforce account ID
|
||||
if (request.sfAccountId) {
|
||||
const duplicateSf = existingMappings.find(
|
||||
(m) => m.sfAccountId === request.sfAccountId,
|
||||
);
|
||||
const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId);
|
||||
if (duplicateSf) {
|
||||
warnings.push(
|
||||
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`,
|
||||
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -219,12 +194,12 @@ export class MappingValidatorService {
|
||||
|
||||
// Warning about data impact
|
||||
warnings.push(
|
||||
"Deleting this mapping will prevent access to WHMCS/Salesforce data for this user",
|
||||
"Deleting this mapping will prevent access to WHMCS/Salesforce data for this user"
|
||||
);
|
||||
|
||||
if (mapping.sfAccountId) {
|
||||
warnings.push(
|
||||
"This mapping includes Salesforce integration - deletion will affect case management",
|
||||
"This mapping includes Salesforce integration - deletion will affect case management"
|
||||
);
|
||||
}
|
||||
|
||||
@ -266,8 +241,7 @@ export class MappingValidatorService {
|
||||
// Private validation helpers
|
||||
|
||||
private isValidUuid(uuid: string): boolean {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(uuid);
|
||||
}
|
||||
|
||||
@ -307,11 +281,7 @@ export class MappingValidatorService {
|
||||
/**
|
||||
* Log validation result
|
||||
*/
|
||||
logValidationResult(
|
||||
operation: string,
|
||||
validation: MappingValidationResult,
|
||||
context?: any,
|
||||
): void {
|
||||
logValidationResult(operation: string, validation: MappingValidationResult, context?: any): void {
|
||||
const summary = this.getValidationSummary(validation);
|
||||
|
||||
if (validation.isValid) {
|
||||
|
||||
@ -0,0 +1,296 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
CreateMappingRequest,
|
||||
UpdateMappingRequest,
|
||||
MappingValidationResult,
|
||||
UserIdMapping,
|
||||
} from "../types/mapping.types";
|
||||
|
||||
@Injectable()
|
||||
export class MappingValidatorService {
|
||||
private readonly logger = new Logger(MappingValidatorService.name);
|
||||
|
||||
/**
|
||||
* Validate create mapping request
|
||||
*/
|
||||
validateCreateRequest(request: CreateMappingRequest): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate user ID
|
||||
if (!request.userId) {
|
||||
errors.push("User ID is required");
|
||||
} else if (!this.isValidUuid(request.userId)) {
|
||||
errors.push("User ID must be a valid UUID");
|
||||
}
|
||||
|
||||
// Validate WHMCS client ID
|
||||
if (!request.whmcsClientId) {
|
||||
errors.push("WHMCS client ID is required");
|
||||
} else if (!Number.isInteger(request.whmcsClientId) || request.whmcsClientId < 1) {
|
||||
errors.push("WHMCS client ID must be a positive integer");
|
||||
}
|
||||
|
||||
// Validate Salesforce account ID (optional)
|
||||
if (request.sfAccountId) {
|
||||
if (!this.isValidSalesforceId(request.sfAccountId)) {
|
||||
errors.push("Salesforce account ID must be a valid 15 or 18 character ID");
|
||||
}
|
||||
} else {
|
||||
warnings.push("Salesforce account ID not provided - mapping will be incomplete");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate update mapping request
|
||||
*/
|
||||
validateUpdateRequest(userId: string, request: UpdateMappingRequest): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate user ID
|
||||
if (!userId) {
|
||||
errors.push("User ID is required");
|
||||
} else if (!this.isValidUuid(userId)) {
|
||||
errors.push("User ID must be a valid UUID");
|
||||
}
|
||||
|
||||
// Check if there's something to update
|
||||
if (!request.whmcsClientId && !request.sfAccountId) {
|
||||
errors.push("At least one field must be provided for update");
|
||||
}
|
||||
|
||||
// Validate WHMCS client ID (if provided)
|
||||
if (request.whmcsClientId !== undefined) {
|
||||
if (!Number.isInteger(request.whmcsClientId) || request.whmcsClientId < 1) {
|
||||
errors.push("WHMCS client ID must be a positive integer");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Salesforce account ID (if provided)
|
||||
if (request.sfAccountId !== undefined) {
|
||||
if (request.sfAccountId && !this.isValidSalesforceId(request.sfAccountId)) {
|
||||
errors.push("Salesforce account ID must be a valid 15 or 18 character ID");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate existing mapping for consistency
|
||||
*/
|
||||
validateExistingMapping(mapping: UserIdMapping): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate user ID
|
||||
if (!mapping.userId || !this.isValidUuid(mapping.userId)) {
|
||||
errors.push("Invalid user ID in existing mapping");
|
||||
}
|
||||
|
||||
// Validate WHMCS client ID
|
||||
if (
|
||||
!mapping.whmcsClientId ||
|
||||
!Number.isInteger(mapping.whmcsClientId) ||
|
||||
mapping.whmcsClientId < 1
|
||||
) {
|
||||
errors.push("Invalid WHMCS client ID in existing mapping");
|
||||
}
|
||||
|
||||
// Validate Salesforce account ID (if present)
|
||||
if (mapping.sfAccountId && !this.isValidSalesforceId(mapping.sfAccountId)) {
|
||||
errors.push("Invalid Salesforce account ID in existing mapping");
|
||||
}
|
||||
|
||||
// Check completeness
|
||||
if (!mapping.sfAccountId) {
|
||||
warnings.push("Mapping is missing Salesforce account ID");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate array of mappings for bulk operations
|
||||
*/
|
||||
validateBulkMappings(mappings: CreateMappingRequest[]): Array<{
|
||||
index: number;
|
||||
validation: MappingValidationResult;
|
||||
}> {
|
||||
return mappings.map((mapping, index) => ({
|
||||
index,
|
||||
validation: this.validateCreateRequest(mapping),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for potential conflicts
|
||||
*/
|
||||
async validateNoConflicts(
|
||||
request: CreateMappingRequest,
|
||||
existingMappings: UserIdMapping[]
|
||||
): Promise<MappingValidationResult> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for duplicate user ID
|
||||
const duplicateUser = existingMappings.find(m => m.userId === request.userId);
|
||||
if (duplicateUser) {
|
||||
errors.push(`User ${request.userId} already has a mapping`);
|
||||
}
|
||||
|
||||
// Check for duplicate WHMCS client ID
|
||||
const duplicateWhmcs = existingMappings.find(m => m.whmcsClientId === request.whmcsClientId);
|
||||
if (duplicateWhmcs) {
|
||||
errors.push(
|
||||
`WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate Salesforce account ID
|
||||
if (request.sfAccountId) {
|
||||
const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId);
|
||||
if (duplicateSf) {
|
||||
warnings.push(
|
||||
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate mapping before deletion
|
||||
*/
|
||||
validateDeletion(mapping: UserIdMapping): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!mapping) {
|
||||
errors.push("Cannot delete non-existent mapping");
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Warning about data impact
|
||||
warnings.push(
|
||||
"Deleting this mapping will prevent access to WHMCS/Salesforce data for this user"
|
||||
);
|
||||
|
||||
if (mapping.sfAccountId) {
|
||||
warnings.push(
|
||||
"This mapping includes Salesforce integration - deletion will affect case management"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize mapping data for safe storage
|
||||
*/
|
||||
sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
|
||||
return {
|
||||
userId: request.userId?.trim(),
|
||||
whmcsClientId: Number(request.whmcsClientId),
|
||||
sfAccountId: request.sfAccountId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize update request
|
||||
*/
|
||||
sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest {
|
||||
const sanitized: UpdateMappingRequest = {};
|
||||
|
||||
if (request.whmcsClientId !== undefined) {
|
||||
sanitized.whmcsClientId = Number(request.whmcsClientId);
|
||||
}
|
||||
|
||||
if (request.sfAccountId !== undefined) {
|
||||
sanitized.sfAccountId = request.sfAccountId?.trim() || undefined;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Private validation helpers
|
||||
|
||||
private isValidUuid(uuid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(uuid);
|
||||
}
|
||||
|
||||
private isValidSalesforceId(sfId: string): boolean {
|
||||
// Salesforce IDs are 15 or 18 characters long
|
||||
// 15-character: case-sensitive
|
||||
// 18-character: case-insensitive (includes checksum)
|
||||
if (!sfId) return false;
|
||||
|
||||
const sfIdRegex = /^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/;
|
||||
return sfIdRegex.test(sfId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation summary for logging
|
||||
*/
|
||||
getValidationSummary(validation: MappingValidationResult): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (validation.isValid) {
|
||||
parts.push("✓ Valid");
|
||||
} else {
|
||||
parts.push("✗ Invalid");
|
||||
}
|
||||
|
||||
if (validation.errors.length > 0) {
|
||||
parts.push(`${validation.errors.length} error(s)`);
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
parts.push(`${validation.warnings.length} warning(s)`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log validation result
|
||||
*/
|
||||
logValidationResult(operation: string, validation: MappingValidationResult, context?: any): void {
|
||||
const summary = this.getValidationSummary(validation);
|
||||
|
||||
if (validation.isValid) {
|
||||
this.logger.debug(`${operation} validation: ${summary}`, context);
|
||||
} else {
|
||||
this.logger.warn(`${operation} validation failed: ${summary}`, {
|
||||
...context,
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,297 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
CreateMappingRequest,
|
||||
UpdateMappingRequest,
|
||||
MappingValidationResult,
|
||||
UserIdMapping,
|
||||
} from "../types/mapping.types";
|
||||
|
||||
@Injectable()
|
||||
export class MappingValidatorService {
|
||||
|
||||
|
||||
/**
|
||||
* Validate create mapping request
|
||||
*/
|
||||
validateCreateRequest(request: CreateMappingRequest): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate user ID
|
||||
if (!request.userId) {
|
||||
errors.push("User ID is required");
|
||||
} else if (!this.isValidUuid(request.userId)) {
|
||||
errors.push("User ID must be a valid UUID");
|
||||
}
|
||||
|
||||
// Validate WHMCS client ID
|
||||
if (!request.whmcsClientId) {
|
||||
errors.push("WHMCS client ID is required");
|
||||
} else if (!Number.isInteger(request.whmcsClientId) || request.whmcsClientId < 1) {
|
||||
errors.push("WHMCS client ID must be a positive integer");
|
||||
}
|
||||
|
||||
// Validate Salesforce account ID (optional)
|
||||
if (request.sfAccountId) {
|
||||
if (!this.isValidSalesforceId(request.sfAccountId)) {
|
||||
errors.push("Salesforce account ID must be a valid 15 or 18 character ID");
|
||||
}
|
||||
} else {
|
||||
warnings.push("Salesforce account ID not provided - mapping will be incomplete");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate update mapping request
|
||||
*/
|
||||
validateUpdateRequest(userId: string, request: UpdateMappingRequest): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate user ID
|
||||
if (!userId) {
|
||||
errors.push("User ID is required");
|
||||
} else if (!this.isValidUuid(userId)) {
|
||||
errors.push("User ID must be a valid UUID");
|
||||
}
|
||||
|
||||
// Check if there's something to update
|
||||
if (!request.whmcsClientId && !request.sfAccountId) {
|
||||
errors.push("At least one field must be provided for update");
|
||||
}
|
||||
|
||||
// Validate WHMCS client ID (if provided)
|
||||
if (request.whmcsClientId !== undefined) {
|
||||
if (!Number.isInteger(request.whmcsClientId) || request.whmcsClientId < 1) {
|
||||
errors.push("WHMCS client ID must be a positive integer");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Salesforce account ID (if provided)
|
||||
if (request.sfAccountId !== undefined) {
|
||||
if (request.sfAccountId && !this.isValidSalesforceId(request.sfAccountId)) {
|
||||
errors.push("Salesforce account ID must be a valid 15 or 18 character ID");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate existing mapping for consistency
|
||||
*/
|
||||
validateExistingMapping(mapping: UserIdMapping): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate user ID
|
||||
if (!mapping.userId || !this.isValidUuid(mapping.userId)) {
|
||||
errors.push("Invalid user ID in existing mapping");
|
||||
}
|
||||
|
||||
// Validate WHMCS client ID
|
||||
if (
|
||||
!mapping.whmcsClientId ||
|
||||
!Number.isInteger(mapping.whmcsClientId) ||
|
||||
mapping.whmcsClientId < 1
|
||||
) {
|
||||
errors.push("Invalid WHMCS client ID in existing mapping");
|
||||
}
|
||||
|
||||
// Validate Salesforce account ID (if present)
|
||||
if (mapping.sfAccountId && !this.isValidSalesforceId(mapping.sfAccountId)) {
|
||||
errors.push("Invalid Salesforce account ID in existing mapping");
|
||||
}
|
||||
|
||||
// Check completeness
|
||||
if (!mapping.sfAccountId) {
|
||||
warnings.push("Mapping is missing Salesforce account ID");
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate array of mappings for bulk operations
|
||||
*/
|
||||
validateBulkMappings(mappings: CreateMappingRequest[]): Array<{
|
||||
index: number;
|
||||
validation: MappingValidationResult;
|
||||
}> {
|
||||
return mappings.map((mapping, index) => ({
|
||||
index,
|
||||
validation: this.validateCreateRequest(mapping),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for potential conflicts
|
||||
*/
|
||||
async validateNoConflicts(
|
||||
request: CreateMappingRequest,
|
||||
existingMappings: UserIdMapping[]
|
||||
): Promise<MappingValidationResult> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for duplicate user ID
|
||||
const duplicateUser = existingMappings.find(m => m.userId === request.userId);
|
||||
if (duplicateUser) {
|
||||
errors.push(`User ${request.userId} already has a mapping`);
|
||||
}
|
||||
|
||||
// Check for duplicate WHMCS client ID
|
||||
const duplicateWhmcs = existingMappings.find(m => m.whmcsClientId === request.whmcsClientId);
|
||||
if (duplicateWhmcs) {
|
||||
errors.push(
|
||||
`WHMCS client ${request.whmcsClientId} is already mapped to user ${duplicateWhmcs.userId}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate Salesforce account ID
|
||||
if (request.sfAccountId) {
|
||||
const duplicateSf = existingMappings.find(m => m.sfAccountId === request.sfAccountId);
|
||||
if (duplicateSf) {
|
||||
warnings.push(
|
||||
`Salesforce account ${request.sfAccountId} is already mapped to user ${duplicateSf.userId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate mapping before deletion
|
||||
*/
|
||||
validateDeletion(mapping: UserIdMapping): MappingValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!mapping) {
|
||||
errors.push("Cannot delete non-existent mapping");
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Warning about data impact
|
||||
warnings.push(
|
||||
"Deleting this mapping will prevent access to WHMCS/Salesforce data for this user"
|
||||
);
|
||||
|
||||
if (mapping.sfAccountId) {
|
||||
warnings.push(
|
||||
"This mapping includes Salesforce integration - deletion will affect case management"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize mapping data for safe storage
|
||||
*/
|
||||
sanitizeCreateRequest(request: CreateMappingRequest): CreateMappingRequest {
|
||||
return {
|
||||
userId: request.userId?.trim(),
|
||||
whmcsClientId: Number(request.whmcsClientId),
|
||||
sfAccountId: request.sfAccountId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize update request
|
||||
*/
|
||||
sanitizeUpdateRequest(request: UpdateMappingRequest): UpdateMappingRequest {
|
||||
const sanitized: UpdateMappingRequest = {};
|
||||
|
||||
if (request.whmcsClientId !== undefined) {
|
||||
sanitized.whmcsClientId = Number(request.whmcsClientId);
|
||||
}
|
||||
|
||||
if (request.sfAccountId !== undefined) {
|
||||
sanitized.sfAccountId = request.sfAccountId?.trim() || undefined;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Private validation helpers
|
||||
|
||||
private isValidUuid(uuid: string): boolean {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(uuid);
|
||||
}
|
||||
|
||||
private isValidSalesforceId(sfId: string): boolean {
|
||||
// Salesforce IDs are 15 or 18 characters long
|
||||
// 15-character: case-sensitive
|
||||
// 18-character: case-insensitive (includes checksum)
|
||||
if (!sfId) return false;
|
||||
|
||||
const sfIdRegex = /^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/;
|
||||
return sfIdRegex.test(sfId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation summary for logging
|
||||
*/
|
||||
getValidationSummary(validation: MappingValidationResult): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (validation.isValid) {
|
||||
parts.push("✓ Valid");
|
||||
} else {
|
||||
parts.push("✗ Invalid");
|
||||
}
|
||||
|
||||
if (validation.errors.length > 0) {
|
||||
parts.push(`${validation.errors.length} error(s)`);
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
parts.push(`${validation.warnings.length} warning(s)`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log validation result
|
||||
*/
|
||||
logValidationResult(operation: string, validation: MappingValidationResult, context?: any): void {
|
||||
const summary = this.getValidationSummary(validation);
|
||||
|
||||
if (validation.isValid) {
|
||||
this.logger.debug(`${operation} validation: ${summary}`, context);
|
||||
} else {
|
||||
this.logger.warn(`${operation} validation failed: ${summary}`, {
|
||||
...context,
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,89 +1,93 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiQuery,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { SubscriptionsService } from './subscriptions.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { Subscription, SubscriptionList, InvoiceList } from '@customer-portal/shared';
|
||||
} from "@nestjs/swagger";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
||||
import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/shared";
|
||||
|
||||
@ApiTags('subscriptions')
|
||||
@Controller('subscriptions')
|
||||
@ApiTags("subscriptions")
|
||||
@Controller("subscriptions")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class SubscriptionsController {
|
||||
constructor(private readonly subscriptionsService: SubscriptionsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Get all user subscriptions',
|
||||
description: 'Retrieves all subscriptions/services for the authenticated user'
|
||||
@ApiOperation({
|
||||
summary: "Get all user subscriptions",
|
||||
description: "Retrieves all subscriptions/services for the authenticated user",
|
||||
})
|
||||
@ApiQuery({ name: 'status', required: false, type: String, description: 'Filter by subscription status' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of user subscriptions',
|
||||
@ApiQuery({
|
||||
name: "status",
|
||||
required: false,
|
||||
type: String,
|
||||
description: "Filter by subscription status",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of user subscriptions",
|
||||
type: Object, // Would be SubscriptionList if we had proper DTO decorators
|
||||
})
|
||||
async getSubscriptions(
|
||||
@Request() req: any,
|
||||
@Query('status') status?: string,
|
||||
@Query("status") status?: string
|
||||
): Promise<SubscriptionList | Subscription[]> {
|
||||
// Validate status if provided
|
||||
if (status && !['Active', 'Suspended', 'Terminated', 'Cancelled', 'Pending'].includes(status)) {
|
||||
throw new BadRequestException('Invalid status filter');
|
||||
if (status && !["Active", "Suspended", "Terminated", "Cancelled", "Pending"].includes(status)) {
|
||||
throw new BadRequestException("Invalid status filter");
|
||||
}
|
||||
|
||||
if (status) {
|
||||
const subscriptions = await this.subscriptionsService.getSubscriptionsByStatus(req.user.id, status);
|
||||
const subscriptions = await this.subscriptionsService.getSubscriptionsByStatus(
|
||||
req.user.id,
|
||||
status
|
||||
);
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
|
||||
return this.subscriptionsService.getSubscriptions(req.user.id);
|
||||
}
|
||||
|
||||
@Get('active')
|
||||
@ApiOperation({
|
||||
summary: 'Get active subscriptions only',
|
||||
description: 'Retrieves only active subscriptions for the authenticated user'
|
||||
@Get("active")
|
||||
@ApiOperation({
|
||||
summary: "Get active subscriptions only",
|
||||
description: "Retrieves only active subscriptions for the authenticated user",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of active subscriptions',
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of active subscriptions",
|
||||
type: [Object], // Would be Subscription[] if we had proper DTO decorators
|
||||
})
|
||||
async getActiveSubscriptions(
|
||||
@Request() req: any,
|
||||
): Promise<Subscription[]> {
|
||||
async getActiveSubscriptions(@Request() req: any): Promise<Subscription[]> {
|
||||
return this.subscriptionsService.getActiveSubscriptions(req.user.id);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({
|
||||
summary: 'Get subscription statistics',
|
||||
description: 'Retrieves subscription count statistics by status'
|
||||
@Get("stats")
|
||||
@ApiOperation({
|
||||
summary: "Get subscription statistics",
|
||||
description: "Retrieves subscription count statistics by status",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Subscription statistics',
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Subscription statistics",
|
||||
type: Object,
|
||||
})
|
||||
async getSubscriptionStats(
|
||||
@Request() req: any,
|
||||
): Promise<{
|
||||
async getSubscriptionStats(@Request() req: any): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
suspended: number;
|
||||
@ -93,70 +97,83 @@ export class SubscriptionsController {
|
||||
return this.subscriptionsService.getSubscriptionStats(req.user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Get subscription details by ID',
|
||||
description: 'Retrieves detailed information for a specific subscription'
|
||||
@Get(":id")
|
||||
@ApiOperation({
|
||||
summary: "Get subscription details by ID",
|
||||
description: "Retrieves detailed information for a specific subscription",
|
||||
})
|
||||
@ApiParam({ name: 'id', type: Number, description: 'Subscription ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Subscription details',
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Subscription details",
|
||||
type: Object, // Would be Subscription if we had proper DTO decorators
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Subscription not found' })
|
||||
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||
async getSubscriptionById(
|
||||
@Request() req: any,
|
||||
@Param('id', ParseIntPipe) subscriptionId: number,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
if (subscriptionId <= 0) {
|
||||
throw new BadRequestException('Subscription ID must be a positive number');
|
||||
throw new BadRequestException("Subscription ID must be a positive number");
|
||||
}
|
||||
|
||||
|
||||
return this.subscriptionsService.getSubscriptionById(req.user.id, subscriptionId);
|
||||
}
|
||||
|
||||
@Get(':id/invoices')
|
||||
@ApiOperation({
|
||||
summary: 'Get invoices for a specific subscription',
|
||||
description: 'Retrieves all invoices related to a specific subscription'
|
||||
@Get(":id/invoices")
|
||||
@ApiOperation({
|
||||
summary: "Get invoices for a specific subscription",
|
||||
description: "Retrieves all invoices related to a specific subscription",
|
||||
})
|
||||
@ApiParam({ name: 'id', type: Number, description: 'Subscription ID' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 10)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'List of invoices for the subscription',
|
||||
@ApiParam({ name: "id", type: Number, description: "Subscription ID" })
|
||||
@ApiQuery({
|
||||
name: "page",
|
||||
required: false,
|
||||
type: Number,
|
||||
description: "Page number (default: 1)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "limit",
|
||||
required: false,
|
||||
type: Number,
|
||||
description: "Items per page (default: 10)",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of invoices for the subscription",
|
||||
type: Object, // Would be InvoiceList if we had proper DTO decorators
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Subscription not found' })
|
||||
@ApiResponse({ status: 404, description: "Subscription not found" })
|
||||
async getSubscriptionInvoices(
|
||||
@Request() req: any,
|
||||
@Param('id', ParseIntPipe) subscriptionId: number,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||
@Query("page") page?: string,
|
||||
@Query("limit") limit?: string
|
||||
): Promise<InvoiceList> {
|
||||
if (subscriptionId <= 0) {
|
||||
throw new BadRequestException('Subscription ID must be a positive number');
|
||||
throw new BadRequestException("Subscription ID must be a positive number");
|
||||
}
|
||||
|
||||
// Validate and sanitize input
|
||||
const pageNum = this.validatePositiveInteger(page, 1, 'page');
|
||||
const limitNum = this.validatePositiveInteger(limit, 10, 'limit');
|
||||
|
||||
const pageNum = this.validatePositiveInteger(page, 1, "page");
|
||||
const limitNum = this.validatePositiveInteger(limit, 10, "limit");
|
||||
|
||||
// Limit max page size for performance
|
||||
if (limitNum > 100) {
|
||||
throw new BadRequestException('Limit cannot exceed 100 items per page');
|
||||
throw new BadRequestException("Limit cannot exceed 100 items per page");
|
||||
}
|
||||
|
||||
return this.subscriptionsService.getSubscriptionInvoices(
|
||||
req.user.id,
|
||||
subscriptionId,
|
||||
{ page: pageNum, limit: limitNum }
|
||||
);
|
||||
|
||||
return this.subscriptionsService.getSubscriptionInvoices(req.user.id, subscriptionId, {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
});
|
||||
}
|
||||
|
||||
private validatePositiveInteger(value: string | undefined, defaultValue: number, fieldName: string): number {
|
||||
private validatePositiveInteger(
|
||||
value: string | undefined,
|
||||
defaultValue: number,
|
||||
fieldName: string
|
||||
): number {
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
@ -1,12 +1,9 @@
|
||||
import { getErrorMessage } from "../common/utils/error.util";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
InvoiceList,
|
||||
} from "@customer-portal/shared";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/shared";
|
||||
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
|
||||
import { MappingsService } from "../mappings/mappings.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
export interface GetSubscriptionsOptions {
|
||||
status?: string;
|
||||
@ -14,11 +11,10 @@ export interface GetSubscriptionsOptions {
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionsService {
|
||||
private readonly logger = new Logger(SubscriptionsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly mappingsService: MappingsService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -26,7 +22,7 @@ export class SubscriptionsService {
|
||||
*/
|
||||
async getSubscriptions(
|
||||
userId: string,
|
||||
options: GetSubscriptionsOptions = {},
|
||||
options: GetSubscriptionsOptions = {}
|
||||
): Promise<SubscriptionList> {
|
||||
const { status } = options;
|
||||
|
||||
@ -41,7 +37,7 @@ export class SubscriptionsService {
|
||||
const subscriptionList = await this.whmcsService.getSubscriptions(
|
||||
mapping.whmcsClientId,
|
||||
userId,
|
||||
{ status },
|
||||
{ status }
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
@ -49,7 +45,7 @@ export class SubscriptionsService {
|
||||
{
|
||||
status,
|
||||
totalCount: subscriptionList.totalCount,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return subscriptionList;
|
||||
@ -63,19 +59,14 @@ export class SubscriptionsService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to retrieve subscriptions: ${getErrorMessage(error)}`,
|
||||
);
|
||||
throw new Error(`Failed to retrieve subscriptions: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual subscription by ID
|
||||
*/
|
||||
async getSubscriptionById(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
): Promise<Subscription> {
|
||||
async getSubscriptionById(userId: string, subscriptionId: number): Promise<Subscription> {
|
||||
try {
|
||||
// Validate subscription ID
|
||||
if (!subscriptionId || subscriptionId < 1) {
|
||||
@ -92,35 +83,27 @@ export class SubscriptionsService {
|
||||
const subscription = await this.whmcsService.getSubscriptionById(
|
||||
mapping.whmcsClientId,
|
||||
userId,
|
||||
subscriptionId,
|
||||
subscriptionId
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Retrieved subscription ${subscriptionId} for user ${userId}`,
|
||||
{
|
||||
productName: subscription.productName,
|
||||
status: subscription.status,
|
||||
amount: subscription.amount,
|
||||
currency: subscription.currency,
|
||||
},
|
||||
);
|
||||
this.logger.log(`Retrieved subscription ${subscriptionId} for user ${userId}`, {
|
||||
productName: subscription.productName,
|
||||
status: subscription.status,
|
||||
amount: subscription.amount,
|
||||
currency: subscription.currency,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get subscription ${subscriptionId} for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get subscription ${subscriptionId} for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to retrieve subscription: ${getErrorMessage(error)}`,
|
||||
);
|
||||
throw new Error(`Failed to retrieve subscription: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,12 +117,9 @@ export class SubscriptionsService {
|
||||
});
|
||||
return subscriptionList.subscriptions;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get active subscriptions for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get active subscriptions for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -147,10 +127,7 @@ export class SubscriptionsService {
|
||||
/**
|
||||
* Get subscriptions by status
|
||||
*/
|
||||
async getSubscriptionsByStatus(
|
||||
userId: string,
|
||||
status: string,
|
||||
): Promise<Subscription[]> {
|
||||
async getSubscriptionsByStatus(userId: string, status: string): Promise<Subscription[]> {
|
||||
try {
|
||||
// Validate status
|
||||
const validStatuses = [
|
||||
@ -162,20 +139,15 @@ export class SubscriptionsService {
|
||||
"Completed",
|
||||
];
|
||||
if (!validStatuses.includes(status)) {
|
||||
throw new Error(
|
||||
`Invalid status. Must be one of: ${validStatuses.join(", ")}`,
|
||||
);
|
||||
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(", ")}`);
|
||||
}
|
||||
|
||||
const subscriptionList = await this.getSubscriptions(userId, { status });
|
||||
return subscriptionList.subscriptions;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get ${status} subscriptions for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -225,7 +197,7 @@ export class SubscriptionsService {
|
||||
// Get basic stats from WHMCS service
|
||||
const basicStats = await this.whmcsService.getSubscriptionStats(
|
||||
mapping.whmcsClientId,
|
||||
userId,
|
||||
userId
|
||||
);
|
||||
|
||||
// Get all subscriptions for financial calculations
|
||||
@ -234,16 +206,16 @@ export class SubscriptionsService {
|
||||
|
||||
// Calculate revenue metrics
|
||||
const totalMonthlyRevenue = subscriptions
|
||||
.filter((s) => s.cycle === "Monthly")
|
||||
.filter(s => s.cycle === "Monthly")
|
||||
.reduce((sum, s) => sum + s.amount, 0);
|
||||
|
||||
const activeMonthlyRevenue = subscriptions
|
||||
.filter((s) => s.status === "Active" && s.cycle === "Monthly")
|
||||
.filter(s => s.status === "Active" && s.cycle === "Monthly")
|
||||
.reduce((sum, s) => sum + s.amount, 0);
|
||||
|
||||
const stats = {
|
||||
...basicStats,
|
||||
completed: subscriptions.filter((s) => s.status === "Completed").length,
|
||||
completed: subscriptions.filter(s => s.status === "Completed").length,
|
||||
totalMonthlyRevenue,
|
||||
activeMonthlyRevenue,
|
||||
currency: subscriptions[0]?.currency || "USD",
|
||||
@ -258,12 +230,9 @@ export class SubscriptionsService {
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to generate subscription stats for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to generate subscription stats for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -271,10 +240,7 @@ export class SubscriptionsService {
|
||||
/**
|
||||
* Get subscriptions expiring soon (within next 30 days)
|
||||
*/
|
||||
async getExpiringSoon(
|
||||
userId: string,
|
||||
days: number = 30,
|
||||
): Promise<Subscription[]> {
|
||||
async getExpiringSoon(userId: string, days: number = 30): Promise<Subscription[]> {
|
||||
try {
|
||||
const subscriptionList = await this.getSubscriptions(userId);
|
||||
const subscriptions = subscriptionList.subscriptions;
|
||||
@ -282,7 +248,7 @@ export class SubscriptionsService {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() + days);
|
||||
|
||||
const expiringSoon = subscriptions.filter((subscription) => {
|
||||
const expiringSoon = subscriptions.filter(subscription => {
|
||||
if (!subscription.nextDue || subscription.status !== "Active") {
|
||||
return false;
|
||||
}
|
||||
@ -292,17 +258,14 @@ export class SubscriptionsService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Found ${expiringSoon.length} subscriptions expiring within ${days} days for user ${userId}`,
|
||||
`Found ${expiringSoon.length} subscriptions expiring within ${days} days for user ${userId}`
|
||||
);
|
||||
return expiringSoon;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get expiring subscriptions for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
days,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get expiring subscriptions for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
days,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -310,10 +273,7 @@ export class SubscriptionsService {
|
||||
/**
|
||||
* Get recent subscription activity (newly created or status changed)
|
||||
*/
|
||||
async getRecentActivity(
|
||||
userId: string,
|
||||
days: number = 30,
|
||||
): Promise<Subscription[]> {
|
||||
async getRecentActivity(userId: string, days: number = 30): Promise<Subscription[]> {
|
||||
try {
|
||||
const subscriptionList = await this.getSubscriptions(userId);
|
||||
const subscriptions = subscriptionList.subscriptions;
|
||||
@ -321,23 +281,20 @@ export class SubscriptionsService {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||
|
||||
const recentActivity = subscriptions.filter((subscription) => {
|
||||
const recentActivity = subscriptions.filter(subscription => {
|
||||
const registrationDate = new Date(subscription.registrationDate);
|
||||
return registrationDate >= cutoffDate;
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Found ${recentActivity.length} recent subscription activities within ${days} days for user ${userId}`,
|
||||
`Found ${recentActivity.length} recent subscription activities within ${days} days for user ${userId}`
|
||||
);
|
||||
return recentActivity;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get recent subscription activity for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
days,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get recent subscription activity for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
days,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -345,10 +302,7 @@ export class SubscriptionsService {
|
||||
/**
|
||||
* Search subscriptions by product name or domain
|
||||
*/
|
||||
async searchSubscriptions(
|
||||
userId: string,
|
||||
query: string,
|
||||
): Promise<Subscription[]> {
|
||||
async searchSubscriptions(userId: string, query: string): Promise<Subscription[]> {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
throw new Error("Search query must be at least 2 characters long");
|
||||
@ -358,7 +312,7 @@ export class SubscriptionsService {
|
||||
const subscriptions = subscriptionList.subscriptions;
|
||||
|
||||
const searchTerm = query.toLowerCase().trim();
|
||||
const matches = subscriptions.filter((subscription) => {
|
||||
const matches = subscriptions.filter(subscription => {
|
||||
const productName = subscription.productName.toLowerCase();
|
||||
const domain = subscription.domain?.toLowerCase() || "";
|
||||
|
||||
@ -366,7 +320,7 @@ export class SubscriptionsService {
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Found ${matches.length} subscriptions matching query "${query}" for user ${userId}`,
|
||||
`Found ${matches.length} subscriptions matching query "${query}" for user ${userId}`
|
||||
);
|
||||
return matches;
|
||||
} catch (error) {
|
||||
@ -384,7 +338,7 @@ export class SubscriptionsService {
|
||||
async getSubscriptionInvoices(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
options: { page?: number; limit?: number } = {},
|
||||
options: { page?: number; limit?: number } = {}
|
||||
): Promise<InvoiceList> {
|
||||
const { page = 1, limit = 10 } = options;
|
||||
|
||||
@ -403,7 +357,7 @@ export class SubscriptionsService {
|
||||
const allInvoices = await this.whmcsService.getInvoicesWithItems(
|
||||
mapping.whmcsClientId,
|
||||
userId,
|
||||
{ page: 1, limit: 1000 }, // Get more to filter locally
|
||||
{ page: 1, limit: 1000 } // Get more to filter locally
|
||||
);
|
||||
|
||||
// Filter invoices that have items related to this subscription
|
||||
@ -412,28 +366,27 @@ export class SubscriptionsService {
|
||||
`Filtering ${allInvoices.invoices.length} invoices for subscription ${subscriptionId}`,
|
||||
{
|
||||
totalInvoices: allInvoices.invoices.length,
|
||||
invoicesWithItems: allInvoices.invoices.filter(
|
||||
(inv) => inv.items && inv.items.length > 0,
|
||||
).length,
|
||||
invoicesWithItems: allInvoices.invoices.filter(inv => inv.items && inv.items.length > 0)
|
||||
.length,
|
||||
subscriptionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const relatedInvoices = allInvoices.invoices.filter((invoice) => {
|
||||
const relatedInvoices = allInvoices.invoices.filter(invoice => {
|
||||
const hasItems = invoice.items && invoice.items.length > 0;
|
||||
if (!hasItems) {
|
||||
this.logger.debug(`Invoice ${invoice.id} has no items`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasMatchingService = invoice.items?.some((item) => {
|
||||
const hasMatchingService = invoice.items?.some(item => {
|
||||
this.logger.debug(
|
||||
`Checking item: serviceId=${item.serviceId}, subscriptionId=${subscriptionId}`,
|
||||
{
|
||||
itemServiceId: item.serviceId,
|
||||
subscriptionId,
|
||||
matches: item.serviceId === subscriptionId,
|
||||
},
|
||||
}
|
||||
);
|
||||
return item.serviceId === subscriptionId;
|
||||
});
|
||||
@ -463,59 +416,45 @@ export class SubscriptionsService {
|
||||
totalRelated: relatedInvoices.length,
|
||||
page,
|
||||
limit,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get invoices for subscription ${subscriptionId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
options,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
subscriptionId,
|
||||
options,
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to retrieve subscription invoices: ${getErrorMessage(error)}`,
|
||||
);
|
||||
throw new Error(`Failed to retrieve subscription invoices: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate subscription cache for a user
|
||||
*/
|
||||
async invalidateCache(
|
||||
userId: string,
|
||||
subscriptionId?: number,
|
||||
): Promise<void> {
|
||||
async invalidateCache(userId: string, subscriptionId?: number): Promise<void> {
|
||||
try {
|
||||
if (subscriptionId) {
|
||||
await this.whmcsService.invalidateSubscriptionCache(
|
||||
userId,
|
||||
subscriptionId,
|
||||
);
|
||||
await this.whmcsService.invalidateSubscriptionCache(userId, subscriptionId);
|
||||
} else {
|
||||
await this.whmcsService.invalidateUserCache(userId);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Invalidated subscription cache for user ${userId}${subscriptionId ? `, subscription ${subscriptionId}` : ""}`,
|
||||
`Invalidated subscription cache for user ${userId}${subscriptionId ? `, subscription ${subscriptionId}` : ""}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to invalidate subscription cache for user ${userId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
subscriptionId,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to invalidate subscription cache for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
subscriptionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
54
apps/bff/src/users/dto/update-billing.dto.ts
Normal file
54
apps/bff/src/users/dto/update-billing.dto.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { IsOptional, IsString, IsEmail, Length, Matches } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class UpdateBillingDto {
|
||||
@ApiProperty({ description: "Billing company name", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
company?: string;
|
||||
|
||||
@ApiProperty({ description: "Billing address street", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 200)
|
||||
street?: string;
|
||||
|
||||
@ApiProperty({ description: "Billing address city", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
city?: string;
|
||||
|
||||
@ApiProperty({ description: "Billing address state/province", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
state?: string;
|
||||
|
||||
@ApiProperty({ description: "Billing address postal code", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 20)
|
||||
postalCode?: string;
|
||||
|
||||
@ApiProperty({ description: "Billing address country", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
country?: string;
|
||||
|
||||
@ApiProperty({ description: "Billing phone number", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 20)
|
||||
@Matches(/^[+]?[1-9][\d]{0,15}$/, {
|
||||
message: "Phone number must be a valid international format",
|
||||
})
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ description: "Billing email address", required: false })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
}
|
||||
36
apps/bff/src/users/dto/update-user.dto.ts
Normal file
36
apps/bff/src/users/dto/update-user.dto.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { IsOptional, IsString, IsEmail, Length, Matches } from "class-validator";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiProperty({ description: "User's first name", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
firstName?: string;
|
||||
|
||||
@ApiProperty({ description: "User's last name", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(1, 50)
|
||||
lastName?: string;
|
||||
|
||||
@ApiProperty({ description: "User's company name", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 100)
|
||||
company?: string;
|
||||
|
||||
@ApiProperty({ description: "User's phone number", required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 20)
|
||||
@Matches(/^[+]?[1-9][\d]{0,15}$/, {
|
||||
message: "Phone number must be a valid international format",
|
||||
})
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ description: "User's email address", required: false })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
}
|
||||
@ -1,35 +1,58 @@
|
||||
import { Controller, Get, Patch, Body, UseGuards, Req } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
UseInterceptors,
|
||||
ClassSerializerInterceptor,
|
||||
} from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
|
||||
import { ApiTags, ApiOperation } from "@nestjs/swagger";
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import { UpdateBillingDto } from "./dto/update-billing.dto";
|
||||
|
||||
@ApiTags("users")
|
||||
@Controller("me")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@UseInterceptors(ClassSerializerInterceptor)
|
||||
export class UsersController {
|
||||
constructor(private usersService: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "Get current user profile" })
|
||||
@ApiResponse({ status: 200, description: "User profile retrieved successfully" })
|
||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||
async getProfile(@Req() req: any) {
|
||||
return this.usersService.findById(req.user.id);
|
||||
}
|
||||
|
||||
@Get("summary")
|
||||
@ApiOperation({ summary: "Get user dashboard summary" })
|
||||
@ApiResponse({ status: 200, description: "User summary retrieved successfully" })
|
||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||
async getSummary(@Req() req: any) {
|
||||
return this.usersService.getUserSummary(req.user.id);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@ApiOperation({ summary: "Update user profile" })
|
||||
async updateProfile(@Req() req: any, @Body() updateData: any) {
|
||||
@ApiResponse({ status: 200, description: "Profile updated successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid input data" })
|
||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||
async updateProfile(@Req() req: any, @Body() updateData: UpdateUserDto) {
|
||||
return this.usersService.update(req.user.id, updateData);
|
||||
}
|
||||
|
||||
@Patch("billing")
|
||||
@ApiOperation({ summary: "Update billing information" })
|
||||
async updateBilling(@Req() req: any, @Body() billingData: any) {
|
||||
@ApiResponse({ status: 200, description: "Billing information updated successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid input data" })
|
||||
@ApiResponse({ status: 401, description: "Unauthorized" })
|
||||
async updateBilling(@Req() _req: any, @Body() _billingData: UpdateBillingDto) {
|
||||
// TODO: Sync to WHMCS custom fields
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
|
||||
@ -20,7 +20,6 @@ export interface EnhancedUser extends Omit<User, "createdAt" | "updatedAt"> {
|
||||
buildingName?: string | null;
|
||||
roomNumber?: string | null;
|
||||
};
|
||||
salesforceAccountId?: string;
|
||||
}
|
||||
|
||||
interface UserUpdateData {
|
||||
@ -50,14 +49,11 @@ export class UsersService {
|
||||
private whmcsService: WhmcsService,
|
||||
private salesforceService: SalesforceService,
|
||||
private mappingsService: MappingsService,
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
// Helper function to convert Prisma user to EnhancedUser type
|
||||
private toEnhancedUser(
|
||||
user: any,
|
||||
extras: Partial<EnhancedUser> = {},
|
||||
): EnhancedUser {
|
||||
private toEnhancedUser(user: any, extras: Partial<EnhancedUser> = {}): EnhancedUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@ -92,8 +88,7 @@ export class UsersService {
|
||||
private validateEmail(email: string): string {
|
||||
const trimmed = email?.toLowerCase().trim();
|
||||
if (!trimmed) throw new Error("Email is required");
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed))
|
||||
throw new Error("Invalid email format");
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) throw new Error("Invalid email format");
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@ -101,9 +96,7 @@ export class UsersService {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed) throw new Error("User ID is required");
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
trimmed,
|
||||
)
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(trimmed)
|
||||
) {
|
||||
throw new Error("Invalid user ID format");
|
||||
}
|
||||
@ -155,9 +148,10 @@ export class UsersService {
|
||||
try {
|
||||
return await this.getEnhancedProfile(validId);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
"Failed to fetch Salesforce data, returning basic user data",
|
||||
);
|
||||
this.logger.warn("Failed to fetch Salesforce data, returning basic user data", {
|
||||
error: getErrorMessage(error),
|
||||
userId: validId,
|
||||
});
|
||||
return this.toEnhancedUser(user);
|
||||
}
|
||||
} catch (error) {
|
||||
@ -176,16 +170,13 @@ export class UsersService {
|
||||
if (!mapping?.sfAccountId) return this.toEnhancedUser(user);
|
||||
|
||||
try {
|
||||
const account = await this.salesforceService.getAccount(
|
||||
mapping.sfAccountId,
|
||||
);
|
||||
const account = await this.salesforceService.getAccount(mapping.sfAccountId);
|
||||
if (!account) return this.toEnhancedUser(user);
|
||||
|
||||
return this.toEnhancedUser(user, {
|
||||
company: account.Name?.trim() || user.company,
|
||||
email: user.email, // Keep original email for now
|
||||
phone: user.phone || undefined, // Keep original phone for now
|
||||
salesforceAccountId: account.Id,
|
||||
// Address temporarily disabled until field issues resolved
|
||||
});
|
||||
} catch (error) {
|
||||
@ -196,7 +187,7 @@ export class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
private hasAddress(account: any): boolean {
|
||||
private hasAddress(_account: any): boolean {
|
||||
// Temporarily disabled until field mapping is resolved
|
||||
return false;
|
||||
}
|
||||
@ -207,8 +198,7 @@ export class UsersService {
|
||||
street: account.PersonMailingStreet || account.BillingStreet || null,
|
||||
city: account.PersonMailingCity || account.BillingCity || null,
|
||||
state: account.PersonMailingState || account.BillingState || null,
|
||||
postalCode:
|
||||
account.PersonMailingPostalCode || account.BillingPostalCode || null,
|
||||
postalCode: account.PersonMailingPostalCode || account.BillingPostalCode || null,
|
||||
country: account.PersonMailingCountry || account.BillingCountry || null,
|
||||
buildingName: account.BuildingName__pc || account.BuildingName__c || null,
|
||||
roomNumber: account.RoomNumber__pc || account.RoomNumber__c || null,
|
||||
@ -243,10 +233,10 @@ export class UsersService {
|
||||
});
|
||||
|
||||
// Try to sync to Salesforce (non-blocking)
|
||||
this.syncToSalesforce(validId, userData).catch((error) =>
|
||||
this.syncToSalesforce(validId, userData).catch(error =>
|
||||
this.logger.warn("Failed to sync to Salesforce", {
|
||||
error: getErrorMessage(error),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
return this.toUser(updatedUser);
|
||||
@ -270,22 +260,16 @@ export class UsersService {
|
||||
sanitized.phone = userData.phone?.trim().substring(0, 20) || null;
|
||||
|
||||
// Handle authentication-related fields
|
||||
if (userData.passwordHash !== undefined)
|
||||
sanitized.passwordHash = userData.passwordHash;
|
||||
if (userData.passwordHash !== undefined) sanitized.passwordHash = userData.passwordHash;
|
||||
if (userData.failedLoginAttempts !== undefined)
|
||||
sanitized.failedLoginAttempts = userData.failedLoginAttempts;
|
||||
if (userData.lastLoginAt !== undefined)
|
||||
sanitized.lastLoginAt = userData.lastLoginAt;
|
||||
if (userData.lockedUntil !== undefined)
|
||||
sanitized.lockedUntil = userData.lockedUntil;
|
||||
if (userData.lastLoginAt !== undefined) sanitized.lastLoginAt = userData.lastLoginAt;
|
||||
if (userData.lockedUntil !== undefined) sanitized.lockedUntil = userData.lockedUntil;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async syncToSalesforce(
|
||||
userId: string,
|
||||
userData: UserUpdateData,
|
||||
): Promise<void> {
|
||||
async syncToSalesforce(userId: string, userData: UserUpdateData): Promise<void> {
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.sfAccountId) return;
|
||||
|
||||
@ -293,10 +277,7 @@ export class UsersService {
|
||||
if (Object.keys(salesforceUpdate).length === 0) return;
|
||||
|
||||
try {
|
||||
await this.salesforceService.updateAccount(
|
||||
mapping.sfAccountId,
|
||||
salesforceUpdate,
|
||||
);
|
||||
await this.salesforceService.updateAccount(mapping.sfAccountId, salesforceUpdate);
|
||||
this.logger.debug("Successfully synced to Salesforce", {
|
||||
fieldsUpdated: Object.keys(salesforceUpdate),
|
||||
});
|
||||
@ -391,21 +372,18 @@ export class UsersService {
|
||||
let recentSubscriptions: any[] = [];
|
||||
if (subscriptionsData.status === "fulfilled") {
|
||||
const subscriptions = subscriptionsData.value.subscriptions;
|
||||
activeSubscriptions = subscriptions.filter(
|
||||
(sub: any) => sub.status === "Active",
|
||||
).length;
|
||||
activeSubscriptions = subscriptions.filter((sub: any) => sub.status === "Active").length;
|
||||
recentSubscriptions = subscriptions
|
||||
.filter((sub: any) => sub.status === "Active")
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(b.registrationDate).getTime() -
|
||||
new Date(a.registrationDate).getTime(),
|
||||
new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime()
|
||||
)
|
||||
.slice(0, 3);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to fetch subscriptions for user ${userId}:`,
|
||||
subscriptionsData.reason,
|
||||
subscriptionsData.reason
|
||||
);
|
||||
}
|
||||
|
||||
@ -418,20 +396,15 @@ export class UsersService {
|
||||
|
||||
// Count unpaid invoices
|
||||
unpaidInvoices = invoices.filter(
|
||||
(inv: any) => inv.status === "Unpaid" || inv.status === "Overdue",
|
||||
(inv: any) => inv.status === "Unpaid" || inv.status === "Overdue"
|
||||
).length;
|
||||
|
||||
// Find next due invoice
|
||||
const upcomingInvoices = invoices
|
||||
.filter(
|
||||
(inv: any) =>
|
||||
(inv.status === "Unpaid" || inv.status === "Overdue") &&
|
||||
inv.dueDate,
|
||||
(inv: any) => (inv.status === "Unpaid" || inv.status === "Overdue") && inv.dueDate
|
||||
)
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(a.dueDate!).getTime() - new Date(b.dueDate!).getTime(),
|
||||
);
|
||||
.sort((a: any, b: any) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime());
|
||||
|
||||
if (upcomingInvoices.length > 0) {
|
||||
const invoice = upcomingInvoices[0];
|
||||
@ -447,50 +420,43 @@ export class UsersService {
|
||||
recentInvoices = invoices
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(b.issuedAt || "").getTime() -
|
||||
new Date(a.issuedAt || "").getTime(),
|
||||
new Date(b.issuedAt || "").getTime() - new Date(a.issuedAt || "").getTime()
|
||||
)
|
||||
.slice(0, 5);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to fetch invoices for user ${userId}:`,
|
||||
invoicesData.reason,
|
||||
);
|
||||
this.logger.error(`Failed to fetch invoices for user ${userId}`, {
|
||||
reason: getErrorMessage(invoicesData.reason),
|
||||
});
|
||||
}
|
||||
|
||||
// Build activity feed
|
||||
const activities: Activity[] = [];
|
||||
|
||||
// Add invoice activities
|
||||
recentInvoices.forEach((invoice) => {
|
||||
recentInvoices.forEach(invoice => {
|
||||
if (invoice.status === "Paid") {
|
||||
activities.push({
|
||||
id: `invoice-paid-${invoice.id}`,
|
||||
type: "invoice_paid",
|
||||
title: `Invoice #${invoice.number} paid`,
|
||||
description: `Payment of ¥${invoice.total.toLocaleString()} processed`,
|
||||
date:
|
||||
invoice.paidDate || invoice.issuedAt || new Date().toISOString(),
|
||||
date: invoice.paidDate || invoice.issuedAt || new Date().toISOString(),
|
||||
relatedId: invoice.id,
|
||||
});
|
||||
} else if (
|
||||
invoice.status === "Unpaid" ||
|
||||
invoice.status === "Overdue"
|
||||
) {
|
||||
} else if (invoice.status === "Unpaid" || invoice.status === "Overdue") {
|
||||
activities.push({
|
||||
id: `invoice-created-${invoice.id}`,
|
||||
type: "invoice_created",
|
||||
title: `Invoice #${invoice.number} created`,
|
||||
description: `Amount: ¥${invoice.total.toLocaleString()}`,
|
||||
date:
|
||||
invoice.issuedAt || invoice.updatedAt || new Date().toISOString(),
|
||||
date: invoice.issuedAt || invoice.updatedAt || new Date().toISOString(),
|
||||
relatedId: invoice.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add subscription activities
|
||||
recentSubscriptions.forEach((subscription) => {
|
||||
recentSubscriptions.forEach(subscription => {
|
||||
activities.push({
|
||||
id: `service-activated-${subscription.id}`,
|
||||
type: "service_activated",
|
||||
@ -502,9 +468,7 @@ export class UsersService {
|
||||
});
|
||||
|
||||
// Sort activities by date and take top 10
|
||||
activities.sort(
|
||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
||||
);
|
||||
activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
const recentActivity = activities.slice(0, 10);
|
||||
|
||||
this.logger.log(`Generated dashboard summary for user ${userId}`, {
|
||||
@ -525,10 +489,10 @@ export class UsersService {
|
||||
recentActivity,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get user summary for ${userId}:`, error);
|
||||
throw new Error(
|
||||
`Failed to retrieve dashboard data: ${getErrorMessage(error)}`,
|
||||
);
|
||||
this.logger.error(`Failed to get user summary for ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error(`Failed to retrieve dashboard data: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { Injectable, OnModuleInit, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
import { SalesforceConnection } from "./services/salesforce-connection.service";
|
||||
import {
|
||||
SalesforceAccountService,
|
||||
@ -26,13 +27,12 @@ import { SupportCase, CreateCaseRequest } from "@customer-portal/shared";
|
||||
*/
|
||||
@Injectable()
|
||||
export class SalesforceService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SalesforceService.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private connection: SalesforceConnection,
|
||||
private accountService: SalesforceAccountService,
|
||||
private caseService: SalesforceCaseService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@ -40,31 +40,25 @@ export class SalesforceService implements OnModuleInit {
|
||||
await this.connection.connect();
|
||||
if (!this.connection.isConnected()) {
|
||||
this.logger.warn(
|
||||
"Salesforce connection is not established. Running without Salesforce integration.",
|
||||
"Salesforce connection is not established. Running without Salesforce integration."
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const nodeEnv =
|
||||
this.configService.get<string>("NODE_ENV") ||
|
||||
process.env.NODE_ENV ||
|
||||
"development";
|
||||
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
|
||||
const isProd = nodeEnv === "production";
|
||||
if (isProd) {
|
||||
this.logger.error("Failed to initialize Salesforce connection");
|
||||
} else {
|
||||
this.logger.error(
|
||||
`Failed to initialize Salesforce connection: ${getErrorMessage(error)}`,
|
||||
);
|
||||
this.logger.error(`Failed to initialize Salesforce connection: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === ACCOUNT METHODS (Actually Used) ===
|
||||
|
||||
async findAccountByCustomerNumber(
|
||||
customerNumber: string,
|
||||
): Promise<{ id: string } | null> {
|
||||
async findAccountByCustomerNumber(customerNumber: string): Promise<{ id: string } | null> {
|
||||
return this.accountService.findByCustomerNumber(customerNumber);
|
||||
}
|
||||
|
||||
@ -84,14 +78,14 @@ export class SalesforceService implements OnModuleInit {
|
||||
|
||||
async getCases(
|
||||
accountId: string,
|
||||
params: CaseQueryParams = {},
|
||||
params: CaseQueryParams = {}
|
||||
): Promise<{ cases: SupportCase[]; totalSize: number }> {
|
||||
return this.caseService.getCases(accountId, params);
|
||||
}
|
||||
|
||||
async createCase(
|
||||
userData: CreateCaseUserData,
|
||||
caseRequest: CreateCaseRequest,
|
||||
caseRequest: CreateCaseRequest
|
||||
): Promise<SupportCase> {
|
||||
return this.caseService.createCase(userData, caseRequest);
|
||||
}
|
||||
@ -99,4 +93,17 @@ export class SalesforceService implements OnModuleInit {
|
||||
async updateCase(caseId: string, updates: any): Promise<void> {
|
||||
return this.caseService.updateCase(caseId, updates);
|
||||
}
|
||||
|
||||
// === HEALTH CHECK ===
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
return this.connection.isConnected();
|
||||
} catch (error) {
|
||||
this.logger.error("Salesforce health check failed", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
96
apps/bff/src/vendors/salesforce/salesforce.service.ts.backup.20250822_120236
vendored
Normal file
96
apps/bff/src/vendors/salesforce/salesforce.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,96 @@
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SalesforceConnection } from "./services/salesforce-connection.service";
|
||||
import {
|
||||
SalesforceAccountService,
|
||||
AccountData,
|
||||
UpsertResult,
|
||||
} from "./services/salesforce-account.service";
|
||||
import {
|
||||
SalesforceCaseService,
|
||||
CaseQueryParams,
|
||||
CreateCaseUserData,
|
||||
} from "./services/salesforce-case.service";
|
||||
import { SupportCase, CreateCaseRequest } from "@customer-portal/shared";
|
||||
|
||||
/**
|
||||
* Clean Salesforce Service - Only includes actually used functionality
|
||||
*
|
||||
* Used Methods:
|
||||
* - findAccountByCustomerNumber() - auth service (WHMCS linking)
|
||||
* - upsertAccount() - auth service (signup)
|
||||
* - getAccount() - users service (profile enhancement)
|
||||
* - getCases() - future support functionality
|
||||
* - createCase() - future support functionality
|
||||
*/
|
||||
@Injectable()
|
||||
export class SalesforceService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SalesforceService.name);
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private connection: SalesforceConnection,
|
||||
private accountService: SalesforceAccountService,
|
||||
private caseService: SalesforceCaseService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.connection.connect();
|
||||
if (!this.connection.isConnected()) {
|
||||
this.logger.warn(
|
||||
"Salesforce connection is not established. Running without Salesforce integration."
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const nodeEnv =
|
||||
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
|
||||
const isProd = nodeEnv === "production";
|
||||
if (isProd) {
|
||||
this.logger.error("Failed to initialize Salesforce connection");
|
||||
} else {
|
||||
this.logger.error(`Failed to initialize Salesforce connection: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === ACCOUNT METHODS (Actually Used) ===
|
||||
|
||||
async findAccountByCustomerNumber(customerNumber: string): Promise<{ id: string } | null> {
|
||||
return this.accountService.findByCustomerNumber(customerNumber);
|
||||
}
|
||||
|
||||
async upsertAccount(accountData: AccountData): Promise<UpsertResult> {
|
||||
return this.accountService.upsert(accountData);
|
||||
}
|
||||
|
||||
async getAccount(accountId: string): Promise<any | null> {
|
||||
return this.accountService.getById(accountId);
|
||||
}
|
||||
|
||||
async updateAccount(accountId: string, updates: any): Promise<void> {
|
||||
return this.accountService.update(accountId, updates);
|
||||
}
|
||||
|
||||
// === CASE METHODS (For Future Support Functionality) ===
|
||||
|
||||
async getCases(
|
||||
accountId: string,
|
||||
params: CaseQueryParams = {}
|
||||
): Promise<{ cases: SupportCase[]; totalSize: number }> {
|
||||
return this.caseService.getCases(accountId, params);
|
||||
}
|
||||
|
||||
async createCase(
|
||||
userData: CreateCaseUserData,
|
||||
caseRequest: CreateCaseRequest
|
||||
): Promise<SupportCase> {
|
||||
return this.caseService.createCase(userData, caseRequest);
|
||||
}
|
||||
|
||||
async updateCase(caseId: string, updates: any): Promise<void> {
|
||||
return this.caseService.updateCase(caseId, updates);
|
||||
}
|
||||
}
|
||||
102
apps/bff/src/vendors/salesforce/salesforce.service.ts.backup.20250822_120518
vendored
Normal file
102
apps/bff/src/vendors/salesforce/salesforce.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,102 @@
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, OnModuleInit, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "./services/salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
SalesforceAccountService,
|
||||
AccountData,
|
||||
UpsertResult,
|
||||
} from "./services/salesforce-account.service";
|
||||
import {
|
||||
SalesforceCaseService,
|
||||
CaseQueryParams,
|
||||
CreateCaseUserData,
|
||||
} from "./services/salesforce-case.service";
|
||||
import { SupportCase, CreateCaseRequest } from "@customer-portal/shared";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
/**
|
||||
* Clean Salesforce Service - Only includes actually used functionality
|
||||
*
|
||||
* Used Methods:
|
||||
* - findAccountByCustomerNumber() - auth service (WHMCS linking)
|
||||
* - upsertAccount() - auth service (signup)
|
||||
* - getAccount() - users service (profile enhancement)
|
||||
* - getCases() - future support functionality
|
||||
* - createCase() - future support functionality
|
||||
*/
|
||||
@Injectable()
|
||||
export class SalesforceService implements OnModuleInit {
|
||||
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private configService: ConfigService,
|
||||
private connection: SalesforceConnection,
|
||||
private accountService: SalesforceAccountService,
|
||||
private caseService: SalesforceCaseService
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
try {
|
||||
await this.connection.connect();
|
||||
if (!this.connection.isConnected()) {
|
||||
this.logger.warn(
|
||||
"Salesforce connection is not established. Running without Salesforce integration."
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
const nodeEnv =
|
||||
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
|
||||
const isProd = nodeEnv === "production";
|
||||
if (isProd) {
|
||||
this.logger.error("Failed to initialize Salesforce connection");
|
||||
} else {
|
||||
this.logger.error(`Failed to initialize Salesforce connection: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === ACCOUNT METHODS (Actually Used) ===
|
||||
|
||||
async findAccountByCustomerNumber(customerNumber: string): Promise<{ id: string } | null> {
|
||||
return this.accountService.findByCustomerNumber(customerNumber);
|
||||
}
|
||||
|
||||
async upsertAccount(accountData: AccountData): Promise<UpsertResult> {
|
||||
return this.accountService.upsert(accountData);
|
||||
}
|
||||
|
||||
async getAccount(accountId: string): Promise<any | null> {
|
||||
return this.accountService.getById(accountId);
|
||||
}
|
||||
|
||||
async updateAccount(accountId: string, updates: any): Promise<void> {
|
||||
return this.accountService.update(accountId, updates);
|
||||
}
|
||||
|
||||
// === CASE METHODS (For Future Support Functionality) ===
|
||||
|
||||
async getCases(
|
||||
accountId: string,
|
||||
params: CaseQueryParams = {}
|
||||
): Promise<{ cases: SupportCase[]; totalSize: number }> {
|
||||
return this.caseService.getCases(accountId, params);
|
||||
}
|
||||
|
||||
async createCase(
|
||||
userData: CreateCaseUserData,
|
||||
caseRequest: CreateCaseRequest
|
||||
): Promise<SupportCase> {
|
||||
return this.caseService.createCase(userData, caseRequest);
|
||||
}
|
||||
|
||||
async updateCase(caseId: string, updates: any): Promise<void> {
|
||||
return this.caseService.updateCase(caseId, updates);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
|
||||
export interface AccountData {
|
||||
@ -21,18 +22,17 @@ export interface UpsertResult {
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceAccountService {
|
||||
private readonly logger = new Logger(SalesforceAccountService.name);
|
||||
constructor(
|
||||
private connection: SalesforceConnection,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
constructor(private connection: SalesforceConnection) {}
|
||||
|
||||
async findByCustomerNumber(
|
||||
customerNumber: string,
|
||||
): Promise<{ id: string } | null> {
|
||||
async findByCustomerNumber(customerNumber: string): Promise<{ id: string } | null> {
|
||||
if (!customerNumber?.trim()) throw new Error("Customer number is required");
|
||||
|
||||
try {
|
||||
const result = await this.connection.query(
|
||||
`SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'`,
|
||||
`SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'`
|
||||
);
|
||||
return result.totalSize > 0 ? { id: result.records[0].Id } : null;
|
||||
} catch (error) {
|
||||
@ -48,7 +48,7 @@ export class SalesforceAccountService {
|
||||
|
||||
try {
|
||||
const existingAccount = await this.connection.query(
|
||||
`SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'`,
|
||||
`SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'`
|
||||
);
|
||||
|
||||
const sfData = {
|
||||
@ -73,9 +73,7 @@ export class SalesforceAccountService {
|
||||
|
||||
if (existingAccount.totalSize > 0) {
|
||||
const accountId = existingAccount.records[0].Id;
|
||||
await this.connection
|
||||
.sobject("Account")
|
||||
.update({ Id: accountId, ...sfData });
|
||||
await this.connection.sobject("Account").update({ Id: accountId, ...sfData });
|
||||
return { id: accountId, created: false };
|
||||
} else {
|
||||
const result = await this.connection.sobject("Account").create(sfData);
|
||||
@ -112,9 +110,7 @@ export class SalesforceAccountService {
|
||||
const validAccountId = this.validateId(accountId);
|
||||
|
||||
try {
|
||||
await this.connection
|
||||
.sobject("Account")
|
||||
.update({ Id: validAccountId, ...updates });
|
||||
await this.connection.sobject("Account").update({ Id: validAccountId, ...updates });
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update account", {
|
||||
error: getErrorMessage(error),
|
||||
@ -125,11 +121,7 @@ export class SalesforceAccountService {
|
||||
|
||||
private validateId(id: string): string {
|
||||
const trimmed = id?.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.length !== 18 ||
|
||||
!/^[a-zA-Z0-9]{18}$/.test(trimmed)
|
||||
) {
|
||||
if (!trimmed || trimmed.length !== 18 || !/^[a-zA-Z0-9]{18}$/.test(trimmed)) {
|
||||
throw new Error("Invalid Salesforce ID format");
|
||||
}
|
||||
return trimmed;
|
||||
|
||||
131
apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts.backup.20250822_120236
vendored
Normal file
131
apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,131 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
|
||||
export interface AccountData {
|
||||
name: string;
|
||||
phone?: string;
|
||||
mailingStreet?: string;
|
||||
mailingCity?: string;
|
||||
mailingState?: string;
|
||||
mailingPostalCode?: string;
|
||||
mailingCountry?: string;
|
||||
buildingName?: string;
|
||||
roomNumber?: string;
|
||||
}
|
||||
|
||||
export interface UpsertResult {
|
||||
id: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceAccountService {
|
||||
private readonly logger = new Logger(SalesforceAccountService.name);
|
||||
|
||||
constructor(private connection: SalesforceConnection) {}
|
||||
|
||||
async findByCustomerNumber(customerNumber: string): Promise<{ id: string } | null> {
|
||||
if (!customerNumber?.trim()) throw new Error("Customer number is required");
|
||||
|
||||
try {
|
||||
const result = await this.connection.query(
|
||||
`SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'`
|
||||
);
|
||||
return result.totalSize > 0 ? { id: result.records[0].Id } : null;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find account by customer number", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to find account");
|
||||
}
|
||||
}
|
||||
|
||||
async upsert(accountData: AccountData): Promise<UpsertResult> {
|
||||
if (!accountData.name?.trim()) throw new Error("Account name is required");
|
||||
|
||||
try {
|
||||
const existingAccount = await this.connection.query(
|
||||
`SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'`
|
||||
);
|
||||
|
||||
const sfData = {
|
||||
Name: accountData.name.trim(),
|
||||
Mobile: accountData.phone, // Account mobile field
|
||||
PersonMobilePhone: accountData.phone, // Person Account mobile field (Contact)
|
||||
PersonMailingStreet: accountData.mailingStreet,
|
||||
PersonMailingCity: accountData.mailingCity,
|
||||
PersonMailingState: accountData.mailingState,
|
||||
PersonMailingPostalCode: accountData.mailingPostalCode,
|
||||
PersonMailingCountry: accountData.mailingCountry,
|
||||
BillingStreet: accountData.mailingStreet, // Also update billing address
|
||||
BillingCity: accountData.mailingCity,
|
||||
BillingState: accountData.mailingState,
|
||||
BillingPostalCode: accountData.mailingPostalCode,
|
||||
BillingCountry: accountData.mailingCountry,
|
||||
BuildingName__pc: accountData.buildingName, // Person Account custom field
|
||||
RoomNumber__pc: accountData.roomNumber, // Person Account custom field
|
||||
BuildingName__c: accountData.buildingName, // Business Account custom field
|
||||
RoomNumber__c: accountData.roomNumber, // Business Account custom field
|
||||
};
|
||||
|
||||
if (existingAccount.totalSize > 0) {
|
||||
const accountId = existingAccount.records[0].Id;
|
||||
await this.connection.sobject("Account").update({ Id: accountId, ...sfData });
|
||||
return { id: accountId, created: false };
|
||||
} else {
|
||||
const result = await this.connection.sobject("Account").create(sfData);
|
||||
return { id: result.id, created: true };
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to upsert account", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to upsert account");
|
||||
}
|
||||
}
|
||||
|
||||
async getById(accountId: string): Promise<any | null> {
|
||||
if (!accountId?.trim()) throw new Error("Account ID is required");
|
||||
|
||||
try {
|
||||
const result = await this.connection.query(`
|
||||
SELECT Id, Name
|
||||
FROM Account
|
||||
WHERE Id = '${this.validateId(accountId)}'
|
||||
`);
|
||||
|
||||
return result.totalSize > 0 ? result.records[0] : null;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get account", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to get account");
|
||||
}
|
||||
}
|
||||
|
||||
async update(accountId: string, updates: any): Promise<void> {
|
||||
const validAccountId = this.validateId(accountId);
|
||||
|
||||
try {
|
||||
await this.connection.sobject("Account").update({ Id: validAccountId, ...updates });
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update account", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to update account");
|
||||
}
|
||||
}
|
||||
|
||||
private validateId(id: string): string {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed || trimmed.length !== 18 || !/^[a-zA-Z0-9]{18}$/.test(trimmed)) {
|
||||
throw new Error("Invalid Salesforce ID format");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private safeSoql(input: string): string {
|
||||
return input.replace(/'/g, "\\'");
|
||||
}
|
||||
}
|
||||
135
apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts.backup.20250822_120518
vendored
Normal file
135
apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,135 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
export interface AccountData {
|
||||
name: string;
|
||||
phone?: string;
|
||||
mailingStreet?: string;
|
||||
mailingCity?: string;
|
||||
mailingState?: string;
|
||||
mailingPostalCode?: string;
|
||||
mailingCountry?: string;
|
||||
buildingName?: string;
|
||||
roomNumber?: string;
|
||||
}
|
||||
|
||||
export interface UpsertResult {
|
||||
id: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceAccountService {
|
||||
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,private connection: SalesforceConnection) {}
|
||||
|
||||
async findByCustomerNumber(customerNumber: string): Promise<{ id: string } | null> {
|
||||
if (!customerNumber?.trim()) throw new Error("Customer number is required");
|
||||
|
||||
try {
|
||||
const result = await this.connection.query(
|
||||
`SELECT Id FROM Account WHERE SF_Account_No__c = '${this.safeSoql(customerNumber.trim())}'`
|
||||
);
|
||||
return result.totalSize > 0 ? { id: result.records[0].Id } : null;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find account by customer number", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to find account");
|
||||
}
|
||||
}
|
||||
|
||||
async upsert(accountData: AccountData): Promise<UpsertResult> {
|
||||
if (!accountData.name?.trim()) throw new Error("Account name is required");
|
||||
|
||||
try {
|
||||
const existingAccount = await this.connection.query(
|
||||
`SELECT Id FROM Account WHERE Name = '${this.safeSoql(accountData.name.trim())}'`
|
||||
);
|
||||
|
||||
const sfData = {
|
||||
Name: accountData.name.trim(),
|
||||
Mobile: accountData.phone, // Account mobile field
|
||||
PersonMobilePhone: accountData.phone, // Person Account mobile field (Contact)
|
||||
PersonMailingStreet: accountData.mailingStreet,
|
||||
PersonMailingCity: accountData.mailingCity,
|
||||
PersonMailingState: accountData.mailingState,
|
||||
PersonMailingPostalCode: accountData.mailingPostalCode,
|
||||
PersonMailingCountry: accountData.mailingCountry,
|
||||
BillingStreet: accountData.mailingStreet, // Also update billing address
|
||||
BillingCity: accountData.mailingCity,
|
||||
BillingState: accountData.mailingState,
|
||||
BillingPostalCode: accountData.mailingPostalCode,
|
||||
BillingCountry: accountData.mailingCountry,
|
||||
BuildingName__pc: accountData.buildingName, // Person Account custom field
|
||||
RoomNumber__pc: accountData.roomNumber, // Person Account custom field
|
||||
BuildingName__c: accountData.buildingName, // Business Account custom field
|
||||
RoomNumber__c: accountData.roomNumber, // Business Account custom field
|
||||
};
|
||||
|
||||
if (existingAccount.totalSize > 0) {
|
||||
const accountId = existingAccount.records[0].Id;
|
||||
await this.connection.sobject("Account").update({ Id: accountId, ...sfData });
|
||||
return { id: accountId, created: false };
|
||||
} else {
|
||||
const result = await this.connection.sobject("Account").create(sfData);
|
||||
return { id: result.id, created: true };
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to upsert account", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to upsert account");
|
||||
}
|
||||
}
|
||||
|
||||
async getById(accountId: string): Promise<any | null> {
|
||||
if (!accountId?.trim()) throw new Error("Account ID is required");
|
||||
|
||||
try {
|
||||
const result = await this.connection.query(`
|
||||
SELECT Id, Name
|
||||
FROM Account
|
||||
WHERE Id = '${this.validateId(accountId)}'
|
||||
`);
|
||||
|
||||
return result.totalSize > 0 ? result.records[0] : null;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get account", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to get account");
|
||||
}
|
||||
}
|
||||
|
||||
async update(accountId: string, updates: any): Promise<void> {
|
||||
const validAccountId = this.validateId(accountId);
|
||||
|
||||
try {
|
||||
await this.connection.sobject("Account").update({ Id: validAccountId, ...updates });
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update account", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to update account");
|
||||
}
|
||||
}
|
||||
|
||||
private validateId(id: string): string {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed || trimmed.length !== 18 || !/^[a-zA-Z0-9]{18}$/.test(trimmed)) {
|
||||
throw new Error("Invalid Salesforce ID format");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private safeSoql(input: string): string {
|
||||
return input.replace(/'/g, "\\'");
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
import { SupportCase, CreateCaseRequest } from "@customer-portal/shared";
|
||||
|
||||
@ -27,13 +28,14 @@ interface CaseData {
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceCaseService {
|
||||
private readonly logger = new Logger(SalesforceCaseService.name);
|
||||
|
||||
constructor(private connection: SalesforceConnection) {}
|
||||
constructor(
|
||||
private connection: SalesforceConnection,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async getCases(
|
||||
accountId: string,
|
||||
params: CaseQueryParams = {},
|
||||
params: CaseQueryParams = {}
|
||||
): Promise<{ cases: SupportCase[]; totalSize: number }> {
|
||||
const validAccountId = this.validateId(accountId);
|
||||
|
||||
@ -74,7 +76,7 @@ export class SalesforceCaseService {
|
||||
|
||||
async createCase(
|
||||
userData: CreateCaseUserData,
|
||||
caseRequest: CreateCaseRequest,
|
||||
caseRequest: CreateCaseRequest
|
||||
): Promise<SupportCase> {
|
||||
try {
|
||||
// Create contact on-demand for case creation
|
||||
@ -95,7 +97,7 @@ export class SalesforceCaseService {
|
||||
});
|
||||
return this.transformCase(sfCase);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create case", error);
|
||||
this.logger.error("Failed to create case", { error: getErrorMessage(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -104,9 +106,7 @@ export class SalesforceCaseService {
|
||||
const validCaseId = this.validateId(caseId);
|
||||
|
||||
try {
|
||||
await this.connection
|
||||
.sobject("Case")
|
||||
.update({ Id: validCaseId, ...updates });
|
||||
await this.connection.sobject("Case").update({ Id: validCaseId, ...updates });
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update case", {
|
||||
error: getErrorMessage(error),
|
||||
@ -115,9 +115,7 @@ export class SalesforceCaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrCreateContact(
|
||||
userData: CreateCaseUserData,
|
||||
): Promise<string> {
|
||||
private async findOrCreateContact(userData: CreateCaseUserData): Promise<string> {
|
||||
try {
|
||||
// Try to find existing contact
|
||||
const existingContact = await this.connection.query(`
|
||||
@ -139,19 +137,17 @@ export class SalesforceCaseService {
|
||||
AccountId: userData.accountId,
|
||||
};
|
||||
|
||||
const result = await this.connection
|
||||
.sobject("Contact")
|
||||
.create(contactData);
|
||||
const result = await this.connection.sobject("Contact").create(contactData);
|
||||
return result.id;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find or create contact for case", error);
|
||||
this.logger.error("Failed to find or create contact for case", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createSalesforceCase(
|
||||
caseData: CaseData & { contactId: string },
|
||||
): Promise<any> {
|
||||
private async createSalesforceCase(caseData: CaseData & { contactId: string }): Promise<any> {
|
||||
const validTypes = ["Question", "Problem", "Feature Request"];
|
||||
const validPriorities = ["Low", "Medium", "High", "Critical"];
|
||||
|
||||
@ -160,12 +156,8 @@ export class SalesforceCaseService {
|
||||
Description: caseData.description.trim().substring(0, 32000),
|
||||
ContactId: caseData.contactId,
|
||||
AccountId: caseData.accountId,
|
||||
Type: validTypes.includes(caseData.type || "")
|
||||
? caseData.type
|
||||
: "Question",
|
||||
Priority: validPriorities.includes(caseData.priority || "")
|
||||
? caseData.priority
|
||||
: "Medium",
|
||||
Type: validTypes.includes(caseData.type || "") ? caseData.type : "Question",
|
||||
Priority: validPriorities.includes(caseData.priority || "") ? caseData.priority : "Medium",
|
||||
Origin: caseData.origin || "Web",
|
||||
};
|
||||
|
||||
@ -203,11 +195,7 @@ export class SalesforceCaseService {
|
||||
|
||||
private validateId(id: string): string {
|
||||
const trimmed = id?.trim();
|
||||
if (
|
||||
!trimmed ||
|
||||
trimmed.length !== 18 ||
|
||||
!/^[a-zA-Z0-9]{18}$/.test(trimmed)
|
||||
) {
|
||||
if (!trimmed || trimmed.length !== 18 || !/^[a-zA-Z0-9]{18}$/.test(trimmed)) {
|
||||
throw new Error("Invalid Salesforce ID format");
|
||||
}
|
||||
return trimmed;
|
||||
|
||||
203
apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts.backup.20250822_120236
vendored
Normal file
203
apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,203 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
import { SupportCase, CreateCaseRequest } from "@customer-portal/shared";
|
||||
|
||||
export interface CaseQueryParams {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateCaseUserData {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface CaseData {
|
||||
subject: string;
|
||||
description: string;
|
||||
accountId: string;
|
||||
type?: string;
|
||||
priority?: string;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceCaseService {
|
||||
private readonly logger = new Logger(SalesforceCaseService.name);
|
||||
|
||||
constructor(private connection: SalesforceConnection) {}
|
||||
|
||||
async getCases(
|
||||
accountId: string,
|
||||
params: CaseQueryParams = {}
|
||||
): Promise<{ cases: SupportCase[]; totalSize: number }> {
|
||||
const validAccountId = this.validateId(accountId);
|
||||
|
||||
try {
|
||||
let query = `
|
||||
SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin,
|
||||
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
|
||||
FROM Case
|
||||
WHERE AccountId = '${validAccountId}'
|
||||
`;
|
||||
|
||||
if (params.status) {
|
||||
query += ` AND Status = '${this.safeSoql(params.status)}'`;
|
||||
}
|
||||
|
||||
query += " ORDER BY CreatedDate DESC";
|
||||
|
||||
if (params.limit) {
|
||||
query += ` LIMIT ${params.limit}`;
|
||||
}
|
||||
|
||||
if (params.offset) {
|
||||
query += ` OFFSET ${params.offset}`;
|
||||
}
|
||||
|
||||
const result = await this.connection.query(query);
|
||||
|
||||
const cases = result.records.map(this.transformCase);
|
||||
|
||||
return { cases, totalSize: result.totalSize };
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get cases", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to get cases");
|
||||
}
|
||||
}
|
||||
|
||||
async createCase(
|
||||
userData: CreateCaseUserData,
|
||||
caseRequest: CreateCaseRequest
|
||||
): Promise<SupportCase> {
|
||||
try {
|
||||
// Create contact on-demand for case creation
|
||||
const contactId = await this.findOrCreateContact(userData);
|
||||
|
||||
const caseData: CaseData = {
|
||||
subject: caseRequest.subject,
|
||||
description: caseRequest.description,
|
||||
accountId: userData.accountId,
|
||||
type: caseRequest.type || "Question",
|
||||
priority: caseRequest.priority || "Medium",
|
||||
origin: "Web",
|
||||
};
|
||||
|
||||
const sfCase = await this.createSalesforceCase({
|
||||
...caseData,
|
||||
contactId,
|
||||
});
|
||||
return this.transformCase(sfCase);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create case", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCase(caseId: string, updates: any): Promise<void> {
|
||||
const validCaseId = this.validateId(caseId);
|
||||
|
||||
try {
|
||||
await this.connection.sobject("Case").update({ Id: validCaseId, ...updates });
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update case", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to update case");
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrCreateContact(userData: CreateCaseUserData): Promise<string> {
|
||||
try {
|
||||
// Try to find existing contact
|
||||
const existingContact = await this.connection.query(`
|
||||
SELECT Id FROM Contact
|
||||
WHERE Email = '${this.safeSoql(userData.email)}'
|
||||
AND AccountId = '${userData.accountId}'
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (existingContact.totalSize > 0) {
|
||||
return existingContact.records[0].Id;
|
||||
}
|
||||
|
||||
// Create new contact
|
||||
const contactData = {
|
||||
Email: userData.email,
|
||||
FirstName: userData.firstName,
|
||||
LastName: userData.lastName,
|
||||
AccountId: userData.accountId,
|
||||
};
|
||||
|
||||
const result = await this.connection.sobject("Contact").create(contactData);
|
||||
return result.id;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find or create contact for case", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createSalesforceCase(caseData: CaseData & { contactId: string }): Promise<any> {
|
||||
const validTypes = ["Question", "Problem", "Feature Request"];
|
||||
const validPriorities = ["Low", "Medium", "High", "Critical"];
|
||||
|
||||
const sfData = {
|
||||
Subject: caseData.subject.trim().substring(0, 255),
|
||||
Description: caseData.description.trim().substring(0, 32000),
|
||||
ContactId: caseData.contactId,
|
||||
AccountId: caseData.accountId,
|
||||
Type: validTypes.includes(caseData.type || "") ? caseData.type : "Question",
|
||||
Priority: validPriorities.includes(caseData.priority || "") ? caseData.priority : "Medium",
|
||||
Origin: caseData.origin || "Web",
|
||||
};
|
||||
|
||||
const result = await this.connection.sobject("Case").create(sfData);
|
||||
|
||||
// Fetch the created case with all fields
|
||||
const createdCase = await this.connection.query(`
|
||||
SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin,
|
||||
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
|
||||
FROM Case
|
||||
WHERE Id = '${result.id}'
|
||||
`);
|
||||
|
||||
return createdCase.records[0];
|
||||
}
|
||||
|
||||
private transformCase(sfCase: any): SupportCase {
|
||||
return {
|
||||
id: sfCase.Id,
|
||||
number: sfCase.CaseNumber, // Use 'number' instead of 'caseNumber'
|
||||
subject: sfCase.Subject,
|
||||
description: sfCase.Description,
|
||||
status: sfCase.Status,
|
||||
priority: sfCase.Priority,
|
||||
type: sfCase.Type,
|
||||
createdDate: sfCase.CreatedDate,
|
||||
lastModifiedDate: sfCase.LastModifiedDate || sfCase.CreatedDate,
|
||||
closedDate: sfCase.ClosedDate,
|
||||
contactId: sfCase.ContactId,
|
||||
accountId: sfCase.AccountId,
|
||||
ownerId: sfCase.OwnerId,
|
||||
ownerName: sfCase.Owner?.Name,
|
||||
};
|
||||
}
|
||||
|
||||
private validateId(id: string): string {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed || trimmed.length !== 18 || !/^[a-zA-Z0-9]{18}$/.test(trimmed)) {
|
||||
throw new Error("Invalid Salesforce ID format");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private safeSoql(input: string): string {
|
||||
return input.replace(/'/g, "\\'");
|
||||
}
|
||||
}
|
||||
208
apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts.backup.20250822_120518
vendored
Normal file
208
apps/bff/src/vendors/salesforce/services/salesforce-case.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,208 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "./salesforce-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SupportCase, CreateCaseRequest } from "@customer-portal/shared";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
export interface CaseQueryParams {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface CreateCaseUserData {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
interface CaseData {
|
||||
subject: string;
|
||||
description: string;
|
||||
accountId: string;
|
||||
type?: string;
|
||||
priority?: string;
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceCaseService {
|
||||
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,private connection: SalesforceConnection) {}
|
||||
|
||||
async getCases(
|
||||
accountId: string,
|
||||
params: CaseQueryParams = {}
|
||||
): Promise<{ cases: SupportCase[]; totalSize: number }> {
|
||||
const validAccountId = this.validateId(accountId);
|
||||
|
||||
try {
|
||||
let query = `
|
||||
SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin,
|
||||
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
|
||||
FROM Case
|
||||
WHERE AccountId = '${validAccountId}'
|
||||
`;
|
||||
|
||||
if (params.status) {
|
||||
query += ` AND Status = '${this.safeSoql(params.status)}'`;
|
||||
}
|
||||
|
||||
query += " ORDER BY CreatedDate DESC";
|
||||
|
||||
if (params.limit) {
|
||||
query += ` LIMIT ${params.limit}`;
|
||||
}
|
||||
|
||||
if (params.offset) {
|
||||
query += ` OFFSET ${params.offset}`;
|
||||
}
|
||||
|
||||
const result = await this.connection.query(query);
|
||||
|
||||
const cases = result.records.map(this.transformCase);
|
||||
|
||||
return { cases, totalSize: result.totalSize };
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get cases", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to get cases");
|
||||
}
|
||||
}
|
||||
|
||||
async createCase(
|
||||
userData: CreateCaseUserData,
|
||||
caseRequest: CreateCaseRequest
|
||||
): Promise<SupportCase> {
|
||||
try {
|
||||
// Create contact on-demand for case creation
|
||||
const contactId = await this.findOrCreateContact(userData);
|
||||
|
||||
const caseData: CaseData = {
|
||||
subject: caseRequest.subject,
|
||||
description: caseRequest.description,
|
||||
accountId: userData.accountId,
|
||||
type: caseRequest.type || "Question",
|
||||
priority: caseRequest.priority || "Medium",
|
||||
origin: "Web",
|
||||
};
|
||||
|
||||
const sfCase = await this.createSalesforceCase({
|
||||
...caseData,
|
||||
contactId,
|
||||
});
|
||||
return this.transformCase(sfCase);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to create case", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCase(caseId: string, updates: any): Promise<void> {
|
||||
const validCaseId = this.validateId(caseId);
|
||||
|
||||
try {
|
||||
await this.connection.sobject("Case").update({ Id: validCaseId, ...updates });
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to update case", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new Error("Failed to update case");
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrCreateContact(userData: CreateCaseUserData): Promise<string> {
|
||||
try {
|
||||
// Try to find existing contact
|
||||
const existingContact = await this.connection.query(`
|
||||
SELECT Id FROM Contact
|
||||
WHERE Email = '${this.safeSoql(userData.email)}'
|
||||
AND AccountId = '${userData.accountId}'
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (existingContact.totalSize > 0) {
|
||||
return existingContact.records[0].Id;
|
||||
}
|
||||
|
||||
// Create new contact
|
||||
const contactData = {
|
||||
Email: userData.email,
|
||||
FirstName: userData.firstName,
|
||||
LastName: userData.lastName,
|
||||
AccountId: userData.accountId,
|
||||
};
|
||||
|
||||
const result = await this.connection.sobject("Contact").create(contactData);
|
||||
return result.id;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to find or create contact for case", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createSalesforceCase(caseData: CaseData & { contactId: string }): Promise<any> {
|
||||
const validTypes = ["Question", "Problem", "Feature Request"];
|
||||
const validPriorities = ["Low", "Medium", "High", "Critical"];
|
||||
|
||||
const sfData = {
|
||||
Subject: caseData.subject.trim().substring(0, 255),
|
||||
Description: caseData.description.trim().substring(0, 32000),
|
||||
ContactId: caseData.contactId,
|
||||
AccountId: caseData.accountId,
|
||||
Type: validTypes.includes(caseData.type || "") ? caseData.type : "Question",
|
||||
Priority: validPriorities.includes(caseData.priority || "") ? caseData.priority : "Medium",
|
||||
Origin: caseData.origin || "Web",
|
||||
};
|
||||
|
||||
const result = await this.connection.sobject("Case").create(sfData);
|
||||
|
||||
// Fetch the created case with all fields
|
||||
const createdCase = await this.connection.query(`
|
||||
SELECT Id, CaseNumber, Subject, Description, Status, Priority, Type, Origin,
|
||||
CreatedDate, LastModifiedDate, ClosedDate, ContactId, AccountId, OwnerId, Owner.Name
|
||||
FROM Case
|
||||
WHERE Id = '${result.id}'
|
||||
`);
|
||||
|
||||
return createdCase.records[0];
|
||||
}
|
||||
|
||||
private transformCase(sfCase: any): SupportCase {
|
||||
return {
|
||||
id: sfCase.Id,
|
||||
number: sfCase.CaseNumber, // Use 'number' instead of 'caseNumber'
|
||||
subject: sfCase.Subject,
|
||||
description: sfCase.Description,
|
||||
status: sfCase.Status,
|
||||
priority: sfCase.Priority,
|
||||
type: sfCase.Type,
|
||||
createdDate: sfCase.CreatedDate,
|
||||
lastModifiedDate: sfCase.LastModifiedDate || sfCase.CreatedDate,
|
||||
closedDate: sfCase.ClosedDate,
|
||||
contactId: sfCase.ContactId,
|
||||
accountId: sfCase.AccountId,
|
||||
ownerId: sfCase.OwnerId,
|
||||
ownerName: sfCase.Owner?.Name,
|
||||
};
|
||||
}
|
||||
|
||||
private validateId(id: string): string {
|
||||
const trimmed = id?.trim();
|
||||
if (!trimmed || trimmed.length !== 18 || !/^[a-zA-Z0-9]{18}$/.test(trimmed)) {
|
||||
throw new Error("Invalid Salesforce ID format");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private safeSoql(input: string): string {
|
||||
return input.replace(/'/g, "\\'");
|
||||
}
|
||||
}
|
||||
@ -1,42 +1,48 @@
|
||||
import { getErrorMessage } from '../../../common/utils/error.util';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as jsforce from 'jsforce';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import * as jsforce from "jsforce";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceConnection {
|
||||
private readonly logger = new Logger(SalesforceConnection.name);
|
||||
private connection: jsforce.Connection;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
this.connection = new jsforce.Connection({
|
||||
loginUrl: this.configService.get<string>('SF_LOGIN_URL') || 'https://login.salesforce.com',
|
||||
loginUrl: this.configService.get<string>("SF_LOGIN_URL") || "https://login.salesforce.com",
|
||||
});
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const nodeEnv = this.configService.get<string>('NODE_ENV') || process.env.NODE_ENV || 'development';
|
||||
const isProd = nodeEnv === 'production';
|
||||
const nodeEnv =
|
||||
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
|
||||
const isProd = nodeEnv === "production";
|
||||
try {
|
||||
const username = this.configService.get<string>('SF_USERNAME');
|
||||
const clientId = this.configService.get<string>('SF_CLIENT_ID');
|
||||
const privateKeyPath = this.configService.get<string>('SF_PRIVATE_KEY_PATH');
|
||||
const audience = this.configService.get<string>('SF_LOGIN_URL') || 'https://login.salesforce.com';
|
||||
const username = this.configService.get<string>("SF_USERNAME");
|
||||
const clientId = this.configService.get<string>("SF_CLIENT_ID");
|
||||
const privateKeyPath = this.configService.get<string>("SF_PRIVATE_KEY_PATH");
|
||||
const audience =
|
||||
this.configService.get<string>("SF_LOGIN_URL") || "https://login.salesforce.com";
|
||||
|
||||
// Gracefully skip connection if not configured for local/dev environments
|
||||
if (!username || !clientId || !privateKeyPath) {
|
||||
const devMessage = 'Missing required Salesforce configuration. Please check SF_USERNAME, SF_CLIENT_ID, and SF_PRIVATE_KEY_PATH environment variables.';
|
||||
throw new Error(isProd ? 'Salesforce configuration is missing' : devMessage);
|
||||
const devMessage =
|
||||
"Missing required Salesforce configuration. Please check SF_USERNAME, SF_CLIENT_ID, and SF_PRIVATE_KEY_PATH environment variables.";
|
||||
throw new Error(isProd ? "Salesforce configuration is missing" : devMessage);
|
||||
}
|
||||
|
||||
// Resolve private key strictly relative to repo root and enforce secrets directory
|
||||
// Use monorepo layout assumption: apps/bff -> repo root is two levels up
|
||||
const appDir = process.cwd();
|
||||
const repoRoot = path.resolve(appDir, '../../');
|
||||
const secretsDir = path.resolve(repoRoot, 'secrets');
|
||||
const repoRoot = path.resolve(appDir, "../../");
|
||||
const secretsDir = path.resolve(repoRoot, "secrets");
|
||||
const resolvedKeyPath = path.resolve(repoRoot, privateKeyPath);
|
||||
|
||||
// Enforce the key to be under repo-root/secrets
|
||||
@ -44,23 +50,24 @@ export class SalesforceConnection {
|
||||
const normalizedSecretsDir = path.normalize(secretsDir) + path.sep;
|
||||
if (!(normalizedKeyPath + path.sep).startsWith(normalizedSecretsDir)) {
|
||||
const devMsg = `Salesforce private key must be located under the root secrets directory: ${secretsDir}`;
|
||||
throw new Error(isProd ? 'Invalid Salesforce private key path' : devMsg);
|
||||
throw new Error(isProd ? "Invalid Salesforce private key path" : devMsg);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(resolvedKeyPath);
|
||||
} catch {
|
||||
const devMsg = `Salesforce private key file not found at: ${resolvedKeyPath}. Ensure the file exists and has proper permissions (chmod 600).`;
|
||||
throw new Error(isProd ? 'Salesforce private key not found' : devMsg);
|
||||
throw new Error(isProd ? "Salesforce private key not found" : devMsg);
|
||||
}
|
||||
|
||||
// Load private key
|
||||
const privateKey = await fs.readFile(resolvedKeyPath, 'utf8');
|
||||
|
||||
const privateKey = await fs.readFile(resolvedKeyPath, "utf8");
|
||||
|
||||
// Validate private key format
|
||||
if (!privateKey.includes('BEGIN PRIVATE KEY') || privateKey.includes('[PLACEHOLDER')) {
|
||||
const devMsg = 'Salesforce private key appears to be invalid or still contains placeholder content. Please replace with your actual private key.';
|
||||
throw new Error(isProd ? 'Invalid Salesforce private key' : devMsg);
|
||||
if (!privateKey.includes("BEGIN PRIVATE KEY") || privateKey.includes("[PLACEHOLDER")) {
|
||||
const devMsg =
|
||||
"Salesforce private key appears to be invalid or still contains placeholder content. Please replace with your actual private key.";
|
||||
throw new Error(isProd ? "Invalid Salesforce private key" : devMsg);
|
||||
}
|
||||
|
||||
// Create JWT assertion
|
||||
@ -71,37 +78,40 @@ export class SalesforceConnection {
|
||||
exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes
|
||||
};
|
||||
|
||||
const assertion = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
|
||||
const assertion = jwt.sign(payload, privateKey, { algorithm: "RS256" });
|
||||
|
||||
// Get access token
|
||||
const tokenUrl = `${audience}/services/oauth2/token`;
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
const logMsg = `Token request failed: ${res.status}` + (isProd ? '' : ` - ${errorText}`);
|
||||
const logMsg = `Token request failed: ${res.status}` + (isProd ? "" : ` - ${errorText}`);
|
||||
this.logger.error(logMsg);
|
||||
throw new Error(isProd ? 'Salesforce authentication failed' : `Token request failed: ${res.status} - ${errorText}`);
|
||||
throw new Error(
|
||||
isProd
|
||||
? "Salesforce authentication failed"
|
||||
: `Token request failed: ${res.status} - ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
const { access_token, instance_url } = await res.json();
|
||||
|
||||
|
||||
this.connection.accessToken = access_token;
|
||||
this.connection.instanceUrl = instance_url;
|
||||
|
||||
this.logger.log('✅ Salesforce connection established');
|
||||
|
||||
|
||||
this.logger.log("✅ Salesforce connection established");
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (isProd) {
|
||||
this.logger.error('Failed to connect to Salesforce');
|
||||
this.logger.error("Failed to connect to Salesforce");
|
||||
} else {
|
||||
this.logger.error(`Failed to connect to Salesforce: ${message}`);
|
||||
}
|
||||
|
||||
131
apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts.backup.20250822_120236
vendored
Normal file
131
apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,131 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as jsforce from "jsforce";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceConnection {
|
||||
private readonly logger = new Logger(SalesforceConnection.name);
|
||||
private connection: jsforce.Connection;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.connection = new jsforce.Connection({
|
||||
loginUrl: this.configService.get<string>("SF_LOGIN_URL") || "https://login.salesforce.com",
|
||||
});
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const nodeEnv =
|
||||
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
|
||||
const isProd = nodeEnv === "production";
|
||||
try {
|
||||
const username = this.configService.get<string>("SF_USERNAME");
|
||||
const clientId = this.configService.get<string>("SF_CLIENT_ID");
|
||||
const privateKeyPath = this.configService.get<string>("SF_PRIVATE_KEY_PATH");
|
||||
const audience =
|
||||
this.configService.get<string>("SF_LOGIN_URL") || "https://login.salesforce.com";
|
||||
|
||||
// Gracefully skip connection if not configured for local/dev environments
|
||||
if (!username || !clientId || !privateKeyPath) {
|
||||
const devMessage =
|
||||
"Missing required Salesforce configuration. Please check SF_USERNAME, SF_CLIENT_ID, and SF_PRIVATE_KEY_PATH environment variables.";
|
||||
throw new Error(isProd ? "Salesforce configuration is missing" : devMessage);
|
||||
}
|
||||
|
||||
// Resolve private key strictly relative to repo root and enforce secrets directory
|
||||
// Use monorepo layout assumption: apps/bff -> repo root is two levels up
|
||||
const appDir = process.cwd();
|
||||
const repoRoot = path.resolve(appDir, "../../");
|
||||
const secretsDir = path.resolve(repoRoot, "secrets");
|
||||
const resolvedKeyPath = path.resolve(repoRoot, privateKeyPath);
|
||||
|
||||
// Enforce the key to be under repo-root/secrets
|
||||
const normalizedKeyPath = path.normalize(resolvedKeyPath);
|
||||
const normalizedSecretsDir = path.normalize(secretsDir) + path.sep;
|
||||
if (!(normalizedKeyPath + path.sep).startsWith(normalizedSecretsDir)) {
|
||||
const devMsg = `Salesforce private key must be located under the root secrets directory: ${secretsDir}`;
|
||||
throw new Error(isProd ? "Invalid Salesforce private key path" : devMsg);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(resolvedKeyPath);
|
||||
} catch {
|
||||
const devMsg = `Salesforce private key file not found at: ${resolvedKeyPath}. Ensure the file exists and has proper permissions (chmod 600).`;
|
||||
throw new Error(isProd ? "Salesforce private key not found" : devMsg);
|
||||
}
|
||||
|
||||
// Load private key
|
||||
const privateKey = await fs.readFile(resolvedKeyPath, "utf8");
|
||||
|
||||
// Validate private key format
|
||||
if (!privateKey.includes("BEGIN PRIVATE KEY") || privateKey.includes("[PLACEHOLDER")) {
|
||||
const devMsg =
|
||||
"Salesforce private key appears to be invalid or still contains placeholder content. Please replace with your actual private key.";
|
||||
throw new Error(isProd ? "Invalid Salesforce private key" : devMsg);
|
||||
}
|
||||
|
||||
// Create JWT assertion
|
||||
const payload = {
|
||||
iss: clientId,
|
||||
sub: username,
|
||||
aud: audience,
|
||||
exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes
|
||||
};
|
||||
|
||||
const assertion = jwt.sign(payload, privateKey, { algorithm: "RS256" });
|
||||
|
||||
// Get access token
|
||||
const tokenUrl = `${audience}/services/oauth2/token`;
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
const logMsg = `Token request failed: ${res.status}` + (isProd ? "" : ` - ${errorText}`);
|
||||
this.logger.error(logMsg);
|
||||
throw new Error(
|
||||
isProd
|
||||
? "Salesforce authentication failed"
|
||||
: `Token request failed: ${res.status} - ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
const { access_token, instance_url } = await res.json();
|
||||
|
||||
this.connection.accessToken = access_token;
|
||||
this.connection.instanceUrl = instance_url;
|
||||
|
||||
this.logger.log("✅ Salesforce connection established");
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (isProd) {
|
||||
this.logger.error("Failed to connect to Salesforce");
|
||||
} else {
|
||||
this.logger.error(`Failed to connect to Salesforce: ${message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose connection methods
|
||||
async query(soql: string): Promise<any> {
|
||||
return await this.connection.query(soql);
|
||||
}
|
||||
|
||||
sobject(type: string): any {
|
||||
return this.connection.sobject(type);
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return !!this.connection.accessToken;
|
||||
}
|
||||
}
|
||||
139
apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts.backup.20250822_120518
vendored
Normal file
139
apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,139 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as jsforce from "jsforce";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as fs from "fs/promises";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import * as path from "path";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class SalesforceConnection {
|
||||
|
||||
private connection: jsforce.Connection;
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,private configService: ConfigService) {
|
||||
this.connection = new jsforce.Connection({
|
||||
loginUrl: this.configService.get<string>("SF_LOGIN_URL") || "https://login.salesforce.com",
|
||||
});
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const nodeEnv =
|
||||
this.configService.get<string>("NODE_ENV") || process.env.NODE_ENV || "development";
|
||||
const isProd = nodeEnv === "production";
|
||||
try {
|
||||
const username = this.configService.get<string>("SF_USERNAME");
|
||||
const clientId = this.configService.get<string>("SF_CLIENT_ID");
|
||||
const privateKeyPath = this.configService.get<string>("SF_PRIVATE_KEY_PATH");
|
||||
const audience =
|
||||
this.configService.get<string>("SF_LOGIN_URL") || "https://login.salesforce.com";
|
||||
|
||||
// Gracefully skip connection if not configured for local/dev environments
|
||||
if (!username || !clientId || !privateKeyPath) {
|
||||
const devMessage =
|
||||
"Missing required Salesforce configuration. Please check SF_USERNAME, SF_CLIENT_ID, and SF_PRIVATE_KEY_PATH environment variables.";
|
||||
throw new Error(isProd ? "Salesforce configuration is missing" : devMessage);
|
||||
}
|
||||
|
||||
// Resolve private key strictly relative to repo root and enforce secrets directory
|
||||
// Use monorepo layout assumption: apps/bff -> repo root is two levels up
|
||||
const appDir = process.cwd();
|
||||
const repoRoot = path.resolve(appDir, "../../");
|
||||
const secretsDir = path.resolve(repoRoot, "secrets");
|
||||
const resolvedKeyPath = path.resolve(repoRoot, privateKeyPath);
|
||||
|
||||
// Enforce the key to be under repo-root/secrets
|
||||
const normalizedKeyPath = path.normalize(resolvedKeyPath);
|
||||
const normalizedSecretsDir = path.normalize(secretsDir) + path.sep;
|
||||
if (!(normalizedKeyPath + path.sep).startsWith(normalizedSecretsDir)) {
|
||||
const devMsg = `Salesforce private key must be located under the root secrets directory: ${secretsDir}`;
|
||||
throw new Error(isProd ? "Invalid Salesforce private key path" : devMsg);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(resolvedKeyPath);
|
||||
} catch {
|
||||
const devMsg = `Salesforce private key file not found at: ${resolvedKeyPath}. Ensure the file exists and has proper permissions (chmod 600).`;
|
||||
throw new Error(isProd ? "Salesforce private key not found" : devMsg);
|
||||
}
|
||||
|
||||
// Load private key
|
||||
const privateKey = await fs.readFile(resolvedKeyPath, "utf8");
|
||||
|
||||
// Validate private key format
|
||||
if (!privateKey.includes("BEGIN PRIVATE KEY") || privateKey.includes("[PLACEHOLDER")) {
|
||||
const devMsg =
|
||||
"Salesforce private key appears to be invalid or still contains placeholder content. Please replace with your actual private key.";
|
||||
throw new Error(isProd ? "Invalid Salesforce private key" : devMsg);
|
||||
}
|
||||
|
||||
// Create JWT assertion
|
||||
const payload = {
|
||||
iss: clientId,
|
||||
sub: username,
|
||||
aud: audience,
|
||||
exp: Math.floor(Date.now() / 1000) + 300, // 5 minutes
|
||||
};
|
||||
|
||||
const assertion = jwt.sign(payload, privateKey, { algorithm: "RS256" });
|
||||
|
||||
// Get access token
|
||||
const tokenUrl = `${audience}/services/oauth2/token`;
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
const logMsg = `Token request failed: ${res.status}` + (isProd ? "" : ` - ${errorText}`);
|
||||
this.logger.error(logMsg);
|
||||
throw new Error(
|
||||
isProd
|
||||
? "Salesforce authentication failed"
|
||||
: `Token request failed: ${res.status} - ${errorText}`
|
||||
);
|
||||
}
|
||||
|
||||
const { access_token, instance_url } = await res.json();
|
||||
|
||||
this.connection.accessToken = access_token;
|
||||
this.connection.instanceUrl = instance_url;
|
||||
|
||||
this.logger.log("✅ Salesforce connection established");
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
if (isProd) {
|
||||
this.logger.error("Failed to connect to Salesforce");
|
||||
} else {
|
||||
this.logger.error(`Failed to connect to Salesforce: ${message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose connection methods
|
||||
async query(soql: string): Promise<any> {
|
||||
return await this.connection.query(soql);
|
||||
}
|
||||
|
||||
sobject(type: string): any {
|
||||
return this.connection.sobject(type);
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return !!this.connection.accessToken;
|
||||
}
|
||||
}
|
||||
6
apps/bff/src/vendors/vendors.module.ts
vendored
6
apps/bff/src/vendors/vendors.module.ts
vendored
@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WhmcsModule } from './whmcs/whmcs.module';
|
||||
import { SalesforceModule } from './salesforce/salesforce.module';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { WhmcsModule } from "./whmcs/whmcs.module";
|
||||
import { SalesforceModule } from "./salesforce/salesforce.module";
|
||||
|
||||
@Module({
|
||||
imports: [WhmcsModule, SalesforceModule],
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CacheService } from '../../../common/cache/cache.service';
|
||||
import { Invoice, InvoiceList, Subscription, SubscriptionList, PaymentMethodList, PaymentGatewayList } from '@customer-portal/shared';
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { CacheService } from "../../../common/cache/cache.service";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
PaymentMethodList,
|
||||
PaymentGatewayList,
|
||||
} from "@customer-portal/shared";
|
||||
|
||||
export interface CacheOptions {
|
||||
ttl?: number;
|
||||
@ -15,53 +24,54 @@ export interface CacheKeyConfig {
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsCacheService {
|
||||
private readonly logger = new Logger(WhmcsCacheService.name);
|
||||
|
||||
// Cache configuration for different data types
|
||||
private readonly cacheConfigs: Record<string, CacheKeyConfig> = {
|
||||
invoices: {
|
||||
prefix: 'whmcs:invoices',
|
||||
prefix: "whmcs:invoices",
|
||||
ttl: 90, // 90 seconds - invoices change frequently
|
||||
tags: ['invoices', 'billing'],
|
||||
tags: ["invoices", "billing"],
|
||||
},
|
||||
invoice: {
|
||||
prefix: 'whmcs:invoice',
|
||||
prefix: "whmcs:invoice",
|
||||
ttl: 300, // 5 minutes - individual invoices change less frequently
|
||||
tags: ['invoice', 'billing'],
|
||||
tags: ["invoice", "billing"],
|
||||
},
|
||||
subscriptions: {
|
||||
prefix: 'whmcs:subscriptions',
|
||||
prefix: "whmcs:subscriptions",
|
||||
ttl: 300, // 5 minutes - subscriptions change less frequently
|
||||
tags: ['subscriptions', 'services'],
|
||||
tags: ["subscriptions", "services"],
|
||||
},
|
||||
subscription: {
|
||||
prefix: 'whmcs:subscription',
|
||||
prefix: "whmcs:subscription",
|
||||
ttl: 600, // 10 minutes - individual subscriptions rarely change
|
||||
tags: ['subscription', 'services'],
|
||||
tags: ["subscription", "services"],
|
||||
},
|
||||
client: {
|
||||
prefix: 'whmcs:client',
|
||||
prefix: "whmcs:client",
|
||||
ttl: 1800, // 30 minutes - client data rarely changes
|
||||
tags: ['client', 'user'],
|
||||
tags: ["client", "user"],
|
||||
},
|
||||
sso: {
|
||||
prefix: 'whmcs:sso',
|
||||
prefix: "whmcs:sso",
|
||||
ttl: 3600, // 1 hour - SSO tokens have their own expiry
|
||||
tags: ['sso', 'auth'],
|
||||
tags: ["sso", "auth"],
|
||||
},
|
||||
paymentMethods: {
|
||||
prefix: 'whmcs:paymentmethods',
|
||||
prefix: "whmcs:paymentmethods",
|
||||
ttl: 900, // 15 minutes - payment methods change occasionally
|
||||
tags: ['paymentmethods', 'billing'],
|
||||
tags: ["paymentmethods", "billing"],
|
||||
},
|
||||
paymentGateways: {
|
||||
prefix: 'whmcs:paymentgateways',
|
||||
prefix: "whmcs:paymentgateways",
|
||||
ttl: 3600, // 1 hour - payment gateways rarely change
|
||||
tags: ['paymentgateways', 'config'],
|
||||
tags: ["paymentgateways", "config"],
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly cacheService: CacheService) {}
|
||||
constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get cached invoices list for a user
|
||||
@ -73,7 +83,7 @@ export class WhmcsCacheService {
|
||||
status?: string
|
||||
): Promise<InvoiceList | null> {
|
||||
const key = this.buildInvoicesKey(userId, page, limit, status);
|
||||
return this.get<InvoiceList>(key, 'invoices');
|
||||
return this.get<InvoiceList>(key, "invoices");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -87,7 +97,7 @@ export class WhmcsCacheService {
|
||||
data: InvoiceList
|
||||
): Promise<void> {
|
||||
const key = this.buildInvoicesKey(userId, page, limit, status);
|
||||
await this.set(key, data, 'invoices', [`user:${userId}`]);
|
||||
await this.set(key, data, "invoices", [`user:${userId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -95,7 +105,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async getInvoice(userId: string, invoiceId: number): Promise<Invoice | null> {
|
||||
const key = this.buildInvoiceKey(userId, invoiceId);
|
||||
return this.get<Invoice>(key, 'invoice');
|
||||
return this.get<Invoice>(key, "invoice");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -103,7 +113,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async setInvoice(userId: string, invoiceId: number, data: Invoice): Promise<void> {
|
||||
const key = this.buildInvoiceKey(userId, invoiceId);
|
||||
await this.set(key, data, 'invoice', [`user:${userId}`, `invoice:${invoiceId}`]);
|
||||
await this.set(key, data, "invoice", [`user:${userId}`, `invoice:${invoiceId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -111,7 +121,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async getSubscriptionsList(userId: string): Promise<SubscriptionList | null> {
|
||||
const key = this.buildSubscriptionsKey(userId);
|
||||
return this.get<SubscriptionList>(key, 'subscriptions');
|
||||
return this.get<SubscriptionList>(key, "subscriptions");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,7 +129,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async setSubscriptionsList(userId: string, data: SubscriptionList): Promise<void> {
|
||||
const key = this.buildSubscriptionsKey(userId);
|
||||
await this.set(key, data, 'subscriptions', [`user:${userId}`]);
|
||||
await this.set(key, data, "subscriptions", [`user:${userId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -127,7 +137,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async getSubscription(userId: string, subscriptionId: number): Promise<Subscription | null> {
|
||||
const key = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
return this.get<Subscription>(key, 'subscription');
|
||||
return this.get<Subscription>(key, "subscription");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -135,7 +145,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async setSubscription(userId: string, subscriptionId: number, data: Subscription): Promise<void> {
|
||||
const key = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
await this.set(key, data, 'subscription', [`user:${userId}`, `subscription:${subscriptionId}`]);
|
||||
await this.set(key, data, "subscription", [`user:${userId}`, `subscription:${subscriptionId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -143,7 +153,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async getClientData(clientId: number): Promise<any | null> {
|
||||
const key = this.buildClientKey(clientId);
|
||||
return this.get<any>(key, 'client');
|
||||
return this.get<any>(key, "client");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -151,7 +161,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async setClientData(clientId: number, data: any): Promise<void> {
|
||||
const key = this.buildClientKey(clientId);
|
||||
await this.set(key, data, 'client', [`client:${clientId}`]);
|
||||
await this.set(key, data, "client", [`client:${clientId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -170,7 +180,9 @@ export class WhmcsCacheService {
|
||||
|
||||
this.logger.log(`Invalidated all cache for user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate cache for user ${userId}`, error);
|
||||
this.logger.error(`Failed to invalidate cache for user ${userId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -189,7 +201,9 @@ export class WhmcsCacheService {
|
||||
|
||||
this.logger.log(`Invalidated cache by tag: ${tag}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate cache by tag ${tag}`, error);
|
||||
this.logger.error(`Failed to invalidate cache by tag ${tag}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -208,7 +222,10 @@ export class WhmcsCacheService {
|
||||
|
||||
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate invoice cache for user ${userId}, invoice ${invoiceId}`, error);
|
||||
this.logger.error(
|
||||
`Failed to invalidate invoice cache for user ${userId}, invoice ${invoiceId}`,
|
||||
{ error: getErrorMessage(error) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,14 +237,16 @@ export class WhmcsCacheService {
|
||||
const specificKey = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
const listKey = this.buildSubscriptionsKey(userId);
|
||||
|
||||
await Promise.all([
|
||||
this.cacheService.del(specificKey),
|
||||
this.cacheService.del(listKey),
|
||||
]);
|
||||
await Promise.all([this.cacheService.del(specificKey), this.cacheService.del(listKey)]);
|
||||
|
||||
this.logger.log(`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`);
|
||||
this.logger.log(
|
||||
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate subscription cache for user ${userId}, subscription ${subscriptionId}`, error);
|
||||
this.logger.error(
|
||||
`Failed to invalidate subscription cache for user ${userId}, subscription ${subscriptionId}`,
|
||||
{ error: getErrorMessage(error) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -236,7 +255,7 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async getPaymentMethods(userId: string): Promise<PaymentMethodList | null> {
|
||||
const key = this.buildPaymentMethodsKey(userId);
|
||||
return this.get<PaymentMethodList>(key, 'paymentMethods');
|
||||
return this.get<PaymentMethodList>(key, "paymentMethods");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -244,23 +263,23 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async setPaymentMethods(userId: string, paymentMethods: PaymentMethodList): Promise<void> {
|
||||
const key = this.buildPaymentMethodsKey(userId);
|
||||
await this.set(key, paymentMethods, 'paymentMethods', [userId]);
|
||||
await this.set(key, paymentMethods, "paymentMethods", [userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached payment gateways (global)
|
||||
*/
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList | null> {
|
||||
const key = 'whmcs:paymentgateways:global';
|
||||
return this.get<PaymentGatewayList>(key, 'paymentGateways');
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
return this.get<PaymentGatewayList>(key, "paymentGateways");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set payment gateways cache (global)
|
||||
*/
|
||||
async setPaymentGateways(paymentGateways: PaymentGatewayList): Promise<void> {
|
||||
const key = 'whmcs:paymentgateways:global';
|
||||
await this.set(key, paymentGateways, 'paymentGateways');
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
await this.set(key, paymentGateways, "paymentGateways");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -281,18 +300,18 @@ export class WhmcsCacheService {
|
||||
*/
|
||||
async invalidatePaymentGateways(): Promise<void> {
|
||||
try {
|
||||
const key = 'whmcs:paymentgateways:global';
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
await this.cacheService.del(key);
|
||||
this.logger.log('Invalidated payment gateways cache');
|
||||
this.logger.log("Invalidated payment gateways cache");
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to invalidate payment gateways cache', error);
|
||||
this.logger.error("Failed to invalidate payment gateways cache", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic get method with configuration
|
||||
*/
|
||||
private async get<T>(key: string, configKey: string): Promise<T | null> {
|
||||
private async get<T>(key: string, _configKey: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await this.cacheService.get<T>(key);
|
||||
if (data) {
|
||||
@ -300,7 +319,7 @@ export class WhmcsCacheService {
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache get error for key ${key}`, error);
|
||||
this.logger.error(`Cache get error for key ${key}`, { error: getErrorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -311,15 +330,15 @@ export class WhmcsCacheService {
|
||||
private async set<T>(
|
||||
key: string,
|
||||
data: T,
|
||||
configKey: string,
|
||||
additionalTags: string[] = []
|
||||
_configKey: string,
|
||||
_additionalTags: string[] = []
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = this.cacheConfigs[configKey];
|
||||
const config = this.cacheConfigs[_configKey];
|
||||
await this.cacheService.set(key, data, config.ttl);
|
||||
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache set error for key ${key}`, error);
|
||||
this.logger.error(`Cache set error for key ${key}`, { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@ -327,7 +346,7 @@ export class WhmcsCacheService {
|
||||
* Build cache key for invoices list
|
||||
*/
|
||||
private buildInvoicesKey(userId: string, page: number, limit: number, status?: string): string {
|
||||
return `${this.cacheConfigs.invoices.prefix}:${userId}:${page}:${limit}:${status || 'all'}`;
|
||||
return `${this.cacheConfigs.invoices.prefix}:${userId}:${page}:${limit}:${status || "all"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -387,9 +406,9 @@ export class WhmcsCacheService {
|
||||
try {
|
||||
const patterns = Object.values(this.cacheConfigs).map(config => `${config.prefix}:*`);
|
||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
||||
this.logger.warn('Cleared all WHMCS cache');
|
||||
this.logger.warn("Cleared all WHMCS cache");
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to clear all WHMCS cache', error);
|
||||
this.logger.error("Failed to clear all WHMCS cache", { error: getErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
407
apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts.backup.20250822_120236
vendored
Normal file
407
apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,407 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { CacheService } from "../../../common/cache/cache.service";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
PaymentMethodList,
|
||||
PaymentGatewayList,
|
||||
} from "@customer-portal/shared";
|
||||
|
||||
export interface CacheOptions {
|
||||
ttl?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface CacheKeyConfig {
|
||||
prefix: string;
|
||||
ttl: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsCacheService {
|
||||
private readonly logger = new Logger(WhmcsCacheService.name);
|
||||
|
||||
// Cache configuration for different data types
|
||||
private readonly cacheConfigs: Record<string, CacheKeyConfig> = {
|
||||
invoices: {
|
||||
prefix: "whmcs:invoices",
|
||||
ttl: 90, // 90 seconds - invoices change frequently
|
||||
tags: ["invoices", "billing"],
|
||||
},
|
||||
invoice: {
|
||||
prefix: "whmcs:invoice",
|
||||
ttl: 300, // 5 minutes - individual invoices change less frequently
|
||||
tags: ["invoice", "billing"],
|
||||
},
|
||||
subscriptions: {
|
||||
prefix: "whmcs:subscriptions",
|
||||
ttl: 300, // 5 minutes - subscriptions change less frequently
|
||||
tags: ["subscriptions", "services"],
|
||||
},
|
||||
subscription: {
|
||||
prefix: "whmcs:subscription",
|
||||
ttl: 600, // 10 minutes - individual subscriptions rarely change
|
||||
tags: ["subscription", "services"],
|
||||
},
|
||||
client: {
|
||||
prefix: "whmcs:client",
|
||||
ttl: 1800, // 30 minutes - client data rarely changes
|
||||
tags: ["client", "user"],
|
||||
},
|
||||
sso: {
|
||||
prefix: "whmcs:sso",
|
||||
ttl: 3600, // 1 hour - SSO tokens have their own expiry
|
||||
tags: ["sso", "auth"],
|
||||
},
|
||||
paymentMethods: {
|
||||
prefix: "whmcs:paymentmethods",
|
||||
ttl: 900, // 15 minutes - payment methods change occasionally
|
||||
tags: ["paymentmethods", "billing"],
|
||||
},
|
||||
paymentGateways: {
|
||||
prefix: "whmcs:paymentgateways",
|
||||
ttl: 3600, // 1 hour - payment gateways rarely change
|
||||
tags: ["paymentgateways", "config"],
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly cacheService: CacheService) {}
|
||||
|
||||
/**
|
||||
* Get cached invoices list for a user
|
||||
*/
|
||||
async getInvoicesList(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
status?: string
|
||||
): Promise<InvoiceList | null> {
|
||||
const key = this.buildInvoicesKey(userId, page, limit, status);
|
||||
return this.get<InvoiceList>(key, "invoices");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache invoices list for a user
|
||||
*/
|
||||
async setInvoicesList(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
status: string | undefined,
|
||||
data: InvoiceList
|
||||
): Promise<void> {
|
||||
const key = this.buildInvoicesKey(userId, page, limit, status);
|
||||
await this.set(key, data, "invoices", [`user:${userId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached individual invoice
|
||||
*/
|
||||
async getInvoice(userId: string, invoiceId: number): Promise<Invoice | null> {
|
||||
const key = this.buildInvoiceKey(userId, invoiceId);
|
||||
return this.get<Invoice>(key, "invoice");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache individual invoice
|
||||
*/
|
||||
async setInvoice(userId: string, invoiceId: number, data: Invoice): Promise<void> {
|
||||
const key = this.buildInvoiceKey(userId, invoiceId);
|
||||
await this.set(key, data, "invoice", [`user:${userId}`, `invoice:${invoiceId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached subscriptions list for a user
|
||||
*/
|
||||
async getSubscriptionsList(userId: string): Promise<SubscriptionList | null> {
|
||||
const key = this.buildSubscriptionsKey(userId);
|
||||
return this.get<SubscriptionList>(key, "subscriptions");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache subscriptions list for a user
|
||||
*/
|
||||
async setSubscriptionsList(userId: string, data: SubscriptionList): Promise<void> {
|
||||
const key = this.buildSubscriptionsKey(userId);
|
||||
await this.set(key, data, "subscriptions", [`user:${userId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached individual subscription
|
||||
*/
|
||||
async getSubscription(userId: string, subscriptionId: number): Promise<Subscription | null> {
|
||||
const key = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
return this.get<Subscription>(key, "subscription");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache individual subscription
|
||||
*/
|
||||
async setSubscription(userId: string, subscriptionId: number, data: Subscription): Promise<void> {
|
||||
const key = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
await this.set(key, data, "subscription", [`user:${userId}`, `subscription:${subscriptionId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached client data
|
||||
*/
|
||||
async getClientData(clientId: number): Promise<any | null> {
|
||||
const key = this.buildClientKey(clientId);
|
||||
return this.get<any>(key, "client");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache client data
|
||||
*/
|
||||
async setClientData(clientId: number, data: any): Promise<void> {
|
||||
const key = this.buildClientKey(clientId);
|
||||
await this.set(key, data, "client", [`client:${clientId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cache for a specific user
|
||||
*/
|
||||
async invalidateUserCache(userId: string): Promise<void> {
|
||||
try {
|
||||
const patterns = [
|
||||
`${this.cacheConfigs.invoices.prefix}:${userId}:*`,
|
||||
`${this.cacheConfigs.invoice.prefix}:${userId}:*`,
|
||||
`${this.cacheConfigs.subscriptions.prefix}:${userId}:*`,
|
||||
`${this.cacheConfigs.subscription.prefix}:${userId}:*`,
|
||||
];
|
||||
|
||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
||||
|
||||
this.logger.log(`Invalidated all cache for user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate cache for user ${userId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache by tags
|
||||
*/
|
||||
async invalidateByTag(tag: string): Promise<void> {
|
||||
try {
|
||||
// This would require a more sophisticated cache implementation with tag support
|
||||
// For now, we'll use pattern-based invalidation
|
||||
const patterns = Object.values(this.cacheConfigs)
|
||||
.filter(config => config.tags.includes(tag))
|
||||
.map(config => `${config.prefix}:*`);
|
||||
|
||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
||||
|
||||
this.logger.log(`Invalidated cache by tag: ${tag}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate cache by tag ${tag}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate specific invoice cache
|
||||
*/
|
||||
async invalidateInvoice(userId: string, invoiceId: number): Promise<void> {
|
||||
try {
|
||||
const specificKey = this.buildInvoiceKey(userId, invoiceId);
|
||||
const listPattern = `${this.cacheConfigs.invoices.prefix}:${userId}:*`;
|
||||
|
||||
await Promise.all([
|
||||
this.cacheService.del(specificKey),
|
||||
this.cacheService.delPattern(listPattern),
|
||||
]);
|
||||
|
||||
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to invalidate invoice cache for user ${userId}, invoice ${invoiceId}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate specific subscription cache
|
||||
*/
|
||||
async invalidateSubscription(userId: string, subscriptionId: number): Promise<void> {
|
||||
try {
|
||||
const specificKey = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
const listKey = this.buildSubscriptionsKey(userId);
|
||||
|
||||
await Promise.all([this.cacheService.del(specificKey), this.cacheService.del(listKey)]);
|
||||
|
||||
this.logger.log(
|
||||
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to invalidate subscription cache for user ${userId}, subscription ${subscriptionId}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached payment methods for a user
|
||||
*/
|
||||
async getPaymentMethods(userId: string): Promise<PaymentMethodList | null> {
|
||||
const key = this.buildPaymentMethodsKey(userId);
|
||||
return this.get<PaymentMethodList>(key, "paymentMethods");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set payment methods cache for a user
|
||||
*/
|
||||
async setPaymentMethods(userId: string, paymentMethods: PaymentMethodList): Promise<void> {
|
||||
const key = this.buildPaymentMethodsKey(userId);
|
||||
await this.set(key, paymentMethods, "paymentMethods", [userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached payment gateways (global)
|
||||
*/
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList | null> {
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
return this.get<PaymentGatewayList>(key, "paymentGateways");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set payment gateways cache (global)
|
||||
*/
|
||||
async setPaymentGateways(paymentGateways: PaymentGatewayList): Promise<void> {
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
await this.set(key, paymentGateways, "paymentGateways");
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate payment methods cache for a user
|
||||
*/
|
||||
async invalidatePaymentMethods(userId: string): Promise<void> {
|
||||
try {
|
||||
const key = this.buildPaymentMethodsKey(userId);
|
||||
await this.cacheService.del(key);
|
||||
this.logger.log(`Invalidated payment methods cache for user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate payment gateways cache (global)
|
||||
*/
|
||||
async invalidatePaymentGateways(): Promise<void> {
|
||||
try {
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
await this.cacheService.del(key);
|
||||
this.logger.log("Invalidated payment gateways cache");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to invalidate payment gateways cache", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic get method with configuration
|
||||
*/
|
||||
private async get<T>(key: string, _configKey: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await this.cacheService.get<T>(key);
|
||||
if (data) {
|
||||
this.logger.debug(`Cache hit: ${key}`);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache get error for key ${key}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic set method with configuration
|
||||
*/
|
||||
private async set<T>(
|
||||
key: string,
|
||||
data: T,
|
||||
_configKey: string,
|
||||
_additionalTags: string[] = []
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = this.cacheConfigs[_configKey];
|
||||
await this.cacheService.set(key, data, config.ttl);
|
||||
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache set error for key ${key}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for invoices list
|
||||
*/
|
||||
private buildInvoicesKey(userId: string, page: number, limit: number, status?: string): string {
|
||||
return `${this.cacheConfigs.invoices.prefix}:${userId}:${page}:${limit}:${status || "all"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for individual invoice
|
||||
*/
|
||||
private buildInvoiceKey(userId: string, invoiceId: number): string {
|
||||
return `${this.cacheConfigs.invoice.prefix}:${userId}:${invoiceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for subscriptions list
|
||||
*/
|
||||
private buildSubscriptionsKey(userId: string): string {
|
||||
return `${this.cacheConfigs.subscriptions.prefix}:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for individual subscription
|
||||
*/
|
||||
private buildSubscriptionKey(userId: string, subscriptionId: number): string {
|
||||
return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for client data
|
||||
*/
|
||||
private buildClientKey(clientId: number): string {
|
||||
return `${this.cacheConfigs.client.prefix}:${clientId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for payment methods
|
||||
*/
|
||||
private buildPaymentMethodsKey(userId: string): string {
|
||||
return `${this.cacheConfigs.paymentMethods.prefix}:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
async getCacheStats(): Promise<{
|
||||
totalKeys: number;
|
||||
keysByType: Record<string, number>;
|
||||
}> {
|
||||
// This would require Redis SCAN or similar functionality
|
||||
// For now, return a placeholder
|
||||
return {
|
||||
totalKeys: 0,
|
||||
keysByType: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all WHMCS cache
|
||||
*/
|
||||
async clearAllCache(): Promise<void> {
|
||||
try {
|
||||
const patterns = Object.values(this.cacheConfigs).map(config => `${config.prefix}:*`);
|
||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
||||
this.logger.warn("Cleared all WHMCS cache");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to clear all WHMCS cache", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
410
apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts.backup.20250822_120518
vendored
Normal file
410
apps/bff/src/vendors/whmcs/cache/whmcs-cache.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,410 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { CacheService } from "../../../common/cache/cache.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
PaymentMethodList,
|
||||
PaymentGatewayList,
|
||||
} from "@customer-portal/shared";
|
||||
|
||||
export interface CacheOptions {
|
||||
ttl?: number;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface CacheKeyConfig {
|
||||
prefix: string;
|
||||
ttl: number;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsCacheService {
|
||||
|
||||
|
||||
// Cache configuration for different data types
|
||||
private readonly cacheConfigs: Record<string, CacheKeyConfig> = {
|
||||
invoices: {
|
||||
prefix: "whmcs:invoices",
|
||||
ttl: 90, // 90 seconds - invoices change frequently
|
||||
tags: ["invoices", "billing"],
|
||||
},
|
||||
invoice: {
|
||||
prefix: "whmcs:invoice",
|
||||
ttl: 300, // 5 minutes - individual invoices change less frequently
|
||||
tags: ["invoice", "billing"],
|
||||
},
|
||||
subscriptions: {
|
||||
prefix: "whmcs:subscriptions",
|
||||
ttl: 300, // 5 minutes - subscriptions change less frequently
|
||||
tags: ["subscriptions", "services"],
|
||||
},
|
||||
subscription: {
|
||||
prefix: "whmcs:subscription",
|
||||
ttl: 600, // 10 minutes - individual subscriptions rarely change
|
||||
tags: ["subscription", "services"],
|
||||
},
|
||||
client: {
|
||||
prefix: "whmcs:client",
|
||||
ttl: 1800, // 30 minutes - client data rarely changes
|
||||
tags: ["client", "user"],
|
||||
},
|
||||
sso: {
|
||||
prefix: "whmcs:sso",
|
||||
ttl: 3600, // 1 hour - SSO tokens have their own expiry
|
||||
tags: ["sso", "auth"],
|
||||
},
|
||||
paymentMethods: {
|
||||
prefix: "whmcs:paymentmethods",
|
||||
ttl: 900, // 15 minutes - payment methods change occasionally
|
||||
tags: ["paymentmethods", "billing"],
|
||||
},
|
||||
paymentGateways: {
|
||||
prefix: "whmcs:paymentgateways",
|
||||
ttl: 3600, // 1 hour - payment gateways rarely change
|
||||
tags: ["paymentgateways", "config"],
|
||||
},
|
||||
};
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,private readonly cacheService: CacheService) {}
|
||||
|
||||
/**
|
||||
* Get cached invoices list for a user
|
||||
*/
|
||||
async getInvoicesList(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
status?: string
|
||||
): Promise<InvoiceList | null> {
|
||||
const key = this.buildInvoicesKey(userId, page, limit, status);
|
||||
return this.get<InvoiceList>(key, "invoices");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache invoices list for a user
|
||||
*/
|
||||
async setInvoicesList(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
status: string | undefined,
|
||||
data: InvoiceList
|
||||
): Promise<void> {
|
||||
const key = this.buildInvoicesKey(userId, page, limit, status);
|
||||
await this.set(key, data, "invoices", [`user:${userId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached individual invoice
|
||||
*/
|
||||
async getInvoice(userId: string, invoiceId: number): Promise<Invoice | null> {
|
||||
const key = this.buildInvoiceKey(userId, invoiceId);
|
||||
return this.get<Invoice>(key, "invoice");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache individual invoice
|
||||
*/
|
||||
async setInvoice(userId: string, invoiceId: number, data: Invoice): Promise<void> {
|
||||
const key = this.buildInvoiceKey(userId, invoiceId);
|
||||
await this.set(key, data, "invoice", [`user:${userId}`, `invoice:${invoiceId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached subscriptions list for a user
|
||||
*/
|
||||
async getSubscriptionsList(userId: string): Promise<SubscriptionList | null> {
|
||||
const key = this.buildSubscriptionsKey(userId);
|
||||
return this.get<SubscriptionList>(key, "subscriptions");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache subscriptions list for a user
|
||||
*/
|
||||
async setSubscriptionsList(userId: string, data: SubscriptionList): Promise<void> {
|
||||
const key = this.buildSubscriptionsKey(userId);
|
||||
await this.set(key, data, "subscriptions", [`user:${userId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached individual subscription
|
||||
*/
|
||||
async getSubscription(userId: string, subscriptionId: number): Promise<Subscription | null> {
|
||||
const key = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
return this.get<Subscription>(key, "subscription");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache individual subscription
|
||||
*/
|
||||
async setSubscription(userId: string, subscriptionId: number, data: Subscription): Promise<void> {
|
||||
const key = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
await this.set(key, data, "subscription", [`user:${userId}`, `subscription:${subscriptionId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached client data
|
||||
*/
|
||||
async getClientData(clientId: number): Promise<any | null> {
|
||||
const key = this.buildClientKey(clientId);
|
||||
return this.get<any>(key, "client");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache client data
|
||||
*/
|
||||
async setClientData(clientId: number, data: any): Promise<void> {
|
||||
const key = this.buildClientKey(clientId);
|
||||
await this.set(key, data, "client", [`client:${clientId}`]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cache for a specific user
|
||||
*/
|
||||
async invalidateUserCache(userId: string): Promise<void> {
|
||||
try {
|
||||
const patterns = [
|
||||
`${this.cacheConfigs.invoices.prefix}:${userId}:*`,
|
||||
`${this.cacheConfigs.invoice.prefix}:${userId}:*`,
|
||||
`${this.cacheConfigs.subscriptions.prefix}:${userId}:*`,
|
||||
`${this.cacheConfigs.subscription.prefix}:${userId}:*`,
|
||||
];
|
||||
|
||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
||||
|
||||
this.logger.log(`Invalidated all cache for user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate cache for user ${userId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache by tags
|
||||
*/
|
||||
async invalidateByTag(tag: string): Promise<void> {
|
||||
try {
|
||||
// This would require a more sophisticated cache implementation with tag support
|
||||
// For now, we'll use pattern-based invalidation
|
||||
const patterns = Object.values(this.cacheConfigs)
|
||||
.filter(config => config.tags.includes(tag))
|
||||
.map(config => `${config.prefix}:*`);
|
||||
|
||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
||||
|
||||
this.logger.log(`Invalidated cache by tag: ${tag}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate cache by tag ${tag}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate specific invoice cache
|
||||
*/
|
||||
async invalidateInvoice(userId: string, invoiceId: number): Promise<void> {
|
||||
try {
|
||||
const specificKey = this.buildInvoiceKey(userId, invoiceId);
|
||||
const listPattern = `${this.cacheConfigs.invoices.prefix}:${userId}:*`;
|
||||
|
||||
await Promise.all([
|
||||
this.cacheService.del(specificKey),
|
||||
this.cacheService.delPattern(listPattern),
|
||||
]);
|
||||
|
||||
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to invalidate invoice cache for user ${userId}, invoice ${invoiceId}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate specific subscription cache
|
||||
*/
|
||||
async invalidateSubscription(userId: string, subscriptionId: number): Promise<void> {
|
||||
try {
|
||||
const specificKey = this.buildSubscriptionKey(userId, subscriptionId);
|
||||
const listKey = this.buildSubscriptionsKey(userId);
|
||||
|
||||
await Promise.all([this.cacheService.del(specificKey), this.cacheService.del(listKey)]);
|
||||
|
||||
this.logger.log(
|
||||
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to invalidate subscription cache for user ${userId}, subscription ${subscriptionId}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached payment methods for a user
|
||||
*/
|
||||
async getPaymentMethods(userId: string): Promise<PaymentMethodList | null> {
|
||||
const key = this.buildPaymentMethodsKey(userId);
|
||||
return this.get<PaymentMethodList>(key, "paymentMethods");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set payment methods cache for a user
|
||||
*/
|
||||
async setPaymentMethods(userId: string, paymentMethods: PaymentMethodList): Promise<void> {
|
||||
const key = this.buildPaymentMethodsKey(userId);
|
||||
await this.set(key, paymentMethods, "paymentMethods", [userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached payment gateways (global)
|
||||
*/
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList | null> {
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
return this.get<PaymentGatewayList>(key, "paymentGateways");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set payment gateways cache (global)
|
||||
*/
|
||||
async setPaymentGateways(paymentGateways: PaymentGatewayList): Promise<void> {
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
await this.set(key, paymentGateways, "paymentGateways");
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate payment methods cache for a user
|
||||
*/
|
||||
async invalidatePaymentMethods(userId: string): Promise<void> {
|
||||
try {
|
||||
const key = this.buildPaymentMethodsKey(userId);
|
||||
await this.cacheService.del(key);
|
||||
this.logger.log(`Invalidated payment methods cache for user ${userId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate payment gateways cache (global)
|
||||
*/
|
||||
async invalidatePaymentGateways(): Promise<void> {
|
||||
try {
|
||||
const key = "whmcs:paymentgateways:global";
|
||||
await this.cacheService.del(key);
|
||||
this.logger.log("Invalidated payment gateways cache");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to invalidate payment gateways cache", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic get method with configuration
|
||||
*/
|
||||
private async get<T>(key: string, _configKey: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await this.cacheService.get<T>(key);
|
||||
if (data) {
|
||||
this.logger.debug(`Cache hit: ${key}`);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache get error for key ${key}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic set method with configuration
|
||||
*/
|
||||
private async set<T>(
|
||||
key: string,
|
||||
data: T,
|
||||
_configKey: string,
|
||||
_additionalTags: string[] = []
|
||||
): Promise<void> {
|
||||
try {
|
||||
const config = this.cacheConfigs[_configKey];
|
||||
await this.cacheService.set(key, data, config.ttl);
|
||||
this.logger.debug(`Cache set: ${key} (TTL: ${config.ttl}s)`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Cache set error for key ${key}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for invoices list
|
||||
*/
|
||||
private buildInvoicesKey(userId: string, page: number, limit: number, status?: string): string {
|
||||
return `${this.cacheConfigs.invoices.prefix}:${userId}:${page}:${limit}:${status || "all"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for individual invoice
|
||||
*/
|
||||
private buildInvoiceKey(userId: string, invoiceId: number): string {
|
||||
return `${this.cacheConfigs.invoice.prefix}:${userId}:${invoiceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for subscriptions list
|
||||
*/
|
||||
private buildSubscriptionsKey(userId: string): string {
|
||||
return `${this.cacheConfigs.subscriptions.prefix}:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for individual subscription
|
||||
*/
|
||||
private buildSubscriptionKey(userId: string, subscriptionId: number): string {
|
||||
return `${this.cacheConfigs.subscription.prefix}:${userId}:${subscriptionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for client data
|
||||
*/
|
||||
private buildClientKey(clientId: number): string {
|
||||
return `${this.cacheConfigs.client.prefix}:${clientId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cache key for payment methods
|
||||
*/
|
||||
private buildPaymentMethodsKey(userId: string): string {
|
||||
return `${this.cacheConfigs.paymentMethods.prefix}:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
async getCacheStats(): Promise<{
|
||||
totalKeys: number;
|
||||
keysByType: Record<string, number>;
|
||||
}> {
|
||||
// This would require Redis SCAN or similar functionality
|
||||
// For now, return a placeholder
|
||||
return {
|
||||
totalKeys: 0,
|
||||
keysByType: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all WHMCS cache
|
||||
*/
|
||||
async clearAllCache(): Promise<void> {
|
||||
try {
|
||||
const patterns = Object.values(this.cacheConfigs).map(config => `${config.prefix}:*`);
|
||||
await Promise.all(patterns.map(pattern => this.cacheService.delPattern(pattern)));
|
||||
this.logger.warn("Cleared all WHMCS cache");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to clear all WHMCS cache", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,16 @@
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import {
|
||||
WhmcsValidateLoginParams,
|
||||
WhmcsAddClientParams,
|
||||
} from "../types/whmcs-api.types";
|
||||
import { WhmcsValidateLoginParams, WhmcsAddClientParams } from "../types/whmcs-api.types";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsClientService {
|
||||
private readonly logger = new Logger(WhmcsClientService.name);
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly cacheService: WhmcsCacheService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -21,7 +18,7 @@ export class WhmcsClientService {
|
||||
*/
|
||||
async validateLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
password: string
|
||||
): Promise<{ userId: number; passwordHash: string }> {
|
||||
try {
|
||||
const params: WhmcsValidateLoginParams = {
|
||||
@ -68,12 +65,9 @@ export class WhmcsClientService {
|
||||
this.logger.log(`Fetched client details for client ${clientId}`);
|
||||
return response.client;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to fetch client details for client ${clientId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to fetch client details for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -83,18 +77,14 @@ export class WhmcsClientService {
|
||||
*/
|
||||
async getClientDetailsByEmail(email: string): Promise<any> {
|
||||
try {
|
||||
const response =
|
||||
await this.connectionService.getClientDetailsByEmail(email);
|
||||
const response = await this.connectionService.getClientDetailsByEmail(email);
|
||||
|
||||
if (!response.client) {
|
||||
throw new NotFoundException(`Client with email ${email} not found`);
|
||||
}
|
||||
|
||||
// Cache by client ID
|
||||
await this.cacheService.setClientData(
|
||||
response.client.id,
|
||||
response.client,
|
||||
);
|
||||
await this.cacheService.setClientData(response.client.id, response.client);
|
||||
|
||||
this.logger.log(`Fetched client details by email: ${email}`);
|
||||
return response.client;
|
||||
@ -109,9 +99,7 @@ export class WhmcsClientService {
|
||||
/**
|
||||
* Add new client
|
||||
*/
|
||||
async addClient(
|
||||
clientData: WhmcsAddClientParams,
|
||||
): Promise<{ clientId: number }> {
|
||||
async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> {
|
||||
try {
|
||||
const response = await this.connectionService.addClient(clientData);
|
||||
|
||||
|
||||
124
apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts.backup.20250822_120236
vendored
Normal file
124
apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,124 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { WhmcsValidateLoginParams, WhmcsAddClientParams } from "../types/whmcs-api.types";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsClientService {
|
||||
private readonly logger = new Logger(WhmcsClientService.name);
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate client login credentials
|
||||
*/
|
||||
async validateLogin(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ userId: number; passwordHash: string }> {
|
||||
try {
|
||||
const params: WhmcsValidateLoginParams = {
|
||||
email,
|
||||
password2: password,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.validateLogin(params);
|
||||
|
||||
this.logger.log(`Validated login for email: ${email}`);
|
||||
return {
|
||||
userId: response.userid,
|
||||
passwordHash: response.passwordhash,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to validate login for email: ${email}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client details by ID
|
||||
*/
|
||||
async getClientDetails(clientId: number): Promise<any> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getClientData(clientId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for client: ${clientId}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await this.connectionService.getClientDetails(clientId);
|
||||
|
||||
if (!response.client) {
|
||||
throw new NotFoundException(`Client ${clientId} not found`);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setClientData(clientId, response.client);
|
||||
|
||||
this.logger.log(`Fetched client details for client ${clientId}`);
|
||||
return response.client;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch client details for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client details by email
|
||||
*/
|
||||
async getClientDetailsByEmail(email: string): Promise<any> {
|
||||
try {
|
||||
const response = await this.connectionService.getClientDetailsByEmail(email);
|
||||
|
||||
if (!response.client) {
|
||||
throw new NotFoundException(`Client with email ${email} not found`);
|
||||
}
|
||||
|
||||
// Cache by client ID
|
||||
await this.cacheService.setClientData(response.client.id, response.client);
|
||||
|
||||
this.logger.log(`Fetched client details by email: ${email}`);
|
||||
return response.client;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch client details by email: ${email}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new client
|
||||
*/
|
||||
async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> {
|
||||
try {
|
||||
const response = await this.connectionService.addClient(clientData);
|
||||
|
||||
this.logger.log(`Created new client: ${response.clientid}`);
|
||||
return { clientId: response.clientid };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create new client`, {
|
||||
error: getErrorMessage(error),
|
||||
email: clientData.email,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a user
|
||||
*/
|
||||
async invalidateUserCache(userId: string): Promise<void> {
|
||||
await this.cacheService.invalidateUserCache(userId);
|
||||
this.logger.log(`Invalidated cache for user ${userId}`);
|
||||
}
|
||||
}
|
||||
130
apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts.backup.20250822_120518
vendored
Normal file
130
apps/bff/src/vendors/whmcs/services/whmcs-client.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,130 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsValidateLoginParams, WhmcsAddClientParams } from "../types/whmcs-api.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsClientService {
|
||||
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validate client login credentials
|
||||
*/
|
||||
async validateLogin(
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<{ userId: number; passwordHash: string }> {
|
||||
try {
|
||||
const params: WhmcsValidateLoginParams = {
|
||||
email,
|
||||
password2: password,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.validateLogin(params);
|
||||
|
||||
this.logger.log(`Validated login for email: ${email}`);
|
||||
return {
|
||||
userId: response.userid,
|
||||
passwordHash: response.passwordhash,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to validate login for email: ${email}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client details by ID
|
||||
*/
|
||||
async getClientDetails(clientId: number): Promise<any> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getClientData(clientId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for client: ${clientId}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const response = await this.connectionService.getClientDetails(clientId);
|
||||
|
||||
if (!response.client) {
|
||||
throw new NotFoundException(`Client ${clientId} not found`);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setClientData(clientId, response.client);
|
||||
|
||||
this.logger.log(`Fetched client details for client ${clientId}`);
|
||||
return response.client;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch client details for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client details by email
|
||||
*/
|
||||
async getClientDetailsByEmail(email: string): Promise<any> {
|
||||
try {
|
||||
const response = await this.connectionService.getClientDetailsByEmail(email);
|
||||
|
||||
if (!response.client) {
|
||||
throw new NotFoundException(`Client with email ${email} not found`);
|
||||
}
|
||||
|
||||
// Cache by client ID
|
||||
await this.cacheService.setClientData(response.client.id, response.client);
|
||||
|
||||
this.logger.log(`Fetched client details by email: ${email}`);
|
||||
return response.client;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch client details by email: ${email}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new client
|
||||
*/
|
||||
async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> {
|
||||
try {
|
||||
const response = await this.connectionService.addClient(clientData);
|
||||
|
||||
this.logger.log(`Created new client: ${response.clientid}`);
|
||||
return { clientId: response.clientid };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create new client`, {
|
||||
error: getErrorMessage(error),
|
||||
email: clientData.email,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a user
|
||||
*/
|
||||
async invalidateUserCache(userId: string): Promise<void> {
|
||||
await this.cacheService.invalidateUserCache(userId);
|
||||
this.logger.log(`Invalidated cache for user ${userId}`);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
WhmcsApiResponse,
|
||||
@ -35,19 +36,18 @@ export interface WhmcsApiConfig {
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsConnectionService {
|
||||
private readonly logger = new Logger(WhmcsConnectionService.name);
|
||||
private readonly config: WhmcsApiConfig;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly configService: ConfigService
|
||||
) {
|
||||
this.config = {
|
||||
baseUrl: this.configService.get<string>("WHMCS_BASE_URL", ""),
|
||||
identifier: this.configService.get<string>("WHMCS_API_IDENTIFIER", ""),
|
||||
secret: this.configService.get<string>("WHMCS_API_SECRET", ""),
|
||||
timeout: this.configService.get<number>("WHMCS_API_TIMEOUT", 30000),
|
||||
retryAttempts: this.configService.get<number>(
|
||||
"WHMCS_API_RETRY_ATTEMPTS",
|
||||
3,
|
||||
),
|
||||
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 3),
|
||||
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
|
||||
};
|
||||
|
||||
@ -57,13 +57,11 @@ export class WhmcsConnectionService {
|
||||
private validateConfig(): void {
|
||||
const requiredFields = ["baseUrl", "identifier", "secret"];
|
||||
const missingFields = requiredFields.filter(
|
||||
(field) => !this.config[field as keyof WhmcsApiConfig],
|
||||
field => !this.config[field as keyof WhmcsApiConfig]
|
||||
);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required WHMCS configuration: ${missingFields.join(", ")}`,
|
||||
);
|
||||
throw new Error(`Missing required WHMCS configuration: ${missingFields.join(", ")}`);
|
||||
}
|
||||
|
||||
if (!this.config.baseUrl.startsWith("http")) {
|
||||
@ -77,7 +75,7 @@ export class WhmcsConnectionService {
|
||||
private async makeRequest<T>(
|
||||
action: string,
|
||||
params: Record<string, any> = {},
|
||||
attempt: number = 1,
|
||||
attempt: number = 1
|
||||
): Promise<T> {
|
||||
const url = `${this.config.baseUrl}/includes/api.php`;
|
||||
|
||||
@ -148,32 +146,24 @@ export class WhmcsConnectionService {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
this.logger.error(
|
||||
`WHMCS API Timeout [${action}] after ${this.config.timeout}ms`,
|
||||
);
|
||||
this.logger.error(`WHMCS API Timeout [${action}] after ${this.config.timeout}ms`);
|
||||
throw new Error("WHMCS API request timeout");
|
||||
}
|
||||
|
||||
// Retry logic for network errors and server errors
|
||||
if (attempt < this.config.retryAttempts! && this.shouldRetry(error)) {
|
||||
this.logger.warn(
|
||||
`WHMCS API Request [${action}] failed, retrying attempt ${attempt + 1}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.warn(`WHMCS API Request [${action}] failed, retrying attempt ${attempt + 1}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
await this.delay(this.config.retryDelay! * attempt);
|
||||
return this.makeRequest<T>(action, params, attempt + 1);
|
||||
}
|
||||
|
||||
this.logger.error(
|
||||
`WHMCS API Request [${action}] failed after ${attempt} attempts`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
params: this.sanitizeLogParams(params),
|
||||
},
|
||||
);
|
||||
this.logger.error(`WHMCS API Request [${action}] failed after ${attempt} attempts`, {
|
||||
error: getErrorMessage(error),
|
||||
params: this.sanitizeLogParams(params),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
@ -190,7 +180,7 @@ export class WhmcsConnectionService {
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private sanitizeParams(params: Record<string, any>): Record<string, string> {
|
||||
@ -210,7 +200,7 @@ export class WhmcsConnectionService {
|
||||
|
||||
// Remove sensitive data from logs
|
||||
const sensitiveFields = ["password", "password2", "secret", "token", "key"];
|
||||
sensitiveFields.forEach((field) => {
|
||||
sensitiveFields.forEach(field => {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = "[REDACTED]";
|
||||
}
|
||||
@ -257,10 +247,7 @@ export class WhmcsConnectionService {
|
||||
try {
|
||||
return await this.makeRequest("GetProducts", { limitnum: 1 });
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
"Failed to get WHMCS system info",
|
||||
getErrorMessage(error),
|
||||
);
|
||||
this.logger.warn("Failed to get WHMCS system info", { error: getErrorMessage(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -281,18 +268,11 @@ export class WhmcsConnectionService {
|
||||
});
|
||||
}
|
||||
|
||||
async validateLogin(
|
||||
params: WhmcsValidateLoginParams,
|
||||
): Promise<WhmcsValidateLoginResponse> {
|
||||
return this.makeRequest<WhmcsValidateLoginResponse>(
|
||||
"ValidateLogin",
|
||||
params,
|
||||
);
|
||||
async validateLogin(params: WhmcsValidateLoginParams): Promise<WhmcsValidateLoginResponse> {
|
||||
return this.makeRequest<WhmcsValidateLoginResponse>("ValidateLogin", params);
|
||||
}
|
||||
|
||||
async addClient(
|
||||
params: WhmcsAddClientParams,
|
||||
): Promise<WhmcsAddClientResponse> {
|
||||
async addClient(params: WhmcsAddClientParams): Promise<WhmcsAddClientResponse> {
|
||||
return this.makeRequest<WhmcsAddClientResponse>("AddClient", params);
|
||||
}
|
||||
|
||||
@ -300,9 +280,7 @@ export class WhmcsConnectionService {
|
||||
// INVOICE API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getInvoices(
|
||||
params: WhmcsGetInvoicesParams,
|
||||
): Promise<WhmcsInvoicesResponse> {
|
||||
async getInvoices(params: WhmcsGetInvoicesParams): Promise<WhmcsInvoicesResponse> {
|
||||
return this.makeRequest<WhmcsInvoicesResponse>("GetInvoices", params);
|
||||
}
|
||||
|
||||
@ -316,13 +294,8 @@ export class WhmcsConnectionService {
|
||||
// PRODUCT/SERVICE API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getClientsProducts(
|
||||
params: WhmcsGetClientsProductsParams,
|
||||
): Promise<WhmcsProductsResponse> {
|
||||
return this.makeRequest<WhmcsProductsResponse>(
|
||||
"GetClientsProducts",
|
||||
params,
|
||||
);
|
||||
async getClientsProducts(params: WhmcsGetClientsProductsParams): Promise<WhmcsProductsResponse> {
|
||||
return this.makeRequest<WhmcsProductsResponse>("GetClientsProducts", params);
|
||||
}
|
||||
|
||||
async getProducts(): Promise<WhmcsCatalogProductsResponse> {
|
||||
@ -333,9 +306,7 @@ export class WhmcsConnectionService {
|
||||
// SSO API METHODS
|
||||
// ==========================================
|
||||
|
||||
async createSsoToken(
|
||||
params: WhmcsCreateSsoTokenParams,
|
||||
): Promise<WhmcsSsoResponse> {
|
||||
async createSsoToken(params: WhmcsCreateSsoTokenParams): Promise<WhmcsSsoResponse> {
|
||||
return this.makeRequest<WhmcsSsoResponse>("CreateSsoToken", params);
|
||||
}
|
||||
|
||||
@ -343,15 +314,11 @@ export class WhmcsConnectionService {
|
||||
// PAYMENT METHOD API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getPayMethods(
|
||||
params: WhmcsGetPayMethodsParams,
|
||||
): Promise<WhmcsPayMethodsResponse> {
|
||||
async getPayMethods(params: WhmcsGetPayMethodsParams): Promise<WhmcsPayMethodsResponse> {
|
||||
return this.makeRequest<WhmcsPayMethodsResponse>("GetPayMethods", params);
|
||||
}
|
||||
|
||||
async addPayMethod(
|
||||
params: WhmcsAddPayMethodParams,
|
||||
): Promise<WhmcsAddPayMethodResponse> {
|
||||
async addPayMethod(params: WhmcsAddPayMethodParams): Promise<WhmcsAddPayMethodResponse> {
|
||||
return this.makeRequest<WhmcsAddPayMethodResponse>("AddPayMethod", params);
|
||||
}
|
||||
|
||||
|
||||
329
apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts.backup.20250822_120236
vendored
Normal file
329
apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,329 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
WhmcsApiResponse,
|
||||
WhmcsErrorResponse,
|
||||
WhmcsInvoicesResponse,
|
||||
WhmcsInvoiceResponse,
|
||||
WhmcsProductsResponse,
|
||||
WhmcsClientResponse,
|
||||
WhmcsSsoResponse,
|
||||
WhmcsValidateLoginResponse,
|
||||
WhmcsAddClientResponse,
|
||||
WhmcsCatalogProductsResponse,
|
||||
WhmcsPayMethodsResponse,
|
||||
WhmcsAddPayMethodResponse,
|
||||
WhmcsPaymentGatewaysResponse,
|
||||
WhmcsGetInvoicesParams,
|
||||
WhmcsGetClientsProductsParams,
|
||||
WhmcsCreateSsoTokenParams,
|
||||
WhmcsValidateLoginParams,
|
||||
WhmcsAddClientParams,
|
||||
WhmcsGetPayMethodsParams,
|
||||
WhmcsAddPayMethodParams,
|
||||
} from "../types/whmcs-api.types";
|
||||
|
||||
export interface WhmcsApiConfig {
|
||||
baseUrl: string;
|
||||
identifier: string;
|
||||
secret: string;
|
||||
timeout?: number;
|
||||
retryAttempts?: number;
|
||||
retryDelay?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsConnectionService {
|
||||
private readonly logger = new Logger(WhmcsConnectionService.name);
|
||||
private readonly config: WhmcsApiConfig;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.config = {
|
||||
baseUrl: this.configService.get<string>("WHMCS_BASE_URL", ""),
|
||||
identifier: this.configService.get<string>("WHMCS_API_IDENTIFIER", ""),
|
||||
secret: this.configService.get<string>("WHMCS_API_SECRET", ""),
|
||||
timeout: this.configService.get<number>("WHMCS_API_TIMEOUT", 30000),
|
||||
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 3),
|
||||
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
|
||||
};
|
||||
|
||||
this.validateConfig();
|
||||
}
|
||||
|
||||
private validateConfig(): void {
|
||||
const requiredFields = ["baseUrl", "identifier", "secret"];
|
||||
const missingFields = requiredFields.filter(
|
||||
field => !this.config[field as keyof WhmcsApiConfig]
|
||||
);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(`Missing required WHMCS configuration: ${missingFields.join(", ")}`);
|
||||
}
|
||||
|
||||
if (!this.config.baseUrl.startsWith("http")) {
|
||||
throw new Error("WHMCS_BASE_URL must be a valid HTTP/HTTPS URL");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the WHMCS API with retry logic and proper error handling
|
||||
*/
|
||||
private async makeRequest<T>(
|
||||
action: string,
|
||||
params: Record<string, any> = {},
|
||||
attempt: number = 1
|
||||
): Promise<T> {
|
||||
const url = `${this.config.baseUrl}/includes/api.php`;
|
||||
|
||||
const requestParams = {
|
||||
action,
|
||||
username: this.config.identifier,
|
||||
password: this.config.secret,
|
||||
responsetype: "json",
|
||||
...this.sanitizeParams(params),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
try {
|
||||
this.logger.debug(`WHMCS API Request [${action}] attempt ${attempt}`, {
|
||||
action,
|
||||
params: this.sanitizeLogParams(params),
|
||||
});
|
||||
|
||||
const formData = new URLSearchParams(requestParams);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Customer-Portal/1.0",
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
let data: WhmcsApiResponse<T>;
|
||||
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
|
||||
responseText: responseText.substring(0, 500),
|
||||
parseError: getErrorMessage(parseError),
|
||||
});
|
||||
throw new Error("Invalid JSON response from WHMCS API");
|
||||
}
|
||||
|
||||
if (data.result === "error") {
|
||||
const errorResponse = data as WhmcsErrorResponse;
|
||||
this.logger.error(`WHMCS API Error [${action}]`, {
|
||||
message: errorResponse.message,
|
||||
errorcode: errorResponse.errorcode,
|
||||
params: this.sanitizeLogParams(params),
|
||||
});
|
||||
throw new Error(`WHMCS API Error: ${errorResponse.message}`);
|
||||
}
|
||||
|
||||
this.logger.debug(`WHMCS API Success [${action}]`, {
|
||||
action,
|
||||
resultSize: JSON.stringify(data).length,
|
||||
});
|
||||
|
||||
return data as T;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
this.logger.error(`WHMCS API Timeout [${action}] after ${this.config.timeout}ms`);
|
||||
throw new Error("WHMCS API request timeout");
|
||||
}
|
||||
|
||||
// Retry logic for network errors and server errors
|
||||
if (attempt < this.config.retryAttempts! && this.shouldRetry(error)) {
|
||||
this.logger.warn(`WHMCS API Request [${action}] failed, retrying attempt ${attempt + 1}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
await this.delay(this.config.retryDelay! * attempt);
|
||||
return this.makeRequest<T>(action, params, attempt + 1);
|
||||
}
|
||||
|
||||
this.logger.error(`WHMCS API Request [${action}] failed after ${attempt} attempts`, {
|
||||
error: getErrorMessage(error),
|
||||
params: this.sanitizeLogParams(params),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetry(error: any): boolean {
|
||||
// Retry on network errors, timeouts, and 5xx server errors
|
||||
return (
|
||||
getErrorMessage(error).includes("fetch") ||
|
||||
getErrorMessage(error).includes("network") ||
|
||||
getErrorMessage(error).includes("timeout") ||
|
||||
getErrorMessage(error).includes("HTTP 5")
|
||||
);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private sanitizeParams(params: Record<string, any>): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
sanitized[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private sanitizeLogParams(params: Record<string, any>): Record<string, any> {
|
||||
const sanitized = { ...params };
|
||||
|
||||
// Remove sensitive data from logs
|
||||
const sensitiveFields = ["password", "password2", "secret", "token", "key"];
|
||||
sensitiveFields.forEach(field => {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = "[REDACTED]";
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PUBLIC API METHODS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test WHMCS API connectivity
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
// Make a simple API call to verify connectivity
|
||||
await this.makeRequest("GetProducts", { limitnum: 1 });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error("WHMCS API Health Check Failed", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WHMCS service is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
return await this.healthCheck();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WHMCS system information
|
||||
*/
|
||||
async getSystemInfo(): Promise<any> {
|
||||
try {
|
||||
return await this.makeRequest("GetProducts", { limitnum: 1 });
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to get WHMCS system info", getErrorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CLIENT API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getClientDetails(clientId: number): Promise<WhmcsClientResponse> {
|
||||
return this.makeRequest<WhmcsClientResponse>("GetClientsDetails", {
|
||||
clientid: clientId,
|
||||
});
|
||||
}
|
||||
|
||||
async getClientDetailsByEmail(email: string): Promise<WhmcsClientResponse> {
|
||||
return this.makeRequest<WhmcsClientResponse>("GetClientsDetails", {
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
async validateLogin(params: WhmcsValidateLoginParams): Promise<WhmcsValidateLoginResponse> {
|
||||
return this.makeRequest<WhmcsValidateLoginResponse>("ValidateLogin", params);
|
||||
}
|
||||
|
||||
async addClient(params: WhmcsAddClientParams): Promise<WhmcsAddClientResponse> {
|
||||
return this.makeRequest<WhmcsAddClientResponse>("AddClient", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INVOICE API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getInvoices(params: WhmcsGetInvoicesParams): Promise<WhmcsInvoicesResponse> {
|
||||
return this.makeRequest<WhmcsInvoicesResponse>("GetInvoices", params);
|
||||
}
|
||||
|
||||
async getInvoice(invoiceId: number): Promise<WhmcsInvoiceResponse> {
|
||||
return this.makeRequest<WhmcsInvoiceResponse>("GetInvoice", {
|
||||
invoiceid: invoiceId,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PRODUCT/SERVICE API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getClientsProducts(params: WhmcsGetClientsProductsParams): Promise<WhmcsProductsResponse> {
|
||||
return this.makeRequest<WhmcsProductsResponse>("GetClientsProducts", params);
|
||||
}
|
||||
|
||||
async getProducts(): Promise<WhmcsCatalogProductsResponse> {
|
||||
return this.makeRequest<WhmcsCatalogProductsResponse>("GetProducts");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SSO API METHODS
|
||||
// ==========================================
|
||||
|
||||
async createSsoToken(params: WhmcsCreateSsoTokenParams): Promise<WhmcsSsoResponse> {
|
||||
return this.makeRequest<WhmcsSsoResponse>("CreateSsoToken", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAYMENT METHOD API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getPayMethods(params: WhmcsGetPayMethodsParams): Promise<WhmcsPayMethodsResponse> {
|
||||
return this.makeRequest<WhmcsPayMethodsResponse>("GetPayMethods", params);
|
||||
}
|
||||
|
||||
async addPayMethod(params: WhmcsAddPayMethodParams): Promise<WhmcsAddPayMethodResponse> {
|
||||
return this.makeRequest<WhmcsAddPayMethodResponse>("AddPayMethod", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAYMENT GATEWAY API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
|
||||
return this.makeRequest<WhmcsPaymentGatewaysResponse>("GetPaymentMethods");
|
||||
}
|
||||
}
|
||||
333
apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts.backup.20250822_120518
vendored
Normal file
333
apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,333 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
WhmcsApiResponse,
|
||||
WhmcsErrorResponse,
|
||||
WhmcsInvoicesResponse,
|
||||
WhmcsInvoiceResponse,
|
||||
WhmcsProductsResponse,
|
||||
WhmcsClientResponse,
|
||||
WhmcsSsoResponse,
|
||||
WhmcsValidateLoginResponse,
|
||||
WhmcsAddClientResponse,
|
||||
WhmcsCatalogProductsResponse,
|
||||
WhmcsPayMethodsResponse,
|
||||
WhmcsAddPayMethodResponse,
|
||||
WhmcsPaymentGatewaysResponse,
|
||||
WhmcsGetInvoicesParams,
|
||||
WhmcsGetClientsProductsParams,
|
||||
WhmcsCreateSsoTokenParams,
|
||||
WhmcsValidateLoginParams,
|
||||
WhmcsAddClientParams,
|
||||
WhmcsGetPayMethodsParams,
|
||||
WhmcsAddPayMethodParams,
|
||||
} from "../types/whmcs-api.types";
|
||||
|
||||
export interface WhmcsApiConfig {
|
||||
baseUrl: string;
|
||||
identifier: string;
|
||||
secret: string;
|
||||
timeout?: number;
|
||||
retryAttempts?: number;
|
||||
retryDelay?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsConnectionService {
|
||||
|
||||
private readonly config: WhmcsApiConfig;
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,private configService: ConfigService) {
|
||||
this.config = {
|
||||
baseUrl: this.configService.get<string>("WHMCS_BASE_URL", ""),
|
||||
identifier: this.configService.get<string>("WHMCS_API_IDENTIFIER", ""),
|
||||
secret: this.configService.get<string>("WHMCS_API_SECRET", ""),
|
||||
timeout: this.configService.get<number>("WHMCS_API_TIMEOUT", 30000),
|
||||
retryAttempts: this.configService.get<number>("WHMCS_API_RETRY_ATTEMPTS", 3),
|
||||
retryDelay: this.configService.get<number>("WHMCS_API_RETRY_DELAY", 1000),
|
||||
};
|
||||
|
||||
this.validateConfig();
|
||||
}
|
||||
|
||||
private validateConfig(): void {
|
||||
const requiredFields = ["baseUrl", "identifier", "secret"];
|
||||
const missingFields = requiredFields.filter(
|
||||
field => !this.config[field as keyof WhmcsApiConfig]
|
||||
);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
throw new Error(`Missing required WHMCS configuration: ${missingFields.join(", ")}`);
|
||||
}
|
||||
|
||||
if (!this.config.baseUrl.startsWith("http")) {
|
||||
throw new Error("WHMCS_BASE_URL must be a valid HTTP/HTTPS URL");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request to the WHMCS API with retry logic and proper error handling
|
||||
*/
|
||||
private async makeRequest<T>(
|
||||
action: string,
|
||||
params: Record<string, any> = {},
|
||||
attempt: number = 1
|
||||
): Promise<T> {
|
||||
const url = `${this.config.baseUrl}/includes/api.php`;
|
||||
|
||||
const requestParams = {
|
||||
action,
|
||||
username: this.config.identifier,
|
||||
password: this.config.secret,
|
||||
responsetype: "json",
|
||||
...this.sanitizeParams(params),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
try {
|
||||
this.logger.debug(`WHMCS API Request [${action}] attempt ${attempt}`, {
|
||||
action,
|
||||
params: this.sanitizeLogParams(params),
|
||||
});
|
||||
|
||||
const formData = new URLSearchParams(requestParams);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "Customer-Portal/1.0",
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
let data: WhmcsApiResponse<T>;
|
||||
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch (parseError) {
|
||||
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
|
||||
responseText: responseText.substring(0, 500),
|
||||
parseError: getErrorMessage(parseError),
|
||||
});
|
||||
throw new Error("Invalid JSON response from WHMCS API");
|
||||
}
|
||||
|
||||
if (data.result === "error") {
|
||||
const errorResponse = data as WhmcsErrorResponse;
|
||||
this.logger.error(`WHMCS API Error [${action}]`, {
|
||||
message: errorResponse.message,
|
||||
errorcode: errorResponse.errorcode,
|
||||
params: this.sanitizeLogParams(params),
|
||||
});
|
||||
throw new Error(`WHMCS API Error: ${errorResponse.message}`);
|
||||
}
|
||||
|
||||
this.logger.debug(`WHMCS API Success [${action}]`, {
|
||||
action,
|
||||
resultSize: JSON.stringify(data).length,
|
||||
});
|
||||
|
||||
return data as T;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
this.logger.error(`WHMCS API Timeout [${action}] after ${this.config.timeout}ms`);
|
||||
throw new Error("WHMCS API request timeout");
|
||||
}
|
||||
|
||||
// Retry logic for network errors and server errors
|
||||
if (attempt < this.config.retryAttempts! && this.shouldRetry(error)) {
|
||||
this.logger.warn(`WHMCS API Request [${action}] failed, retrying attempt ${attempt + 1}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
await this.delay(this.config.retryDelay! * attempt);
|
||||
return this.makeRequest<T>(action, params, attempt + 1);
|
||||
}
|
||||
|
||||
this.logger.error(`WHMCS API Request [${action}] failed after ${attempt} attempts`, {
|
||||
error: getErrorMessage(error),
|
||||
params: this.sanitizeLogParams(params),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private shouldRetry(error: any): boolean {
|
||||
// Retry on network errors, timeouts, and 5xx server errors
|
||||
return (
|
||||
getErrorMessage(error).includes("fetch") ||
|
||||
getErrorMessage(error).includes("network") ||
|
||||
getErrorMessage(error).includes("timeout") ||
|
||||
getErrorMessage(error).includes("HTTP 5")
|
||||
);
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private sanitizeParams(params: Record<string, any>): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
sanitized[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private sanitizeLogParams(params: Record<string, any>): Record<string, any> {
|
||||
const sanitized = { ...params };
|
||||
|
||||
// Remove sensitive data from logs
|
||||
const sensitiveFields = ["password", "password2", "secret", "token", "key"];
|
||||
sensitiveFields.forEach(field => {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = "[REDACTED]";
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PUBLIC API METHODS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Test WHMCS API connectivity
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
// Make a simple API call to verify connectivity
|
||||
await this.makeRequest("GetProducts", { limitnum: 1 });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error("WHMCS API Health Check Failed", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WHMCS service is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
return await this.healthCheck();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WHMCS system information
|
||||
*/
|
||||
async getSystemInfo(): Promise<any> {
|
||||
try {
|
||||
return await this.makeRequest("GetProducts", { limitnum: 1 });
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to get WHMCS system info", getErrorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CLIENT API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getClientDetails(clientId: number): Promise<WhmcsClientResponse> {
|
||||
return this.makeRequest<WhmcsClientResponse>("GetClientsDetails", {
|
||||
clientid: clientId,
|
||||
});
|
||||
}
|
||||
|
||||
async getClientDetailsByEmail(email: string): Promise<WhmcsClientResponse> {
|
||||
return this.makeRequest<WhmcsClientResponse>("GetClientsDetails", {
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
async validateLogin(params: WhmcsValidateLoginParams): Promise<WhmcsValidateLoginResponse> {
|
||||
return this.makeRequest<WhmcsValidateLoginResponse>("ValidateLogin", params);
|
||||
}
|
||||
|
||||
async addClient(params: WhmcsAddClientParams): Promise<WhmcsAddClientResponse> {
|
||||
return this.makeRequest<WhmcsAddClientResponse>("AddClient", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INVOICE API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getInvoices(params: WhmcsGetInvoicesParams): Promise<WhmcsInvoicesResponse> {
|
||||
return this.makeRequest<WhmcsInvoicesResponse>("GetInvoices", params);
|
||||
}
|
||||
|
||||
async getInvoice(invoiceId: number): Promise<WhmcsInvoiceResponse> {
|
||||
return this.makeRequest<WhmcsInvoiceResponse>("GetInvoice", {
|
||||
invoiceid: invoiceId,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PRODUCT/SERVICE API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getClientsProducts(params: WhmcsGetClientsProductsParams): Promise<WhmcsProductsResponse> {
|
||||
return this.makeRequest<WhmcsProductsResponse>("GetClientsProducts", params);
|
||||
}
|
||||
|
||||
async getProducts(): Promise<WhmcsCatalogProductsResponse> {
|
||||
return this.makeRequest<WhmcsCatalogProductsResponse>("GetProducts");
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SSO API METHODS
|
||||
// ==========================================
|
||||
|
||||
async createSsoToken(params: WhmcsCreateSsoTokenParams): Promise<WhmcsSsoResponse> {
|
||||
return this.makeRequest<WhmcsSsoResponse>("CreateSsoToken", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAYMENT METHOD API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getPayMethods(params: WhmcsGetPayMethodsParams): Promise<WhmcsPayMethodsResponse> {
|
||||
return this.makeRequest<WhmcsPayMethodsResponse>("GetPayMethods", params);
|
||||
}
|
||||
|
||||
async addPayMethod(params: WhmcsAddPayMethodParams): Promise<WhmcsAddPayMethodResponse> {
|
||||
return this.makeRequest<WhmcsAddPayMethodResponse>("AddPayMethod", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAYMENT GATEWAY API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
|
||||
return this.makeRequest<WhmcsPaymentGatewaysResponse>("GetPaymentMethods");
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Invoice, InvoiceList } from "@customer-portal/shared";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
@ -14,12 +15,11 @@ export interface InvoiceFilters {
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsInvoiceService {
|
||||
private readonly logger = new Logger(WhmcsInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -28,22 +28,15 @@ export class WhmcsInvoiceService {
|
||||
async getInvoices(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {},
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
const { status, page = 1, limit = 10 } = filters;
|
||||
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getInvoicesList(
|
||||
userId,
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
);
|
||||
const cached = await this.cacheService.getInvoicesList(userId, page, limit, status);
|
||||
if (cached) {
|
||||
this.logger.debug(
|
||||
`Cache hit for invoices: user ${userId}, page ${page}`,
|
||||
);
|
||||
this.logger.debug(`Cache hit for invoices: user ${userId}, page ${page}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
@ -76,36 +69,32 @@ export class WhmcsInvoiceService {
|
||||
|
||||
// Transform invoices (note: items are not included by GetInvoices API)
|
||||
const invoices = response.invoices.invoice
|
||||
.map((whmcsInvoice) => {
|
||||
.map(whmcsInvoice => {
|
||||
try {
|
||||
return this.dataTransformer.transformInvoice(whmcsInvoice);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to transform invoice ${whmcsInvoice.id}`,
|
||||
error,
|
||||
);
|
||||
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((invoice): invoice is Invoice => invoice !== null);
|
||||
|
||||
// Build result with pagination
|
||||
this.logger.debug(
|
||||
`WHMCS GetInvoices Response Analysis for Client ${clientId}:`,
|
||||
{
|
||||
totalresults: response.totalresults,
|
||||
numreturned: response.numreturned,
|
||||
startnumber: response.startnumber,
|
||||
actualInvoicesReturned: invoices.length,
|
||||
requestParams: {
|
||||
userid: clientId,
|
||||
limitstart,
|
||||
limitnum: limit,
|
||||
orderby: "date",
|
||||
order: "DESC",
|
||||
},
|
||||
this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, {
|
||||
totalresults: response.totalresults,
|
||||
numreturned: response.numreturned,
|
||||
startnumber: response.startnumber,
|
||||
actualInvoicesReturned: invoices.length,
|
||||
requestParams: {
|
||||
userid: clientId,
|
||||
limitstart,
|
||||
limitnum: limit,
|
||||
orderby: "date",
|
||||
order: "DESC",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const totalItems = response.totalresults || 0;
|
||||
const totalPages = Math.ceil(totalItems / limit);
|
||||
@ -121,17 +110,9 @@ export class WhmcsInvoiceService {
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setInvoicesList(
|
||||
userId,
|
||||
page,
|
||||
limit,
|
||||
status,
|
||||
result,
|
||||
);
|
||||
await this.cacheService.setInvoicesList(userId, page, limit, status, result);
|
||||
|
||||
this.logger.log(
|
||||
`Fetched ${invoices.length} invoices for client ${clientId}, page ${page}`,
|
||||
);
|
||||
this.logger.log(`Fetched ${invoices.length} invoices for client ${clientId}, page ${page}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch invoices for client ${clientId}`, {
|
||||
@ -149,7 +130,7 @@ export class WhmcsInvoiceService {
|
||||
async getInvoicesWithItems(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {},
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
try {
|
||||
// First get the basic invoices list
|
||||
@ -157,24 +138,20 @@ export class WhmcsInvoiceService {
|
||||
|
||||
// For each invoice, fetch the detailed version with items
|
||||
const invoicesWithItems = await Promise.all(
|
||||
invoiceList.invoices.map(async (invoice) => {
|
||||
invoiceList.invoices.map(async invoice => {
|
||||
try {
|
||||
// Get detailed invoice with items
|
||||
const detailedInvoice = await this.getInvoiceById(
|
||||
clientId,
|
||||
userId,
|
||||
invoice.id,
|
||||
);
|
||||
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
|
||||
return detailedInvoice;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch details for invoice ${invoice.id}`,
|
||||
getErrorMessage(error),
|
||||
getErrorMessage(error)
|
||||
);
|
||||
// Return the basic invoice if detailed fetch fails
|
||||
return invoice;
|
||||
}
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const result: InvoiceList = {
|
||||
@ -183,17 +160,14 @@ export class WhmcsInvoiceService {
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Fetched ${invoicesWithItems.length} invoices with items for client ${clientId}`,
|
||||
`Fetched ${invoicesWithItems.length} invoices with items for client ${clientId}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to fetch invoices with items for client ${clientId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
filters,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to fetch invoices with items for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
filters,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -201,18 +175,12 @@ export class WhmcsInvoiceService {
|
||||
/**
|
||||
* Get individual invoice by ID with caching
|
||||
*/
|
||||
async getInvoiceById(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
invoiceId: number,
|
||||
): Promise<Invoice> {
|
||||
async getInvoiceById(clientId: number, userId: string, invoiceId: number): Promise<Invoice> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getInvoice(userId, invoiceId);
|
||||
if (cached) {
|
||||
this.logger.debug(
|
||||
`Cache hit for invoice: user ${userId}, invoice ${invoiceId}`,
|
||||
);
|
||||
this.logger.debug(`Cache hit for invoice: user ${userId}, invoice ${invoiceId}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
@ -243,12 +211,9 @@ export class WhmcsInvoiceService {
|
||||
this.logger.log(`Fetched invoice ${invoiceId} for client ${clientId}`);
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to fetch invoice ${invoiceId} for client ${clientId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to fetch invoice ${invoiceId} for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -256,13 +221,8 @@ export class WhmcsInvoiceService {
|
||||
/**
|
||||
* Invalidate cache for a specific invoice
|
||||
*/
|
||||
async invalidateInvoiceCache(
|
||||
userId: string,
|
||||
invoiceId: number,
|
||||
): Promise<void> {
|
||||
async invalidateInvoiceCache(userId: string, invoiceId: number): Promise<void> {
|
||||
await this.cacheService.invalidateInvoice(userId, invoiceId);
|
||||
this.logger.log(
|
||||
`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`,
|
||||
);
|
||||
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
226
apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts.backup.20250822_120236
vendored
Normal file
226
apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,226 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Invoice, InvoiceList } from "@customer-portal/shared";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { WhmcsGetInvoicesParams } from "../types/whmcs-api.types";
|
||||
|
||||
export interface InvoiceFilters {
|
||||
status?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsInvoiceService {
|
||||
private readonly logger = new Logger(WhmcsInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get paginated invoices for a client with caching
|
||||
*/
|
||||
async getInvoices(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
const { status, page = 1, limit = 10 } = filters;
|
||||
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getInvoicesList(userId, page, limit, status);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for invoices: user ${userId}, page ${page}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Calculate pagination for WHMCS API
|
||||
const limitstart = (page - 1) * limit;
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const params: WhmcsGetInvoicesParams = {
|
||||
userid: clientId, // WHMCS API uses 'userid' parameter, not 'clientid'
|
||||
limitstart,
|
||||
limitnum: limit,
|
||||
orderby: "date",
|
||||
order: "DESC",
|
||||
...(status && { status: status as any }),
|
||||
};
|
||||
|
||||
const response = await this.connectionService.getInvoices(params);
|
||||
|
||||
if (!response.invoices?.invoice) {
|
||||
this.logger.warn(`No invoices found for client ${clientId}`);
|
||||
return {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Transform invoices (note: items are not included by GetInvoices API)
|
||||
const invoices = response.invoices.invoice
|
||||
.map(whmcsInvoice => {
|
||||
try {
|
||||
return this.dataTransformer.transformInvoice(whmcsInvoice);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((invoice): invoice is Invoice => invoice !== null);
|
||||
|
||||
// Build result with pagination
|
||||
this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, {
|
||||
totalresults: response.totalresults,
|
||||
numreturned: response.numreturned,
|
||||
startnumber: response.startnumber,
|
||||
actualInvoicesReturned: invoices.length,
|
||||
requestParams: {
|
||||
userid: clientId,
|
||||
limitstart,
|
||||
limitnum: limit,
|
||||
orderby: "date",
|
||||
order: "DESC",
|
||||
},
|
||||
});
|
||||
|
||||
const totalItems = response.totalresults || 0;
|
||||
const totalPages = Math.ceil(totalItems / limit);
|
||||
|
||||
const result: InvoiceList = {
|
||||
invoices,
|
||||
pagination: {
|
||||
page,
|
||||
totalPages,
|
||||
totalItems,
|
||||
nextCursor: page < totalPages ? (page + 1).toString() : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setInvoicesList(userId, page, limit, status, result);
|
||||
|
||||
this.logger.log(`Fetched ${invoices.length} invoices for client ${clientId}, page ${page}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch invoices for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
filters,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices with items (for subscription linking)
|
||||
* This method fetches invoices and then enriches them with item details
|
||||
*/
|
||||
async getInvoicesWithItems(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
try {
|
||||
// First get the basic invoices list
|
||||
const invoiceList = await this.getInvoices(clientId, userId, filters);
|
||||
|
||||
// For each invoice, fetch the detailed version with items
|
||||
const invoicesWithItems = await Promise.all(
|
||||
invoiceList.invoices.map(async invoice => {
|
||||
try {
|
||||
// Get detailed invoice with items
|
||||
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
|
||||
return detailedInvoice;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch details for invoice ${invoice.id}`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
// Return the basic invoice if detailed fetch fails
|
||||
return invoice;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const result: InvoiceList = {
|
||||
invoices: invoicesWithItems,
|
||||
pagination: invoiceList.pagination,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Fetched ${invoicesWithItems.length} invoices with items for client ${clientId}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch invoices with items for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
filters,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual invoice by ID with caching
|
||||
*/
|
||||
async getInvoiceById(clientId: number, userId: string, invoiceId: number): Promise<Invoice> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getInvoice(userId, invoiceId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for invoice: user ${userId}, invoice ${invoiceId}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const response = await this.connectionService.getInvoice(invoiceId);
|
||||
|
||||
if (!response.invoiceid) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
|
||||
// Verify the invoice belongs to this client
|
||||
const invoiceClientId = response.userid;
|
||||
if (invoiceClientId !== clientId) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
|
||||
// Transform invoice
|
||||
const invoice = this.dataTransformer.transformInvoice(response);
|
||||
|
||||
// Validate transformation
|
||||
if (!this.dataTransformer.validateInvoice(invoice)) {
|
||||
throw new Error(`Invalid invoice data after transformation`);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setInvoice(userId, invoiceId, invoice);
|
||||
|
||||
this.logger.log(`Fetched invoice ${invoiceId} for client ${clientId}`);
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch invoice ${invoiceId} for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific invoice
|
||||
*/
|
||||
async invalidateInvoiceCache(userId: string, invoiceId: number): Promise<void> {
|
||||
await this.cacheService.invalidateInvoice(userId, invoiceId);
|
||||
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||
}
|
||||
}
|
||||
234
apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts.backup.20250822_120518
vendored
Normal file
234
apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,234 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Invoice, InvoiceList } from "@customer-portal/shared";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsGetInvoicesParams } from "../types/whmcs-api.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
export interface InvoiceFilters {
|
||||
status?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsInvoiceService {
|
||||
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get paginated invoices for a client with caching
|
||||
*/
|
||||
async getInvoices(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
const { status, page = 1, limit = 10 } = filters;
|
||||
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getInvoicesList(userId, page, limit, status);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for invoices: user ${userId}, page ${page}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Calculate pagination for WHMCS API
|
||||
const limitstart = (page - 1) * limit;
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const params: WhmcsGetInvoicesParams = {
|
||||
userid: clientId, // WHMCS API uses 'userid' parameter, not 'clientid'
|
||||
limitstart,
|
||||
limitnum: limit,
|
||||
orderby: "date",
|
||||
order: "DESC",
|
||||
...(status && { status: status as any }),
|
||||
};
|
||||
|
||||
const response = await this.connectionService.getInvoices(params);
|
||||
|
||||
if (!response.invoices?.invoice) {
|
||||
this.logger.warn(`No invoices found for client ${clientId}`);
|
||||
return {
|
||||
invoices: [],
|
||||
pagination: {
|
||||
page,
|
||||
totalPages: 0,
|
||||
totalItems: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Transform invoices (note: items are not included by GetInvoices API)
|
||||
const invoices = response.invoices.invoice
|
||||
.map(whmcsInvoice => {
|
||||
try {
|
||||
return this.dataTransformer.transformInvoice(whmcsInvoice);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((invoice): invoice is Invoice => invoice !== null);
|
||||
|
||||
// Build result with pagination
|
||||
this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, {
|
||||
totalresults: response.totalresults,
|
||||
numreturned: response.numreturned,
|
||||
startnumber: response.startnumber,
|
||||
actualInvoicesReturned: invoices.length,
|
||||
requestParams: {
|
||||
userid: clientId,
|
||||
limitstart,
|
||||
limitnum: limit,
|
||||
orderby: "date",
|
||||
order: "DESC",
|
||||
},
|
||||
});
|
||||
|
||||
const totalItems = response.totalresults || 0;
|
||||
const totalPages = Math.ceil(totalItems / limit);
|
||||
|
||||
const result: InvoiceList = {
|
||||
invoices,
|
||||
pagination: {
|
||||
page,
|
||||
totalPages,
|
||||
totalItems,
|
||||
nextCursor: page < totalPages ? (page + 1).toString() : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setInvoicesList(userId, page, limit, status, result);
|
||||
|
||||
this.logger.log(`Fetched ${invoices.length} invoices for client ${clientId}, page ${page}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch invoices for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
filters,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices with items (for subscription linking)
|
||||
* This method fetches invoices and then enriches them with item details
|
||||
*/
|
||||
async getInvoicesWithItems(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
try {
|
||||
// First get the basic invoices list
|
||||
const invoiceList = await this.getInvoices(clientId, userId, filters);
|
||||
|
||||
// For each invoice, fetch the detailed version with items
|
||||
const invoicesWithItems = await Promise.all(
|
||||
invoiceList.invoices.map(async invoice => {
|
||||
try {
|
||||
// Get detailed invoice with items
|
||||
const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id);
|
||||
return detailedInvoice;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch details for invoice ${invoice.id}`,
|
||||
getErrorMessage(error)
|
||||
);
|
||||
// Return the basic invoice if detailed fetch fails
|
||||
return invoice;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const result: InvoiceList = {
|
||||
invoices: invoicesWithItems,
|
||||
pagination: invoiceList.pagination,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Fetched ${invoicesWithItems.length} invoices with items for client ${clientId}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch invoices with items for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
filters,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual invoice by ID with caching
|
||||
*/
|
||||
async getInvoiceById(clientId: number, userId: string, invoiceId: number): Promise<Invoice> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getInvoice(userId, invoiceId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for invoice: user ${userId}, invoice ${invoiceId}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const response = await this.connectionService.getInvoice(invoiceId);
|
||||
|
||||
if (!response.invoiceid) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
|
||||
// Verify the invoice belongs to this client
|
||||
const invoiceClientId = response.userid;
|
||||
if (invoiceClientId !== clientId) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
}
|
||||
|
||||
// Transform invoice
|
||||
const invoice = this.dataTransformer.transformInvoice(response);
|
||||
|
||||
// Validate transformation
|
||||
if (!this.dataTransformer.validateInvoice(invoice)) {
|
||||
throw new Error(`Invalid invoice data after transformation`);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setInvoice(userId, invoiceId, invoice);
|
||||
|
||||
this.logger.log(`Fetched invoice ${invoiceId} for client ${clientId}`);
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch invoice ${invoiceId} for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific invoice
|
||||
*/
|
||||
async invalidateInvoiceCache(userId: string, invoiceId: number): Promise<void> {
|
||||
await this.cacheService.invalidateInvoice(userId, invoiceId);
|
||||
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,7 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
PaymentMethod,
|
||||
PaymentMethodList,
|
||||
PaymentGateway,
|
||||
PaymentGatewayList,
|
||||
} from "@customer-portal/shared";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { PaymentMethodList, PaymentGateway, PaymentGatewayList } from "@customer-portal/shared";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
@ -13,21 +9,17 @@ import { WhmcsCreateSsoTokenParams } from "../types/whmcs-api.types";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsPaymentService {
|
||||
private readonly logger = new Logger(WhmcsPaymentService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get payment methods for a client
|
||||
*/
|
||||
async getPaymentMethods(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
): Promise<PaymentMethodList> {
|
||||
async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getPaymentMethods(userId);
|
||||
@ -38,9 +30,7 @@ export class WhmcsPaymentService {
|
||||
|
||||
// TODO: GetPayMethods API might not exist in WHMCS
|
||||
// For now, return empty list until we verify the correct API
|
||||
this.logger.warn(
|
||||
`GetPayMethods API not yet implemented for client ${clientId}`,
|
||||
);
|
||||
this.logger.warn(`GetPayMethods API not yet implemented for client ${clientId}`);
|
||||
|
||||
const result: PaymentMethodList = {
|
||||
paymentMethods: [],
|
||||
@ -50,18 +40,13 @@ export class WhmcsPaymentService {
|
||||
// Cache the empty result for now
|
||||
await this.cacheService.setPaymentMethods(userId, result);
|
||||
|
||||
this.logger.log(
|
||||
`Payment methods feature temporarily disabled for client ${clientId}`,
|
||||
);
|
||||
this.logger.log(`Payment methods feature temporarily disabled for client ${clientId}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to fetch payment methods for client ${clientId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -91,14 +76,13 @@ export class WhmcsPaymentService {
|
||||
|
||||
// Transform payment gateways
|
||||
const gateways = response.gateways.gateway
|
||||
.map((whmcsGateway) => {
|
||||
.map(whmcsGateway => {
|
||||
try {
|
||||
return this.dataTransformer.transformPaymentGateway(whmcsGateway);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to transform payment gateway ${whmcsGateway.name}`,
|
||||
error,
|
||||
);
|
||||
this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
@ -130,7 +114,7 @@ export class WhmcsPaymentService {
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
paymentMethodId?: number,
|
||||
gatewayName?: string,
|
||||
gatewayName?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
// Use WHMCS Friendly URL format for direct payment page access
|
||||
@ -158,25 +142,19 @@ export class WhmcsPaymentService {
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`,
|
||||
{
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
invoiceUrl,
|
||||
},
|
||||
);
|
||||
this.logger.log(`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`, {
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
invoiceUrl,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to create payment SSO token for client ${clientId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
invoiceId,
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to create payment SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
invoiceId,
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
181
apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts.backup.20250822_120236
vendored
Normal file
181
apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,181 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { PaymentMethodList, PaymentGateway, PaymentGatewayList } from "@customer-portal/shared";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { WhmcsCreateSsoTokenParams } from "../types/whmcs-api.types";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsPaymentService {
|
||||
private readonly logger = new Logger(WhmcsPaymentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get payment methods for a client
|
||||
*/
|
||||
async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getPaymentMethods(userId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for payment methods: user ${userId}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// TODO: GetPayMethods API might not exist in WHMCS
|
||||
// For now, return empty list until we verify the correct API
|
||||
this.logger.warn(`GetPayMethods API not yet implemented for client ${clientId}`);
|
||||
|
||||
const result: PaymentMethodList = {
|
||||
paymentMethods: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
// Cache the empty result for now
|
||||
await this.cacheService.setPaymentMethods(userId, result);
|
||||
|
||||
this.logger.log(`Payment methods feature temporarily disabled for client ${clientId}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available payment gateways
|
||||
*/
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getPaymentGateways();
|
||||
if (cached) {
|
||||
this.logger.debug("Cache hit for payment gateways");
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const response = await this.connectionService.getPaymentGateways();
|
||||
|
||||
if (!response.gateways?.gateway) {
|
||||
this.logger.warn("No payment gateways found");
|
||||
return {
|
||||
gateways: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform payment gateways
|
||||
const gateways = response.gateways.gateway
|
||||
.map(whmcsGateway => {
|
||||
try {
|
||||
return this.dataTransformer.transformPaymentGateway(whmcsGateway);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((gateway): gateway is PaymentGateway => gateway !== null);
|
||||
|
||||
const result: PaymentGatewayList = {
|
||||
gateways,
|
||||
totalCount: gateways.length,
|
||||
};
|
||||
|
||||
// Cache the result (cache for 1 hour since gateways don't change often)
|
||||
await this.cacheService.setPaymentGateways(result);
|
||||
|
||||
this.logger.log(`Fetched ${gateways.length} payment gateways`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to fetch payment gateways", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO token with payment method for invoice payment
|
||||
* This creates a direct link to the payment form with gateway pre-selected
|
||||
*/
|
||||
async createPaymentSsoToken(
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
paymentMethodId?: number,
|
||||
gatewayName?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
// Use WHMCS Friendly URL format for direct payment page access
|
||||
// This goes directly to the payment page, not just invoice view
|
||||
let invoiceUrl = `index.php?rp=/invoice/${invoiceId}/pay`;
|
||||
|
||||
if (paymentMethodId) {
|
||||
// Pre-select specific saved payment method
|
||||
invoiceUrl += `&paymentmethod=${paymentMethodId}`;
|
||||
} else if (gatewayName) {
|
||||
// Pre-select specific gateway
|
||||
invoiceUrl += `&gateway=${gatewayName}`;
|
||||
}
|
||||
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: "sso:custom_redirect",
|
||||
sso_redirect_path: invoiceUrl,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`, {
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
invoiceUrl,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create payment SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
invoiceId,
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products catalog
|
||||
*/
|
||||
async getProducts(): Promise<any> {
|
||||
try {
|
||||
const response = await this.connectionService.getProducts();
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get products", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform product data (delegate to transformer)
|
||||
*/
|
||||
transformProduct(whmcsProduct: any): any {
|
||||
return this.dataTransformer.transformProduct(whmcsProduct);
|
||||
}
|
||||
}
|
||||
189
apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts.backup.20250822_120518
vendored
Normal file
189
apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,189 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { PaymentMethodList, PaymentGateway, PaymentGatewayList } from "@customer-portal/shared";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsCreateSsoTokenParams } from "../types/whmcs-api.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsPaymentService {
|
||||
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get payment methods for a client
|
||||
*/
|
||||
async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getPaymentMethods(userId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for payment methods: user ${userId}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// TODO: GetPayMethods API might not exist in WHMCS
|
||||
// For now, return empty list until we verify the correct API
|
||||
this.logger.warn(`GetPayMethods API not yet implemented for client ${clientId}`);
|
||||
|
||||
const result: PaymentMethodList = {
|
||||
paymentMethods: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
// Cache the empty result for now
|
||||
await this.cacheService.setPaymentMethods(userId, result);
|
||||
|
||||
this.logger.log(`Payment methods feature temporarily disabled for client ${clientId}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available payment gateways
|
||||
*/
|
||||
async getPaymentGateways(): Promise<PaymentGatewayList> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getPaymentGateways();
|
||||
if (cached) {
|
||||
this.logger.debug("Cache hit for payment gateways");
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const response = await this.connectionService.getPaymentGateways();
|
||||
|
||||
if (!response.gateways?.gateway) {
|
||||
this.logger.warn("No payment gateways found");
|
||||
return {
|
||||
gateways: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform payment gateways
|
||||
const gateways = response.gateways.gateway
|
||||
.map(whmcsGateway => {
|
||||
try {
|
||||
return this.dataTransformer.transformPaymentGateway(whmcsGateway);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((gateway): gateway is PaymentGateway => gateway !== null);
|
||||
|
||||
const result: PaymentGatewayList = {
|
||||
gateways,
|
||||
totalCount: gateways.length,
|
||||
};
|
||||
|
||||
// Cache the result (cache for 1 hour since gateways don't change often)
|
||||
await this.cacheService.setPaymentGateways(result);
|
||||
|
||||
this.logger.log(`Fetched ${gateways.length} payment gateways`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to fetch payment gateways", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO token with payment method for invoice payment
|
||||
* This creates a direct link to the payment form with gateway pre-selected
|
||||
*/
|
||||
async createPaymentSsoToken(
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
paymentMethodId?: number,
|
||||
gatewayName?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
// Use WHMCS Friendly URL format for direct payment page access
|
||||
// This goes directly to the payment page, not just invoice view
|
||||
let invoiceUrl = `index.php?rp=/invoice/${invoiceId}/pay`;
|
||||
|
||||
if (paymentMethodId) {
|
||||
// Pre-select specific saved payment method
|
||||
invoiceUrl += `&paymentmethod=${paymentMethodId}`;
|
||||
} else if (gatewayName) {
|
||||
// Pre-select specific gateway
|
||||
invoiceUrl += `&gateway=${gatewayName}`;
|
||||
}
|
||||
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: "sso:custom_redirect",
|
||||
sso_redirect_path: invoiceUrl,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`, {
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
invoiceUrl,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create payment SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
invoiceId,
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get products catalog
|
||||
*/
|
||||
async getProducts(): Promise<any> {
|
||||
try {
|
||||
const response = await this.connectionService.getProducts();
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get products", {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform product data (delegate to transformer)
|
||||
*/
|
||||
transformProduct(whmcsProduct: any): any {
|
||||
return this.dataTransformer.transformProduct(whmcsProduct);
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,15 @@
|
||||
import { getErrorMessage } from '../../../common/utils/error.util';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { WhmcsConnectionService } from './whmcs-connection.service';
|
||||
import { WhmcsCreateSsoTokenParams } from '../types/whmcs-api.types';
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsCreateSsoTokenParams } from "../types/whmcs-api.types";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsSsoService {
|
||||
private readonly logger = new Logger(WhmcsSsoService.name);
|
||||
|
||||
constructor(private readonly connectionService: WhmcsConnectionService) {}
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create SSO token for WHMCS access
|
||||
@ -36,7 +38,6 @@ export class WhmcsSsoService {
|
||||
ssoRedirectPath,
|
||||
});
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
@ -51,22 +52,22 @@ export class WhmcsSsoService {
|
||||
* Helper function to create SSO links for invoices (following WHMCS best practices)
|
||||
*/
|
||||
async whmcsSsoForInvoice(
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
target: 'view' | 'download' | 'pay'
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
target: "view" | "download" | "pay"
|
||||
): Promise<string> {
|
||||
let path: string;
|
||||
|
||||
|
||||
switch (target) {
|
||||
case 'pay':
|
||||
case "pay":
|
||||
// Direct payment page using Friendly URLs
|
||||
path = `index.php?rp=/invoice/${invoiceId}/pay`;
|
||||
break;
|
||||
case 'download':
|
||||
case "download":
|
||||
// PDF download
|
||||
path = `dl.php?type=i&id=${invoiceId}`;
|
||||
break;
|
||||
case 'view':
|
||||
case "view":
|
||||
default:
|
||||
// Invoice view page
|
||||
path = `viewinvoice.php?id=${invoiceId}`;
|
||||
@ -75,12 +76,12 @@ export class WhmcsSsoService {
|
||||
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: 'sso:custom_redirect',
|
||||
destination: "sso:custom_redirect",
|
||||
sso_redirect_path: path,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
|
||||
// Return the 60s, one-time URL
|
||||
return response.redirect_url;
|
||||
}
|
||||
@ -95,7 +96,7 @@ export class WhmcsSsoService {
|
||||
try {
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: adminPath || 'clientarea.php',
|
||||
destination: adminPath || "clientarea.php",
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
@ -109,7 +110,6 @@ export class WhmcsSsoService {
|
||||
adminPath,
|
||||
});
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create admin SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
@ -143,7 +143,7 @@ export class WhmcsSsoService {
|
||||
|
||||
const ssoParams: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: 'sso:custom_redirect',
|
||||
destination: "sso:custom_redirect",
|
||||
sso_redirect_path: modulePath,
|
||||
};
|
||||
|
||||
@ -161,7 +161,6 @@ export class WhmcsSsoService {
|
||||
modulePath,
|
||||
});
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create module SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
|
||||
172
apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts.backup.20250822_120236
vendored
Normal file
172
apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,172 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsCreateSsoTokenParams } from "../types/whmcs-api.types";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsSsoService {
|
||||
private readonly logger = new Logger(WhmcsSsoService.name);
|
||||
|
||||
constructor(private readonly connectionService: WhmcsConnectionService) {}
|
||||
|
||||
/**
|
||||
* Create SSO token for WHMCS access
|
||||
*/
|
||||
async createSsoToken(
|
||||
clientId: number,
|
||||
destination?: string,
|
||||
ssoRedirectPath?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
...(destination && { destination }),
|
||||
...(ssoRedirectPath && { sso_redirect_path: ssoRedirectPath }),
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(`Created SSO token for client ${clientId}`, {
|
||||
destination,
|
||||
ssoRedirectPath,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
destination,
|
||||
ssoRedirectPath,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create SSO links for invoices (following WHMCS best practices)
|
||||
*/
|
||||
async whmcsSsoForInvoice(
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
target: "view" | "download" | "pay"
|
||||
): Promise<string> {
|
||||
let path: string;
|
||||
|
||||
switch (target) {
|
||||
case "pay":
|
||||
// Direct payment page using Friendly URLs
|
||||
path = `index.php?rp=/invoice/${invoiceId}/pay`;
|
||||
break;
|
||||
case "download":
|
||||
// PDF download
|
||||
path = `dl.php?type=i&id=${invoiceId}`;
|
||||
break;
|
||||
case "view":
|
||||
default:
|
||||
// Invoice view page
|
||||
path = `viewinvoice.php?id=${invoiceId}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: "sso:custom_redirect",
|
||||
sso_redirect_path: path,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
// Return the 60s, one-time URL
|
||||
return response.redirect_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO token for direct WHMCS admin access
|
||||
*/
|
||||
async createAdminSsoToken(
|
||||
clientId: number,
|
||||
adminPath?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: adminPath || "clientarea.php",
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(`Created admin SSO token for client ${clientId}`, {
|
||||
adminPath,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create admin SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
adminPath,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO token for specific WHMCS module/page
|
||||
*/
|
||||
async createModuleSsoToken(
|
||||
clientId: number,
|
||||
module: string,
|
||||
action?: string,
|
||||
params?: Record<string, any>
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
// Build the module path
|
||||
let modulePath = `index.php?m=${module}`;
|
||||
if (action) {
|
||||
modulePath += `&a=${action}`;
|
||||
}
|
||||
if (params) {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
if (queryParams) {
|
||||
modulePath += `&${queryParams}`;
|
||||
}
|
||||
}
|
||||
|
||||
const ssoParams: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: "sso:custom_redirect",
|
||||
sso_redirect_path: modulePath,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(ssoParams);
|
||||
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(`Created module SSO token for client ${clientId}`, {
|
||||
module,
|
||||
action,
|
||||
params,
|
||||
modulePath,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create module SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
module,
|
||||
action,
|
||||
params,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts.backup.20250822_120518
vendored
Normal file
177
apps/bff/src/vendors/whmcs/services/whmcs-sso.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,177 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsCreateSsoTokenParams } from "../types/whmcs-api.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsSsoService {
|
||||
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,private readonly connectionService: WhmcsConnectionService) {}
|
||||
|
||||
/**
|
||||
* Create SSO token for WHMCS access
|
||||
*/
|
||||
async createSsoToken(
|
||||
clientId: number,
|
||||
destination?: string,
|
||||
ssoRedirectPath?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
...(destination && { destination }),
|
||||
...(ssoRedirectPath && { sso_redirect_path: ssoRedirectPath }),
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(`Created SSO token for client ${clientId}`, {
|
||||
destination,
|
||||
ssoRedirectPath,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
destination,
|
||||
ssoRedirectPath,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create SSO links for invoices (following WHMCS best practices)
|
||||
*/
|
||||
async whmcsSsoForInvoice(
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
target: "view" | "download" | "pay"
|
||||
): Promise<string> {
|
||||
let path: string;
|
||||
|
||||
switch (target) {
|
||||
case "pay":
|
||||
// Direct payment page using Friendly URLs
|
||||
path = `index.php?rp=/invoice/${invoiceId}/pay`;
|
||||
break;
|
||||
case "download":
|
||||
// PDF download
|
||||
path = `dl.php?type=i&id=${invoiceId}`;
|
||||
break;
|
||||
case "view":
|
||||
default:
|
||||
// Invoice view page
|
||||
path = `viewinvoice.php?id=${invoiceId}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: "sso:custom_redirect",
|
||||
sso_redirect_path: path,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
// Return the 60s, one-time URL
|
||||
return response.redirect_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO token for direct WHMCS admin access
|
||||
*/
|
||||
async createAdminSsoToken(
|
||||
clientId: number,
|
||||
adminPath?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
const params: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: adminPath || "clientarea.php",
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(params);
|
||||
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(`Created admin SSO token for client ${clientId}`, {
|
||||
adminPath,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create admin SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
adminPath,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create SSO token for specific WHMCS module/page
|
||||
*/
|
||||
async createModuleSsoToken(
|
||||
clientId: number,
|
||||
module: string,
|
||||
action?: string,
|
||||
params?: Record<string, any>
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
try {
|
||||
// Build the module path
|
||||
let modulePath = `index.php?m=${module}`;
|
||||
if (action) {
|
||||
modulePath += `&a=${action}`;
|
||||
}
|
||||
if (params) {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
if (queryParams) {
|
||||
modulePath += `&${queryParams}`;
|
||||
}
|
||||
}
|
||||
|
||||
const ssoParams: WhmcsCreateSsoTokenParams = {
|
||||
client_id: clientId,
|
||||
destination: "sso:custom_redirect",
|
||||
sso_redirect_path: modulePath,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.createSsoToken(ssoParams);
|
||||
|
||||
const result = {
|
||||
url: response.redirect_url,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(), // 1 hour
|
||||
};
|
||||
|
||||
this.logger.log(`Created module SSO token for client ${clientId}`, {
|
||||
module,
|
||||
action,
|
||||
params,
|
||||
modulePath,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create module SSO token for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
module,
|
||||
action,
|
||||
params,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,11 @@
|
||||
import { getErrorMessage } from '../../../common/utils/error.util';
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Subscription, SubscriptionList } from '@customer-portal/shared';
|
||||
import { WhmcsConnectionService } from './whmcs-connection.service';
|
||||
import { WhmcsDataTransformer } from '../transformers/whmcs-data.transformer';
|
||||
import { WhmcsCacheService } from '../cache/whmcs-cache.service';
|
||||
import { WhmcsGetClientsProductsParams } from '../types/whmcs-api.types';
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Subscription, SubscriptionList } from "@customer-portal/shared";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { WhmcsGetClientsProductsParams } from "../types/whmcs-api.types";
|
||||
|
||||
export interface SubscriptionFilters {
|
||||
status?: string;
|
||||
@ -12,12 +13,11 @@ export interface SubscriptionFilters {
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsSubscriptionService {
|
||||
private readonly logger = new Logger(WhmcsSubscriptionService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -33,7 +33,7 @@ export class WhmcsSubscriptionService {
|
||||
const cached = await this.cacheService.getSubscriptionsList(userId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for subscriptions: user ${userId}`);
|
||||
|
||||
|
||||
// Apply status filter if needed
|
||||
if (filters.status) {
|
||||
const filtered = cached.subscriptions.filter(
|
||||
@ -44,15 +44,15 @@ export class WhmcsSubscriptionService {
|
||||
totalCount: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const params: WhmcsGetClientsProductsParams = {
|
||||
clientid: clientId,
|
||||
orderby: 'regdate',
|
||||
order: 'DESC',
|
||||
orderby: "regdate",
|
||||
order: "DESC",
|
||||
};
|
||||
|
||||
const response = await this.connectionService.getClientsProducts(params);
|
||||
@ -66,14 +66,18 @@ export class WhmcsSubscriptionService {
|
||||
}
|
||||
|
||||
// Transform subscriptions
|
||||
const subscriptions = response.products.product.map(whmcsProduct => {
|
||||
try {
|
||||
return this.dataTransformer.transformSubscription(whmcsProduct);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, error);
|
||||
return null;
|
||||
}
|
||||
}).filter((subscription): subscription is Subscription => subscription !== null);
|
||||
const subscriptions = response.products.product
|
||||
.map(whmcsProduct => {
|
||||
try {
|
||||
return this.dataTransformer.transformSubscription(whmcsProduct);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((subscription): subscription is Subscription => subscription !== null);
|
||||
|
||||
const result: SubscriptionList = {
|
||||
subscriptions,
|
||||
@ -97,7 +101,6 @@ export class WhmcsSubscriptionService {
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch subscriptions for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
@ -119,7 +122,9 @@ export class WhmcsSubscriptionService {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getSubscription(userId, subscriptionId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for subscription: user ${userId}, subscription ${subscriptionId}`);
|
||||
this.logger.debug(
|
||||
`Cache hit for subscription: user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
return cached;
|
||||
}
|
||||
|
||||
@ -136,7 +141,6 @@ export class WhmcsSubscriptionService {
|
||||
|
||||
this.logger.log(`Fetched subscription ${subscriptionId} for client ${clientId}`);
|
||||
return subscription;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch subscription ${subscriptionId} for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
@ -150,6 +154,8 @@ export class WhmcsSubscriptionService {
|
||||
*/
|
||||
async invalidateSubscriptionCache(userId: string, subscriptionId: number): Promise<void> {
|
||||
await this.cacheService.invalidateSubscription(userId, subscriptionId);
|
||||
this.logger.log(`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`);
|
||||
this.logger.log(
|
||||
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
159
apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts.backup.20250822_120236
vendored
Normal file
159
apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,159 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Subscription, SubscriptionList } from "@customer-portal/shared";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { WhmcsGetClientsProductsParams } from "../types/whmcs-api.types";
|
||||
|
||||
export interface SubscriptionFilters {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsSubscriptionService {
|
||||
private readonly logger = new Logger(WhmcsSubscriptionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get client subscriptions/services with caching
|
||||
*/
|
||||
async getSubscriptions(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: SubscriptionFilters = {}
|
||||
): Promise<SubscriptionList> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getSubscriptionsList(userId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for subscriptions: user ${userId}`);
|
||||
|
||||
// Apply status filter if needed
|
||||
if (filters.status) {
|
||||
const filtered = cached.subscriptions.filter(
|
||||
sub => sub.status.toLowerCase() === filters.status!.toLowerCase()
|
||||
);
|
||||
return {
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const params: WhmcsGetClientsProductsParams = {
|
||||
clientid: clientId,
|
||||
orderby: "regdate",
|
||||
order: "DESC",
|
||||
};
|
||||
|
||||
const response = await this.connectionService.getClientsProducts(params);
|
||||
|
||||
if (!response.products?.product) {
|
||||
this.logger.warn(`No products found for client ${clientId}`);
|
||||
return {
|
||||
subscriptions: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform subscriptions
|
||||
const subscriptions = response.products.product
|
||||
.map(whmcsProduct => {
|
||||
try {
|
||||
return this.dataTransformer.transformSubscription(whmcsProduct);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((subscription): subscription is Subscription => subscription !== null);
|
||||
|
||||
const result: SubscriptionList = {
|
||||
subscriptions,
|
||||
totalCount: subscriptions.length,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setSubscriptionsList(userId, result);
|
||||
|
||||
this.logger.log(`Fetched ${subscriptions.length} subscriptions for client ${clientId}`);
|
||||
|
||||
// Apply status filter if needed
|
||||
if (filters.status) {
|
||||
const filtered = result.subscriptions.filter(
|
||||
sub => sub.status.toLowerCase() === filters.status!.toLowerCase()
|
||||
);
|
||||
return {
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch subscriptions for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
filters,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual subscription by ID
|
||||
*/
|
||||
async getSubscriptionById(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getSubscription(userId, subscriptionId);
|
||||
if (cached) {
|
||||
this.logger.debug(
|
||||
`Cache hit for subscription: user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get all subscriptions and find the specific one
|
||||
const subscriptionList = await this.getSubscriptions(clientId, userId);
|
||||
const subscription = subscriptionList.subscriptions.find(s => s.id === subscriptionId);
|
||||
|
||||
if (!subscription) {
|
||||
throw new NotFoundException(`Subscription ${subscriptionId} not found`);
|
||||
}
|
||||
|
||||
// Cache the individual subscription
|
||||
await this.cacheService.setSubscription(userId, subscriptionId, subscription);
|
||||
|
||||
this.logger.log(`Fetched subscription ${subscriptionId} for client ${clientId}`);
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch subscription ${subscriptionId} for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific subscription
|
||||
*/
|
||||
async invalidateSubscriptionCache(userId: string, subscriptionId: number): Promise<void> {
|
||||
await this.cacheService.invalidateSubscription(userId, subscriptionId);
|
||||
this.logger.log(
|
||||
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
167
apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts.backup.20250822_120518
vendored
Normal file
167
apps/bff/src/vendors/whmcs/services/whmcs-subscription.service.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,167 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Subscription, SubscriptionList } from "@customer-portal/shared";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsGetClientsProductsParams } from "../types/whmcs-api.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
export interface SubscriptionFilters {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsSubscriptionService {
|
||||
|
||||
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get client subscriptions/services with caching
|
||||
*/
|
||||
async getSubscriptions(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: SubscriptionFilters = {}
|
||||
): Promise<SubscriptionList> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getSubscriptionsList(userId);
|
||||
if (cached) {
|
||||
this.logger.debug(`Cache hit for subscriptions: user ${userId}`);
|
||||
|
||||
// Apply status filter if needed
|
||||
if (filters.status) {
|
||||
const filtered = cached.subscriptions.filter(
|
||||
sub => sub.status.toLowerCase() === filters.status!.toLowerCase()
|
||||
);
|
||||
return {
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const params: WhmcsGetClientsProductsParams = {
|
||||
clientid: clientId,
|
||||
orderby: "regdate",
|
||||
order: "DESC",
|
||||
};
|
||||
|
||||
const response = await this.connectionService.getClientsProducts(params);
|
||||
|
||||
if (!response.products?.product) {
|
||||
this.logger.warn(`No products found for client ${clientId}`);
|
||||
return {
|
||||
subscriptions: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform subscriptions
|
||||
const subscriptions = response.products.product
|
||||
.map(whmcsProduct => {
|
||||
try {
|
||||
return this.dataTransformer.transformSubscription(whmcsProduct);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((subscription): subscription is Subscription => subscription !== null);
|
||||
|
||||
const result: SubscriptionList = {
|
||||
subscriptions,
|
||||
totalCount: subscriptions.length,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setSubscriptionsList(userId, result);
|
||||
|
||||
this.logger.log(`Fetched ${subscriptions.length} subscriptions for client ${clientId}`);
|
||||
|
||||
// Apply status filter if needed
|
||||
if (filters.status) {
|
||||
const filtered = result.subscriptions.filter(
|
||||
sub => sub.status.toLowerCase() === filters.status!.toLowerCase()
|
||||
);
|
||||
return {
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch subscriptions for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
filters,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual subscription by ID
|
||||
*/
|
||||
async getSubscriptionById(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
try {
|
||||
// Try cache first
|
||||
const cached = await this.cacheService.getSubscription(userId, subscriptionId);
|
||||
if (cached) {
|
||||
this.logger.debug(
|
||||
`Cache hit for subscription: user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Get all subscriptions and find the specific one
|
||||
const subscriptionList = await this.getSubscriptions(clientId, userId);
|
||||
const subscription = subscriptionList.subscriptions.find(s => s.id === subscriptionId);
|
||||
|
||||
if (!subscription) {
|
||||
throw new NotFoundException(`Subscription ${subscriptionId} not found`);
|
||||
}
|
||||
|
||||
// Cache the individual subscription
|
||||
await this.cacheService.setSubscription(userId, subscriptionId, subscription);
|
||||
|
||||
this.logger.log(`Fetched subscription ${subscriptionId} for client ${clientId}`);
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch subscription ${subscriptionId} for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific subscription
|
||||
*/
|
||||
async invalidateSubscriptionCache(userId: string, subscriptionId: number): Promise<void> {
|
||||
await this.cacheService.invalidateSubscription(userId, subscriptionId);
|
||||
this.logger.log(
|
||||
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,20 +1,20 @@
|
||||
import { getErrorMessage } from '../../../common/utils/error.util';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceItem as BaseInvoiceItem,
|
||||
Subscription,
|
||||
PaymentMethod,
|
||||
PaymentGateway,
|
||||
} from '@customer-portal/shared';
|
||||
} from "@customer-portal/shared";
|
||||
import {
|
||||
WhmcsInvoice,
|
||||
WhmcsProduct,
|
||||
WhmcsCustomFields,
|
||||
WhmcsInvoiceItems,
|
||||
WhmcsPaymentMethod,
|
||||
WhmcsPaymentGateway,
|
||||
} from '../types/whmcs-api.types';
|
||||
} from "../types/whmcs-api.types";
|
||||
|
||||
// Extended InvoiceItem interface to include serviceId
|
||||
interface InvoiceItem extends BaseInvoiceItem {
|
||||
@ -23,7 +23,7 @@ interface InvoiceItem extends BaseInvoiceItem {
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsDataTransformer {
|
||||
private readonly logger = new Logger(WhmcsDataTransformer.name);
|
||||
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
||||
|
||||
/**
|
||||
* Transform WHMCS invoice to our standard Invoice format
|
||||
@ -31,7 +31,7 @@ export class WhmcsDataTransformer {
|
||||
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
|
||||
const invoiceId = whmcsInvoice.invoiceid || whmcsInvoice.id;
|
||||
if (!whmcsInvoice || !invoiceId) {
|
||||
throw new Error('Invalid invoice data from WHMCS');
|
||||
throw new Error("Invalid invoice data from WHMCS");
|
||||
}
|
||||
|
||||
try {
|
||||
@ -39,8 +39,9 @@ export class WhmcsDataTransformer {
|
||||
id: Number(invoiceId),
|
||||
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
|
||||
status: this.normalizeInvoiceStatus(whmcsInvoice.status),
|
||||
currency: whmcsInvoice.currencycode || 'USD',
|
||||
currencySymbol: whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || 'USD'),
|
||||
currency: whmcsInvoice.currencycode || "USD",
|
||||
currencySymbol:
|
||||
whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "USD"),
|
||||
total: this.parseAmount(whmcsInvoice.total),
|
||||
subtotal: this.parseAmount(whmcsInvoice.subtotal),
|
||||
tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2),
|
||||
@ -56,7 +57,7 @@ export class WhmcsDataTransformer {
|
||||
total: invoice.total,
|
||||
currency: invoice.currency,
|
||||
itemCount: invoice.items?.length || 0,
|
||||
itemsWithServices: invoice.items?.filter((item) => item.serviceId).length || 0,
|
||||
itemsWithServices: invoice.items?.filter(item => item.serviceId).length || 0,
|
||||
});
|
||||
|
||||
return invoice;
|
||||
@ -74,7 +75,7 @@ export class WhmcsDataTransformer {
|
||||
*/
|
||||
transformSubscription(whmcsProduct: WhmcsProduct): Subscription {
|
||||
if (!whmcsProduct || !whmcsProduct.id) {
|
||||
throw new Error('Invalid product data from WHMCS');
|
||||
throw new Error("Invalid product data from WHMCS");
|
||||
}
|
||||
|
||||
try {
|
||||
@ -87,17 +88,12 @@ export class WhmcsDataTransformer {
|
||||
status: this.normalizeProductStatus(whmcsProduct.status),
|
||||
nextDue: this.formatDate(whmcsProduct.nextduedate),
|
||||
amount: this.getProductAmount(whmcsProduct),
|
||||
currency: whmcsProduct.currencycode || 'USD',
|
||||
currency: whmcsProduct.currencycode || "USD",
|
||||
|
||||
registrationDate:
|
||||
this.formatDate(whmcsProduct.regdate) ||
|
||||
new Date().toISOString().split('T')[0],
|
||||
this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0],
|
||||
notes: undefined, // WHMCS products don't typically have notes
|
||||
customFields: this.transformCustomFields(whmcsProduct.customfields),
|
||||
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
this.logger.debug(`Transformed subscription ${subscription.id}`, {
|
||||
@ -125,13 +121,13 @@ export class WhmcsDataTransformer {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items.item.map((item) => {
|
||||
return items.item.map(item => {
|
||||
const transformedItem: InvoiceItem = {
|
||||
id: Number(item.id),
|
||||
description: item.description || 'Unknown Item',
|
||||
description: item.description || "Unknown Item",
|
||||
amount: this.parseAmount(item.amount),
|
||||
quantity: 1, // WHMCS items don't have quantity field
|
||||
type: item.type || 'item',
|
||||
type: item.type || "item",
|
||||
};
|
||||
|
||||
// Link to service/product if relid is provided and greater than 0
|
||||
@ -146,13 +142,15 @@ export class WhmcsDataTransformer {
|
||||
/**
|
||||
* Transform custom fields from WHMCS format
|
||||
*/
|
||||
private transformCustomFields(customFields?: WhmcsCustomFields): Record<string, string> | undefined {
|
||||
private transformCustomFields(
|
||||
customFields?: WhmcsCustomFields
|
||||
): Record<string, string> | undefined {
|
||||
if (!customFields?.customfield || !Array.isArray(customFields.customfield)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
|
||||
customFields.customfield.forEach(field => {
|
||||
if (field.name && field.value) {
|
||||
result[field.name] = field.value;
|
||||
@ -171,7 +169,7 @@ export class WhmcsDataTransformer {
|
||||
whmcsProduct.translated_name ||
|
||||
whmcsProduct.productname ||
|
||||
whmcsProduct.packagename ||
|
||||
'Unknown Product'
|
||||
"Unknown Product"
|
||||
);
|
||||
}
|
||||
|
||||
@ -182,7 +180,7 @@ export class WhmcsDataTransformer {
|
||||
// Prioritize recurring amount, fallback to first payment amount
|
||||
const recurringAmount = this.parseAmount(whmcsProduct.recurringamount);
|
||||
const firstPaymentAmount = this.parseAmount(whmcsProduct.firstpaymentamount);
|
||||
|
||||
|
||||
return recurringAmount > 0 ? recurringAmount : firstPaymentAmount;
|
||||
}
|
||||
|
||||
@ -191,16 +189,16 @@ export class WhmcsDataTransformer {
|
||||
*/
|
||||
private normalizeInvoiceStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'paid': 'Paid',
|
||||
'unpaid': 'Unpaid',
|
||||
'cancelled': 'Cancelled',
|
||||
'overdue': 'Overdue',
|
||||
'collections': 'Collections',
|
||||
'draft': 'Draft',
|
||||
'refunded': 'Refunded',
|
||||
paid: "Paid",
|
||||
unpaid: "Unpaid",
|
||||
cancelled: "Cancelled",
|
||||
overdue: "Overdue",
|
||||
collections: "Collections",
|
||||
draft: "Draft",
|
||||
refunded: "Refunded",
|
||||
};
|
||||
|
||||
return statusMap[status?.toLowerCase()] || status || 'Unknown';
|
||||
return statusMap[status?.toLowerCase()] || status || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
@ -208,16 +206,16 @@ export class WhmcsDataTransformer {
|
||||
*/
|
||||
private normalizeProductStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'active': 'Active',
|
||||
'suspended': 'Suspended',
|
||||
'terminated': 'Terminated',
|
||||
'cancelled': 'Cancelled',
|
||||
'pending': 'Pending',
|
||||
'completed': 'Completed',
|
||||
'fraud': 'Fraud',
|
||||
active: "Active",
|
||||
suspended: "Suspended",
|
||||
terminated: "Terminated",
|
||||
cancelled: "Cancelled",
|
||||
pending: "Pending",
|
||||
completed: "Completed",
|
||||
fraud: "Fraud",
|
||||
};
|
||||
|
||||
return statusMap[status?.toLowerCase()] || status || 'Unknown';
|
||||
return statusMap[status?.toLowerCase()] || status || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
@ -225,31 +223,31 @@ export class WhmcsDataTransformer {
|
||||
*/
|
||||
private normalizeBillingCycle(cycle: string): string {
|
||||
const cycleMap: Record<string, string> = {
|
||||
'monthly': 'Monthly',
|
||||
'quarterly': 'Quarterly',
|
||||
'semiannually': 'Semi-Annually',
|
||||
'annually': 'Annually',
|
||||
'biennially': 'Biennially',
|
||||
'triennially': 'Triennially',
|
||||
'onetime': 'One Time',
|
||||
'free': 'Free',
|
||||
monthly: "Monthly",
|
||||
quarterly: "Quarterly",
|
||||
semiannually: "Semi-Annually",
|
||||
annually: "Annually",
|
||||
biennially: "Biennially",
|
||||
triennially: "Triennially",
|
||||
onetime: "One Time",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
return cycleMap[cycle?.toLowerCase()] || cycle || 'Unknown';
|
||||
return cycleMap[cycle?.toLowerCase()] || cycle || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse amount string to number with proper error handling
|
||||
*/
|
||||
private parseAmount(value: any): number {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handle string values that might have currency symbols
|
||||
if (typeof value === 'string') {
|
||||
if (typeof value === "string") {
|
||||
// Remove currency symbols and whitespace
|
||||
const cleanValue = value.replace(/[^0-9.-]/g, '');
|
||||
const cleanValue = value.replace(/[^0-9.-]/g, "");
|
||||
const parsed = parseFloat(cleanValue);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
@ -262,12 +260,12 @@ export class WhmcsDataTransformer {
|
||||
* Format date string to ISO format with proper validation
|
||||
*/
|
||||
private formatDate(dateString: any): string | undefined {
|
||||
if (!dateString || dateString === '0000-00-00' || dateString === '0000-00-00 00:00:00') {
|
||||
if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If it's already a valid ISO string, return it
|
||||
if (typeof dateString === 'string' && dateString.includes('T')) {
|
||||
if (typeof dateString === "string" && dateString.includes("T")) {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
@ -293,12 +291,12 @@ export class WhmcsDataTransformer {
|
||||
*/
|
||||
private sanitizeForLog(data: any): any {
|
||||
const sanitized = { ...data };
|
||||
|
||||
|
||||
// Remove sensitive fields
|
||||
const sensitiveFields = ['password', 'token', 'secret', 'creditcard'];
|
||||
const sensitiveFields = ["password", "token", "secret", "creditcard"];
|
||||
sensitiveFields.forEach(field => {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = '[REDACTED]';
|
||||
sanitized[field] = "[REDACTED]";
|
||||
}
|
||||
});
|
||||
|
||||
@ -309,7 +307,7 @@ export class WhmcsDataTransformer {
|
||||
* Validate transformation result
|
||||
*/
|
||||
validateInvoice(invoice: Invoice): boolean {
|
||||
const requiredFields = ['id', 'number', 'status', 'currency', 'total'];
|
||||
const requiredFields = ["id", "number", "status", "currency", "total"];
|
||||
return requiredFields.every(field => invoice[field as keyof Invoice] !== undefined);
|
||||
}
|
||||
|
||||
@ -332,49 +330,49 @@ export class WhmcsDataTransformer {
|
||||
*/
|
||||
private getCurrencySymbol(currencyCode: string): string {
|
||||
const currencyMap: Record<string, string> = {
|
||||
'USD': '$',
|
||||
'EUR': '€',
|
||||
'GBP': '£',
|
||||
'JPY': '¥',
|
||||
'CAD': 'C$',
|
||||
'AUD': 'A$',
|
||||
'CNY': '¥',
|
||||
'INR': '₹',
|
||||
'BRL': 'R$',
|
||||
'MXN': '$',
|
||||
'CHF': 'CHF',
|
||||
'SEK': 'kr',
|
||||
'NOK': 'kr',
|
||||
'DKK': 'kr',
|
||||
'PLN': 'zł',
|
||||
'CZK': 'Kč',
|
||||
'HUF': 'Ft',
|
||||
'RUB': '₽',
|
||||
'TRY': '₺',
|
||||
'KRW': '₩',
|
||||
'SGD': 'S$',
|
||||
'HKD': 'HK$',
|
||||
'THB': '฿',
|
||||
'MYR': 'RM',
|
||||
'PHP': '₱',
|
||||
'IDR': 'Rp',
|
||||
'VND': '₫',
|
||||
'ZAR': 'R',
|
||||
'ILS': '₪',
|
||||
'AED': 'د.إ',
|
||||
'SAR': 'ر.س',
|
||||
'EGP': 'ج.م',
|
||||
'NZD': 'NZ$',
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
CAD: "C$",
|
||||
AUD: "A$",
|
||||
CNY: "¥",
|
||||
INR: "₹",
|
||||
BRL: "R$",
|
||||
MXN: "$",
|
||||
CHF: "CHF",
|
||||
SEK: "kr",
|
||||
NOK: "kr",
|
||||
DKK: "kr",
|
||||
PLN: "zł",
|
||||
CZK: "Kč",
|
||||
HUF: "Ft",
|
||||
RUB: "₽",
|
||||
TRY: "₺",
|
||||
KRW: "₩",
|
||||
SGD: "S$",
|
||||
HKD: "HK$",
|
||||
THB: "฿",
|
||||
MYR: "RM",
|
||||
PHP: "₱",
|
||||
IDR: "Rp",
|
||||
VND: "₫",
|
||||
ZAR: "R",
|
||||
ILS: "₪",
|
||||
AED: "د.إ",
|
||||
SAR: "ر.س",
|
||||
EGP: "ج.م",
|
||||
NZD: "NZ$",
|
||||
};
|
||||
|
||||
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || '$';
|
||||
|
||||
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate subscription transformation result
|
||||
*/
|
||||
validateSubscription(subscription: Subscription): boolean {
|
||||
const requiredFields = ['id', 'serviceId', 'productName', 'status', 'currency'];
|
||||
const requiredFields = ["id", "serviceId", "productName", "status", "currency"];
|
||||
return requiredFields.every(field => subscription[field as keyof Subscription] !== undefined);
|
||||
}
|
||||
|
||||
@ -393,7 +391,7 @@ export class WhmcsDataTransformer {
|
||||
supportsTokenization: whmcsGateway.supports_tokenization || false,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to transform payment gateway', {
|
||||
this.logger.error("Failed to transform payment gateway", {
|
||||
error: getErrorMessage(error),
|
||||
gatewayName: whmcsGateway.name,
|
||||
});
|
||||
@ -405,7 +403,7 @@ export class WhmcsDataTransformer {
|
||||
* Validate payment method transformation result
|
||||
*/
|
||||
validatePaymentMethod(paymentMethod: PaymentMethod): boolean {
|
||||
const requiredFields = ['id', 'type', 'description'];
|
||||
const requiredFields = ["id", "type", "description"];
|
||||
return requiredFields.every(field => paymentMethod[field as keyof PaymentMethod] !== undefined);
|
||||
}
|
||||
|
||||
@ -413,7 +411,7 @@ export class WhmcsDataTransformer {
|
||||
* Validate payment gateway transformation result
|
||||
*/
|
||||
validatePaymentGateway(gateway: PaymentGateway): boolean {
|
||||
const requiredFields = ['name', 'displayName', 'type', 'isActive'];
|
||||
const requiredFields = ["name", "displayName", "type", "isActive"];
|
||||
return requiredFields.every(field => gateway[field as keyof PaymentGateway] !== undefined);
|
||||
}
|
||||
}
|
||||
|
||||
416
apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts.backup.20250822_120236
vendored
Normal file
416
apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts.backup.20250822_120236
vendored
Normal file
@ -0,0 +1,416 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceItem as BaseInvoiceItem,
|
||||
Subscription,
|
||||
PaymentMethod,
|
||||
PaymentGateway,
|
||||
} from "@customer-portal/shared";
|
||||
import {
|
||||
WhmcsInvoice,
|
||||
WhmcsProduct,
|
||||
WhmcsCustomFields,
|
||||
WhmcsInvoiceItems,
|
||||
WhmcsPaymentGateway,
|
||||
} from "../types/whmcs-api.types";
|
||||
|
||||
// Extended InvoiceItem interface to include serviceId
|
||||
interface InvoiceItem extends BaseInvoiceItem {
|
||||
serviceId?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsDataTransformer {
|
||||
private readonly logger = new Logger(WhmcsDataTransformer.name);
|
||||
|
||||
/**
|
||||
* Transform WHMCS invoice to our standard Invoice format
|
||||
*/
|
||||
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
|
||||
const invoiceId = whmcsInvoice.invoiceid || whmcsInvoice.id;
|
||||
if (!whmcsInvoice || !invoiceId) {
|
||||
throw new Error("Invalid invoice data from WHMCS");
|
||||
}
|
||||
|
||||
try {
|
||||
const invoice: Invoice = {
|
||||
id: Number(invoiceId),
|
||||
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
|
||||
status: this.normalizeInvoiceStatus(whmcsInvoice.status),
|
||||
currency: whmcsInvoice.currencycode || "USD",
|
||||
currencySymbol:
|
||||
whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "USD"),
|
||||
total: this.parseAmount(whmcsInvoice.total),
|
||||
subtotal: this.parseAmount(whmcsInvoice.subtotal),
|
||||
tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2),
|
||||
issuedAt: this.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated),
|
||||
dueDate: this.formatDate(whmcsInvoice.duedate),
|
||||
paidDate: this.formatDate(whmcsInvoice.datepaid),
|
||||
description: whmcsInvoice.notes || undefined,
|
||||
items: this.transformInvoiceItems(whmcsInvoice.items),
|
||||
};
|
||||
|
||||
this.logger.debug(`Transformed invoice ${invoice.id}`, {
|
||||
status: invoice.status,
|
||||
total: invoice.total,
|
||||
currency: invoice.currency,
|
||||
itemCount: invoice.items?.length || 0,
|
||||
itemsWithServices: invoice.items?.filter(item => item.serviceId).length || 0,
|
||||
});
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform invoice ${invoiceId}`, {
|
||||
error: getErrorMessage(error),
|
||||
whmcsData: this.sanitizeForLog(whmcsInvoice),
|
||||
});
|
||||
throw new Error(`Failed to transform invoice: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS product/service to our standard Subscription format
|
||||
*/
|
||||
transformSubscription(whmcsProduct: WhmcsProduct): Subscription {
|
||||
if (!whmcsProduct || !whmcsProduct.id) {
|
||||
throw new Error("Invalid product data from WHMCS");
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription: Subscription = {
|
||||
id: Number(whmcsProduct.id),
|
||||
serviceId: Number(whmcsProduct.id),
|
||||
productName: this.getProductName(whmcsProduct),
|
||||
domain: whmcsProduct.domain || undefined,
|
||||
cycle: this.normalizeBillingCycle(whmcsProduct.billingcycle),
|
||||
status: this.normalizeProductStatus(whmcsProduct.status),
|
||||
nextDue: this.formatDate(whmcsProduct.nextduedate),
|
||||
amount: this.getProductAmount(whmcsProduct),
|
||||
currency: whmcsProduct.currencycode || "USD",
|
||||
|
||||
registrationDate:
|
||||
this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0],
|
||||
notes: undefined, // WHMCS products don't typically have notes
|
||||
customFields: this.transformCustomFields(whmcsProduct.customfields),
|
||||
};
|
||||
|
||||
this.logger.debug(`Transformed subscription ${subscription.id}`, {
|
||||
productName: subscription.productName,
|
||||
status: subscription.status,
|
||||
amount: subscription.amount,
|
||||
currency: subscription.currency,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, {
|
||||
error: getErrorMessage(error),
|
||||
whmcsData: this.sanitizeForLog(whmcsProduct),
|
||||
});
|
||||
throw new Error(`Failed to transform subscription: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform invoice items with service linking
|
||||
*/
|
||||
private transformInvoiceItems(items?: WhmcsInvoiceItems): InvoiceItem[] | undefined {
|
||||
if (!items?.item || !Array.isArray(items.item)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items.item.map(item => {
|
||||
const transformedItem: InvoiceItem = {
|
||||
id: Number(item.id),
|
||||
description: item.description || "Unknown Item",
|
||||
amount: this.parseAmount(item.amount),
|
||||
quantity: 1, // WHMCS items don't have quantity field
|
||||
type: item.type || "item",
|
||||
};
|
||||
|
||||
// Link to service/product if relid is provided and greater than 0
|
||||
if (item.relid && item.relid > 0) {
|
||||
transformedItem.serviceId = Number(item.relid);
|
||||
}
|
||||
|
||||
return transformedItem;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform custom fields from WHMCS format
|
||||
*/
|
||||
private transformCustomFields(
|
||||
customFields?: WhmcsCustomFields
|
||||
): Record<string, string> | undefined {
|
||||
if (!customFields?.customfield || !Array.isArray(customFields.customfield)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
customFields.customfield.forEach(field => {
|
||||
if (field.name && field.value) {
|
||||
result[field.name] = field.value;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available product name from WHMCS data
|
||||
*/
|
||||
private getProductName(whmcsProduct: WhmcsProduct): string {
|
||||
return (
|
||||
whmcsProduct.name ||
|
||||
whmcsProduct.translated_name ||
|
||||
whmcsProduct.productname ||
|
||||
whmcsProduct.packagename ||
|
||||
"Unknown Product"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate amount for a product (recurring vs first payment)
|
||||
*/
|
||||
private getProductAmount(whmcsProduct: WhmcsProduct): number {
|
||||
// Prioritize recurring amount, fallback to first payment amount
|
||||
const recurringAmount = this.parseAmount(whmcsProduct.recurringamount);
|
||||
const firstPaymentAmount = this.parseAmount(whmcsProduct.firstpaymentamount);
|
||||
|
||||
return recurringAmount > 0 ? recurringAmount : firstPaymentAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize invoice status to our standard values
|
||||
*/
|
||||
private normalizeInvoiceStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
paid: "Paid",
|
||||
unpaid: "Unpaid",
|
||||
cancelled: "Cancelled",
|
||||
overdue: "Overdue",
|
||||
collections: "Collections",
|
||||
draft: "Draft",
|
||||
refunded: "Refunded",
|
||||
};
|
||||
|
||||
return statusMap[status?.toLowerCase()] || status || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize product status to our standard values
|
||||
*/
|
||||
private normalizeProductStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
active: "Active",
|
||||
suspended: "Suspended",
|
||||
terminated: "Terminated",
|
||||
cancelled: "Cancelled",
|
||||
pending: "Pending",
|
||||
completed: "Completed",
|
||||
fraud: "Fraud",
|
||||
};
|
||||
|
||||
return statusMap[status?.toLowerCase()] || status || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize billing cycle to our standard values
|
||||
*/
|
||||
private normalizeBillingCycle(cycle: string): string {
|
||||
const cycleMap: Record<string, string> = {
|
||||
monthly: "Monthly",
|
||||
quarterly: "Quarterly",
|
||||
semiannually: "Semi-Annually",
|
||||
annually: "Annually",
|
||||
biennially: "Biennially",
|
||||
triennially: "Triennially",
|
||||
onetime: "One Time",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
return cycleMap[cycle?.toLowerCase()] || cycle || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse amount string to number with proper error handling
|
||||
*/
|
||||
private parseAmount(value: any): number {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handle string values that might have currency symbols
|
||||
if (typeof value === "string") {
|
||||
// Remove currency symbols and whitespace
|
||||
const cleanValue = value.replace(/[^0-9.-]/g, "");
|
||||
const parsed = parseFloat(cleanValue);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string to ISO format with proper validation
|
||||
*/
|
||||
private formatDate(dateString: any): string | undefined {
|
||||
if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If it's already a valid ISO string, return it
|
||||
if (typeof dateString === "string" && dateString.includes("T")) {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse and convert to ISO string
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
return date.toISOString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize data for logging (remove sensitive information)
|
||||
*/
|
||||
private sanitizeForLog(data: any): any {
|
||||
const sanitized = { ...data };
|
||||
|
||||
// Remove sensitive fields
|
||||
const sensitiveFields = ["password", "token", "secret", "creditcard"];
|
||||
sensitiveFields.forEach(field => {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = "[REDACTED]";
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate transformation result
|
||||
*/
|
||||
validateInvoice(invoice: Invoice): boolean {
|
||||
const requiredFields = ["id", "number", "status", "currency", "total"];
|
||||
return requiredFields.every(field => invoice[field as keyof Invoice] !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS product for catalog
|
||||
*/
|
||||
transformProduct(whmcsProduct: any): any {
|
||||
return {
|
||||
id: whmcsProduct.pid,
|
||||
name: whmcsProduct.name,
|
||||
description: whmcsProduct.description,
|
||||
group: whmcsProduct.gname,
|
||||
pricing: whmcsProduct.pricing || [],
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency symbol from currency code
|
||||
*/
|
||||
private getCurrencySymbol(currencyCode: string): string {
|
||||
const currencyMap: Record<string, string> = {
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
CAD: "C$",
|
||||
AUD: "A$",
|
||||
CNY: "¥",
|
||||
INR: "₹",
|
||||
BRL: "R$",
|
||||
MXN: "$",
|
||||
CHF: "CHF",
|
||||
SEK: "kr",
|
||||
NOK: "kr",
|
||||
DKK: "kr",
|
||||
PLN: "zł",
|
||||
CZK: "Kč",
|
||||
HUF: "Ft",
|
||||
RUB: "₽",
|
||||
TRY: "₺",
|
||||
KRW: "₩",
|
||||
SGD: "S$",
|
||||
HKD: "HK$",
|
||||
THB: "฿",
|
||||
MYR: "RM",
|
||||
PHP: "₱",
|
||||
IDR: "Rp",
|
||||
VND: "₫",
|
||||
ZAR: "R",
|
||||
ILS: "₪",
|
||||
AED: "د.إ",
|
||||
SAR: "ر.س",
|
||||
EGP: "ج.م",
|
||||
NZD: "NZ$",
|
||||
};
|
||||
|
||||
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate subscription transformation result
|
||||
*/
|
||||
validateSubscription(subscription: Subscription): boolean {
|
||||
const requiredFields = ["id", "serviceId", "productName", "status", "currency"];
|
||||
return requiredFields.every(field => subscription[field as keyof Subscription] !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS payment gateway to shared PaymentGateway interface
|
||||
*/
|
||||
transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway {
|
||||
try {
|
||||
return {
|
||||
name: whmcsGateway.name,
|
||||
displayName: whmcsGateway.display_name || whmcsGateway.name,
|
||||
type: whmcsGateway.type,
|
||||
isActive: whmcsGateway.active,
|
||||
acceptsCreditCards: whmcsGateway.accepts_credit_cards || false,
|
||||
acceptsBankAccount: whmcsGateway.accepts_bank_account || false,
|
||||
supportsTokenization: whmcsGateway.supports_tokenization || false,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to transform payment gateway", {
|
||||
error: getErrorMessage(error),
|
||||
gatewayName: whmcsGateway.name,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment method transformation result
|
||||
*/
|
||||
validatePaymentMethod(paymentMethod: PaymentMethod): boolean {
|
||||
const requiredFields = ["id", "type", "description"];
|
||||
return requiredFields.every(field => paymentMethod[field as keyof PaymentMethod] !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment gateway transformation result
|
||||
*/
|
||||
validatePaymentGateway(gateway: PaymentGateway): boolean {
|
||||
const requiredFields = ["name", "displayName", "type", "isActive"];
|
||||
return requiredFields.every(field => gateway[field as keyof PaymentGateway] !== undefined);
|
||||
}
|
||||
}
|
||||
418
apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts.backup.20250822_120518
vendored
Normal file
418
apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts.backup.20250822_120518
vendored
Normal file
@ -0,0 +1,418 @@
|
||||
import { getErrorMessage } from "../../../common/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceItem as BaseInvoiceItem,
|
||||
Subscription,
|
||||
PaymentMethod,
|
||||
PaymentGateway,
|
||||
} from "@customer-portal/shared";
|
||||
import {
|
||||
WhmcsInvoice,
|
||||
WhmcsProduct,
|
||||
WhmcsCustomFields,
|
||||
WhmcsInvoiceItems,
|
||||
WhmcsPaymentGateway,
|
||||
} from "../types/whmcs-api.types";
|
||||
|
||||
// Extended InvoiceItem interface to include serviceId
|
||||
interface InvoiceItem extends BaseInvoiceItem {
|
||||
serviceId?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsDataTransformer {
|
||||
|
||||
|
||||
/**
|
||||
* Transform WHMCS invoice to our standard Invoice format
|
||||
*/
|
||||
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
|
||||
const invoiceId = whmcsInvoice.invoiceid || whmcsInvoice.id;
|
||||
if (!whmcsInvoice || !invoiceId) {
|
||||
throw new Error("Invalid invoice data from WHMCS");
|
||||
}
|
||||
|
||||
try {
|
||||
const invoice: Invoice = {
|
||||
id: Number(invoiceId),
|
||||
number: whmcsInvoice.invoicenum || `INV-${invoiceId}`,
|
||||
status: this.normalizeInvoiceStatus(whmcsInvoice.status),
|
||||
currency: whmcsInvoice.currencycode || "USD",
|
||||
currencySymbol:
|
||||
whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "USD"),
|
||||
total: this.parseAmount(whmcsInvoice.total),
|
||||
subtotal: this.parseAmount(whmcsInvoice.subtotal),
|
||||
tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2),
|
||||
issuedAt: this.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated),
|
||||
dueDate: this.formatDate(whmcsInvoice.duedate),
|
||||
paidDate: this.formatDate(whmcsInvoice.datepaid),
|
||||
description: whmcsInvoice.notes || undefined,
|
||||
items: this.transformInvoiceItems(whmcsInvoice.items),
|
||||
};
|
||||
|
||||
this.logger.debug(`Transformed invoice ${invoice.id}`, {
|
||||
status: invoice.status,
|
||||
total: invoice.total,
|
||||
currency: invoice.currency,
|
||||
itemCount: invoice.items?.length || 0,
|
||||
itemsWithServices: invoice.items?.filter(item => item.serviceId).length || 0,
|
||||
});
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform invoice ${invoiceId}`, {
|
||||
error: getErrorMessage(error),
|
||||
whmcsData: this.sanitizeForLog(whmcsInvoice),
|
||||
});
|
||||
throw new Error(`Failed to transform invoice: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS product/service to our standard Subscription format
|
||||
*/
|
||||
transformSubscription(whmcsProduct: WhmcsProduct): Subscription {
|
||||
if (!whmcsProduct || !whmcsProduct.id) {
|
||||
throw new Error("Invalid product data from WHMCS");
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription: Subscription = {
|
||||
id: Number(whmcsProduct.id),
|
||||
serviceId: Number(whmcsProduct.id),
|
||||
productName: this.getProductName(whmcsProduct),
|
||||
domain: whmcsProduct.domain || undefined,
|
||||
cycle: this.normalizeBillingCycle(whmcsProduct.billingcycle),
|
||||
status: this.normalizeProductStatus(whmcsProduct.status),
|
||||
nextDue: this.formatDate(whmcsProduct.nextduedate),
|
||||
amount: this.getProductAmount(whmcsProduct),
|
||||
currency: whmcsProduct.currencycode || "USD",
|
||||
|
||||
registrationDate:
|
||||
this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0],
|
||||
notes: undefined, // WHMCS products don't typically have notes
|
||||
customFields: this.transformCustomFields(whmcsProduct.customfields),
|
||||
};
|
||||
|
||||
this.logger.debug(`Transformed subscription ${subscription.id}`, {
|
||||
productName: subscription.productName,
|
||||
status: subscription.status,
|
||||
amount: subscription.amount,
|
||||
currency: subscription.currency,
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, {
|
||||
error: getErrorMessage(error),
|
||||
whmcsData: this.sanitizeForLog(whmcsProduct),
|
||||
});
|
||||
throw new Error(`Failed to transform subscription: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform invoice items with service linking
|
||||
*/
|
||||
private transformInvoiceItems(items?: WhmcsInvoiceItems): InvoiceItem[] | undefined {
|
||||
if (!items?.item || !Array.isArray(items.item)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items.item.map(item => {
|
||||
const transformedItem: InvoiceItem = {
|
||||
id: Number(item.id),
|
||||
description: item.description || "Unknown Item",
|
||||
amount: this.parseAmount(item.amount),
|
||||
quantity: 1, // WHMCS items don't have quantity field
|
||||
type: item.type || "item",
|
||||
};
|
||||
|
||||
// Link to service/product if relid is provided and greater than 0
|
||||
if (item.relid && item.relid > 0) {
|
||||
transformedItem.serviceId = Number(item.relid);
|
||||
}
|
||||
|
||||
return transformedItem;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform custom fields from WHMCS format
|
||||
*/
|
||||
private transformCustomFields(
|
||||
customFields?: WhmcsCustomFields
|
||||
): Record<string, string> | undefined {
|
||||
if (!customFields?.customfield || !Array.isArray(customFields.customfield)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
customFields.customfield.forEach(field => {
|
||||
if (field.name && field.value) {
|
||||
result[field.name] = field.value;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the best available product name from WHMCS data
|
||||
*/
|
||||
private getProductName(whmcsProduct: WhmcsProduct): string {
|
||||
return (
|
||||
whmcsProduct.name ||
|
||||
whmcsProduct.translated_name ||
|
||||
whmcsProduct.productname ||
|
||||
whmcsProduct.packagename ||
|
||||
"Unknown Product"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate amount for a product (recurring vs first payment)
|
||||
*/
|
||||
private getProductAmount(whmcsProduct: WhmcsProduct): number {
|
||||
// Prioritize recurring amount, fallback to first payment amount
|
||||
const recurringAmount = this.parseAmount(whmcsProduct.recurringamount);
|
||||
const firstPaymentAmount = this.parseAmount(whmcsProduct.firstpaymentamount);
|
||||
|
||||
return recurringAmount > 0 ? recurringAmount : firstPaymentAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize invoice status to our standard values
|
||||
*/
|
||||
private normalizeInvoiceStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
paid: "Paid",
|
||||
unpaid: "Unpaid",
|
||||
cancelled: "Cancelled",
|
||||
overdue: "Overdue",
|
||||
collections: "Collections",
|
||||
draft: "Draft",
|
||||
refunded: "Refunded",
|
||||
};
|
||||
|
||||
return statusMap[status?.toLowerCase()] || status || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize product status to our standard values
|
||||
*/
|
||||
private normalizeProductStatus(status: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
active: "Active",
|
||||
suspended: "Suspended",
|
||||
terminated: "Terminated",
|
||||
cancelled: "Cancelled",
|
||||
pending: "Pending",
|
||||
completed: "Completed",
|
||||
fraud: "Fraud",
|
||||
};
|
||||
|
||||
return statusMap[status?.toLowerCase()] || status || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize billing cycle to our standard values
|
||||
*/
|
||||
private normalizeBillingCycle(cycle: string): string {
|
||||
const cycleMap: Record<string, string> = {
|
||||
monthly: "Monthly",
|
||||
quarterly: "Quarterly",
|
||||
semiannually: "Semi-Annually",
|
||||
annually: "Annually",
|
||||
biennially: "Biennially",
|
||||
triennially: "Triennially",
|
||||
onetime: "One Time",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
return cycleMap[cycle?.toLowerCase()] || cycle || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse amount string to number with proper error handling
|
||||
*/
|
||||
private parseAmount(value: any): number {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Handle string values that might have currency symbols
|
||||
if (typeof value === "string") {
|
||||
// Remove currency symbols and whitespace
|
||||
const cleanValue = value.replace(/[^0-9.-]/g, "");
|
||||
const parsed = parseFloat(cleanValue);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string to ISO format with proper validation
|
||||
*/
|
||||
private formatDate(dateString: any): string | undefined {
|
||||
if (!dateString || dateString === "0000-00-00" || dateString === "0000-00-00 00:00:00") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If it's already a valid ISO string, return it
|
||||
if (typeof dateString === "string" && dateString.includes("T")) {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse and convert to ISO string
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
return date.toISOString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize data for logging (remove sensitive information)
|
||||
*/
|
||||
private sanitizeForLog(data: any): any {
|
||||
const sanitized = { ...data };
|
||||
|
||||
// Remove sensitive fields
|
||||
const sensitiveFields = ["password", "token", "secret", "creditcard"];
|
||||
sensitiveFields.forEach(field => {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = "[REDACTED]";
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate transformation result
|
||||
*/
|
||||
validateInvoice(invoice: Invoice): boolean {
|
||||
const requiredFields = ["id", "number", "status", "currency", "total"];
|
||||
return requiredFields.every(field => invoice[field as keyof Invoice] !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS product for catalog
|
||||
*/
|
||||
transformProduct(whmcsProduct: any): any {
|
||||
return {
|
||||
id: whmcsProduct.pid,
|
||||
name: whmcsProduct.name,
|
||||
description: whmcsProduct.description,
|
||||
group: whmcsProduct.gname,
|
||||
pricing: whmcsProduct.pricing || [],
|
||||
available: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency symbol from currency code
|
||||
*/
|
||||
private getCurrencySymbol(currencyCode: string): string {
|
||||
const currencyMap: Record<string, string> = {
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
CAD: "C$",
|
||||
AUD: "A$",
|
||||
CNY: "¥",
|
||||
INR: "₹",
|
||||
BRL: "R$",
|
||||
MXN: "$",
|
||||
CHF: "CHF",
|
||||
SEK: "kr",
|
||||
NOK: "kr",
|
||||
DKK: "kr",
|
||||
PLN: "zł",
|
||||
CZK: "Kč",
|
||||
HUF: "Ft",
|
||||
RUB: "₽",
|
||||
TRY: "₺",
|
||||
KRW: "₩",
|
||||
SGD: "S$",
|
||||
HKD: "HK$",
|
||||
THB: "฿",
|
||||
MYR: "RM",
|
||||
PHP: "₱",
|
||||
IDR: "Rp",
|
||||
VND: "₫",
|
||||
ZAR: "R",
|
||||
ILS: "₪",
|
||||
AED: "د.إ",
|
||||
SAR: "ر.س",
|
||||
EGP: "ج.م",
|
||||
NZD: "NZ$",
|
||||
};
|
||||
|
||||
return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate subscription transformation result
|
||||
*/
|
||||
validateSubscription(subscription: Subscription): boolean {
|
||||
const requiredFields = ["id", "serviceId", "productName", "status", "currency"];
|
||||
return requiredFields.every(field => subscription[field as keyof Subscription] !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS payment gateway to shared PaymentGateway interface
|
||||
*/
|
||||
transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway {
|
||||
try {
|
||||
return {
|
||||
name: whmcsGateway.name,
|
||||
displayName: whmcsGateway.display_name || whmcsGateway.name,
|
||||
type: whmcsGateway.type,
|
||||
isActive: whmcsGateway.active,
|
||||
acceptsCreditCards: whmcsGateway.accepts_credit_cards || false,
|
||||
acceptsBankAccount: whmcsGateway.accepts_bank_account || false,
|
||||
supportsTokenization: whmcsGateway.supports_tokenization || false,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to transform payment gateway", {
|
||||
error: getErrorMessage(error),
|
||||
gatewayName: whmcsGateway.name,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment method transformation result
|
||||
*/
|
||||
validatePaymentMethod(paymentMethod: PaymentMethod): boolean {
|
||||
const requiredFields = ["id", "type", "description"];
|
||||
return requiredFields.every(field => paymentMethod[field as keyof PaymentMethod] !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment gateway transformation result
|
||||
*/
|
||||
validatePaymentGateway(gateway: PaymentGateway): boolean {
|
||||
const requiredFields = ["name", "displayName", "type", "isActive"];
|
||||
return requiredFields.every(field => gateway[field as keyof PaymentGateway] !== undefined);
|
||||
}
|
||||
}
|
||||
33
apps/bff/src/vendors/whmcs/whmcs.module.ts
vendored
33
apps/bff/src/vendors/whmcs/whmcs.module.ts
vendored
@ -1,19 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { WhmcsDataTransformer } from './transformers/whmcs-data.transformer';
|
||||
import { WhmcsCacheService } from './cache/whmcs-cache.service';
|
||||
import { WhmcsService } from './whmcs.service';
|
||||
import { WhmcsConnectionService } from './services/whmcs-connection.service';
|
||||
import { WhmcsInvoiceService } from './services/whmcs-invoice.service';
|
||||
import { WhmcsSubscriptionService } from './services/whmcs-subscription.service';
|
||||
import { WhmcsClientService } from './services/whmcs-client.service';
|
||||
import { WhmcsPaymentService } from './services/whmcs-payment.service';
|
||||
import { WhmcsSsoService } from './services/whmcs-sso.service';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { WhmcsDataTransformer } from "./transformers/whmcs-data.transformer";
|
||||
import { WhmcsCacheService } from "./cache/whmcs-cache.service";
|
||||
import { WhmcsService } from "./whmcs.service";
|
||||
import { WhmcsConnectionService } from "./services/whmcs-connection.service";
|
||||
import { WhmcsInvoiceService } from "./services/whmcs-invoice.service";
|
||||
import { WhmcsSubscriptionService } from "./services/whmcs-subscription.service";
|
||||
import { WhmcsClientService } from "./services/whmcs-client.service";
|
||||
import { WhmcsPaymentService } from "./services/whmcs-payment.service";
|
||||
import { WhmcsSsoService } from "./services/whmcs-sso.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule,
|
||||
],
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
WhmcsDataTransformer,
|
||||
WhmcsCacheService,
|
||||
@ -25,11 +23,6 @@ import { WhmcsSsoService } from './services/whmcs-sso.service';
|
||||
WhmcsSsoService,
|
||||
WhmcsService,
|
||||
],
|
||||
exports: [
|
||||
WhmcsService,
|
||||
WhmcsConnectionService,
|
||||
WhmcsDataTransformer,
|
||||
WhmcsCacheService,
|
||||
],
|
||||
exports: [WhmcsService, WhmcsConnectionService, WhmcsDataTransformer, WhmcsCacheService],
|
||||
})
|
||||
export class WhmcsModule {}
|
||||
|
||||
106
apps/bff/src/vendors/whmcs/whmcs.service.ts
vendored
106
apps/bff/src/vendors/whmcs/whmcs.service.ts
vendored
@ -1,22 +1,15 @@
|
||||
import { getErrorMessage } from "../../common/utils/error.util";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
Subscription,
|
||||
SubscriptionList,
|
||||
PaymentMethod,
|
||||
PaymentMethodList,
|
||||
PaymentGateway,
|
||||
PaymentGatewayList,
|
||||
InvoiceSsoLink,
|
||||
InvoicePaymentLink,
|
||||
} from "@customer-portal/shared";
|
||||
import { WhmcsConnectionService } from "./services/whmcs-connection.service";
|
||||
import {
|
||||
WhmcsInvoiceService,
|
||||
InvoiceFilters,
|
||||
} from "./services/whmcs-invoice.service";
|
||||
import { WhmcsInvoiceService, InvoiceFilters } from "./services/whmcs-invoice.service";
|
||||
import {
|
||||
WhmcsSubscriptionService,
|
||||
SubscriptionFilters,
|
||||
@ -25,14 +18,13 @@ import { WhmcsClientService } from "./services/whmcs-client.service";
|
||||
import { WhmcsPaymentService } from "./services/whmcs-payment.service";
|
||||
import { WhmcsSsoService } from "./services/whmcs-sso.service";
|
||||
import { WhmcsAddClientParams } from "./types/whmcs-api.types";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
// Re-export interfaces for backward compatibility
|
||||
export type { InvoiceFilters, SubscriptionFilters };
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsService {
|
||||
private readonly logger = new Logger(WhmcsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly invoiceService: WhmcsInvoiceService,
|
||||
@ -40,6 +32,7 @@ export class WhmcsService {
|
||||
private readonly clientService: WhmcsClientService,
|
||||
private readonly paymentService: WhmcsPaymentService,
|
||||
private readonly ssoService: WhmcsSsoService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
@ -52,7 +45,7 @@ export class WhmcsService {
|
||||
async getInvoices(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {},
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.invoiceService.getInvoices(clientId, userId, filters);
|
||||
}
|
||||
@ -63,7 +56,7 @@ export class WhmcsService {
|
||||
async getInvoicesWithItems(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: InvoiceFilters = {},
|
||||
filters: InvoiceFilters = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.invoiceService.getInvoicesWithItems(clientId, userId, filters);
|
||||
}
|
||||
@ -71,21 +64,14 @@ export class WhmcsService {
|
||||
/**
|
||||
* Get individual invoice by ID with caching
|
||||
*/
|
||||
async getInvoiceById(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
invoiceId: number,
|
||||
): Promise<Invoice> {
|
||||
async getInvoiceById(clientId: number, userId: string, invoiceId: number): Promise<Invoice> {
|
||||
return this.invoiceService.getInvoiceById(clientId, userId, invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific invoice
|
||||
*/
|
||||
async invalidateInvoiceCache(
|
||||
userId: string,
|
||||
invoiceId: number,
|
||||
): Promise<void> {
|
||||
async invalidateInvoiceCache(userId: string, invoiceId: number): Promise<void> {
|
||||
return this.invoiceService.invalidateInvoiceCache(userId, invoiceId);
|
||||
}
|
||||
|
||||
@ -99,7 +85,7 @@ export class WhmcsService {
|
||||
async getSubscriptions(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
filters: SubscriptionFilters = {},
|
||||
filters: SubscriptionFilters = {}
|
||||
): Promise<SubscriptionList> {
|
||||
return this.subscriptionService.getSubscriptions(clientId, userId, filters);
|
||||
}
|
||||
@ -110,26 +96,16 @@ export class WhmcsService {
|
||||
async getSubscriptionById(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
subscriptionId: number
|
||||
): Promise<Subscription> {
|
||||
return this.subscriptionService.getSubscriptionById(
|
||||
clientId,
|
||||
userId,
|
||||
subscriptionId,
|
||||
);
|
||||
return this.subscriptionService.getSubscriptionById(clientId, userId, subscriptionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache for a specific subscription
|
||||
*/
|
||||
async invalidateSubscriptionCache(
|
||||
userId: string,
|
||||
subscriptionId: number,
|
||||
): Promise<void> {
|
||||
return this.subscriptionService.invalidateSubscriptionCache(
|
||||
userId,
|
||||
subscriptionId,
|
||||
);
|
||||
async invalidateSubscriptionCache(userId: string, subscriptionId: number): Promise<void> {
|
||||
return this.subscriptionService.invalidateSubscriptionCache(userId, subscriptionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -137,7 +113,7 @@ export class WhmcsService {
|
||||
*/
|
||||
async getSubscriptionStats(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
userId: string
|
||||
): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
@ -146,33 +122,24 @@ export class WhmcsService {
|
||||
pending: number;
|
||||
}> {
|
||||
try {
|
||||
const subscriptionList = await this.subscriptionService.getSubscriptions(
|
||||
clientId,
|
||||
userId,
|
||||
);
|
||||
const subscriptionList = await this.subscriptionService.getSubscriptions(clientId, userId);
|
||||
const subscriptions = subscriptionList.subscriptions;
|
||||
|
||||
const stats = {
|
||||
total: subscriptions.length,
|
||||
active: subscriptions.filter((s) => s.status === "Active").length,
|
||||
suspended: subscriptions.filter((s) => s.status === "Suspended").length,
|
||||
cancelled: subscriptions.filter((s) => s.status === "Cancelled").length,
|
||||
pending: subscriptions.filter((s) => s.status === "Pending").length,
|
||||
active: subscriptions.filter(s => s.status === "Active").length,
|
||||
suspended: subscriptions.filter(s => s.status === "Suspended").length,
|
||||
cancelled: subscriptions.filter(s => s.status === "Cancelled").length,
|
||||
pending: subscriptions.filter(s => s.status === "Pending").length,
|
||||
};
|
||||
|
||||
this.logger.debug(
|
||||
`Generated subscription stats for client ${clientId}:`,
|
||||
stats,
|
||||
);
|
||||
this.logger.debug(`Generated subscription stats for client ${clientId}:`, stats);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get subscription stats for client ${clientId}`,
|
||||
{
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
},
|
||||
);
|
||||
this.logger.error(`Failed to get subscription stats for client ${clientId}`, {
|
||||
error: getErrorMessage(error),
|
||||
userId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -186,7 +153,7 @@ export class WhmcsService {
|
||||
*/
|
||||
async validateLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
password: string
|
||||
): Promise<{ userId: number; passwordHash: string }> {
|
||||
return this.clientService.validateLogin(email, password);
|
||||
}
|
||||
@ -208,9 +175,7 @@ export class WhmcsService {
|
||||
/**
|
||||
* Add new client
|
||||
*/
|
||||
async addClient(
|
||||
clientData: WhmcsAddClientParams,
|
||||
): Promise<{ clientId: number }> {
|
||||
async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> {
|
||||
return this.clientService.addClient(clientData);
|
||||
}
|
||||
|
||||
@ -228,10 +193,7 @@ export class WhmcsService {
|
||||
/**
|
||||
* Get payment methods for a client
|
||||
*/
|
||||
async getPaymentMethods(
|
||||
clientId: number,
|
||||
userId: string,
|
||||
): Promise<PaymentMethodList> {
|
||||
async getPaymentMethods(clientId: number, userId: string): Promise<PaymentMethodList> {
|
||||
return this.paymentService.getPaymentMethods(clientId, userId);
|
||||
}
|
||||
|
||||
@ -249,13 +211,13 @@ export class WhmcsService {
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
paymentMethodId?: number,
|
||||
gatewayName?: string,
|
||||
gatewayName?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
return this.paymentService.createPaymentSsoToken(
|
||||
clientId,
|
||||
invoiceId,
|
||||
paymentMethodId,
|
||||
gatewayName,
|
||||
gatewayName
|
||||
);
|
||||
}
|
||||
|
||||
@ -283,13 +245,9 @@ export class WhmcsService {
|
||||
async createSsoToken(
|
||||
clientId: number,
|
||||
destination?: string,
|
||||
ssoRedirectPath?: string,
|
||||
ssoRedirectPath?: string
|
||||
): Promise<{ url: string; expiresAt: string }> {
|
||||
return this.ssoService.createSsoToken(
|
||||
clientId,
|
||||
destination,
|
||||
ssoRedirectPath,
|
||||
);
|
||||
return this.ssoService.createSsoToken(clientId, destination, ssoRedirectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -298,7 +256,7 @@ export class WhmcsService {
|
||||
async whmcsSsoForInvoice(
|
||||
clientId: number,
|
||||
invoiceId: number,
|
||||
target: "view" | "download" | "pay",
|
||||
target: "view" | "download" | "pay"
|
||||
): Promise<string> {
|
||||
return this.ssoService.whmcsSsoForInvoice(clientId, invoiceId, target);
|
||||
}
|
||||
|
||||
38
apps/bff/src/webhooks/guards/webhook-signature.guard.ts
Normal file
38
apps/bff/src/webhooks/guards/webhook-signature.guard.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Request } from "express";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
@Injectable()
|
||||
export class WebhookSignatureGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const signature = request.headers["x-whmcs-signature"] || request.headers["x-sf-signature"];
|
||||
|
||||
if (!signature) {
|
||||
throw new UnauthorizedException("Webhook signature is required");
|
||||
}
|
||||
|
||||
// Get the appropriate secret based on the webhook type
|
||||
const isWhmcs = request.headers["x-whmcs-signature"];
|
||||
const secret = isWhmcs
|
||||
? this.configService.get("WHMCS_WEBHOOK_SECRET")
|
||||
: this.configService.get("SF_WEBHOOK_SECRET");
|
||||
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException("Webhook secret not configured");
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const payload = JSON.stringify(request.body);
|
||||
const expectedSignature = crypto.createHmac("sha256", secret).update(payload).digest("hex");
|
||||
|
||||
if (signature !== expectedSignature) {
|
||||
throw new UnauthorizedException("Invalid webhook signature");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,58 @@
|
||||
import { Controller } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Headers,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { WebhooksService } from "./webhooks.service";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from "@nestjs/swagger";
|
||||
import { ThrottlerGuard } from "@nestjs/throttler";
|
||||
import { WebhookSignatureGuard } from "./guards/webhook-signature.guard";
|
||||
|
||||
@ApiTags("webhooks")
|
||||
@Controller("webhooks")
|
||||
@UseGuards(ThrottlerGuard) // Rate limit webhook endpoints
|
||||
export class WebhooksController {
|
||||
constructor(private webhooksService: WebhooksService) {}
|
||||
|
||||
// TODO: Implement webhook endpoints
|
||||
@Post("whmcs")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(WebhookSignatureGuard)
|
||||
@ApiOperation({ summary: "WHMCS webhook endpoint" })
|
||||
@ApiResponse({ status: 200, description: "Webhook processed successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
||||
@ApiResponse({ status: 401, description: "Invalid signature" })
|
||||
@ApiHeader({ name: "X-WHMCS-Signature", description: "WHMCS webhook signature" })
|
||||
async handleWhmcsWebhook(@Body() payload: any, @Headers("x-whmcs-signature") signature: string) {
|
||||
try {
|
||||
await this.webhooksService.processWhmcsWebhook(payload, signature);
|
||||
return { success: true, message: "Webhook processed successfully" };
|
||||
} catch {
|
||||
throw new BadRequestException("Failed to process webhook");
|
||||
}
|
||||
}
|
||||
|
||||
@Post("salesforce")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(WebhookSignatureGuard)
|
||||
@ApiOperation({ summary: "Salesforce webhook endpoint" })
|
||||
@ApiResponse({ status: 200, description: "Webhook processed successfully" })
|
||||
@ApiResponse({ status: 400, description: "Invalid webhook data" })
|
||||
@ApiResponse({ status: 401, description: "Invalid signature" })
|
||||
@ApiHeader({ name: "X-SF-Signature", description: "Salesforce webhook signature" })
|
||||
async handleSalesforceWebhook(
|
||||
@Body() payload: any,
|
||||
@Headers("x-sf-signature") signature: string
|
||||
) {
|
||||
try {
|
||||
await this.webhooksService.processSalesforceWebhook(payload, signature);
|
||||
return { success: true, message: "Webhook processed successfully" };
|
||||
} catch {
|
||||
throw new BadRequestException("Failed to process webhook");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,61 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
export class WebhooksService {
|
||||
// TODO: Implement webhook business logic
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async processWhmcsWebhook(payload: any, signature: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log("Processing WHMCS webhook", {
|
||||
webhookType: payload.action || "unknown",
|
||||
clientId: payload.client_id,
|
||||
signatureLength: signature?.length || 0,
|
||||
});
|
||||
|
||||
// TODO: Implement WHMCS webhook processing logic
|
||||
// This should handle various WHMCS events like:
|
||||
// - Invoice creation/update
|
||||
// - Payment received
|
||||
// - Client status changes
|
||||
// - Service changes
|
||||
|
||||
this.logger.log("WHMCS webhook processed successfully");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to process WHMCS webhook", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
payload: payload.action || "unknown",
|
||||
});
|
||||
throw new BadRequestException("Failed to process WHMCS webhook");
|
||||
}
|
||||
}
|
||||
|
||||
async processSalesforceWebhook(payload: any, signature: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log("Processing Salesforce webhook", {
|
||||
webhookType: payload.event?.type || "unknown",
|
||||
recordId: payload.sobject?.Id,
|
||||
signatureLength: signature?.length || 0,
|
||||
});
|
||||
|
||||
// TODO: Implement Salesforce webhook processing logic
|
||||
// This should handle various Salesforce events like:
|
||||
// - Account updates
|
||||
// - Contact changes
|
||||
// - Opportunity updates
|
||||
// - Custom object changes
|
||||
|
||||
this.logger.log("Salesforce webhook processed successfully");
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to process Salesforce webhook", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
payload: payload.event?.type || "unknown",
|
||||
});
|
||||
throw new BadRequestException("Failed to process Salesforce webhook");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,37 +1,25 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// NestJS with ES2024 for modern Node.js (24+)
|
||||
// NestJS overrides
|
||||
"module": "CommonJS",
|
||||
"target": "ES2024",
|
||||
"moduleResolution": "node", // CommonJS needs node resolution
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
|
||||
|
||||
// Build settings
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"removeComments": true,
|
||||
|
||||
|
||||
// Path mappings
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
// Production-ready strict settings
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictBindCallApply": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
|
||||
// NestJS-specific adjustments
|
||||
"strictPropertyInitialization": false, // DTOs use decorators, not initializers
|
||||
"noImplicitOverride": false,
|
||||
|
||||
// Additional strict checks
|
||||
"noUncheckedIndexedAccess": false
|
||||
"noImplicitOverride": false
|
||||
},
|
||||
"include": ["src/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
41
apps/portal/.gitignore
vendored
41
apps/portal/.gitignore
vendored
@ -1,41 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: {
|
||||
env: { browser: true, es6: true, node: true },
|
||||
extends: ["eslint:recommended"],
|
||||
parserOptions: { ecmaVersion: "latest", sourceType: "module" },
|
||||
},
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
// Global ignores
|
||||
{
|
||||
ignores: [
|
||||
".next/**",
|
||||
"node_modules/**",
|
||||
"out/**",
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"*.config.js"
|
||||
]
|
||||
},
|
||||
|
||||
// Next.js recommended config
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
|
||||
// TypeScript specific config
|
||||
...compat.extends("next/typescript"),
|
||||
|
||||
// Custom rules for all files
|
||||
{
|
||||
rules: {
|
||||
// TypeScript rules
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_"
|
||||
}],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/consistent-type-imports": "warn",
|
||||
|
||||
// Console statements - warn in development
|
||||
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
|
||||
// React/Next.js specific
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-page-custom-font": "off",
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user